Compare commits

..

51 Commits

Author SHA1 Message Date
zhayujie
2517f2add8 feat(models): support gpt-5.5 2026-05-22 11:04:55 +08:00
zhayujie
a534266025 feat(models): add qwen3.7-max 2026-05-22 10:54:56 +08:00
zhayujie
8c25395805 feat(models): support gemini-3.5-flash 2026-05-22 10:39:04 +08:00
zhayujie
36b913124b docs: update models and channels doc 2026-05-22 10:10:07 +08:00
zhayujie
90773ab69f feat(models): allow viewing and editing search vendor credentials 2026-05-21 20:22:09 +08:00
zhayujie
b7734c3926 feat(search): multi-provider web search + console integration
Search tool now supports 4 backends with unified output (bocha,
qianfan, zhipu, linkai) and a routing layer:
  - strategy 'auto' (default): pick first configured in canonical order
    bocha > qianfan > zhipu > linkai
  - strategy 'fixed': pin a specific provider
  - agent may pass `provider` to override per-call (only exposed when
    ≥2 providers configured + auto strategy)
2026-05-21 19:58:03 +08:00
zhayujie
d3faf9c8dc fix(web): re-render JS-built views on language switch 2026-05-21 17:33:32 +08:00
zhayujie
bca97a1d14 feat(voice): enable TTS on Weixin / DingTalk / WeCom Bot with text-then-voice delivery
- Clear NOT_SUPPORT_REPLYTYPE on weixin, wecom_bot, dingtalk so TTS replies
  are actually synthesized for these channels.
- Wire desire_rtype=VOICE in weixin and wecom_bot _compose_context so the
  always_reply_voice / voice_reply_voice toggles take effect.
- DingTalk: send native sampleAudio (mediaId + duration). The media API
  only accepts ogg/amr, so convert TTS mp3/wav to amr on the fly.
- WeCom Bot: send native voice msgtype via ws (respond + active push),
  converting TTS audio to amr before upload.
- Weixin (ilink): no outbound voice item, deliver TTS as a file attachment.
- chat_channel: when a TEXT reply is converted to VOICE, stash original
  text in context["voice_reply_text"] and send a text bubble before the
  voice reply. Skipped for feishu_streamed and wechatcom_app, which
  already render text alongside the voice.
2026-05-21 17:29:26 +08:00
zhayujie
b8333e351c feat(voice): rework TTS/ASR stack and unify tool/skill config schema 2026-05-21 16:00:54 +08:00
zhayujie
2b90f377e6 feat(voice): add dashscope & zhipu ASR, in-page mic input 2026-05-20 22:36:37 +08:00
zhayujie
fff7326209 feat(memory): hot-swap embedding provider on rebuild-index
Switching embedding provider in the web console no longer requires a
restart and no longer drops the running conversation
2026-05-20 21:32:53 +08:00
zhayujie
c181e500bc feat(web): redesign multi-models console
Overhauls the Models tab in the Web Console with a vendor-first layout and
ships a runtime-accurate dispatcher view for vision and image generation.
2026-05-20 20:59:04 +08:00
zhayujie
16b7271826 feat(openai): inject app attribution headers for OpenRouter and Vercel AI Gateway 2026-05-20 11:43:17 +08:00
zhayujie
4a1f62b185 Merge pull request #2822 from a1094174619/fix/tool-error-status-persist
fix: persist tool error status in conversation history reload
2026-05-20 11:06:57 +08:00
zhayujie
d23a0754c1 feat(memory): exclude dream diaries from vector index 2026-05-20 11:04:54 +08:00
zhayujie
3ffb563a44 feat(memory): support multi-vendor embedding fallback
Add embedding_provider config knob with native support for
openai / dashscope / doubao / zhipu / linkai, plus an in-chat
/memory status and /memory rebuild-index workflow for switching
vendors safely.
2026-05-20 11:00:53 +08:00
a1094174619
4e42f2a017 fix: persist tool error status in conversation history reload
When reloading a conversation, failed tool calls incorrectly showed checkmark instead of X because the is_error field was lost in the history rendering pipeline. Propagate is_error from DB extraction through to the frontend rendering to match the live SSE behavior.
2026-05-19 23:50:29 +08:00
zhayujie
a0dfdb79df feat(browser): persistent login + CDP attach mode #2809
Browser sessions now reuse a Chromium user profile across runs by default
(`~/.cow/browser_profile`), so users only log in to a site once.
Three launch modes are selectable via `tools.browser` in config.json:
  - persistent (default): Playwright Chromium with a persistent user_data_dir
  - cdp: attach to an externally launched real Chrome via `cdp_endpoint`
    (full fingerprints, ideal for sites with strict bot detection)
  - fresh: clean context every run, set `persistent: false`

Also:
  - Self-heal when the user closes the browser window mid-session: detect
    closed page/context/browser via close listeners and exception scanning,
    then transparently relaunch on the next request.
  - Graceful CDP shutdown: disconnect only, never kill the user's Chrome.
  - Friendly errors when the CDP endpoint is unreachable or the persistent
    profile is locked, so the LLM can guide the user instead of looping.
  - Fix tool config being silently overwritten by workspace config in
    AgentInitializer; per-tool user settings (e.g. browser.cdp_endpoint)
    are now merged instead of replaced.
  - Update zh / en / ja docs with the new login-persistence section,
    including the Chrome 137+ requirement to pair --remote-debugging-port
    with a dedicated --user-data-dir.
2026-05-19 11:52:11 +08:00
zhayujie
a85c5f9d4e fix(scheduler): make scheduler init idempotent to prevent duplicate task runs 2026-05-18 18:36:48 +08:00
zhayujie
2720bba5b7 fix(mimo): round-trip reasoning_content for thinking-mode providers 2026-05-18 17:49:41 +08:00
zhayujie
4634a7bc2f fix(web): avoid TypeError on single-file upload 2026-05-17 19:00:07 +08:00
zhayujie
16d9b449c9 feat(web): set the web_host to the default value of 127.0.0.1 2026-05-16 18:18:17 +08:00
zhayujie
8761997757 feat(web): add web_host config and password hint for safer deployment 2026-05-16 17:37:07 +08:00
zhayujie
19bba4abbc feat(web): vendor all frontend assets locally #2816 2026-05-16 17:22:04 +08:00
zhayujie
7839f0aac5 Merge pull request #2815 from TryToMakeUsBetter/master
feat(web): support folder upload
2026-05-15 18:57:15 +08:00
Tian
83def1db30 Merge branch 'zhayujie:master' into master 2026-05-15 18:51:53 +08:00
tianyu Gu
a0b29d1ffe fix(web): remove upload dir button, one-time upload all files,path check adapt windows 2026-05-15 18:48:37 +08:00
zhayujie
f5479c56af feat(models): support reasoning_effort config for DeepSeek V4 2026-05-15 18:17:35 +08:00
tianyu Gu
246f0a45c8 feat(web): support folder upload 2026-05-14 17:16:11 +08:00
zhayujie
fe871aad77 fix(tools): unify text file truncation thresholds in read tool 2026-05-13 16:15:06 +08:00
zhayujie
6f860e1bc4 Merge pull request #2810 from Jacques-Zhao/bugfix/wecom_bot_msg_error
fix(wecom_bot): Invalid control character
2026-05-13 10:26:52 +08:00
Zhao Ke Ke
249ea40ae3 fix(wecom_bot): Invalid control character 2026-05-12 18:45:03 +08:00
zhayujie
20d8ae19a7 Merge pull request #2804 from yangluxin613/feat/web-port-browser
feat(web): auto-switch port on conflict and open browser on startup
2026-05-12 10:35:49 +08:00
ooaaooaa123
ad51aabfd7 feat(web): open browser on startup with safe fallback; friendly error on port conflict 2026-05-10 19:30:07 +08:00
zhayujie
1cf395c041 Merge pull request #2807 from yangluxin613/feat/log-ui
feat(log): add level coloring, multiline inherit, and filter checkboxes
2026-05-10 18:59:05 +08:00
zhayujie
745179a5bf Merge pull request #2806 from yangluxin613/feat/app-keyboard-interrupt
fix(app): suppress KeyboardInterrupt traceback on Ctrl+C
2026-05-10 18:58:10 +08:00
zhayujie
ff5d477fa5 Merge pull request #2808 from yangluxin613/fix/update-username-in-docs
docs: update contributor username from ooaaooaa123 to yangluxin613
2026-05-10 18:42:09 +08:00
zhayujie
907825601d feat(models): add baidu ernie-5.1 2026-05-10 18:39:38 +08:00
ooaaooaa123
c2ec26910a docs: update contributor username from ooaaooaa123 to yangluxin613 2026-05-10 18:12:00 +08:00
ooaaooaa123
83f2aea123 feat(log): enhance critical log line color visibility 2026-05-10 17:43:26 +08:00
ooaaooaa123
a5c5439315 feat(log): add level coloring, multiline inherit, and filter checkboxes 2026-05-10 17:21:08 +08:00
ooaaooaa123
eca9b60235 fix(app): suppress KeyboardInterrupt traceback on Ctrl+C 2026-05-10 17:21:01 +08:00
ooaaooaa123
d2d5d98d78 feat(web): auto-switch port on conflict and open browser on startup 2026-05-10 17:20:45 +08:00
zhayujie
fb341b869b docs(mcp): add MCP tools guide 2026-05-08 16:14:48 +08:00
zhayujie
29e66cb186 fix(mcp): correct hot-reload sync on default Agent 2026-05-08 15:40:29 +08:00
zhayujie
307769b949 feat(mcp): load MCP servers asynchronously at startup
Boot MCP servers (npx/uvx) on a background thread instead of blocking
agent init. Built-in tools serve traffic immediately while MCP comes
online; each new agent reads whatever is ready at creation time.
Idempotent via _mcp_loaded flag — concurrent sessions never re-fork
subprocesses. Per-server failures are isolated and warmup is triggered
in app.py so loading overlaps with channel startup.
2026-05-08 15:22:42 +08:00
zhayujie
9a09e057d6 Merge pull request #2801 from ooaaooaa123/feat/mcp-integration
feat(mcp): add MCP (Model Context Protocol) tool integration
2026-05-08 12:06:43 +08:00
zhayujie
3e28659528 fix(feishu): support file message and use absolute workspace path 2026-05-08 11:31:22 +08:00
ooaaooaa123
b861eef26f fix(mcp): address PR review feedback on stability and config
Stability fixes in mcp_client.py:
- Fix stderr buffer overflow: start daemon thread to continuously drain
  stderr pipe, preventing 64KB buffer fill that blocks child process
- Fix notification interference: loop readline and skip JSON-RPC messages
  without 'id' field (notifications) instead of treating them as responses
- Fix concurrent race condition: wrap send+receive in _call_lock so
  multiple sessions cannot interleave reads/writes on the same client
- Fix missing timeout: use select.select() with 30s timeout in
  _readline_with_timeout() to prevent infinite block on dead MCP server

Config improvements in tool_manager.py:
- Add _normalize_mcp_configs() to support both list format (mcp_servers)
  and dict format (mcpServers used by Claude Desktop / Cursor)
- Add _load_mcp_configs() to load from ~/cow/mcp.json first, falling back
  to config.json mcp_servers field for backward compatibility

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 09:58:40 +08:00
ooaaooaa123
caaf006a49 fix(mcp): wire MCP tools into agent and fix env var inheritance
Two bugs found during end-to-end validation with Amap and Chrome DevTools
MCP servers:

1. MCP tools were loaded into ToolManager._mcp_tool_instances but never
   added to the agent's tool list. AgentInitializer._load_tools() only
   iterated tool_classes (built-in tools). Added a second pass to append
   all MCP tool instances.

2. When a MCP server config contains an "env" dict, it was passed directly
   to subprocess.Popen, replacing the entire process environment. This
   caused npx to fail because PATH and other inherited vars were missing.
   Fixed by merging config env on top of os.environ.

Validated with:
- @amap/amap-maps-mcp-server (12 tools, stdio + API key env var)
- chrome-devtools-mcp (29 tools, stdio + remote debugging port)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 20:40:56 +08:00
ooaaooaa123
b2429ec30c feat(mcp): add MCP (Model Context Protocol) tool integration
Allows CowAgent to dynamically load tools from any MCP server at startup,
extending the agent from a fixed toolset to an open, extensible tool ecosystem.

## What's added

- `agent/tools/mcp/mcp_client.py`: lightweight JSON-RPC client supporting both
  stdio (subprocess) and SSE (HTTP) transports — zero extra dependencies
- `agent/tools/mcp/mcp_tool.py`: `McpTool` wraps a single MCP tool as a
  `BaseTool`, with dynamic name/description/params set at instance level
- `agent/tools/tool_manager.py`: new `_load_mcp_tools()` loads MCP servers at
  startup via `McpClientRegistry`; falls back gracefully on any error; no-op
  when `mcp_servers` is not configured
- `config.py`: registers `mcp_servers` in `available_setting` with inline docs

## Design

- No new dependencies — JSON-RPC implemented from scratch using stdlib only
- MCP clients are long-lived (initialized once, shared across tool calls)
- `McpClientRegistry` holds all subprocess handles and shuts them down cleanly
- Server init failures are non-fatal: logged as warnings, agent continues normally
- Zero overhead when `mcp_servers` is absent from config

## Config example

```json
"mcp_servers": [
  {
    "name": "filesystem",
    "type": "stdio",
    "command": "npx",
    "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
  }
]
```

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 20:16:04 +08:00
136 changed files with 12031 additions and 1360 deletions

View File

@@ -26,7 +26,7 @@
-**长期记忆:** 自动将对话记忆持久化至本地文件和数据库中,包括核心记忆、日级记忆和梦境蒸馏,支持关键词及向量检索
-**个人知识库:** 自动整理结构化知识,通过交叉引用构建知识图谱,支持通过对话管理和可视化浏览知识库
-**技能系统:** Skills 安装和运行的引擎,支持从 [Skill Hub](https://skills.cowagent.ai/)、GitHub 等一键安装技能,或通过对话创造 Skills
-**工具系统:** 内置文件读写、终端执行、浏览器操作、定时任务等工具Agent 自主调用完成复杂任务
-**工具系统:** 内置文件读写、终端执行、浏览器操作、定时任务等工具,支持 MCP 协议,通过 Agent 自主调用完成复杂任务
-**CLI系统** 提供终端命令和对话命令,支持进程管理、技能安装、配置修改等操作
-**多模态消息:** 支持对文本、图片、语音、文件等多类型消息进行解析、处理、生成、发送等操作
-**多模型支持:** 支持 DeepSeek、MiniMax、Claude、Gemini、OpenAI、GLM、Qwen、Doubao、Kimi 等国内外主流模型厂商
@@ -117,7 +117,7 @@ irm https://cdn.link-ai.tech/code/cow/run.ps1 | iex
项目支持国内外主流厂商的模型接口,可选模型及配置说明参考:[模型说明](#模型说明)。
> Agent 模式下推荐使用以下模型可根据效果及成本综合选择deepseek-v4-flash、MiniMax-M2.7、glm-5.1、kimi-k2.6、qwen3.5-plus、claude-sonnet-4-6、gemini-3.1-pro-preview、gpt-5.4、gpt-5.4-mini、ernie-5.0
> Agent 模式下推荐使用以下模型可根据效果及成本综合选择deepseek-v4-flash、MiniMax-M2.7、glm-5.1、kimi-k2.6、qwen3.5-plus、claude-sonnet-4-6、gemini-3.1-pro-preview、gpt-5.4、gpt-5.4-mini、ernie-5.1
同时支持使用 **LinkAI 平台** 接口,支持上述全部模型,并支持知识库、工作流、插件等 Agent 技能,参考 [接口文档](https://docs.link-ai.tech/platform/api)。
@@ -204,7 +204,7 @@ cow install-browser
"group_speech_recognition": false, # 是否开启群组语音识别
"voice_reply_voice": false, # 是否使用语音回复语音
"use_linkai": false, # 是否使用 LinkAI 接口,默认关闭,设置为 true 后可对接 LinkAI 平台模型
"web_password": "", # Web 控制台访问密码,留空则不启用密码保护
"web_password": "", # Web 控制台访问密码,留空则不启用密码保护(监听 0.0.0.0 时务必设置)
"agent": true, # 是否启用 Agent 模式启用后拥有多轮工具决策、长期记忆、Skills 能力等
"agent_workspace": "~/cow", # Agent 的工作空间路径,用于存储 memory、skills、系统设定等
"agent_max_context_tokens": 50000, # Agent 模式下最大上下文 tokens超出将自动智能压缩处理
@@ -605,13 +605,13 @@ API Key 创建:在 [控制台](https://aistudio.google.com/app/apikey?hl=zh-cn
```json
{
"model": "ernie-5.0",
"model": "ernie-5.1",
"qianfan_api_key": "",
"qianfan_api_base": "https://qianfan.baidubce.com/v2"
}
```
- `model`: 默认推荐填写 `ernie-5.0`(多模态,可直接识图),也可填写 `ernie-x1.1``ernie-4.5-turbo-128k``ernie-4.5-turbo-32k`;当主模型为纯文本 ERNIE 时Vision 工具会自动 fallback 到 `ernie-4.5-turbo-vl`
- `model`: 默认推荐填写 `ernie-5.1`(多模态,可直接识图),也可填写 `ernie-5.0``ernie-x1.1``ernie-4.5-turbo-128k``ernie-4.5-turbo-32k`;当主模型为纯文本 ERNIE 时Vision 工具会自动 fallback 到 `ernie-4.5-turbo-vl`
- `qianfan_api_key`: 百度千帆 API Key通常以 `bce-v3/` 开头,可在百度智能云控制台创建
- `qianfan_api_base`: 可选,默认为 `https://qianfan.baidubce.com/v2`
@@ -619,7 +619,7 @@ API Key 创建:在 [控制台](https://aistudio.google.com/app/apikey?hl=zh-cn
```json
{
"bot_type": "openai",
"model": "ernie-5.0",
"model": "ernie-5.1",
"open_ai_api_base": "https://qianfan.baidubce.com/v2",
"open_ai_api_key": ""
}
@@ -715,13 +715,16 @@ Coding Plan 是各厂商推出的编程包月套餐,所有厂商均可通过 O
```json
{
"channel_type": "web",
"web_host": "0.0.0.0",
"web_password": "YOUR PASSWORD",
"web_port": 9899
}
```
- `web_host`: 监听地址,默认 `127.0.0.1`(仅本机),如需公网访问请改为 `0.0.0.0` 并设置密码
- `web_port`: 默认为 9899可按需更改需要服务器防火墙和安全组放行该端口
- `web_password`: 访问密码,留空则不启用密码保护。部署在公网环境时建议设置
- 如本地运行,启动后请访问 `http://localhost:9899/chat` ;如服务器运行,请访问 `http://ip:9899/chat`
- `web_password`: 访问密码,留空则不启用密码保护。部署在公网环境时请务必设置
- 如本地运行,启动后请访问 `http://localhost:9899` ;如服务器运行,请访问 `http://YOUR_IP:9899`
> 注:请将上述 url 中的 ip 或者 port 替换为实际的值
</details>

View File

@@ -44,6 +44,7 @@ CREATE TABLE IF NOT EXISTS messages (
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at INTEGER NOT NULL,
extras TEXT NOT NULL DEFAULT '',
UNIQUE (session_id, seq)
);
@@ -67,6 +68,12 @@ _MIGRATION_ADD_CONTEXT_START_SEQ = """
ALTER TABLE sessions ADD COLUMN context_start_seq INTEGER NOT NULL DEFAULT 0;
"""
# Generic JSON sidecar for per-message attachments (TTS audio URL, future use).
# Always optional — readers must tolerate missing column / empty / invalid JSON.
_MIGRATION_ADD_MSG_EXTRAS = """
ALTER TABLE messages ADD COLUMN extras TEXT NOT NULL DEFAULT '';
"""
DEFAULT_MAX_AGE_DAYS: int = 30
@@ -116,9 +123,10 @@ def _extract_tool_calls(content: Any) -> List[Dict[str, Any]]:
]
def _extract_tool_results(content: Any) -> Dict[str, str]:
def _extract_tool_results(content: Any) -> Dict[str, dict]:
"""
Extract tool_result blocks from a user message, keyed by tool_use_id.
Values are {"result": str, "is_error": bool}.
"""
if not isinstance(content, list):
return {}
@@ -133,7 +141,7 @@ def _extract_tool_results(content: Any) -> Dict[str, str]:
rb.get("text", "") for rb in result_content
if isinstance(rb, dict) and rb.get("type") == "text"
)
results[tool_id] = str(result_content)
results[tool_id] = {"result": str(result_content), "is_error": bool(b.get("is_error", False))}
return results
@@ -168,20 +176,26 @@ def _group_into_display_turns(
cur_rest: List[tuple] = []
started = False
for role, raw_content, created_at in rows:
for role, raw_content, created_at, raw_extras in rows:
try:
content = json.loads(raw_content)
except Exception:
content = raw_content
try:
extras = json.loads(raw_extras) if raw_extras else {}
if not isinstance(extras, dict):
extras = {}
except Exception:
extras = {}
if role == "user" and _is_visible_user_message(content):
if started:
groups.append((cur_user, cur_rest))
cur_user = (content, created_at)
cur_user = (content, created_at, extras)
cur_rest = []
started = True
else:
cur_rest.append((role, content, created_at))
cur_rest.append((role, content, created_at, extras))
if started:
groups.append((cur_user, cur_rest))
@@ -194,7 +208,7 @@ def _group_into_display_turns(
for user_row, rest in groups:
# User turn
if user_row:
content, created_at = user_row
content, created_at, _u_extras = user_row
text = _extract_display_text(content)
if text:
turns.append({"role": "user", "content": text, "created_at": created_at})
@@ -205,8 +219,11 @@ def _group_into_display_turns(
tool_results: Dict[str, str] = {}
final_text = ""
final_ts: Optional[int] = None
merged_extras: Dict[str, Any] = {}
for role, content, created_at in rest:
for role, content, created_at, extras in rest:
if role == "assistant" and isinstance(extras, dict):
merged_extras.update(extras)
if role == "user":
tool_results.update(_extract_tool_results(content))
elif role == "assistant":
@@ -242,7 +259,11 @@ def _group_into_display_turns(
# Attach tool results to tool steps
for step in steps:
if step["type"] == "tool":
step["result"] = tool_results.get(step.get("id", ""), "")
tr = tool_results.get(step.get("id", ""), {})
if not isinstance(tr, dict):
tr = {"result": tr}
step["result"] = tr.get("result", "")
step["is_error"] = tr.get("is_error", False)
if steps or final_text:
turn = {
@@ -251,6 +272,8 @@ def _group_into_display_turns(
"steps": steps,
"created_at": final_ts or (user_row[1] if user_row else 0),
}
if merged_extras:
turn["extras"] = merged_extras
turns.append(turn)
return turns
@@ -406,13 +429,15 @@ class ConversationStore:
content = json.dumps(
msg.get("content", ""), ensure_ascii=False
)
extras_obj = msg.get("extras") or {}
extras = json.dumps(extras_obj, ensure_ascii=False) if extras_obj else ""
conn.execute(
"""
INSERT OR IGNORE INTO messages
(session_id, seq, role, content, created_at)
VALUES (?, ?, ?, ?, ?)
(session_id, seq, role, content, created_at, extras)
VALUES (?, ?, ?, ?, ?, ?)
""",
(session_id, next_seq, role, content, now),
(session_id, next_seq, role, content, now, extras),
)
next_seq += 1
@@ -646,6 +671,55 @@ class ConversationStore:
logger.info(f"[ConversationStore] Pruned {deleted} expired sessions")
return deleted
def attach_extras_to_last_assistant(
self,
session_id: str,
extras: Dict[str, Any],
) -> Optional[int]:
"""
Merge ``extras`` into the latest assistant message of a session.
Used by post-processing (e.g. TTS) that needs to annotate an already
persisted bot reply with attachments such as audio URLs.
Returns the message seq that was updated, or ``None`` if no assistant
message exists or the update could not be applied.
"""
if not extras:
return None
with self._lock:
conn = self._connect()
try:
row = conn.execute(
"""
SELECT seq, extras FROM messages
WHERE session_id = ? AND role = 'assistant'
ORDER BY seq DESC LIMIT 1
""",
(session_id,),
).fetchone()
if not row:
return None
seq, raw = row
try:
cur = json.loads(raw) if raw else {}
if not isinstance(cur, dict):
cur = {}
except Exception:
cur = {}
cur.update(extras)
conn.execute(
"UPDATE messages SET extras = ? WHERE session_id = ? AND seq = ?",
(json.dumps(cur, ensure_ascii=False), session_id, seq),
)
conn.commit()
return seq
except Exception as e:
logger.warning(f"[ConversationStore] attach_extras failed: {e}")
return None
finally:
conn.close()
def load_history_page(
self,
session_id: str,
@@ -693,15 +767,31 @@ class ConversationStore:
).fetchone()
ctx_start = ctx_row[0] if ctx_row else 0
rows = conn.execute(
"""
SELECT seq, role, content, created_at
FROM messages
WHERE session_id = ?
ORDER BY seq ASC
""",
(session_id,),
).fetchall()
# extras column is added by migration; tolerate older DBs that
# might miss it by falling back to a NULL literal.
try:
rows = conn.execute(
"""
SELECT seq, role, content, created_at, extras
FROM messages
WHERE session_id = ?
ORDER BY seq ASC
""",
(session_id,),
).fetchall()
except sqlite3.OperationalError:
rows = [
(seq, role, content, created_at, "")
for (seq, role, content, created_at) in conn.execute(
"""
SELECT seq, role, content, created_at
FROM messages
WHERE session_id = ?
ORDER BY seq ASC
""",
(session_id,),
).fetchall()
]
finally:
conn.close()
@@ -714,13 +804,16 @@ class ConversationStore:
include_thinking = False
# Strip seq for display grouping, but record max seq per visible user group
plain_rows = [(role, content, created_at) for _seq, role, content, created_at in rows]
plain_rows = [
(role, content, created_at, extras_raw)
for _seq, role, content, created_at, extras_raw in rows
]
visible = _group_into_display_turns(plain_rows, include_thinking=include_thinking)
# Build a mapping: find the seq of each visible user message to annotate context boundary.
# Walk through rows to find visible user message seqs in order.
visible_user_seqs: List[int] = []
for seq, role, raw_content, _ts in rows:
for seq, role, raw_content, _ts, _extras in rows:
if role != "user":
continue
try:
@@ -906,6 +999,18 @@ class ConversationStore:
except Exception as e:
logger.warning(f"[ConversationStore] Migration (context_start_seq) failed: {e}")
msg_cols = {
row[1]
for row in conn.execute("PRAGMA table_info(messages)").fetchall()
}
if "extras" not in msg_cols:
try:
conn.execute(_MIGRATION_ADD_MSG_EXTRAS)
conn.commit()
logger.info("[ConversationStore] Migrated: added messages.extras column")
except Exception as e:
logger.warning(f"[ConversationStore] Migration (extras) failed: {e}")
def _connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(str(self._db_path), timeout=10)
conn.execute("PRAGMA journal_mode=WAL")

View File

@@ -1,167 +0,0 @@
"""
Embedding providers for memory
Supports OpenAI and local embedding models
"""
import hashlib
from abc import ABC, abstractmethod
from typing import List, Optional
class EmbeddingProvider(ABC):
"""Base class for embedding providers"""
@abstractmethod
def embed(self, text: str) -> List[float]:
"""Generate embedding for text"""
pass
@abstractmethod
def embed_batch(self, texts: List[str]) -> List[List[float]]:
"""Generate embeddings for multiple texts"""
pass
@property
@abstractmethod
def dimensions(self) -> int:
"""Get embedding dimensions"""
pass
class OpenAIEmbeddingProvider(EmbeddingProvider):
"""OpenAI embedding provider using REST API"""
def __init__(self, model: str = "text-embedding-3-small", api_key: Optional[str] = None,
api_base: Optional[str] = None, extra_headers: Optional[dict] = None):
"""
Initialize OpenAI embedding provider
Args:
model: Model name (text-embedding-3-small or text-embedding-3-large)
api_key: OpenAI API key
api_base: Optional API base URL
extra_headers: Optional extra headers to include in API requests
"""
self.model = model
self.api_key = api_key
self.api_base = api_base or "https://api.openai.com/v1"
self.extra_headers = extra_headers or {}
# Validate API key
if not self.api_key or self.api_key in ["", "YOUR API KEY", "YOUR_API_KEY"]:
raise ValueError("OpenAI API key is not configured. Please set 'open_ai_api_key' in config.json")
# Set dimensions based on model
self._dimensions = 1536 if "small" in model else 3072
def _call_api(self, input_data):
"""Call OpenAI embedding API using requests"""
import requests
url = f"{self.api_base}/embeddings"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {self.api_key}",
**self.extra_headers,
}
data = {
"input": input_data,
"model": self.model
}
try:
response = requests.post(url, headers=headers, json=data, timeout=5)
response.raise_for_status()
return response.json()
except requests.exceptions.ConnectionError as e:
raise ConnectionError(f"Failed to connect to OpenAI API at {url}. Please check your network connection and api_base configuration. Error: {str(e)}")
except requests.exceptions.Timeout as e:
raise TimeoutError(f"OpenAI API request timed out after 10s. Please check your network connection. Error: {str(e)}")
except requests.exceptions.HTTPError as e:
if e.response.status_code == 401:
raise ValueError(f"Invalid OpenAI API key. Please check your 'open_ai_api_key' in config.json")
elif e.response.status_code == 429:
raise ValueError(f"OpenAI API rate limit exceeded. Please try again later.")
else:
raise ValueError(f"OpenAI API request failed: {e.response.status_code} - {e.response.text}")
def embed(self, text: str) -> List[float]:
"""Generate embedding for text"""
result = self._call_api(text)
return result["data"][0]["embedding"]
def embed_batch(self, texts: List[str]) -> List[List[float]]:
"""Generate embeddings for multiple texts"""
if not texts:
return []
result = self._call_api(texts)
return [item["embedding"] for item in result["data"]]
@property
def dimensions(self) -> int:
return self._dimensions
# LocalEmbeddingProvider removed - only use OpenAI embedding or keyword search
class EmbeddingCache:
"""Cache for embeddings to avoid recomputation"""
def __init__(self):
self.cache = {}
def get(self, text: str, provider: str, model: str) -> Optional[List[float]]:
"""Get cached embedding"""
key = self._compute_key(text, provider, model)
return self.cache.get(key)
def put(self, text: str, provider: str, model: str, embedding: List[float]):
"""Cache embedding"""
key = self._compute_key(text, provider, model)
self.cache[key] = embedding
@staticmethod
def _compute_key(text: str, provider: str, model: str) -> str:
"""Compute cache key"""
content = f"{provider}:{model}:{text}"
return hashlib.md5(content.encode('utf-8')).hexdigest()
def clear(self):
"""Clear cache"""
self.cache.clear()
def create_embedding_provider(
provider: str = "openai",
model: Optional[str] = None,
api_key: Optional[str] = None,
api_base: Optional[str] = None,
extra_headers: Optional[dict] = None
) -> EmbeddingProvider:
"""
Factory function to create embedding provider
Supports "openai" and "linkai" providers (both use OpenAI-compatible REST API).
If initialization fails, caller should fall back to keyword-only search.
Args:
provider: Provider name ("openai" or "linkai")
model: Model name (default: text-embedding-3-small)
api_key: API key (required)
api_base: API base URL
extra_headers: Optional extra headers to include in API requests
Returns:
EmbeddingProvider instance
Raises:
ValueError: If provider is unsupported or api_key is missing
"""
if provider not in ("openai", "linkai"):
raise ValueError(f"Unsupported embedding provider: {provider}. Use 'openai' or 'linkai'.")
model = model or "text-embedding-3-small"
return OpenAIEmbeddingProvider(model=model, api_key=api_key, api_base=api_base, extra_headers=extra_headers)

View File

@@ -0,0 +1,41 @@
"""
Embedding subsystem for memory.
Public API:
create_embedding_provider, EmbeddingProvider, OpenAIEmbeddingProvider,
EMBEDDING_VENDORS, EmbeddingCache
RebuildResult, clear_index, rebuild_in_process
detect_index_dim, cleanup_legacy_state_file
"""
from agent.memory.embedding.provider import (
EMBEDDING_VENDORS,
DoubaoEmbeddingProvider,
EmbeddingCache,
EmbeddingProvider,
OpenAIEmbeddingProvider,
create_embedding_provider,
)
from agent.memory.embedding.rebuild import (
RebuildResult,
clear_index,
rebuild_in_process,
)
from agent.memory.embedding.state import (
cleanup_legacy_state_file,
detect_index_dim,
)
__all__ = [
"EMBEDDING_VENDORS",
"DoubaoEmbeddingProvider",
"EmbeddingCache",
"EmbeddingProvider",
"OpenAIEmbeddingProvider",
"create_embedding_provider",
"RebuildResult",
"clear_index",
"rebuild_in_process",
"cleanup_legacy_state_file",
"detect_index_dim",
]

View File

@@ -0,0 +1,486 @@
"""
Embedding providers for memory
Supports multiple OpenAI-compatible embedding vendors:
- openai (text-embedding-3-small / large)
- linkai (OpenAI-compatible passthrough)
- dashscope (Aliyun Tongyi text-embedding-v4)
- doubao (ByteDance Doubao Seed1.5 / large-text on Volcengine Ark)
- zhipu (ZhipuAI embedding-3)
Vendor keys here intentionally match the project's bot_type constants in
common.const (OPENAI, LINKAI, QWEN_DASHSCOPE, DOUBAO, ZHIPU_AI).
All providers share a single OpenAI-compatible REST client. Vendor-specific
behaviors (truncation, query instruction prefix) are configured via metadata.
"""
import hashlib
import math
from abc import ABC, abstractmethod
from typing import List, Optional
# HTTP read timeout for a single embeddings request (seconds). A batch of
# 64+ chunks can take 30-50s end-to-end from China-side networks, so 30s is
# routinely too tight; 90s gives meaningful headroom without letting bad
# endpoints hang forever.
EMBEDDING_HTTP_TIMEOUT = 90
class EmbeddingProvider(ABC):
"""Base class for embedding providers"""
@abstractmethod
def embed(self, text: str) -> List[float]:
"""Generate embedding for a single text (treated as a query by default)"""
pass
@abstractmethod
def embed_batch(self, texts: List[str]) -> List[List[float]]:
"""Generate embeddings for multiple texts (treated as documents)"""
pass
def embed_query(self, text: str) -> List[float]:
"""Generate embedding for a query string (may apply vendor instruction prefix)"""
return self.embed(text)
@property
@abstractmethod
def dimensions(self) -> int:
"""Effective embedding dimensions"""
pass
# ---------------------------------------------------------------------------
# Vendor metadata table
# ---------------------------------------------------------------------------
#
# Each entry describes how to reach a vendor's embedding endpoint. Most
# vendors expose an OpenAI-compatible /embeddings API; the few that don't
# (currently: doubao) set `provider_class` to pick a dedicated adapter.
# Fields:
# provider_class : optional adapter key ("doubao"); defaults to OpenAI-compat
# default_base_url : default API base when not overridden by user
# default_model : default embedding model name
# default_dimensions : recommended unified dim when explicit path is enabled
# supports_dim_param : whether the API accepts a `dimensions` request param
# needs_client_truncate : whether to slice + L2-normalize on the client side
# needs_client_normalize : whether to L2-normalize on the client (always safe)
# query_instruction : optional prefix for asymmetric retrieval (Doubao Seed)
# max_batch_size : max texts per /embeddings request; embed_batch
# auto-paginates above this. Conservative defaults.
#
EMBEDDING_VENDORS = {
"openai": {
"default_base_url": "https://api.openai.com/v1",
"default_model": "text-embedding-3-small",
# Match the legacy default so users adding `embedding_provider: openai`
# to an existing index don't need to rebuild. Override via
# embedding_dimensions if you want 1024 / 1536 / 3072.
"default_dimensions": 1536,
"supports_dim_param": True,
"needs_client_truncate": False,
"needs_client_normalize": False,
"query_instruction": "",
# OpenAI permits up to 2048 items per request, but a single call
# carrying hundreds of long chunks routinely exceeds the 30s read
# timeout from China-side networks. 64 keeps each call well under
# both the token-per-request budget and a reasonable wall clock.
"max_batch_size": 64,
},
"linkai": {
"default_base_url": "https://api.link-ai.tech/v1",
"default_model": "text-embedding-3-small",
"default_dimensions": 1536,
"supports_dim_param": True,
"needs_client_truncate": False,
"needs_client_normalize": False,
"query_instruction": "",
"max_batch_size": 64,
},
"dashscope": {
"default_base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"default_model": "text-embedding-v4",
"default_dimensions": 1024,
"supports_dim_param": True,
"needs_client_truncate": False,
"needs_client_normalize": False,
"query_instruction": "",
"max_batch_size": 10, # DashScope hard cap (text-embedding-v4)
},
"doubao": {
# Doubao no longer offers an OpenAI-compatible /v1/embeddings endpoint.
# Current models are unified under /api/v3/embeddings/multimodal
# which uses a structured `input` payload — see DoubaoEmbeddingProvider.
"provider_class": "doubao",
"default_base_url": "https://ark.cn-beijing.volces.com/api/v3",
"default_model": "doubao-embedding-vision-251215",
# Native options: 1024 or 2048. We default to 1024 to align with the
# other Chinese vendors (dashscope/zhipu) and keep storage footprint
# consistent across providers; users can still override via
# `embedding_dimensions: 2048` in config.
"default_dimensions": 1024,
"supports_dim_param": True,
"needs_client_truncate": False,
"needs_client_normalize": False,
"query_instruction": "",
# Multimodal endpoint produces ONE embedding per call (input list is
# a single document's parts, not a batch). embed_batch loops.
"max_batch_size": 1,
},
"zhipu": {
"default_base_url": "https://open.bigmodel.cn/api/paas/v4",
"default_model": "embedding-3",
"default_dimensions": 1024,
"supports_dim_param": True,
"needs_client_truncate": False,
"needs_client_normalize": False,
"query_instruction": "",
"max_batch_size": 64,
},
}
def _l2_normalize(vec: List[float]) -> List[float]:
"""Normalize a vector to unit length (L2 norm). Returns input on zero vector."""
norm = math.sqrt(sum(v * v for v in vec))
if norm == 0:
return vec
return [v / norm for v in vec]
class OpenAIEmbeddingProvider(EmbeddingProvider):
"""
OpenAI-compatible embedding provider.
Used for openai/linkai/dashscope/ark/zhipu by configuring the metadata
fields. The legacy two-arg constructor (model, api_key, api_base) keeps
working, so the original OpenAI/LinkAI fallback code path is unchanged.
"""
def __init__(
self,
model: str = "text-embedding-3-small",
api_key: Optional[str] = None,
api_base: Optional[str] = None,
extra_headers: Optional[dict] = None,
dimensions: Optional[int] = None,
supports_dim_param: bool = True,
needs_client_truncate: bool = False,
needs_client_normalize: bool = False,
query_instruction: str = "",
max_batch_size: int = 256,
):
"""
Args:
model: Model name (e.g. text-embedding-3-small, text-embedding-v4, embedding-3)
api_key: API key (required)
api_base: API base URL (defaults to OpenAI)
extra_headers: Optional extra HTTP headers
dimensions: Target output dimension. Required when supports_dim_param
is False and needs_client_truncate is True (used to slice).
supports_dim_param: Whether the vendor accepts a `dimensions` body param
needs_client_truncate: Slice the returned vector to `dimensions`
needs_client_normalize: L2-normalize on the client after slicing
query_instruction: Optional prefix prepended to query texts only
max_batch_size: Max items per /embeddings request; embed_batch
auto-paginates above this.
"""
self.model = model
self.api_key = api_key
self.api_base = api_base or "https://api.openai.com/v1"
self.extra_headers = extra_headers or {}
self.supports_dim_param = supports_dim_param
self.needs_client_truncate = needs_client_truncate
self.needs_client_normalize = needs_client_normalize
self.query_instruction = query_instruction or ""
self.max_batch_size = max(1, int(max_batch_size or 1))
if not self.api_key or self.api_key in ["", "YOUR API KEY", "YOUR_API_KEY"]:
raise ValueError("Embedding API key is not configured")
if dimensions is not None and dimensions > 0:
self._dimensions = dimensions
else:
# Legacy heuristic for OpenAI text-embedding-3-* family
self._dimensions = 1536 if "small" in model else 3072
def _call_api(self, input_data):
"""Call OpenAI-compatible /embeddings endpoint"""
import requests
url = f"{self.api_base}/embeddings"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {self.api_key}",
**self.extra_headers,
}
data = {
"input": input_data,
"model": self.model,
}
if self.supports_dim_param and self._dimensions:
data["dimensions"] = self._dimensions
try:
response = requests.post(url, headers=headers, json=data, timeout=EMBEDDING_HTTP_TIMEOUT)
response.raise_for_status()
return response.json()
except requests.exceptions.ConnectionError as e:
raise ConnectionError(
f"Failed to connect to embedding API at {url}. "
f"Please check network and api_base. Error: {str(e)}"
)
except requests.exceptions.Timeout as e:
raise TimeoutError(f"Embedding API request timed out. Error: {str(e)}")
except requests.exceptions.HTTPError as e:
if e.response.status_code == 401:
raise ValueError("Invalid embedding API key")
elif e.response.status_code == 429:
raise ValueError("Embedding API rate limit exceeded")
else:
raise ValueError(
f"Embedding API request failed: "
f"{e.response.status_code} - {e.response.text}"
)
def _post_process(self, raw: List[float]) -> List[float]:
"""Apply optional client-side truncation + normalization"""
vec = raw
if self.needs_client_truncate and self._dimensions and len(vec) > self._dimensions:
vec = vec[: self._dimensions]
if self.needs_client_normalize:
vec = _l2_normalize(vec)
return vec
def embed(self, text: str) -> List[float]:
"""Generate embedding (treated as document by default)"""
result = self._call_api(text)
return self._post_process(result["data"][0]["embedding"])
def embed_query(self, text: str) -> List[float]:
"""Generate embedding for a query (applies vendor instruction prefix if any)"""
if self.query_instruction:
text = f"{self.query_instruction}{text}"
return self.embed(text)
def embed_batch(self, texts: List[str]) -> List[List[float]]:
"""Generate embeddings for multiple documents.
Automatically paginates by self.max_batch_size so callers can pass any
number of texts. Order of returned vectors matches the input order.
"""
if not texts:
return []
out: List[List[float]] = []
step = self.max_batch_size
for i in range(0, len(texts), step):
chunk = texts[i:i + step]
result = self._call_api(chunk)
out.extend(self._post_process(item["embedding"]) for item in result["data"])
return out
@property
def dimensions(self) -> int:
return self._dimensions
class DoubaoEmbeddingProvider(EmbeddingProvider):
"""
Doubao (Volcengine Ark) multimodal embedding provider.
Doubao deprecated their OpenAI-compatible /v1/embeddings endpoint and
unified everything under /api/v3/embeddings/multimodal, which uses a
structured `input: [{type, text|image_url|video_url}, ...]` payload.
Notes:
* The endpoint produces ONE embedding per call (input list is multiple
modality parts of a single document, not a batch). embed_batch
therefore loops per-text — no native batch support.
* Native dimensions: 1024 or 2048 (default 1024 to align with other
Chinese vendors). No client-side truncation needed.
* Auth: Bearer ARK API key.
"""
def __init__(
self,
model: str,
api_key: Optional[str] = None,
api_base: Optional[str] = None,
extra_headers: Optional[dict] = None,
dimensions: Optional[int] = None,
):
self.model = model
self.api_key = api_key
self.api_base = api_base or "https://ark.cn-beijing.volces.com/api/v3"
self.extra_headers = extra_headers or {}
if not self.api_key or self.api_key in ["", "YOUR API KEY", "YOUR_API_KEY"]:
raise ValueError("Doubao embedding API key (ark_api_key) is not configured")
if dimensions in (1024, 2048):
self._dimensions = dimensions
elif dimensions is None:
self._dimensions = 1024
else:
raise ValueError(
f"Doubao embedding dimensions must be 1024 or 2048, got {dimensions}"
)
def _call_api(self, text: str) -> List[float]:
"""One call → one embedding. multimodal endpoint takes a single
document represented as a list of typed parts; we send a single
text part."""
import requests
url = f"{self.api_base}/embeddings/multimodal"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {self.api_key}",
**self.extra_headers,
}
payload = {
"model": self.model,
"input": [{"type": "text", "text": text}],
"dimensions": self._dimensions,
"encoding_format": "float",
}
try:
response = requests.post(url, headers=headers, json=payload, timeout=EMBEDDING_HTTP_TIMEOUT)
response.raise_for_status()
body = response.json()
except requests.exceptions.ConnectionError as e:
raise ConnectionError(
f"Failed to connect to Doubao embedding API at {url}. "
f"Please check network and api_base. Error: {str(e)}"
)
except requests.exceptions.Timeout as e:
raise TimeoutError(f"Doubao embedding API request timed out. Error: {str(e)}")
except requests.exceptions.HTTPError as e:
if e.response.status_code == 401:
raise ValueError("Invalid Doubao (ark) embedding API key")
elif e.response.status_code == 429:
raise ValueError("Doubao embedding API rate limit exceeded")
else:
raise ValueError(
f"Doubao embedding API request failed: "
f"{e.response.status_code} - {e.response.text}"
)
# Response shape per docs: {"data": {"embedding": [...]}}
data = body.get("data")
if isinstance(data, dict) and "embedding" in data:
return data["embedding"]
# Some providers wrap as a list of one — be defensive
if isinstance(data, list) and data and "embedding" in data[0]:
return data[0]["embedding"]
raise ValueError(f"Unexpected Doubao embedding response shape: {body}")
def embed(self, text: str) -> List[float]:
return self._call_api(text)
def embed_batch(self, texts: List[str]) -> List[List[float]]:
# Endpoint produces one embedding per call; loop. Order preserved.
return [self._call_api(t) for t in texts]
@property
def dimensions(self) -> int:
return self._dimensions
class EmbeddingCache:
"""In-memory cache for embeddings to avoid recomputation"""
def __init__(self):
self.cache = {}
def get(self, text: str, provider: str, model: str) -> Optional[List[float]]:
key = self._compute_key(text, provider, model)
return self.cache.get(key)
def put(self, text: str, provider: str, model: str, embedding: List[float]):
key = self._compute_key(text, provider, model)
self.cache[key] = embedding
@staticmethod
def _compute_key(text: str, provider: str, model: str) -> str:
content = f"{provider}:{model}:{text}"
return hashlib.md5(content.encode("utf-8")).hexdigest()
def clear(self):
self.cache.clear()
def create_embedding_provider(
provider: str = "openai",
model: Optional[str] = None,
api_key: Optional[str] = None,
api_base: Optional[str] = None,
extra_headers: Optional[dict] = None,
dimensions: Optional[int] = None,
) -> EmbeddingProvider:
"""
Factory function to create an embedding provider.
Backward compatible: when called with provider in {"openai", "linkai"}
and no `dimensions` arg, behaves exactly as before (1536-dim OpenAI).
New providers ("dashscope", "doubao", "zhipu") require explicit configuration
and use the unified 1024-dim defaults from EMBEDDING_VENDORS.
Args:
provider: Vendor key (one of EMBEDDING_VENDORS)
model: Model name (uses vendor default if None)
api_key: API key (required)
api_base: API base URL (uses vendor default if None)
extra_headers: Optional extra HTTP headers
dimensions: Target output dimension (uses vendor default if None)
Returns:
EmbeddingProvider instance
"""
meta = EMBEDDING_VENDORS.get(provider)
if meta is None:
raise ValueError(
f"Unsupported embedding provider: {provider}. "
f"Supported: {sorted(EMBEDDING_VENDORS.keys())}"
)
# Doubao uses a non-OpenAI-compatible multimodal endpoint.
if meta.get("provider_class") == "doubao":
final_dim = dimensions if (dimensions and dimensions > 0) else meta["default_dimensions"]
return DoubaoEmbeddingProvider(
model=model or meta["default_model"],
api_key=api_key,
api_base=api_base or meta["default_base_url"],
extra_headers=extra_headers,
dimensions=final_dim,
)
# Legacy two-arg call for openai/linkai keeps 1536-dim default behavior
# so existing data isn't invalidated.
is_legacy_call = (
provider in ("openai", "linkai")
and dimensions is None
)
if is_legacy_call:
return OpenAIEmbeddingProvider(
model=model or "text-embedding-3-small",
api_key=api_key,
api_base=api_base,
extra_headers=extra_headers,
)
final_dim = dimensions if (dimensions and dimensions > 0) else meta["default_dimensions"]
return OpenAIEmbeddingProvider(
model=model or meta["default_model"],
api_key=api_key,
api_base=api_base or meta["default_base_url"],
extra_headers=extra_headers,
dimensions=final_dim,
supports_dim_param=meta["supports_dim_param"],
needs_client_truncate=meta["needs_client_truncate"],
needs_client_normalize=meta["needs_client_normalize"],
query_instruction=meta["query_instruction"],
max_batch_size=meta.get("max_batch_size", 256),
)

View File

@@ -0,0 +1,191 @@
"""
Rebuild memory vector index.
Recommended entry point (in-chat, while agent is running):
/memory rebuild-index
Backward-compatible CLI entry (must run from project root):
python -m agent.memory.rebuild_index
What it does:
1. Probes the embedding endpoint with a tiny call to fail fast on
bad provider/model/key — before touching the index.
2. Clears the SQLite chunks/files tables (workspace markdown stays intact).
3. Runs a fresh sync, regenerating embeddings with the currently configured
provider/model/dimensions.
This is the only safe way to switch embedding_provider after the existing
index has been populated by a different-dim model.
"""
from __future__ import annotations
import asyncio
import sys
from dataclasses import dataclass
from typing import Optional
from common.log import logger
from common.utils import expand_path
@dataclass
class RebuildResult:
"""Outcome of a rebuild_in_process() call"""
ok: bool
removed: int = 0
chunks: int = 0
files: int = 0
error: Optional[str] = None
def clear_index(db_path, storage=None) -> int:
"""Wipe chunks/files, reset FTS5, and clean up any legacy state file.
Args:
db_path: Path of the index DB (also used to locate the legacy state
file for migration cleanup, and — when *storage* is None — to
open a fresh connection).
storage: Optional pre-opened MemoryStorage. When provided we reuse it
so the live connection's triggers stay in sync — opening a second
connection would leave the original one's triggers pointing at a
DROP'd chunks_fts table.
We reset (DROP+recreate) chunks_fts because its shadow tables can become
inconsistent across rebuild cycles, causing bm25() / ORDER BY rank to
raise "database disk image is malformed" even when raw MATCH still works.
Returns number of chunks removed.
"""
from agent.memory.embedding.state import cleanup_legacy_state_file
from agent.memory.storage import MemoryStorage
owns_storage = storage is None
if owns_storage:
storage = MemoryStorage(db_path)
try:
before = storage.conn.execute("SELECT COUNT(*) FROM chunks").fetchone()[0]
storage.conn.execute("DELETE FROM chunks")
storage.conn.execute("DELETE FROM files")
storage.conn.commit()
storage.reset_fts5()
finally:
if owns_storage:
storage.close()
cleanup_legacy_state_file(db_path)
return int(before)
def rebuild_in_process(memory_manager) -> RebuildResult:
"""
Rebuild the index using an existing, fully-initialized MemoryManager.
Used by the in-chat /memory rebuild-index command. The caller already has
config loaded, embedding_provider built, and (optionally) the agent
running, so we only need to:
1. Clear chunks/files + state on the manager's storage.
2. Re-sync (force=True).
NOTE: caller must ensure memory_manager.embedding_provider is set, otherwise
sync() will silently skip embedding generation.
"""
if memory_manager is None:
return RebuildResult(ok=False, error="memory_manager is None")
if memory_manager.embedding_provider is None:
return RebuildResult(ok=False, error="embedding_provider is not initialized")
# Probe the embedding endpoint BEFORE clearing the index. A bad
# provider/model/key would otherwise leave the user with an empty index
# that not even keyword search can serve.
try:
memory_manager.embedding_provider.embed_query("ping")
except Exception as e:
logger.error(f"[RebuildIndex] embedding probe failed, aborting rebuild: {e}")
return RebuildResult(ok=False, error=f"embedding endpoint not reachable: {e}")
db_path = memory_manager.config.get_db_path()
try:
removed = clear_index(db_path, storage=memory_manager.storage)
except Exception as e:
logger.exception("[RebuildIndex] clear_index failed")
return RebuildResult(ok=False, error=f"clear failed: {e}")
try:
asyncio.run(memory_manager.sync(force=True))
except RuntimeError:
# Already inside a running event loop (rare in chat handler thread).
loop = asyncio.new_event_loop()
try:
loop.run_until_complete(memory_manager.sync(force=True))
finally:
loop.close()
except Exception as e:
logger.exception("[RebuildIndex] sync failed")
return RebuildResult(ok=False, removed=removed, error=f"re-embed failed: {e}")
stats = memory_manager.storage.get_stats()
chunks = int(stats.get("chunks", 0))
embedded = int(stats.get("embedded", 0))
# sync() degrades to "no embeddings" on batch failure so keyword search
# still works at startup — but in a /rebuild-index request the user
# explicitly asked for vectors. Surface that as a failure.
if chunks > 0 and embedded == 0:
return RebuildResult(
ok=False,
removed=removed,
chunks=chunks,
files=int(stats.get("files", 0)),
error=(
"embedding API failed during sync; index now has chunks but no "
"vectors. Check embedding provider/model/key and retry."
),
)
return RebuildResult(
ok=True,
removed=removed,
chunks=chunks,
files=int(stats.get("files", 0)),
)
def main() -> int:
"""Standalone CLI entry. Must be run from project root (relative config path)."""
from config import conf, load_config
from agent.memory import MemoryConfig, MemoryManager
load_config()
workspace_root = expand_path(conf().get("agent_workspace", "~/cow"))
memory_config = MemoryConfig(workspace_root=workspace_root)
logger.info(f"[RebuildIndex] Workspace: {workspace_root}")
logger.info(f"[RebuildIndex] Index db: {memory_config.get_db_path()}")
from bridge.agent_initializer import AgentInitializer
initializer = AgentInitializer(bridge=None, agent_bridge=None)
embedding_provider = initializer._init_embedding_provider(memory_config, session_id=None)
if embedding_provider is None:
logger.error(
"[RebuildIndex] No embedding provider could be initialized. "
"Check your config.json. Aborting rebuild."
)
return 1
manager = MemoryManager(memory_config, embedding_provider=embedding_provider)
result = rebuild_in_process(manager)
if not result.ok:
logger.error(f"[RebuildIndex] {result.error}")
return 1
logger.info(
f"[RebuildIndex] Done. removed={result.removed}, "
f"chunks={result.chunks}, files={result.files}"
)
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,47 @@
"""
Embedding-related index utilities.
We don't keep a sidecar state file — the SQLite index is the source of truth
and config.json is the source of intent. The two functions below are the
only things needing on-disk awareness:
detect_index_dim : read the dim of stored vectors (display-only)
cleanup_legacy_state_file: remove old embedding_state.json from earlier
versions; safe no-op when absent.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Optional, Union
PathLike = Union[str, os.PathLike]
def detect_index_dim(storage) -> Optional[int]:
"""Return the dim of the first stored embedding, or None if the index
has no embeddings. Used by /memory status."""
try:
row = storage.conn.execute(
"SELECT embedding FROM chunks WHERE embedding IS NOT NULL LIMIT 1"
).fetchone()
except Exception:
return None
if not row or not row["embedding"]:
return None
try:
emb = json.loads(row["embedding"])
return len(emb) if isinstance(emb, list) else None
except (json.JSONDecodeError, TypeError):
return None
def cleanup_legacy_state_file(db_path: PathLike) -> None:
"""Remove old embedding_state.json files from earlier versions.
Safe to call repeatedly; no-op if the file is absent."""
legacy = Path(db_path).parent / "embedding_state.json"
try:
legacy.unlink(missing_ok=True)
except Exception:
pass

View File

@@ -13,7 +13,7 @@ from datetime import datetime, timedelta
from agent.memory.config import MemoryConfig, get_default_memory_config
from agent.memory.storage import MemoryStorage, MemoryChunk, SearchResult
from agent.memory.chunker import TextChunker
from agent.memory.embedding import create_embedding_provider, EmbeddingProvider
from agent.memory.embedding import EmbeddingProvider
from agent.memory.summarizer import MemoryFlushManager, create_memory_files_if_needed
@@ -50,49 +50,17 @@ class MemoryManager:
overlap_tokens=self.config.chunk_overlap_tokens
)
# Initialize embedding provider (optional, prefer OpenAI, fallback to LinkAI)
self.embedding_provider = None
if embedding_provider:
self.embedding_provider = embedding_provider
else:
# Try OpenAI first
try:
api_key = os.environ.get('OPENAI_API_KEY')
api_base = os.environ.get('OPENAI_API_BASE')
if api_key:
self.embedding_provider = create_embedding_provider(
provider="openai",
model=self.config.embedding_model,
api_key=api_key,
api_base=api_base
)
except Exception as e:
from common.log import logger
logger.warning(f"[MemoryManager] OpenAI embedding failed: {e}")
# Fallback to LinkAI
if self.embedding_provider is None:
try:
linkai_key = os.environ.get('LINKAI_API_KEY')
linkai_base = os.environ.get('LINKAI_API_BASE', 'https://api.link-ai.tech')
if linkai_key:
from common.utils import get_cloud_headers
cloud_headers = get_cloud_headers(linkai_key)
cloud_headers.pop("Authorization", None)
self.embedding_provider = create_embedding_provider(
provider="linkai",
model=self.config.embedding_model,
api_key=linkai_key,
api_base=f"{linkai_base}/v1",
extra_headers=cloud_headers,
)
except Exception as e:
from common.log import logger
logger.warning(f"[MemoryManager] LinkAI embedding failed: {e}")
if self.embedding_provider is None:
from common.log import logger
logger.info(f"[MemoryManager] Memory will work with keyword search only (no vector search)")
# Embedding provider is owned by the caller (agent_initializer is the
# canonical entry point and handles legacy/explicit + state validation).
# When None is passed, memory degrades to keyword-only search instead
# of silently re-initializing a vendor here, which would bypass the
# caller's state checks and risk corrupting the index.
self.embedding_provider = embedding_provider
if self.embedding_provider is None:
from common.log import logger
logger.info(
"[MemoryManager] No embedding provider; memory will use keyword search only"
)
# Initialize memory flush manager
workspace_dir = self.config.get_workspace()
@@ -153,12 +121,14 @@ class MemoryManager:
if self.config.sync_on_search and self._dirty:
await self.sync()
# Perform vector search (if embedding provider available)
from common.log import logger
# Perform vector search (if embedding provider available).
# Failures degrade silently to keyword-only — no exception is raised.
vector_results = []
if self.embedding_provider:
try:
from common.log import logger
query_embedding = self.embedding_provider.embed(query)
query_embedding = self.embedding_provider.embed_query(query)
vector_results = self.storage.search_vector(
query_embedding=query_embedding,
user_id=user_id,
@@ -167,19 +137,19 @@ class MemoryManager:
)
logger.info(f"[MemoryManager] Vector search found {len(vector_results)} results for query: {query}")
except Exception as e:
from common.log import logger
logger.warning(f"[MemoryManager] Vector search failed: {e}")
# Perform keyword search
logger.error(
f"[MemoryManager] Vector search failed, falling back to keyword-only: {e}"
)
# Perform keyword search (also runs as fallback when vector failed)
keyword_results = self.storage.search_keyword(
query=query,
user_id=user_id,
scopes=scopes,
limit=max_results * 2
)
from common.log import logger
logger.info(f"[MemoryManager] Keyword search found {len(keyword_results)} results for query: {query}")
# Merge results
merged = self._merge_results(
vector_results,
@@ -187,7 +157,7 @@ class MemoryManager:
self.config.vector_weight,
self.config.keyword_weight
)
# Filter by min score and limit
filtered = [r for r in merged if r.score >= min_score]
return filtered[:max_results]
@@ -269,132 +239,163 @@ class MemoryManager:
async def sync(self, force: bool = False):
"""
Synchronize memory from files
Synchronize memory from files.
Two-pass design to amortize embedding HTTP cost:
1. Walk all files, chunk those whose hash changed, collect pending
chunks across files. No embedding calls yet.
2. Run a single embed_batch over the union of pending chunks (the
provider auto-paginates by vendor cap), then persist per-file.
For workspaces with many small files (101 files / ~1 chunk each), this
cuts ~100 HTTP calls down to ~ceil(total_chunks / vendor_cap).
Args:
force: Force full reindex
"""
memory_dir = self.config.get_memory_dir()
workspace_dir = self.config.get_workspace()
# Scan MEMORY.md (workspace root)
files_to_scan: List[tuple] = [] # (file_path, source, scope, user_id)
memory_file = Path(workspace_dir) / "MEMORY.md"
if memory_file.exists():
await self._sync_file(memory_file, "memory", "shared", None)
# Scan memory directory (including daily summaries)
files_to_scan.append((memory_file, "memory", "shared", None))
if memory_dir.exists():
for file_path in memory_dir.rglob("*.md"):
# Skip hidden directories (e.g. .dreams/)
if any(part.startswith('.') for part in file_path.relative_to(workspace_dir).parts):
rel_parts = file_path.relative_to(workspace_dir).parts
if any(part.startswith('.') for part in rel_parts):
continue
# Determine scope and user_id from path
rel_path = file_path.relative_to(workspace_dir)
parts = rel_path.parts
# Check if it's in daily summary directory
if "daily" in parts:
# Daily summary files
if "users" in parts or len(parts) > 3:
# User-scoped daily summary: memory/daily/{user_id}/2024-01-29.md
user_idx = parts.index("daily") + 1
user_id = parts[user_idx] if user_idx < len(parts) else None
# Dream diaries are narrative reflections produced by Deep
# Dream; their factual content has already been distilled
# into MEMORY.md. Indexing them adds noisy near-duplicates
# that crowd out the authoritative entry in retrieval.
if "dreams" in rel_parts:
continue
if "daily" in rel_parts:
if "users" in rel_parts or len(rel_parts) > 3:
user_idx = rel_parts.index("daily") + 1
user_id = rel_parts[user_idx] if user_idx < len(rel_parts) else None
scope = "user"
else:
# Shared daily summary: memory/daily/2024-01-29.md
user_id = None
scope = "shared"
elif "users" in parts:
# User-scoped memory
user_idx = parts.index("users") + 1
user_id = parts[user_idx] if user_idx < len(parts) else None
elif "users" in rel_parts:
user_idx = rel_parts.index("users") + 1
user_id = rel_parts[user_idx] if user_idx < len(rel_parts) else None
scope = "user"
else:
# Shared memory
user_id = None
scope = "shared"
await self._sync_file(file_path, "memory", scope, user_id)
files_to_scan.append((file_path, "memory", scope, user_id))
# Scan knowledge directory (structured knowledge wiki)
from config import conf
if conf().get("knowledge", True):
knowledge_dir = Path(workspace_dir) / "knowledge"
if knowledge_dir.exists():
for file_path in knowledge_dir.rglob("*.md"):
await self._sync_file(file_path, "knowledge", "shared", None)
self._dirty = False
async def _sync_file(
self,
file_path: Path,
source: str,
scope: str,
user_id: Optional[str]
):
"""Sync a single file"""
# Compute file hash
content = file_path.read_text(encoding='utf-8')
file_hash = MemoryStorage.compute_hash(content)
# Get relative path
workspace_dir = self.config.get_workspace()
rel_path = str(file_path.relative_to(workspace_dir))
# Check if file changed
stored_hash = self.storage.get_file_hash(rel_path)
if stored_hash == file_hash:
return # No changes
# Delete old chunks
self.storage.delete_by_path(rel_path)
# Chunk and embed
chunks = self.chunker.chunk_text(content)
if not chunks:
files_to_scan.append((file_path, "knowledge", "shared", None))
# Pass 1: inline chunking + change detection. Inlined (instead of
# calling self._prepare_file_for_sync) so this method does not depend
# on any sibling helpers — keeps it robust against partial reloads
# where the class object is older than the method's source.
pending: List[Dict[str, Any]] = []
workspace_dir_path = self.config.get_workspace()
for file_path, source, scope, user_id in files_to_scan:
try:
content = file_path.read_text(encoding='utf-8')
except Exception:
continue
file_hash = MemoryStorage.compute_hash(content)
rel_path = str(file_path.relative_to(workspace_dir_path))
if self.storage.get_file_hash(rel_path) == file_hash:
continue
chunks = self.chunker.chunk_text(content)
if not chunks:
continue
pending.append({
"file_path": file_path,
"rel_path": rel_path,
"source": source,
"scope": scope,
"user_id": user_id,
"file_hash": file_hash,
"chunks": chunks,
"texts": [c.text for c in chunks],
})
if not pending:
self._dirty = False
return
texts = [chunk.text for chunk in chunks]
if self.embedding_provider:
embeddings = self.embedding_provider.embed_batch(texts)
# Pass 2: single batched embed across all pending chunks.
# CRITICAL: never touch the index until we hold valid embeddings.
# If embed_batch fails, leave the existing index intact (chunks +
# file_hash) so the next sync will retry the same files. Writing
# NULL embeddings + updating file_hash here would mark the file as
# "successfully synced" and silently strand it without vectors.
all_texts: List[str] = []
for entry in pending:
all_texts.extend(entry["texts"])
if not self.embedding_provider:
# No provider configured at all (legacy keyword-only). Persist
# chunks without embeddings — this is the user's intent.
all_embeddings: List[Optional[List[float]]] = [None] * len(all_texts)
else:
embeddings = [None] * len(texts)
# Create memory chunks
memory_chunks = []
for chunk, embedding in zip(chunks, embeddings):
chunk_id = self._generate_chunk_id(rel_path, chunk.start_line, chunk.end_line)
chunk_hash = MemoryStorage.compute_hash(chunk.text)
memory_chunks.append(MemoryChunk(
id=chunk_id,
user_id=user_id,
scope=scope,
source=source,
try:
all_embeddings = self.embedding_provider.embed_batch(all_texts)
except Exception as e:
from common.log import logger
logger.error(
f"[MemoryManager] Batch embedding failed for {len(all_texts)} "
f"chunks across {len(pending)} files: {e}. "
f"Index left untouched; will retry on next sync."
)
# Bail before touching storage. self._dirty stays True so
# callers know there is pending work.
return
# Pass 3: inline persist — same self-contained reasoning as Pass 1.
cursor = 0
for entry in pending:
n = len(entry["texts"])
entry_embeddings = all_embeddings[cursor:cursor + n]
cursor += n
rel_path = entry["rel_path"]
self.storage.delete_by_path(rel_path)
memory_chunks = []
for chunk, embedding in zip(entry["chunks"], entry_embeddings):
chunk_id = self._generate_chunk_id(rel_path, chunk.start_line, chunk.end_line)
chunk_hash = MemoryStorage.compute_hash(chunk.text)
memory_chunks.append(MemoryChunk(
id=chunk_id,
user_id=entry["user_id"],
scope=entry["scope"],
source=entry["source"],
path=rel_path,
start_line=chunk.start_line,
end_line=chunk.end_line,
text=chunk.text,
embedding=embedding,
hash=chunk_hash,
metadata=None,
))
self.storage.save_chunks_batch(memory_chunks)
stat = entry["file_path"].stat()
self.storage.update_file_metadata(
path=rel_path,
start_line=chunk.start_line,
end_line=chunk.end_line,
text=chunk.text,
embedding=embedding,
hash=chunk_hash,
metadata=None
))
# Save
self.storage.save_chunks_batch(memory_chunks)
# Update file metadata
stat = file_path.stat()
self.storage.update_file_metadata(
path=rel_path,
source=source,
file_hash=file_hash,
mtime=int(stat.st_mtime),
size=stat.st_size
)
source=entry["source"],
file_hash=entry["file_hash"],
mtime=int(stat.st_mtime),
size=stat.st_size,
)
self._dirty = False
def flush_memory(
self,
messages: list,

View File

@@ -0,0 +1,14 @@
"""
Backward-compatible shim for the legacy entry point:
python -m agent.memory.rebuild_index
The implementation now lives in agent.memory.embedding.rebuild.
Prefer using `/memory rebuild-index` in chat going forward.
"""
from agent.memory.embedding.rebuild import main
if __name__ == "__main__":
import sys
sys.exit(main())

View File

@@ -144,45 +144,37 @@ class MemoryStorage:
ON chunks(path, hash)
""")
# Create FTS5 virtual table for keyword search (only if supported)
# Create FTS5 virtual table + triggers (only if supported).
# Self-heal: if the previous process crashed mid-rebuild and left
# triggers pointing at a missing chunks_fts (or vice versa), wipe
# both sides and recreate cleanly. Otherwise next chunks INSERT
# will fail with "no such table: chunks_fts".
if self.fts5_available:
# Use default unicode61 tokenizer (stable and compatible)
# For CJK support, we'll use LIKE queries as fallback
self.conn.execute("""
CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(
text,
id UNINDEXED,
user_id UNINDEXED,
path UNINDEXED,
source UNINDEXED,
scope UNINDEXED,
content='chunks',
content_rowid='rowid'
if self._fts5_state_inconsistent():
from common.log import logger
logger.warning(
"[MemoryStorage] FTS5 state inconsistent (triggers/table mismatch). "
"Resetting chunks_fts to recover."
)
""")
# Create triggers to keep FTS in sync
self.conn.execute("""
CREATE TRIGGER IF NOT EXISTS chunks_ai AFTER INSERT ON chunks BEGIN
INSERT INTO chunks_fts(rowid, text, id, user_id, path, source, scope)
VALUES (new.rowid, new.text, new.id, new.user_id, new.path, new.source, new.scope);
END
""")
self.conn.execute("""
CREATE TRIGGER IF NOT EXISTS chunks_ad AFTER DELETE ON chunks BEGIN
DELETE FROM chunks_fts WHERE rowid = old.rowid;
END
""")
self.conn.execute("""
CREATE TRIGGER IF NOT EXISTS chunks_au AFTER UPDATE ON chunks BEGIN
UPDATE chunks_fts SET text = new.text, id = new.id,
user_id = new.user_id, path = new.path, source = new.source, scope = new.scope
WHERE rowid = new.rowid;
END
""")
self.conn.execute("DROP TRIGGER IF EXISTS chunks_ai")
self.conn.execute("DROP TRIGGER IF EXISTS chunks_ad")
self.conn.execute("DROP TRIGGER IF EXISTS chunks_au")
self.conn.execute("DROP TABLE IF EXISTS chunks_fts")
self.conn.commit()
self._create_fts5_objects()
# Probe FTS5 shadow tables. The schema may be intact but the
# internal _data/_idx/_docsize blob can still be corrupt — that
# surfaces as "database disk image is malformed" on bm25 / MATCH.
# We rebuild from the chunks table when that happens; data isn't
# lost because chunks (the content table) is the source of truth.
if self._fts5_shadow_corrupt():
from common.log import logger
logger.warning(
"[MemoryStorage] FTS5 shadow tables corrupt; rebuilding from chunks."
)
self._rebuild_fts5_from_chunks()
# Create files metadata table
self.conn.execute("""
CREATE TABLE IF NOT EXISTS files (
@@ -196,7 +188,116 @@ class MemoryStorage:
""")
self.conn.commit()
def _fts5_state_inconsistent(self) -> bool:
"""Detect a half-broken FTS5 setup (e.g. trigger exists but table doesn't)."""
try:
row = self.conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='chunks_fts'"
).fetchone()
table_exists = row is not None
row = self.conn.execute(
"SELECT COUNT(*) FROM sqlite_master WHERE type='trigger' "
"AND name IN ('chunks_ai','chunks_ad','chunks_au')"
).fetchone()
trigger_count = int(row[0]) if row else 0
except Exception:
return False
# Healthy = both present (3 triggers + table) or both absent.
return table_exists != (trigger_count > 0)
def _create_fts5_objects(self):
"""Create chunks_fts virtual table and the 3 sync triggers.
Idempotent: uses IF NOT EXISTS. Caller must hold self.conn.
"""
self.conn.execute("""
CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(
text,
id UNINDEXED,
user_id UNINDEXED,
path UNINDEXED,
source UNINDEXED,
scope UNINDEXED,
content='chunks',
content_rowid='rowid'
)
""")
self.conn.execute("""
CREATE TRIGGER IF NOT EXISTS chunks_ai AFTER INSERT ON chunks BEGIN
INSERT INTO chunks_fts(rowid, text, id, user_id, path, source, scope)
VALUES (new.rowid, new.text, new.id, new.user_id, new.path, new.source, new.scope);
END
""")
self.conn.execute("""
CREATE TRIGGER IF NOT EXISTS chunks_ad AFTER DELETE ON chunks BEGIN
DELETE FROM chunks_fts WHERE rowid = old.rowid;
END
""")
self.conn.execute("""
CREATE TRIGGER IF NOT EXISTS chunks_au AFTER UPDATE ON chunks BEGIN
UPDATE chunks_fts SET text = new.text, id = new.id,
user_id = new.user_id, path = new.path,
source = new.source, scope = new.scope
WHERE rowid = new.rowid;
END
""")
def reset_fts5(self):
"""Drop and recreate chunks_fts + triggers in one transaction.
Used by rebuild_index to recover from FTS5 shadow-table corruption
(bm25/ORDER BY rank may raise "database disk image is malformed"
even when raw MATCH still works).
Triggers must be dropped first; otherwise the next chunks INSERT/DELETE
on the existing connection will hit "no such table: chunks_fts".
"""
if not self.fts5_available:
return
self.conn.execute("DROP TRIGGER IF EXISTS chunks_ai")
self.conn.execute("DROP TRIGGER IF EXISTS chunks_ad")
self.conn.execute("DROP TRIGGER IF EXISTS chunks_au")
self.conn.execute("DROP TABLE IF EXISTS chunks_fts")
self._create_fts5_objects()
self.conn.commit()
def _fts5_shadow_corrupt(self) -> bool:
"""Probe whether bm25 over chunks_fts errors out at startup.
Schema (table + triggers) can be intact while the underlying
FTS5 shadow blobs are malformed — typically because the previous
process crashed mid-write or wrote with a different SQLite build.
A cheap MATCH probe surfaces it immediately."""
try:
self.conn.execute(
"SELECT bm25(chunks_fts) FROM chunks_fts WHERE chunks_fts MATCH 'a' LIMIT 1"
).fetchone()
return False
except sqlite3.DatabaseError as e:
msg = str(e).lower()
return "malformed" in msg or "corrupt" in msg
except Exception:
# Any other error (e.g. table missing) is handled by the
# state-inconsistent path; treat as healthy here.
return False
def _rebuild_fts5_from_chunks(self):
"""Drop FTS5, recreate it, then INSERT every row from chunks.
Safe data-wise: chunks (the content table) is the source of truth.
Done in one transaction so a crash leaves either fully old or fully
new state, not a partial rebuild.
"""
# Reset schema first; this clears any malformed shadow blobs.
self.reset_fts5()
# Re-feed content. Triggers handle future writes automatically.
self.conn.execute("""
INSERT INTO chunks_fts(rowid, text, id, user_id, path, source, scope)
SELECT rowid, text, id, user_id, path, source, scope FROM chunks
""")
self.conn.commit()
def save_chunk(self, chunk: MemoryChunk):
"""Save a memory chunk"""
self.conn.execute("""
@@ -283,13 +384,26 @@ class MemoryStorage:
"""
rows = self.conn.execute(query, params).fetchall()
# Calculate cosine similarity
# Calculate cosine similarity. We probe the first row's dim to fail
# loudly on a query/index dim mismatch — otherwise every doc would
# score 0 silently, leaving the user wondering why search broke.
results = []
query_dim = len(query_embedding)
if rows:
first = json.loads(rows[0]['embedding'])
if isinstance(first, list) and len(first) != query_dim:
raise ValueError(
f"Embedding dim mismatch: query is {query_dim}-dim but "
f"index stores {len(first)}-dim vectors. The configured "
f"embedding model differs from the one that built the "
f"index — run /memory rebuild-index to re-embed."
)
for row in rows:
embedding = json.loads(row['embedding'])
similarity = self._cosine_similarity(query_embedding, embedding)
if similarity > 0:
results.append((similarity, row))
@@ -319,27 +433,24 @@ class MemoryStorage:
) -> List[SearchResult]:
"""
Keyword search using FTS5 + LIKE fallback
Strategy:
1. If FTS5 available: Try FTS5 search first (good for English and word-based languages)
2. If no FTS5 or no results and query contains CJK: Use LIKE search
1. If FTS5 available and healthy: try FTS5 first
2. Always fall back to LIKE for CJK queries
3. If FTS5 fails OR returns empty for non-CJK, also try LIKE so a
broken FTS5 shadow table doesn't silently kill keyword search.
"""
if scopes is None:
scopes = ["shared"]
if user_id:
scopes.append("user")
# Try FTS5 search first (if available)
if self.fts5_available:
fts_results = self._search_fts5(query, user_id, scopes, limit)
if fts_results:
return fts_results
# Fallback to LIKE search (always for CJK, or if FTS5 not available)
if not self.fts5_available or MemoryStorage._contains_cjk(query):
return self._search_like(query, user_id, scopes, limit)
return []
return self._search_like(query, user_id, scopes, limit)
def _search_fts5(
self,
@@ -394,7 +505,11 @@ class MemoryStorage:
)
for row in rows
]
except Exception:
except Exception as e:
from common.log import logger
logger.error(
f"[MemoryStorage] FTS5 search failed (caller will fall back to LIKE): {e}"
)
return []
def _search_like(
@@ -404,21 +519,28 @@ class MemoryStorage:
scopes: List[str],
limit: int
) -> List[SearchResult]:
"""LIKE-based search for CJK characters"""
"""LIKE-based search.
Used as the keyword-search fallback when FTS5 is unavailable, fails,
or returns empty. Supports both CJK runs and ASCII word tokens so it
can serve as a true safety net for any query.
"""
import re
# Extract CJK words (2+ characters)
# CJK runs (2+ chars) + ASCII word tokens (3+ chars to avoid noise)
cjk_words = re.findall(r'[\u4e00-\u9fff]{2,}', query)
if not cjk_words:
ascii_words = [t for t in re.findall(r'[A-Za-z0-9_]+', query) if len(t) >= 3]
words = cjk_words + ascii_words
if not words:
return []
scope_placeholders = ','.join('?' * len(scopes))
# Build LIKE conditions for each word
# Build LIKE conditions for each word (case-insensitive for ASCII)
like_conditions = []
params = []
for word in cjk_words:
like_conditions.append("text LIKE ?")
params.append(f'%{word}%')
for word in words:
like_conditions.append("LOWER(text) LIKE ?")
params.append(f'%{word.lower()}%')
where_clause = ' OR '.join(like_conditions)
params.extend(scopes)
@@ -455,7 +577,9 @@ class MemoryStorage:
)
for row in rows
]
except Exception:
except Exception as e:
from common.log import logger
logger.error(f"[MemoryStorage] LIKE search failed: {e}")
return []
def delete_by_path(self, path: str):
@@ -485,14 +609,19 @@ class MemoryStorage:
chunks_count = self.conn.execute("""
SELECT COUNT(*) as cnt FROM chunks
""").fetchone()['cnt']
files_count = self.conn.execute("""
SELECT COUNT(*) as cnt FROM files
""").fetchone()['cnt']
embedded_count = self.conn.execute("""
SELECT COUNT(*) as cnt FROM chunks WHERE embedding IS NOT NULL
""").fetchone()['cnt']
return {
'chunks': chunks_count,
'files': files_count
'files': files_count,
'embedded': embedded_count,
}
def close(self):

View File

@@ -594,15 +594,33 @@ class AgentStreamExecutor:
turns = self._identify_complete_turns()
logger.info(f"Sending {len(messages)} messages ({len(turns)} turns) to LLM")
# Prepare tool definitions (OpenAI/Claude format)
# Pull in any MCP tools that finished loading since this turn started.
# Cheap dict reconciliation (microseconds) — lets the agent pick up
# newly available MCP tools mid-conversation without a session restart.
try:
from agent.tools import ToolManager
ToolManager().sync_mcp_into_agent(self)
except Exception as e:
logger.debug(f"[Agent] MCP sync skipped: {e}")
# Prepare tool definitions. Prefer get_json_schema() when it yields
# real properties (lets tools augment schema at runtime), otherwise
# fall back to the static `tool.params` (MCP tools rely on this).
tools_schema = None
if self.tools:
tools_schema = []
for tool in self.tools.values():
input_schema = tool.params
try:
dynamic = (tool.get_json_schema() or {}).get("parameters") or {}
if dynamic.get("properties"):
input_schema = dynamic
except Exception:
pass
tools_schema.append({
"name": tool.name,
"description": tool.description,
"input_schema": tool.params # Claude uses input_schema
"input_schema": input_schema,
})
# Create request

View File

@@ -107,6 +107,22 @@ def _import_browser_tool():
BrowserTool = _import_browser_tool()
# MCP Tools (no extra dependencies, loaded on demand)
def _import_mcp_tools():
"""导入 MCP 工具模块(无额外依赖,按需加载)"""
from common.log import logger
try:
from agent.tools.mcp.mcp_tool import McpTool
from agent.tools.mcp.mcp_client import McpClientRegistry
return {'McpTool': McpTool, 'McpClientRegistry': McpClientRegistry}
except Exception as e:
logger.warning(f"[Tools] MCP tools not loaded: {e}")
return {}
_mcp_tools = _import_mcp_tools()
McpTool = _mcp_tools.get('McpTool')
McpClientRegistry = _mcp_tools.get('McpClientRegistry')
# Export all tools (including optional ones that might be None)
__all__ = [
'BaseTool',
@@ -125,6 +141,7 @@ __all__ = [
'WebFetch',
'Vision',
'BrowserTool',
'McpTool',
]
"""

View File

@@ -15,6 +15,10 @@ import threading
from typing import Optional, Dict, Any, List, Callable
from common.log import logger
from common.utils import expand_path
_DEFAULT_USER_DATA_DIR = "~/.cow/browser_profile"
try:
from playwright.sync_api import sync_playwright, Browser, BrowserContext, Page, Playwright
@@ -212,6 +216,21 @@ _SNAPSHOT_JS = """
)
_BROWSER_DEAD_HINTS = (
"has been closed",
"browser has disconnected",
"target closed",
"browser closed",
"context or browser has been closed",
)
def _is_browser_dead_error(err: Exception) -> bool:
"""Return True if *err* indicates the browser / page died out from under us."""
msg = str(err).lower()
return any(h in msg for h in _BROWSER_DEAD_HINTS)
def _should_use_headless() -> bool:
"""Decide headless mode: headless on Linux servers without display, headed elsewhere."""
if sys.platform in ("win32", "darwin"):
@@ -302,11 +321,38 @@ class BrowserService:
self._context = None
self._page = None
# Launch mode: one of "fresh" | "persistent" | "cdp".
# - cdp: connect to an externally launched Chrome via CDP endpoint.
# - persistent: launch with launch_persistent_context using a user_data_dir
# so cookies / login state survive across runs (default).
# - fresh: classic launch + new_context, clean state every run.
cdp_endpoint = self._config.get("cdp_endpoint") or ""
persistent_flag = self._config.get("persistent", True)
user_data_dir_cfg = self._config.get("user_data_dir")
if user_data_dir_cfg is None:
user_data_dir_cfg = _DEFAULT_USER_DATA_DIR
self._cdp_endpoint: str = cdp_endpoint.strip() if isinstance(cdp_endpoint, str) else ""
if self._cdp_endpoint:
self._launch_mode = "cdp"
self._user_data_dir: str = ""
elif persistent_flag and user_data_dir_cfg:
self._launch_mode = "persistent"
self._user_data_dir = expand_path(str(user_data_dir_cfg))
else:
self._launch_mode = "fresh"
self._user_data_dir = ""
# Idle auto-release
idle_cfg = self._config.get("idle_timeout")
self._idle_timeout: float = float(idle_cfg) if idle_cfg is not None else self._IDLE_TIMEOUT_DEFAULT
self._idle_timer: Optional[threading.Timer] = None
# Set when the browser / page is detected to have died externally
# (e.g. user manually closed the window). The next _submit() will then
# tear down the stale thread and relaunch.
self._needs_restart = False
# ------------------------------------------------------------------
# Background-thread lifecycle
# ------------------------------------------------------------------
@@ -354,6 +400,12 @@ class BrowserService:
result_slot["value"] = fn(*args, **kwargs)
except Exception as e:
result_slot["error"] = e
if _is_browser_dead_error(e):
self._needs_restart = True
logger.warning(
f"[Browser] Detected closed page/context ({e}); "
"will relaunch on next request."
)
finally:
result_slot["event"].set()
@@ -375,7 +427,7 @@ class BrowserService:
result_slot["event"].set()
def _launch_browser(self):
"""Launch Chromium on the background thread."""
"""Launch / connect Chromium on the background thread."""
if self._headless is None:
headless_cfg = self._config.get("headless")
self._headless = headless_cfg if headless_cfg is not None else _should_use_headless()
@@ -390,36 +442,142 @@ class BrowserService:
viewport_w = self._config.get("viewport_width", 1280)
viewport_h = self._config.get("viewport_height", 720)
viewport = {"width": viewport_w, "height": viewport_h}
user_agent = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/131.0.0.0 Safari/537.36"
)
self._playwright = sync_playwright().start()
logger.info(f"[Browser] Launching Chromium (headless={self._headless})")
if self._launch_mode == "cdp":
self._connect_cdp(viewport)
elif self._launch_mode == "persistent":
self._launch_persistent(launch_args, viewport, user_agent)
else:
self._launch_fresh(launch_args, viewport, user_agent)
logger.info("[Browser] Browser ready")
def _launch_fresh(self, launch_args: List[str], viewport: Dict[str, int], user_agent: str):
"""Classic launch: brand new Chromium with an empty context."""
logger.info(f"[Browser] Launching Chromium (fresh, headless={self._headless})")
self._browser = self._playwright.chromium.launch(
headless=self._headless,
args=launch_args,
)
self._context = self._browser.new_context(
viewport={"width": viewport_w, "height": viewport_h},
user_agent=(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/131.0.0.0 Safari/537.36"
),
viewport=viewport,
user_agent=user_agent,
)
self._page = self._context.new_page()
logger.info("[Browser] Browser ready")
self._wire_close_listeners()
def _launch_persistent(self, launch_args: List[str], viewport: Dict[str, int], user_agent: str):
"""Launch Chromium with a persistent user_data_dir so login state survives."""
os.makedirs(self._user_data_dir, exist_ok=True)
logger.info(
f"[Browser] Launching Chromium (persistent, headless={self._headless}, "
f"profile={self._user_data_dir})"
)
try:
self._context = self._playwright.chromium.launch_persistent_context(
user_data_dir=self._user_data_dir,
headless=self._headless,
args=launch_args,
viewport=viewport,
user_agent=user_agent,
)
except Exception as e:
# Profile is locked when another Chromium instance already holds it.
msg = str(e).lower()
if "singletonlock" in msg or "profile" in msg or "lock" in msg:
raise RuntimeError(
f"Browser profile '{self._user_data_dir}' is in use by another process. "
"Close the other Chromium / cow instance, or set a different "
"tools.browser.user_data_dir."
) from e
raise
# Persistent context has no parent Browser handle; reuse the auto-created page.
self._browser = None
pages = self._context.pages
self._page = pages[0] if pages else self._context.new_page()
self._wire_close_listeners()
def _connect_cdp(self, viewport: Dict[str, int]):
"""Attach to an existing Chrome started with --remote-debugging-port."""
endpoint = self._cdp_endpoint
logger.info(f"[Browser] Connecting to existing Chrome via CDP: {endpoint}")
try:
self._browser = self._playwright.chromium.connect_over_cdp(endpoint)
except Exception as e:
msg = str(e).lower()
if "econnrefused" in msg or "connect" in msg or "refused" in msg:
raise RuntimeError(
f"Cannot reach Chrome at {endpoint}. The CDP browser is not "
"running. Ask the user to launch Chrome with "
"--remote-debugging-port and --user-data-dir, then retry. "
"Do not retry this tool until the user confirms."
) from e
raise
contexts = self._browser.contexts
if contexts:
self._context = contexts[0]
else:
self._context = self._browser.new_context(viewport=viewport)
pages = self._context.pages
self._page = pages[0] if pages else self._context.new_page()
self._wire_close_listeners()
def _wire_close_listeners(self):
"""Mark needs_restart whenever the browser / context / page dies externally."""
def _on_dead(_obj=None):
self._needs_restart = True
try:
if self._browser:
self._browser.on("disconnected", _on_dead)
if self._context:
self._context.on("close", _on_dead)
if self._page:
self._page.on("close", _on_dead)
except Exception as e:
logger.debug(f"[Browser] Failed to wire close listeners: {e}")
def _shutdown_browser(self):
"""Shut down all Playwright resources on the background thread."""
"""Shut down Playwright resources on the background thread.
Mode-specific behavior:
- cdp: only disconnect the Playwright client; leave the user's Chrome
and its tabs untouched (do NOT close the context).
- persistent: close the persistent context (no separate browser handle).
- fresh: close context, then browser.
"""
self._cancel_idle_timer()
for obj, label in [
(self._context, "context"),
(self._browser, "browser"),
]:
if self._launch_mode == "cdp":
# For CDP, browser.close() only detaches the Playwright client;
# the user's Chrome process and its tabs stay alive.
try:
if obj:
obj.close()
if self._browser:
self._browser.close()
except Exception as e:
logger.debug(f"[Browser] {label} close error: {e}")
logger.debug(f"[Browser] cdp disconnect error: {e}")
else:
for obj, label in [
(self._context, "context"),
(self._browser, "browser"),
]:
try:
if obj:
obj.close()
except Exception as e:
logger.debug(f"[Browser] {label} close error: {e}")
try:
if self._playwright:
self._playwright.stop()
@@ -433,6 +591,13 @@ class BrowserService:
def _submit(self, fn: Callable, *args, **kwargs):
"""Submit *fn* to the background thread and block until it completes."""
# If the browser died externally (e.g. user closed the window), tear
# down the stale thread first so _start_thread() will relaunch fresh.
if self._needs_restart:
logger.info("[Browser] Restarting after detecting closed browser")
self.close()
self._needs_restart = False
self._start_thread()
if not self._alive:
@@ -481,6 +646,7 @@ class BrowserService:
self._cancel_idle_timer()
with self._lock:
if not self._alive:
self._needs_restart = False
return
self._alive = False
t = self._thread
@@ -490,6 +656,7 @@ class BrowserService:
t.join(timeout=10)
with self._lock:
self._thread = None
self._needs_restart = False
# ------------------------------------------------------------------
# Actions (each method is dispatched to the background thread)

View File

@@ -4,6 +4,15 @@ Browser tool - Control a Chromium browser for web navigation and interaction.
Uses Playwright under the hood. Browser instance is lazily started on first
use, reused across tool calls within the same session, and cleaned up via
close().
Launch modes (configured under `tools.browser` in config.json):
- persistent (default): Chromium runs with a persistent user_data_dir
(default `~/.cow/browser_profile`), so cookies and login state survive
across runs. The user only needs to log in once.
- cdp: When `cdp_endpoint` is set, attach to an externally launched Chrome
via the Chrome DevTools Protocol. Lets the agent reuse the user's real
browser (with all logins / extensions / true fingerprints).
- fresh: Set `persistent` to false to fall back to a clean context every run.
"""
import json
@@ -25,7 +34,10 @@ class BrowserTool(BaseTool):
"get_text, press, evaluate.\n\n"
"Workflow: navigate (auto-includes snapshot with element refs) → click/fill/select by ref → snapshot to verify.\n\n"
"Use snapshot as the primary way to read pages. Use screenshot + send to show key results to the user. "
"For login/CAPTCHA/authorization etc., screenshot and ask the user for help."
"For login/CAPTCHA/authorization etc., screenshot and ask the user for help. "
"Login state is persisted across sessions (cookies / localStorage are kept in a "
"user profile directory), so once the user logs in to a site, the agent can keep "
"using it without logging in again."
)
params: dict = {

View File

@@ -0,0 +1,4 @@
from agent.tools.mcp.mcp_client import McpClient, McpClientRegistry
from agent.tools.mcp.mcp_tool import McpTool
__all__ = ["McpClient", "McpClientRegistry", "McpTool"]

View File

@@ -0,0 +1,374 @@
"""
MCP (Model Context Protocol) client module.
Implements JSON-RPC 2.0 over stdio and SSE transports without any external
MCP SDK dependency.
"""
import json
import os
import select
import subprocess
import threading
import urllib.request
import urllib.error
from typing import Optional
from common.log import logger
class McpClient:
"""Single MCP Server client supporting stdio and SSE transports."""
def __init__(self, config: dict):
"""
config examples:
stdio: {"name": "filesystem", "type": "stdio", "command": "npx", "args": [...]}
SSE: {"name": "my-api", "type": "sse", "url": "http://localhost:8000/sse"}
"""
self.config = config
self.name: str = config.get("name", "unknown")
self.transport: str = config.get("type", "stdio")
# stdio state
self._proc: Optional[subprocess.Popen] = None
# SSE state
self._sse_url: Optional[str] = None
self._post_url: Optional[str] = None # endpoint for sending messages (resolved from SSE)
# Shared state
self._next_id = 1
self._id_lock = threading.Lock()
self._call_lock = threading.Lock()
self._initialized = False
# ------------------------------------------------------------------
# Public interface
# ------------------------------------------------------------------
def initialize(self) -> bool:
"""Connect and perform the MCP handshake. Returns True on success."""
try:
if self.transport == "stdio":
return self._init_stdio()
elif self.transport == "sse":
return self._init_sse()
else:
logger.warning(f"[MCP:{self.name}] Unknown transport type: {self.transport!r}")
return False
except Exception as e:
logger.warning(f"[MCP:{self.name}] Initialization failed: {e}")
return False
def list_tools(self) -> list:
"""Return the tool list from this server.
Each item is a dict: {"name": str, "description": str, "inputSchema": dict}
"""
try:
resp = self._send_request("tools/list", {})
tools = resp.get("result", {}).get("tools", [])
return [
{
"name": t.get("name", ""),
"description": t.get("description", ""),
"inputSchema": t.get("inputSchema", {}),
}
for t in tools
]
except Exception as e:
logger.warning(f"[MCP:{self.name}] list_tools failed: {e}")
return []
def call_tool(self, name: str, arguments: dict) -> str:
"""Call a tool and return the result as a string."""
try:
resp = self._send_request("tools/call", {"name": name, "arguments": arguments})
content = resp.get("result", {}).get("content", [])
parts = [item.get("text", "") for item in content if item.get("type") == "text"]
return "\n".join(parts)
except Exception as e:
logger.warning(f"[MCP:{self.name}] call_tool({name}) failed: {e}")
return f"Error: {e}"
def shutdown(self):
"""Close the connection / terminate the child process."""
if self._proc is not None:
try:
self._proc.stdin.close()
except Exception:
pass
try:
self._proc.terminate()
self._proc.wait(timeout=5)
except Exception:
try:
self._proc.kill()
except Exception:
pass
self._proc = None
logger.debug(f"[MCP:{self.name}] stdio process terminated")
self._initialized = False
# ------------------------------------------------------------------
# stdio transport
# ------------------------------------------------------------------
def _init_stdio(self) -> bool:
command = self.config.get("command")
if not command:
logger.warning(f"[MCP:{self.name}] stdio config missing 'command'")
return False
args = self.config.get("args", [])
extra_env = self.config.get("env", None)
env = {**os.environ, **extra_env} if extra_env else None
self._proc = subprocess.Popen(
[command] + list(args),
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
env=env,
)
logger.debug(f"[MCP:{self.name}] stdio process started (pid={self._proc.pid})")
threading.Thread(
target=self._drain_stderr, daemon=True, name=f"mcp-stderr-{self.name}"
).start()
return self._handshake()
def _drain_stderr(self):
for line in self._proc.stderr:
line = line.strip()
if line:
logger.debug(f"[MCP:{self.name}] stderr: {line}")
def _readline_with_timeout(self, timeout: int = 30) -> str:
"""Read one line from stdio stdout with a hard timeout."""
ready, _, _ = select.select([self._proc.stdout], [], [], timeout)
if not ready:
raise TimeoutError(f"[MCP:{self.name}] stdio read timed out after {timeout}s")
return self._proc.stdout.readline()
def _stdio_send(self, message: dict) -> dict:
"""Send a JSON-RPC message over stdio and read the response."""
raw = json.dumps(message) + "\n"
self._proc.stdin.write(raw)
self._proc.stdin.flush()
while True:
line = self._readline_with_timeout()
if not line:
raise IOError(f"[MCP:{self.name}] stdio process closed unexpectedly")
line = line.strip()
if not line:
continue
try:
data = json.loads(line)
except json.JSONDecodeError:
continue
if "id" not in data:
logger.debug(f"[MCP:{self.name}] notification skipped: {data.get('method', '?')}")
continue
return data
# ------------------------------------------------------------------
# SSE transport
# ------------------------------------------------------------------
def _init_sse(self) -> bool:
url = self.config.get("url")
if not url:
logger.warning(f"[MCP:{self.name}] SSE config missing 'url'")
return False
self._sse_url = url
# Read the first SSE event to discover the POST endpoint
try:
self._post_url = self._sse_discover_endpoint()
except Exception as e:
logger.warning(f"[MCP:{self.name}] SSE endpoint discovery failed: {e}")
return False
return self._handshake()
def _sse_discover_endpoint(self) -> str:
"""Open SSE stream and read the 'endpoint' event to learn the POST URL."""
req = urllib.request.Request(
self._sse_url,
headers={"Accept": "text/event-stream"},
)
with urllib.request.urlopen(req, timeout=10) as resp:
for raw_line in resp:
line = raw_line.decode("utf-8").rstrip("\n\r")
if line.startswith("data:"):
data = line[len("data:"):].strip()
# Some servers send JSON with a "uri" or plain path
if data.startswith("{"):
parsed = json.loads(data)
return parsed.get("uri") or parsed.get("url") or parsed.get("endpoint")
# Plain relative or absolute URL
if data.startswith("http"):
return data
# Relative path: resolve against SSE base
from urllib.parse import urljoin
return urljoin(self._sse_url, data)
raise ValueError(f"[MCP:{self.name}] No endpoint event received from SSE stream")
def _sse_send(self, message: dict) -> dict:
"""POST a JSON-RPC message to the server and return the response."""
body = json.dumps(message).encode("utf-8")
req = urllib.request.Request(
self._post_url,
data=body,
method="POST",
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=30) as resp:
raw = resp.read().decode("utf-8")
return json.loads(raw)
# ------------------------------------------------------------------
# Common JSON-RPC helpers
# ------------------------------------------------------------------
def _next_request_id(self) -> int:
with self._id_lock:
rid = self._next_id
self._next_id += 1
return rid
def _build_request(self, method: str, params: dict) -> dict:
return {
"jsonrpc": "2.0",
"id": self._next_request_id(),
"method": method,
"params": params,
}
def _build_notification(self, method: str, params: dict) -> dict:
return {"jsonrpc": "2.0", "method": method, "params": params}
def _send_request(self, method: str, params: dict) -> dict:
"""Send a request and return the full response dict."""
if not self._initialized and method != "initialize":
raise RuntimeError(f"[MCP:{self.name}] Client not initialized")
message = self._build_request(method, params)
with self._call_lock:
if self.transport == "stdio":
return self._stdio_send(message)
elif self.transport == "sse":
return self._sse_send(message)
else:
raise ValueError(f"[MCP:{self.name}] Unsupported transport: {self.transport}")
def _send_notification(self, method: str, params: dict):
"""Fire-and-forget notification (no response expected)."""
notification = self._build_notification(method, params)
raw = json.dumps(notification) + "\n"
if self.transport == "stdio":
self._proc.stdin.write(raw)
self._proc.stdin.flush()
elif self.transport == "sse":
body = raw.encode("utf-8")
req = urllib.request.Request(
self._post_url,
data=body,
method="POST",
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=10):
pass
except Exception:
pass # notifications are fire-and-forget
def _handshake(self) -> bool:
"""Perform the MCP initialize / notifications/initialized handshake."""
init_params = {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "CowAgent", "version": "1.0"},
}
# Temporarily mark as initialized so _send_request doesn't block
self._initialized = True
try:
resp = self._send_request("initialize", init_params)
except Exception as e:
self._initialized = False
logger.warning(f"[MCP:{self.name}] Handshake initialize failed: {e}")
return False
if "error" in resp:
self._initialized = False
logger.warning(f"[MCP:{self.name}] Handshake error: {resp['error']}")
return False
self._send_notification("notifications/initialized", {})
logger.debug(f"[MCP:{self.name}] Handshake complete")
return True
class McpClientRegistry:
"""Global singleton managing the lifecycle of all MCP Server clients."""
_instance = None
_instance_lock = threading.Lock()
def __new__(cls):
with cls._instance_lock:
if cls._instance is None:
obj = super().__new__(cls)
obj._clients: dict[str, McpClient] = {}
obj._registry_lock = threading.Lock()
cls._instance = obj
return cls._instance
def start_all(self, configs: list) -> None:
"""Initialize McpClient for each config entry; skip failures with a warning."""
if not configs:
return
for cfg in configs:
name = cfg.get("name", "<unnamed>")
client = McpClient(cfg)
ok = client.initialize()
if ok:
with self._registry_lock:
self._clients[name] = client
logger.info(f"[MCP] Server '{name}' initialized successfully")
else:
logger.warning(f"[MCP] Server '{name}' failed to initialize — skipping")
def get(self, server_name: str) -> Optional[McpClient]:
"""Return the initialized client for server_name, or None."""
with self._registry_lock:
return self._clients.get(server_name)
def all_clients(self) -> dict:
"""Return a copy of the {name: McpClient} mapping."""
with self._registry_lock:
return dict(self._clients)
def shutdown_all(self) -> None:
"""Shut down all managed clients."""
with self._registry_lock:
clients = list(self._clients.values())
self._clients.clear()
for client in clients:
try:
client.shutdown()
except Exception as e:
logger.warning(f"[MCP] Error shutting down '{client.name}': {e}")
logger.info("[MCP] All servers shut down")

View File

@@ -0,0 +1,31 @@
from agent.tools.base_tool import BaseTool, ToolResult
from common.log import logger
class McpTool(BaseTool):
"""
将单个 MCP 工具包装为 BaseTool。
一个 MCP Server 可以提供多个工具,每个工具对应一个 McpTool 实例。
"""
def __init__(self, client, tool_schema: dict, server_name: str):
"""
:param client: 该工具所属的 McpClient 实例
:param tool_schema: MCP 返回的工具描述,格式:
{"name": str, "description": str, "inputSchema": dict}
:param server_name: Server 名称,用于日志
"""
self.client = client
self.server_name = server_name
self.name = tool_schema["name"]
self.description = tool_schema.get("description", "")
self.params = tool_schema.get("inputSchema", {})
def execute(self, params: dict) -> ToolResult:
logger.info(f"[McpTool] server={self.server_name} tool={self.name} params={params}")
try:
result = self.client.call_tool(self.name, params)
return ToolResult.success(result)
except Exception as e:
logger.error(f"[McpTool] server={self.server_name} tool={self.name} error: {e}")
return ToolResult.fail(str(e))

View File

@@ -245,16 +245,11 @@ class Read(BaseTool):
})
# Read file (utf-8-sig strips BOM automatically on Windows)
# Note: Truncation is unified via truncate_head (DEFAULT_MAX_LINES / DEFAULT_MAX_BYTES)
# so that offset/limit can paginate the entire file correctly.
with open(absolute_path, 'r', encoding='utf-8-sig') as f:
content = f.read()
# Truncate content if too long (20K characters max for model context)
MAX_CONTENT_CHARS = 20 * 1024 # 20K characters
content_truncated = False
if len(content) > MAX_CONTENT_CHARS:
content = content[:MAX_CONTENT_CHARS]
content_truncated = True
all_lines = content.split('\n')
total_file_lines = len(all_lines)
@@ -290,11 +285,7 @@ class Read(BaseTool):
output_text = ""
details = {}
# Add truncation warning if content was truncated
if content_truncated:
output_text = f"[文件内容已截断到前 {format_size(MAX_CONTENT_CHARS)},完整文件大小: {format_size(file_size)}]\n\n"
if truncation.first_line_exceeds_limit:
# First line exceeds 30KB limit
first_line_size = format_size(len(all_lines[start_line].encode('utf-8')))

View File

@@ -3,6 +3,7 @@ Integration module for scheduler with AgentBridge
"""
import os
import threading
from typing import Optional
from config import conf
from common.log import logger
@@ -13,65 +14,82 @@ from bridge.reply import Reply, ReplyType
# Global scheduler service instance
_scheduler_service = None
_task_store = None
# Module-level lock to guard idempotent initialization across threads
_init_lock = threading.Lock()
def init_scheduler(agent_bridge) -> bool:
"""
Initialize scheduler service
Initialize scheduler service (idempotent).
Safe to call multiple times and from multiple threads: only the first
successful call creates the singleton ``SchedulerService`` + background
scanning thread. Subsequent calls return immediately.
Args:
agent_bridge: AgentBridge instance
Returns:
True if initialized successfully
True if scheduler is initialized (newly created or already running)
"""
global _scheduler_service, _task_store
try:
from agent.tools.scheduler.task_store import TaskStore
from agent.tools.scheduler.scheduler_service import SchedulerService
# Get workspace from config
workspace_root = expand_path(conf().get("agent_workspace", "~/cow"))
store_path = os.path.join(workspace_root, "scheduler", "tasks.json")
# Create task store
_task_store = TaskStore(store_path)
logger.debug(f"[Scheduler] Task store initialized: {store_path}")
# Create execute callback
def execute_task_callback(task: dict):
"""Callback to execute a scheduled task"""
try:
action = task.get("action", {})
action_type = action.get("type")
if action_type == "agent_task":
_execute_agent_task(task, agent_bridge)
elif action_type == "send_message":
# Legacy support for old tasks
_execute_send_message(task, agent_bridge)
elif action_type == "tool_call":
# Legacy support for old tasks
_execute_tool_call(task, agent_bridge)
elif action_type == "skill_call":
# Legacy support for old tasks
_execute_skill_call(task, agent_bridge)
else:
logger.warning(f"[Scheduler] Unknown action type: {action_type}")
except Exception as e:
logger.error(f"[Scheduler] Error executing task {task.get('id')}: {e}")
# Create scheduler service
_scheduler_service = SchedulerService(_task_store, execute_task_callback)
_scheduler_service.start()
logger.debug("[Scheduler] Scheduler service initialized and started")
# Fast path: already initialized and running
if _scheduler_service is not None and getattr(_scheduler_service, "running", False):
return True
except Exception as e:
logger.error(f"[Scheduler] Failed to initialize scheduler: {e}")
return False
with _init_lock:
# Re-check under the lock to avoid races where multiple threads
# passed the fast-path check before any of them acquired the lock.
if _scheduler_service is not None and getattr(_scheduler_service, "running", False):
return True
try:
from agent.tools.scheduler.task_store import TaskStore
from agent.tools.scheduler.scheduler_service import SchedulerService
# Get workspace from config
workspace_root = expand_path(conf().get("agent_workspace", "~/cow"))
store_path = os.path.join(workspace_root, "scheduler", "tasks.json")
# Create task store (reuse if already created)
if _task_store is None:
_task_store = TaskStore(store_path)
logger.debug(f"[Scheduler] Task store initialized: {store_path}")
# Create execute callback
def execute_task_callback(task: dict):
"""Callback to execute a scheduled task"""
try:
action = task.get("action", {})
action_type = action.get("type")
if action_type == "agent_task":
_execute_agent_task(task, agent_bridge)
elif action_type == "send_message":
# Legacy support for old tasks
_execute_send_message(task, agent_bridge)
elif action_type == "tool_call":
# Legacy support for old tasks
_execute_tool_call(task, agent_bridge)
elif action_type == "skill_call":
# Legacy support for old tasks
_execute_skill_call(task, agent_bridge)
else:
logger.warning(f"[Scheduler] Unknown action type: {action_type}")
except Exception as e:
logger.error(f"[Scheduler] Error executing task {task.get('id')}: {e}")
# Create scheduler service
_scheduler_service = SchedulerService(_task_store, execute_task_callback)
_scheduler_service.start()
logger.debug("[Scheduler] Scheduler service initialized and started")
return True
except Exception as e:
logger.error(f"[Scheduler] Failed to initialize scheduler: {e}")
return False
def get_task_store():

View File

@@ -1,5 +1,6 @@
import importlib
import importlib.util
import threading
from pathlib import Path
from typing import Dict, Any, Type
from agent.tools.base_tool import BaseTool
@@ -7,6 +8,26 @@ from common.log import logger
from config import conf
def _normalize_mcp_configs(raw) -> list:
"""
Convert MCP server config to internal list format.
Supports:
- list format (mcp_servers): [{"name": "x", "type": "stdio", ...}]
- dict format (mcpServers): {"x": {"command": "npx", ...}}
"""
if isinstance(raw, list):
return raw
if isinstance(raw, dict):
result = []
for name, cfg in raw.items():
entry = {"name": name, **cfg}
if "type" not in entry:
entry["type"] = "sse" if "url" in entry else "stdio"
result.append(entry)
return result
return []
class ToolManager:
"""
Tool manager for managing tools.
@@ -25,6 +46,31 @@ class ToolManager:
# Initialize only once
if not hasattr(self, 'tool_classes'):
self.tool_classes = {} # Dictionary to store tool classes
if not hasattr(self, '_mcp_registry'):
self._mcp_registry = None # Lazy init: only created when MCP servers are configured
if not hasattr(self, '_mcp_tool_instances'):
self._mcp_tool_instances: dict = {} # tool_name -> McpTool instance
if not hasattr(self, '_mcp_lock'):
# Guards _mcp_loaded check-then-set so concurrent callers
# don't trigger duplicate background loaders.
self._mcp_lock = threading.Lock()
if not hasattr(self, '_mcp_loaded'):
# Idempotency flag. Flipped to True the moment the first loader
# is dispatched (synchronously, inside _mcp_lock). Subsequent
# _load_mcp_tools() calls become no-ops, so per-session agent
# initialization never re-forks MCP subprocesses.
self._mcp_loaded = False
if not hasattr(self, '_mcp_status'):
# server_name -> "pending" / "ready" / "failed"
# Useful for UI / introspection while async loading is in progress.
self._mcp_status: dict = {}
if not hasattr(self, '_mcp_signature'):
# (mtime, sha256) of mcp.json the last time we loaded.
# Used by refresh_mcp_if_changed() to skip re-parsing when nothing changed.
self._mcp_signature: tuple = (None, None)
if not hasattr(self, '_mcp_active_configs'):
# server_name -> normalized config dict, for diff-based reload.
self._mcp_active_configs: dict = {}
def load_tools(self, tools_dir: str = "", config_dict=None):
"""
@@ -39,6 +85,8 @@ class ToolManager:
self._load_tools_from_init()
self._configure_tools_from_config(config_dict)
self._load_mcp_tools()
def _load_tools_from_init(self) -> bool:
"""
Load tool classes from tools.__init__.__all__
@@ -70,10 +118,14 @@ class ToolManager:
and cls != BaseTool
):
try:
# Skip memory tools (they need special initialization with memory_manager)
# Skip tools that need special initialization
if class_name in ["MemorySearchTool", "MemoryGetTool"]:
logger.debug(f"Skipped tool {class_name} (requires memory_manager)")
continue
# McpTool instances are registered dynamically via _load_mcp_tools()
if class_name == "McpTool":
logger.debug(f"Skipped tool {class_name} (registered dynamically via mcp_servers config)")
continue
# Create a temporary instance to get the name
temp_instance = cls()
@@ -212,6 +264,306 @@ class ToolManager:
except Exception as e:
logger.error(f"Error configuring tools from config: {e}")
def _mcp_json_path(self) -> str:
import os
workspace = os.path.expanduser(conf().get("agent_workspace", "~/cow"))
return os.path.join(workspace, "mcp.json")
def _read_mcp_json_signature(self):
"""
Return (mtime, sha256_of_bytes) for ~/cow/mcp.json without parsing.
Returns (None, None) if the file doesn't exist or is unreadable.
Cheap enough (one stat + one small read) to call on every agent init.
"""
import os
import hashlib
path = self._mcp_json_path()
try:
mtime = os.path.getmtime(path)
except OSError:
return (None, None)
try:
with open(path, "rb") as f:
digest = hashlib.sha256(f.read()).hexdigest()
except OSError:
return (mtime, None)
return (mtime, digest)
def _load_mcp_configs(self) -> list:
"""
Load MCP server configs with priority:
1. ~/cow/mcp.json (supports both mcpServers and mcp_servers keys)
2. config.json mcp_servers field (fallback)
"""
import os
import json as _json
mcp_json_path = self._mcp_json_path()
if os.path.exists(mcp_json_path):
try:
with open(mcp_json_path, "r", encoding="utf-8") as f:
data = _json.load(f)
raw = data.get("mcpServers") or data.get("mcp_servers") or data
logger.info(f"[ToolManager] Loading MCP config from {mcp_json_path}")
return _normalize_mcp_configs(raw)
except Exception as e:
logger.warning(f"[ToolManager] Failed to read {mcp_json_path}: {e}, falling back to config.json")
raw = conf().get("mcp_servers", [])
return _normalize_mcp_configs(raw)
def _load_mcp_tools(self):
"""
Trigger MCP tool loading in a background thread (idempotent).
Returns immediately. Booting MCP servers (npx, uvx, etc.) takes
seconds to tens of seconds on first run, which would otherwise
block agent initialization and the user's first message.
Built-in tools work fine without MCP, so we let the agent serve
traffic right away and let MCP servers come online in the
background. Per-session agents read a snapshot of whatever is
ready at construction time and gracefully ignore the rest.
"""
with self._mcp_lock:
if self._mcp_loaded:
return
mcp_servers_config = self._load_mcp_configs()
# Snapshot the signature now so future refresh_mcp_if_changed()
# calls can short-circuit when nothing has changed on disk.
self._mcp_signature = self._read_mcp_json_signature()
self._mcp_active_configs = {
cfg.get("name", "<unnamed>"): cfg for cfg in mcp_servers_config
}
if not mcp_servers_config:
# Mark as loaded even when there is nothing to load,
# so we don't re-read the config file on every call.
self._mcp_loaded = True
return
# Mark pending immediately so list_mcp_status() callers see
# the in-progress state instead of an empty dict.
for cfg in mcp_servers_config:
name = cfg.get("name", "<unnamed>")
self._mcp_status[name] = "pending"
self._mcp_loaded = True
threading.Thread(
target=self._load_mcp_tools_async,
args=(mcp_servers_config,),
daemon=True,
name="mcp-loader",
).start()
logger.info(
f"[ToolManager] MCP loading started in background "
f"({len(mcp_servers_config)} server(s) configured)"
)
def refresh_mcp_if_changed(self):
"""
Cheap check whether ~/cow/mcp.json has changed since last load.
If it has, do a diff-based reload: start newly added servers,
shut down removed ones, and restart any whose config was edited.
Untouched servers are left running.
Designed to be called on every agent creation. The fast path is
a single os.stat() — completely free when nothing has changed.
"""
with self._mcp_lock:
new_sig = self._read_mcp_json_signature()
if new_sig == self._mcp_signature:
return # no-op fast path
try:
new_configs = self._load_mcp_configs()
except Exception as e:
logger.warning(f"[ToolManager] MCP reload — failed to parse config: {e}")
return
new_by_name = {
cfg.get("name", "<unnamed>"): cfg for cfg in new_configs
}
old_by_name = self._mcp_active_configs
added = [n for n in new_by_name if n not in old_by_name]
removed = [n for n in old_by_name if n not in new_by_name]
changed = [
n for n in new_by_name
if n in old_by_name and new_by_name[n] != old_by_name[n]
]
if not (added or removed or changed):
# Signature drifted but content is logically identical
# (e.g. user re-saved the file without edits). Just sync.
self._mcp_signature = new_sig
return
logger.info(
f"[ToolManager] mcp.json changed — "
f"adding={added}, removing={removed}, restarting={changed}"
)
# Tear down removed + changed servers (changed ones get restarted below)
for name in removed + changed:
self._teardown_mcp_server(name)
# Spin up newly added + changed servers in the background
to_start = [new_by_name[n] for n in added + changed]
if to_start:
for cfg in to_start:
self._mcp_status[cfg.get("name", "<unnamed>")] = "pending"
threading.Thread(
target=self._load_mcp_tools_async,
args=(to_start,),
daemon=True,
name="mcp-loader-reload",
).start()
self._mcp_active_configs = new_by_name
self._mcp_signature = new_sig
def _teardown_mcp_server(self, server_name: str):
"""Shut down one MCP server and drop its tools from the registry."""
if self._mcp_registry is None:
return
client = None
with self._mcp_registry._registry_lock:
client = self._mcp_registry._clients.pop(server_name, None)
if client is not None:
try:
client.shutdown()
except Exception as e:
logger.warning(f"[MCP] Error shutting down '{server_name}': {e}")
# Drop tools that belonged to this server.
for tool_name in list(self._mcp_tool_instances.keys()):
tool = self._mcp_tool_instances.get(tool_name)
if tool is not None and getattr(tool, "server_name", None) == server_name:
self._mcp_tool_instances.pop(tool_name, None)
self._mcp_status.pop(server_name, None)
def _load_mcp_tools_async(self, mcp_servers_config):
"""
Background worker: bring up each MCP server one-by-one and
publish ready tools to _mcp_tool_instances as they come online.
Server failures are isolated — one bad server cannot block
the others, and never raises out of the worker thread.
"""
try:
from agent.tools.mcp.mcp_client import McpClient, McpClientRegistry
from agent.tools.mcp.mcp_tool import McpTool
registry = McpClientRegistry()
self._mcp_registry = registry
for cfg in mcp_servers_config:
server_name = cfg.get("name", "<unnamed>")
try:
client = McpClient(cfg)
if not client.initialize():
self._mcp_status[server_name] = "failed"
logger.warning(
f"[MCP] Server '{server_name}' failed to initialize — skipping"
)
continue
tool_schemas = client.list_tools()
added = []
for schema in tool_schemas:
tool_name = schema.get("name", "")
if not tool_name:
continue
mcp_tool = McpTool(client, schema, server_name)
# Atomic dict assignment is GIL-safe; readers iterate
# over a list() snapshot to avoid concurrent mutation.
self._mcp_tool_instances[tool_name] = mcp_tool
added.append(tool_name)
# Register client into the shared registry only after its
# tools are visible, so callers never see a half-loaded server.
with registry._registry_lock:
registry._clients[server_name] = client
self._mcp_status[server_name] = "ready"
logger.info(
f"[MCP] Server '{server_name}' ready — "
f"{len(added)} tool(s): {added}"
)
except Exception as e:
self._mcp_status[server_name] = "failed"
logger.warning(f"[MCP] Server '{server_name}' load failed: {e}")
ready = sum(1 for s in self._mcp_status.values() if s == "ready")
total = len(self._mcp_status)
logger.info(
f"[ToolManager] MCP loading complete: "
f"{ready}/{total} server(s) ready, "
f"{len(self._mcp_tool_instances)} tool(s) available"
)
except Exception as e:
logger.warning(f"[ToolManager] MCP background loader crashed: {e}")
def list_mcp_status(self) -> dict:
"""Return {server_name: status} snapshot for UI / debugging."""
return dict(self._mcp_status)
def sync_mcp_into_agent(self, agent) -> tuple:
"""
Reconcile a live agent's tool collection with the current MCP tool registry.
Adds tools that finished loading after the agent was created,
and removes tools whose MCP server was torn down. Built-in tools
on the agent are left untouched.
Handles both representations CowAgent uses:
- Agent.tools: list[BaseTool] (default Agent class)
- AgentStream.tools: dict[str, BaseTool] (streaming agent)
Returns (added_names, removed_names) for logging.
"""
if agent is None or not hasattr(agent, "tools"):
return ([], [])
from agent.tools.mcp.mcp_tool import McpTool
current = self._mcp_tool_instances
registry_names = set(current.keys())
agent_tools = agent.tools
if isinstance(agent_tools, dict):
agent_mcp_names = {
name for name, tool in agent_tools.items()
if isinstance(tool, McpTool)
}
added = registry_names - agent_mcp_names
removed = agent_mcp_names - registry_names
if not (added or removed):
return ([], [])
for name in added:
agent_tools[name] = current[name]
for name in removed:
agent_tools.pop(name, None)
elif isinstance(agent_tools, list):
agent_mcp_names = {
t.name for t in agent_tools if isinstance(t, McpTool)
}
added = registry_names - agent_mcp_names
removed = agent_mcp_names - registry_names
if not (added or removed):
return ([], [])
if removed:
agent.tools = [
t for t in agent_tools
if not (isinstance(t, McpTool) and t.name in removed)
]
for name in added:
agent.tools.append(current[name])
else:
return ([], [])
return (sorted(added), sorted(removed))
def create_tool(self, name: str) -> BaseTool:
"""
Get a new instance of a tool by name.
@@ -229,6 +581,12 @@ class ToolManager:
tool_instance.config = self.tool_configs[name]
return tool_instance
# Fall back to MCP tool instances
mcp_tool = self._mcp_tool_instances.get(name)
if mcp_tool:
return mcp_tool
return None
def list_tools(self) -> dict:
@@ -245,4 +603,17 @@ class ToolManager:
"description": temp_instance.description,
"parameters": temp_instance.get_json_schema()
}
# Include MCP tool instances
for name, mcp_tool in self._mcp_tool_instances.items():
result[name] = {
"description": mcp_tool.description,
"parameters": mcp_tool.params,
}
return result
def shutdown_mcp(self):
"""Shut down all MCP server clients."""
if self._mcp_registry:
self._mcp_registry.shutdown_all()

View File

@@ -3,7 +3,7 @@ Vision tool - Analyze images using Vision API.
Supports local files (auto base64-encoded) and HTTP URLs.
Provider resolution:
- tool.vision.model (if set) means "prefer this model first; fall back to
- tools.vision.model (if set) means "prefer this model first; fall back to
other configured providers if it fails". The model name is mapped to its
native provider (e.g. doubao-* → Doubao, kimi-* → Moonshot, gpt-* →
OpenAI/LinkAI). That provider is tried first, then the standard auto
@@ -30,7 +30,7 @@ from common import const
from common.log import logger
from config import conf
DEFAULT_MODEL = const.GPT_41_MINI
DEFAULT_MODEL = const.GPT_55
DEFAULT_TIMEOUT = 60
MAX_TOKENS = 1000
COMPRESS_THRESHOLD = 1_048_576 # 1 MB
@@ -53,14 +53,14 @@ _DISCOVERABLE_MODELS = [
("ark_api_key", const.DOUBAO, const.DOUBAO_SEED_2_PRO, "Doubao"),
("dashscope_api_key", const.QWEN_DASHSCOPE, const.QWEN36_PLUS, "DashScope"),
("claude_api_key", const.CLAUDEAPI, const.CLAUDE_4_6_SONNET, "Claude"),
("gemini_api_key", const.GEMINI, const.GEMINI_31_FLASH_LITE_PRE, "Gemini"),
("gemini_api_key", const.GEMINI, const.GEMINI_35_FLASH, "Gemini"),
("qianfan_api_key", const.QIANFAN, const.ERNIE_45_TURBO_VL, "Qianfan"),
("zhipu_ai_api_key", const.ZHIPU_AI, const.GLM_4_7, "ZhipuAI"),
("minimax_api_key", const.MiniMax, const.MINIMAX_M2_7, "MiniMax"),
]
# Model name prefix → discoverable provider display_name.
# Used to auto-route tool.vision.model to its native provider.
# Used to auto-route tools.vision.model to its native provider.
# Matched case-insensitively; longest prefix wins.
_MODEL_PREFIX_TO_PROVIDER = [
("doubao-", "Doubao"),
@@ -154,7 +154,7 @@ class Vision(BaseTool):
# Default model is only used as a last-resort placeholder for providers
# whose VisionProvider.model_override is None (e.g. raw OpenAI provider
# when the user did not configure tool.vision.model).
# when the user did not configure tools.vision.model).
return self._call_with_fallback(providers, DEFAULT_MODEL, question, image_content)
def _call_with_fallback(self, providers: List[VisionProvider], model: str,
@@ -193,12 +193,12 @@ class Vision(BaseTool):
"""
Build an ordered list of providers to try.
Semantics of `tool.vision.model`:
Semantics of `tools.vision.model`:
"Prefer this model first; fall back to other configured providers
if it fails."
Order:
1. The provider that natively serves `tool.vision.model` (if any
1. The provider that natively serves `tools.vision.model` (if any
and its API key is configured) — using the user-specified model
name verbatim.
2. Auto-discovery chain as fallback:
@@ -213,7 +213,7 @@ class Vision(BaseTool):
user_model = self._resolve_user_vision_model()
providers: List[VisionProvider] = []
# Step 1: preferred provider derived from tool.vision.model
# Step 1: preferred provider derived from tools.vision.model
if user_model:
preferred = self._route_by_model_name(user_model)
if preferred:
@@ -251,11 +251,11 @@ class Vision(BaseTool):
@staticmethod
def _resolve_user_vision_model() -> Optional[str]:
"""Read tool.vision.model from config; return None if unset/blank."""
tool_conf = conf().get("tool", {})
if not isinstance(tool_conf, dict):
"""Read tools.vision.model (singular ``tool`` kept as runtime fallback)."""
tools_conf = conf().get("tools") or conf().get("tool") or {}
if not isinstance(tools_conf, dict):
return None
vision_conf = tool_conf.get("vision", {})
vision_conf = tools_conf.get("vision", {})
if not isinstance(vision_conf, dict):
return None
m = vision_conf.get("model")
@@ -303,7 +303,7 @@ class Vision(BaseTool):
self._append_provider(providers, lambda: self._build_linkai_provider(user_model))
if providers:
return providers
logger.warning(f"[Vision] tool.vision.model='{user_model}' looks like an OpenAI "
logger.warning(f"[Vision] tools.vision.model='{user_model}' looks like an OpenAI "
f"model but neither OPENAI_API_KEY nor LINKAI_API_KEY is configured.")
return None # fall through to auto
@@ -317,7 +317,7 @@ class Vision(BaseTool):
continue
api_key = conf().get(config_key, "")
if not api_key or not api_key.strip():
logger.warning(f"[Vision] tool.vision.model='{user_model}' routes to "
logger.warning(f"[Vision] tools.vision.model='{user_model}' routes to "
f"'{display_name}' but '{config_key}' is not configured. "
f"Falling back to auto-discovery.")
return None # fall through to auto
@@ -452,8 +452,8 @@ class Vision(BaseTool):
if not self._main_bot_supports_vision(bot):
return None
# Use the configured main model name; do NOT inject tool.vision.model
# here, because by the time we reach this branch the tool.vision.model
# Use the configured main model name; do NOT inject tools.vision.model
# here, because by the time we reach this branch the tools.vision.model
# routing has already been attempted (and either matched the main bot
# or failed to find a provider).
main_model_name = conf().get("model") or None

View File

@@ -1,13 +1,27 @@
"""
Web Search tool - Search the web using Bocha or LinkAI search API.
Supports two backends with unified response format:
1. Bocha Search (primary, requires BOCHA_API_KEY)
2. LinkAI Search (fallback, requires LINKAI_API_KEY)
"""Web Search tool. Supports four backends with a unified response format:
- bocha (https://open.bochaai.com)
- zhipu (https://docs.bigmodel.cn/cn/guide/tools/web-search)
- qianfan (https://cloud.baidu.com/doc/qianfan/s/2mh4su4uy)
- linkai (https://link-ai.tech, fallback)
Provider selection
- strategy 'auto' (default): pick the first configured provider in the
canonical order [bocha, zhipu, qianfan, linkai]. When the caller passes
an explicit `provider` it overrides the pick; an invalid/unconfigured
one silently falls back to the auto order.
- strategy 'fixed': use the configured provider; if its credential is
missing at call time, silently fall back to auto order (no card hint).
Credentials
- bocha : tools.web_search.bocha_api_key -> env BOCHA_API_KEY
- zhipu : conf.zhipu_ai_api_key -> env ZHIPUAI_API_KEY
- qianfan : conf.qianfan_api_key -> env QIANFAN_API_KEY
- linkai : conf.linkai_api_key -> env LINKAI_API_KEY
"""
import os
import json
from typing import Dict, Any, Optional
import os
from typing import Any, Dict, List, Optional
import requests
@@ -16,12 +30,63 @@ from common.log import logger
from config import conf
# Default timeout for API requests (seconds)
DEFAULT_TIMEOUT = 30
# Canonical fallback order. Empirically ordered by Chinese real-time
# quality + relevance: bocha (best overall), qianfan (best for hot news),
# zhipu (strong on long-form articles), linkai (cloud aggregator, last
# resort).
PROVIDER_ORDER = ("bocha", "qianfan", "zhipu", "linkai")
PROVIDER_LABELS = {
"bocha": "Bocha",
"zhipu": "Zhipu",
"qianfan": "Baidu Qianfan",
"linkai": "LinkAI",
}
def _tools_web_search_conf() -> dict:
"""Return the tools.web_search config block (dict-like)."""
tools_cfg = conf().get("tools") or {}
if not isinstance(tools_cfg, dict):
return {}
block = tools_cfg.get("web_search") or {}
return block if isinstance(block, dict) else {}
def _get_api_key(provider: str) -> str:
"""Resolve API key for a provider, with conf -> env fallback."""
if provider == "bocha":
key = (_tools_web_search_conf().get("bocha_api_key") or "").strip()
return key or os.environ.get("BOCHA_API_KEY", "").strip()
if provider == "zhipu":
key = (conf().get("zhipu_ai_api_key") or "").strip()
return key or os.environ.get("ZHIPUAI_API_KEY", "").strip()
if provider == "qianfan":
key = (conf().get("qianfan_api_key") or "").strip()
return key or os.environ.get("QIANFAN_API_KEY", "").strip()
if provider == "linkai":
key = (conf().get("linkai_api_key") or "").strip()
return key or os.environ.get("LINKAI_API_KEY", "").strip()
return ""
def configured_providers() -> List[str]:
"""Return configured providers in canonical order."""
return [p for p in PROVIDER_ORDER if _get_api_key(p)]
def _configured_strategy() -> str:
return (_tools_web_search_conf().get("strategy") or "auto").strip().lower()
def _configured_provider() -> str:
return (_tools_web_search_conf().get("provider") or "").strip().lower()
class WebSearch(BaseTool):
"""Tool for searching the web using Bocha or LinkAI search API"""
"""Tool for searching the web across multiple providers."""
name: str = "web_search"
description: str = "Search the web for real-time information. Returns titles, URLs, and snippets."
@@ -55,264 +120,368 @@ class WebSearch(BaseTool):
def __init__(self, config: dict = None):
self.config = config or {}
self._backend = None # Will be resolved on first execute
@staticmethod
def is_available() -> bool:
"""Check if web search is available (at least one API key is configured)"""
return bool(os.environ.get("BOCHA_API_KEY") or os.environ.get("LINKAI_API_KEY"))
"""Tool is offered to the agent when at least one provider has a key."""
return bool(configured_providers())
def _resolve_backend(self) -> Optional[str]:
"""
Determine which search backend to use.
Priority: Bocha > LinkAI
@classmethod
def get_json_schema(cls) -> dict:
"""Augment the static schema with a `provider` field — only when the
user has ≥2 providers configured AND strategy is 'auto'. Otherwise
the backend picks silently and exposing the field would only waste
the agent's tokens."""
schema = {
"name": cls.name,
"description": cls.description,
"parameters": json.loads(json.dumps(cls.params)), # deep copy
}
if _configured_strategy() != "auto":
return schema
available = configured_providers()
if len(available) < 2:
return schema
:return: 'bocha', 'linkai', or None
schema["parameters"]["properties"]["provider"] = {
"type": "string",
"enum": available,
"description": "Optional. Specifies the search backend. You may switch between providers when the user wants results from a particular source or from multiple sources.",
}
return schema
# ------------------------------------------------------------------
# Provider resolution
# ------------------------------------------------------------------
def _resolve_provider(self, requested: Optional[str]) -> Optional[str]:
"""Pick a provider for this call.
Priority: caller-supplied (if configured) > fixed strategy (if
configured) > first configured in PROVIDER_ORDER. Silent fallback
when the desired one has no key.
"""
if os.environ.get("BOCHA_API_KEY"):
return "bocha"
if os.environ.get("LINKAI_API_KEY"):
return "linkai"
return None
available = configured_providers()
if not available:
return None
if requested:
req = requested.strip().lower()
if req in available:
return req
logger.warning(f"[WebSearch] requested provider '{requested}' unavailable, falling back")
if _configured_strategy() == "fixed":
pinned = _configured_provider()
if pinned in available:
return pinned
if pinned:
logger.warning(f"[WebSearch] pinned provider '{pinned}' unavailable, falling back to auto")
return available[0]
@staticmethod
def _resolution_reason(requested: Optional[str], chosen: str) -> str:
"""Human-readable explanation for why `chosen` won the resolver."""
if requested and requested.strip().lower() == chosen:
return "caller-requested"
strategy = _configured_strategy()
if strategy == "fixed" and _configured_provider() == chosen:
return "fixed-strategy"
return "auto-fallback"
# ------------------------------------------------------------------
# Entry point
# ------------------------------------------------------------------
def execute(self, args: Dict[str, Any]) -> ToolResult:
"""
Execute web search
:param args: Search parameters (query, count, freshness, summary)
:return: Search results
"""
query = args.get("query", "").strip()
query = (args.get("query") or "").strip()
if not query:
return ToolResult.fail("Error: 'query' parameter is required")
count = args.get("count", 10)
freshness = args.get("freshness", "noLimit")
summary = args.get("summary", False)
# Validate count
if not isinstance(count, int) or count < 1 or count > 50:
count = 10
# Resolve backend
backend = self._resolve_backend()
if not backend:
requested = args.get("provider")
provider = self._resolve_provider(requested)
if not provider:
return ToolResult.fail(
"Error: No search API key configured. "
"Please set BOCHA_API_KEY or LINKAI_API_KEY using env_config tool.\n"
" - Bocha Search: https://open.bocha.cn\n"
" - LinkAI Search: https://link-ai.tech"
"Error: No search provider configured. "
"Configure one of BOCHA_API_KEY / zhipu_ai_api_key / qianfan_api_key / linkai_api_key."
)
# Always log the routing decision so multi-provider deployments can
# tell at a glance which backend served any given query.
available = configured_providers()
reason = self._resolution_reason(requested, provider)
q_preview = query if len(query) <= 60 else (query[:57] + "...")
logger.info(
f"[WebSearch] provider={provider} reason={reason} "
f"available={list(available)} query={q_preview!r} count={count} freshness={freshness}"
)
try:
if backend == "bocha":
if provider == "bocha":
return self._search_bocha(query, count, freshness, summary)
else:
if provider == "zhipu":
return self._search_zhipu(query, count, freshness)
if provider == "qianfan":
return self._search_qianfan(query, count, freshness)
if provider == "linkai":
return self._search_linkai(query, count, freshness)
return ToolResult.fail(f"Error: Unknown provider '{provider}'")
except requests.Timeout:
return ToolResult.fail(f"Error: Search request timed out after {DEFAULT_TIMEOUT}s")
except requests.ConnectionError:
return ToolResult.fail("Error: Failed to connect to search API")
except Exception as e:
logger.error(f"[WebSearch] Unexpected error: {e}", exc_info=True)
logger.error(f"[WebSearch] Unexpected error ({provider}): {e}", exc_info=True)
return ToolResult.fail(f"Error: Search failed - {str(e)}")
# ------------------------------------------------------------------
# Bocha
# ------------------------------------------------------------------
def _search_bocha(self, query: str, count: int, freshness: str, summary: bool) -> ToolResult:
"""
Search using Bocha API
:param query: Search query
:param count: Number of results
:param freshness: Time range filter
:param summary: Whether to include summary
:return: Formatted search results
"""
api_key = os.environ.get("BOCHA_API_KEY", "")
url = "https://api.bocha.cn/v1/web-search"
api_key = _get_api_key("bocha")
url = "https://api.bochaai.com/v1/web-search"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"Accept": "application/json"
"Accept": "application/json",
}
payload = {"query": query, "count": count, "freshness": freshness, "summary": summary}
payload = {
"query": query,
"count": count,
"freshness": freshness,
"summary": summary
}
logger.debug(f"[WebSearch] bocha: query='{query}', count={count}")
resp = requests.post(url, headers=headers, json=payload, timeout=DEFAULT_TIMEOUT)
logger.debug(f"[WebSearch] Bocha search: query='{query}', count={count}")
if resp.status_code == 401:
return ToolResult.fail("Error: Invalid bocha API key.")
if resp.status_code == 403:
return ToolResult.fail("Error: bocha API — insufficient balance. Top up at https://open.bochaai.com")
if resp.status_code == 429:
return ToolResult.fail("Error: bocha API rate limit reached.")
if resp.status_code != 200:
return ToolResult.fail(f"Error: bocha API returned HTTP {resp.status_code}")
response = requests.post(url, headers=headers, json=payload, timeout=DEFAULT_TIMEOUT)
if response.status_code == 401:
return ToolResult.fail("Error: Invalid BOCHA_API_KEY. Please check your API key.")
if response.status_code == 403:
return ToolResult.fail("Error: Bocha API - insufficient balance. Please top up at https://open.bocha.cn")
if response.status_code == 429:
return ToolResult.fail("Error: Bocha API rate limit reached. Please try again later.")
if response.status_code != 200:
return ToolResult.fail(f"Error: Bocha API returned HTTP {response.status_code}")
data = response.json()
# Check API-level error code
data = resp.json()
api_code = data.get("code")
if api_code is not None and api_code != 200:
msg = data.get("msg") or "Unknown error"
return ToolResult.fail(f"Error: Bocha API error (code={api_code}): {msg}")
# Extract and format results
return self._format_bocha_results(data, query)
def _format_bocha_results(self, data: dict, query: str) -> ToolResult:
"""
Format Bocha API response into unified result structure
:param data: Raw API response
:param query: Original query
:return: Formatted ToolResult
"""
search_data = data.get("data", {})
web_pages = search_data.get("webPages", {})
pages = web_pages.get("value", [])
if not pages:
return ToolResult.success({
"query": query,
"backend": "bocha",
"total": 0,
"results": [],
"message": "No results found"
})
return ToolResult.fail(f"Error: bocha API error (code={api_code}): {msg}")
pages = (data.get("data") or {}).get("webPages", {}).get("value", []) or []
results = []
for page in pages:
result = {
"title": page.get("name", ""),
"url": page.get("url", ""),
"snippet": page.get("snippet", ""),
"siteName": page.get("siteName", ""),
"datePublished": page.get("datePublished") or page.get("dateLastCrawled", ""),
for p in pages:
item = {
"title": p.get("name", ""),
"url": p.get("url", ""),
"snippet": p.get("snippet", ""),
"siteName": p.get("siteName", ""),
"datePublished": p.get("datePublished") or p.get("dateLastCrawled", ""),
}
# Include summary only if present
if page.get("summary"):
result["summary"] = page["summary"]
results.append(result)
total = web_pages.get("totalEstimatedMatches", len(results))
if p.get("summary"):
item["summary"] = p["summary"]
results.append(item)
total = (data.get("data") or {}).get("webPages", {}).get("totalEstimatedMatches", len(results))
return ToolResult.success({
"query": query,
"backend": "bocha",
"total": total,
"count": len(results),
"results": results
"query": query, "backend": "bocha",
"total": total, "count": len(results), "results": results,
})
def _search_linkai(self, query: str, count: int, freshness: str) -> ToolResult:
"""
Search using LinkAI plugin API
# ------------------------------------------------------------------
# Zhipu
# ------------------------------------------------------------------
:param query: Search query
:param count: Number of results
:param freshness: Time range filter
:return: Formatted search results
"""
api_key = os.environ.get("LINKAI_API_KEY", "")
api_base = conf().get("linkai_api_base", "https://api.link-ai.tech")
url = f"{api_base.rstrip('/')}/v1/plugin/execute"
def _search_zhipu(self, query: str, count: int, freshness: str) -> ToolResult:
api_key = _get_api_key("zhipu")
api_base = (conf().get("zhipu_ai_api_base") or "https://open.bigmodel.cn/api/paas/v4").rstrip("/")
url = f"{api_base}/web_search"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
# Zhipu Web Search expects `search_query` <= 70 chars; truncate
# gracefully so a long agent-supplied query doesn't get rejected.
trimmed_query = (query or "")[:70]
engine = (_tools_web_search_conf().get("zhipu_search_engine") or "search_pro").strip().lower()
if engine not in ("search_std", "search_pro", "search_pro_sogou", "search_pro_quark"):
engine = "search_pro"
payload: Dict[str, Any] = {
"search_engine": engine,
"search_query": trimmed_query,
"search_intent": False,
"count": max(1, min(int(count or 10), 50)),
"search_recency_filter": freshness if freshness in (
"oneDay", "oneWeek", "oneMonth", "oneYear", "noLimit"
) else "noLimit",
}
content_size = (_tools_web_search_conf().get("zhipu_content_size") or "").strip().lower()
if content_size in ("medium", "high"):
payload["content_size"] = content_size
logger.debug(f"[WebSearch] zhipu: query='{trimmed_query}', count={payload['count']}, engine={engine}")
resp = requests.post(url, headers=headers, json=payload, timeout=DEFAULT_TIMEOUT)
if resp.status_code == 401:
return ToolResult.fail("Error: Invalid Zhipu API key.")
if resp.status_code != 200:
return ToolResult.fail(f"Error: Zhipu API returned HTTP {resp.status_code}: {resp.text[:200]}")
data = resp.json()
# Business-level errors (1701/1702/1703 etc.) come back as
# {"error": {"code","message"}} even on HTTP 200.
if isinstance(data, dict) and data.get("error"):
err = data["error"] or {}
return ToolResult.fail(f"Error: Zhipu returned {err.get('code')}: {err.get('message','')}")
items = data.get("search_result") or (data.get("data") or {}).get("search_result") or []
results = []
for it in items:
results.append({
"title": it.get("title", ""),
"url": it.get("link") or it.get("url", ""),
"snippet": it.get("content") or it.get("snippet", ""),
"siteName": it.get("media") or it.get("siteName", ""),
"datePublished": it.get("publish_date") or it.get("datePublished", ""),
})
return ToolResult.success({
"query": query, "backend": "zhipu",
"total": len(results), "count": len(results), "results": results,
})
# ------------------------------------------------------------------
# Qianfan (Baidu)
# ------------------------------------------------------------------
def _search_qianfan(self, query: str, count: int, freshness: str) -> ToolResult:
api_key = _get_api_key("qianfan")
api_base = (conf().get("qianfan_api_base") or "https://qianfan.baidubce.com/v2").rstrip("/")
url = f"{api_base}/ai_search/web_search"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"X-Appbuilder-From": "cow",
}
count = max(1, min(int(count or 10), 50))
payload: Dict[str, Any] = {
"messages": [{"role": "user", "content": query}],
"search_source": "baidu_search_v2",
"resource_type_filter": [{"type": "web", "top_k": count}],
}
# Baidu AI Search expects freshness as a date-range filter, not a
# named recency token. Translate our shared vocabulary into the
# underlying page_time range expected by the API.
search_filter = self._qianfan_build_freshness_filter(freshness)
if search_filter:
payload["search_filter"] = search_filter
logger.debug(f"[WebSearch] qianfan: query='{query}', count={count}, freshness={freshness!r}")
resp = requests.post(url, headers=headers, json=payload, timeout=DEFAULT_TIMEOUT)
if resp.status_code == 401:
return ToolResult.fail("Error: Invalid Qianfan API key.")
if resp.status_code != 200:
return ToolResult.fail(f"Error: Qianfan API returned HTTP {resp.status_code}: {resp.text[:200]}")
data = resp.json()
# Even on HTTP 200 Baidu surfaces business errors as {"code","message"}.
if isinstance(data, dict) and data.get("code"):
return ToolResult.fail(f"Error: Qianfan returned {data.get('code')}: {data.get('message','')}")
refs = data.get("references") or []
results = []
for d in refs:
results.append({
"title": d.get("title", ""),
"url": d.get("url", ""),
"snippet": (d.get("content") or "")[:200],
"siteName": d.get("web_anchor") or d.get("website") or "",
"datePublished": d.get("date", ""),
})
return ToolResult.success({
"query": query, "backend": "qianfan",
"total": len(results), "count": len(results), "results": results,
})
@staticmethod
def _qianfan_build_freshness_filter(freshness: str) -> Optional[Dict[str, Any]]:
if not freshness or freshness == "noLimit":
return None
delta_days = {"oneDay": 1, "oneWeek": 7, "oneMonth": 30, "oneYear": 365}.get(freshness)
if not delta_days:
return None
from datetime import datetime, timedelta
now = datetime.now()
end_date = (now + timedelta(days=1)).strftime("%Y-%m-%d")
start_date = (now - timedelta(days=delta_days)).strftime("%Y-%m-%d")
return {"range": {"page_time": {"gte": start_date, "lt": end_date}}}
# ------------------------------------------------------------------
# LinkAI (plugin)
# ------------------------------------------------------------------
def _search_linkai(self, query: str, count: int, freshness: str) -> ToolResult:
api_key = _get_api_key("linkai")
api_base = (conf().get("linkai_api_base") or "https://api.link-ai.tech").rstrip("/")
url = f"{api_base}/v1/plugin/execute"
from common.utils import get_cloud_headers
headers = get_cloud_headers(api_key)
payload = {
"code": "web-search",
"args": {
"query": query,
"count": count,
"freshness": freshness
}
}
payload = {"code": "web-search", "args": {"query": query, "count": count, "freshness": freshness}}
logger.debug(f"[WebSearch] linkai: query='{query}', count={count}")
resp = requests.post(url, headers=headers, json=payload, timeout=DEFAULT_TIMEOUT)
logger.debug(f"[WebSearch] LinkAI search: query='{query}', count={count}")
response = requests.post(url, headers=headers, json=payload, timeout=DEFAULT_TIMEOUT)
if response.status_code == 401:
return ToolResult.fail("Error: Invalid LINKAI_API_KEY. Please check your API key.")
if response.status_code != 200:
return ToolResult.fail(f"Error: LinkAI API returned HTTP {response.status_code}")
data = response.json()
if resp.status_code == 401:
return ToolResult.fail("Error: Invalid LinkAI API key.")
if resp.status_code != 200:
return ToolResult.fail(f"Error: LinkAI API returned HTTP {resp.status_code}")
data = resp.json()
if not data.get("success"):
msg = data.get("message") or "Unknown error"
return ToolResult.fail(f"Error: LinkAI search failed: {msg}")
return self._format_linkai_results(data, query)
def _format_linkai_results(self, data: dict, query: str) -> ToolResult:
"""
Format LinkAI API response into unified result structure.
LinkAI returns the search data in data.data field, which follows
the same Bing-compatible format as Bocha.
:param data: Raw API response
:param query: Original query
:return: Formatted ToolResult
"""
raw_data = data.get("data", "")
# LinkAI may return data as a JSON string
if isinstance(raw_data, str):
raw = data.get("data", "")
if isinstance(raw, str):
try:
raw_data = json.loads(raw_data)
raw = json.loads(raw)
except (json.JSONDecodeError, TypeError):
# If data is plain text, return it as a single result
return ToolResult.success({
"query": query,
"backend": "linkai",
"total": 1,
"count": 1,
"results": [{"content": raw_data}]
"query": query, "backend": "linkai",
"total": 1, "count": 1, "results": [{"content": raw}],
})
# If the response follows Bing-compatible structure
if isinstance(raw_data, dict):
web_pages = raw_data.get("webPages", {})
pages = web_pages.get("value", [])
if isinstance(raw, dict):
pages = (raw.get("webPages") or {}).get("value", []) or []
if pages:
results = []
for page in pages:
result = {
"title": page.get("name", ""),
"url": page.get("url", ""),
"snippet": page.get("snippet", ""),
"siteName": page.get("siteName", ""),
"datePublished": page.get("datePublished") or page.get("dateLastCrawled", ""),
for p in pages:
item = {
"title": p.get("name", ""),
"url": p.get("url", ""),
"snippet": p.get("snippet", ""),
"siteName": p.get("siteName", ""),
"datePublished": p.get("datePublished") or p.get("dateLastCrawled", ""),
}
if page.get("summary"):
result["summary"] = page["summary"]
results.append(result)
total = web_pages.get("totalEstimatedMatches", len(results))
if p.get("summary"):
item["summary"] = p["summary"]
results.append(item)
total = (raw.get("webPages") or {}).get("totalEstimatedMatches", len(results))
return ToolResult.success({
"query": query,
"backend": "linkai",
"total": total,
"count": len(results),
"results": results
"query": query, "backend": "linkai",
"total": total, "count": len(results), "results": results,
})
# Fallback: return raw data
return ToolResult.success({
"query": query,
"backend": "linkai",
"total": 1,
"count": 1,
"results": [{"content": str(raw_data)}]
"query": query, "backend": "linkai",
"total": 1, "count": 1, "results": [{"content": str(raw)}],
})

20
app.py
View File

@@ -274,6 +274,20 @@ def sigterm_handler_wrap(_signo):
signal.signal(_signo, func)
def _warmup_mcp_tools():
"""
Kick off MCP server loading at process startup so subprocesses
(npx / uvx etc.) finish initializing before the first user message
arrives. Returns immediately — the actual work happens on a daemon
thread inside ToolManager. Safe to call when MCP is not configured.
"""
try:
from agent.tools import ToolManager
ToolManager()._load_mcp_tools()
except Exception as e:
logger.warning(f"[App] MCP warmup failed (non-fatal): {e}")
def _sync_builtin_skills():
"""Sync builtin skills from project skills/ to workspace skills/ on startup."""
import shutil
@@ -335,6 +349,10 @@ def run():
# Sync builtin skills to workspace before channels start
_sync_builtin_skills()
# Kick off MCP server loading in the background so first-message
# latency isn't dominated by npx package downloads.
_warmup_mcp_tools()
logger.info(f"[App] Starting channels: {channel_names}")
_channel_mgr = ChannelManager()
@@ -342,6 +360,8 @@ def run():
while True:
time.sleep(1)
except KeyboardInterrupt:
pass
except Exception as e:
logger.error("App startup failed!")
logger.exception(e)

View File

@@ -172,10 +172,17 @@ class AgentLLMModel(LLMModel):
# reasoning trace, but still benefit from the higher answer
# quality the thinking pass produces.
from config import conf
thinking_enabled = bool(conf().get("enable_thinking", False))
kwargs['thinking'] = (
{"type": "enabled"} if conf().get("enable_thinking", False)
{"type": "enabled"} if thinking_enabled
else {"type": "disabled"}
)
# Reasoning effort is only meaningful when thinking is on.
# Bots that don't understand the kwarg drop it silently.
if thinking_enabled:
effort = conf().get("reasoning_effort", "high")
if effort in ("high", "max"):
kwargs['reasoning_effort'] = effort
response = self.bot.call_with_tools(**kwargs)
return self._format_response(response)
@@ -227,10 +234,17 @@ class AgentLLMModel(LLMModel):
# reasoning trace, but still benefit from the higher answer
# quality the thinking pass produces.
from config import conf
thinking_enabled = bool(conf().get("enable_thinking", False))
kwargs['thinking'] = (
{"type": "enabled"} if conf().get("enable_thinking", False)
{"type": "enabled"} if thinking_enabled
else {"type": "disabled"}
)
# Reasoning effort is only meaningful when thinking is on.
# Bots that don't understand the kwarg drop it silently.
if thinking_enabled:
effort = conf().get("reasoning_effort", "high")
if effort in ("high", "max"):
kwargs['reasoning_effort'] = effort
stream = self.bot.call_with_tools(**kwargs)
@@ -462,6 +476,12 @@ class AgentBridge:
except Exception as e:
logger.warning(f"[AgentBridge] Failed to clear DB after recovery: {e}")
# Post-message hot-reload: detect edits to ~/cow/mcp.json and
# sync any new/removed MCP tools into the live agent in the
# background. Off the critical path so user latency is unaffected;
# changes take effect on the user's next message.
self._schedule_mcp_hot_reload(agent)
# Check if there are files to send (from send/read tool)
if hasattr(agent, 'stream_executor') and hasattr(agent.stream_executor, 'files_to_send'):
files_to_send = agent.stream_executor.files_to_send
@@ -494,6 +514,31 @@ class AgentBridge:
logger.warning(f"[AgentBridge] Failed to clear DB after error: {db_err}")
return Reply(ReplyType.ERROR, f"Agent error: {str(e)}")
def _schedule_mcp_hot_reload(self, agent):
"""
Fire-and-forget: detect mcp.json edits and reconcile the agent's
tool dict in the background. Runs after the user's reply is sent,
so any cost (file stat, hash, server boot) never adds to user latency.
Failures are isolated and never raise into the message pipeline.
"""
import threading
from agent.tools import ToolManager
def _run():
try:
tm = ToolManager()
tm.refresh_mcp_if_changed()
added, removed = tm.sync_mcp_into_agent(agent)
if added or removed:
logger.info(
f"[AgentBridge] Agent tools synced — "
f"added={added}, removed={removed}"
)
except Exception as e:
logger.warning(f"[AgentBridge] MCP hot-reload failed (non-fatal): {e}")
threading.Thread(target=_run, daemon=True, name="mcp-hot-reload").start()
def _create_file_reply(self, file_info: dict, text_response: str, context: Context = None) -> Reply:
"""
Create a reply for sending files

View File

@@ -5,6 +5,7 @@ Agent Initializer - Handles agent initialization logic
import os
import asyncio
import datetime
import threading
import time
from typing import Optional, List
@@ -13,6 +14,13 @@ from agent.tools import ToolManager
from common.log import logger
from common.utils import expand_path
# Module-level lock to serialize scheduler init across concurrent sessions
_scheduler_init_lock = threading.Lock()
# Track whether the embedding model log has been printed in this process,
# so we avoid spamming it once per session.
_embedding_logged: bool = False
class AgentInitializer:
"""
@@ -268,52 +276,19 @@ class AgentInitializer:
memory_tools = []
try:
from agent.memory import MemoryManager, MemoryConfig, create_embedding_provider
from agent.memory import MemoryManager, MemoryConfig
from agent.tools import MemorySearchTool, MemoryGetTool
from config import conf
# Initialize embedding provider (prefer OpenAI, fallback to LinkAI)
embedding_provider = None
openai_api_key = conf().get("open_ai_api_key", "")
openai_api_base = conf().get("open_ai_api_base", "")
if openai_api_key and openai_api_key not in ["", "YOUR API KEY", "YOUR_API_KEY"]:
try:
embedding_provider = create_embedding_provider(
provider="openai",
model="text-embedding-3-small",
api_key=openai_api_key,
api_base=openai_api_base or "https://api.openai.com/v1"
)
if session_id is None:
logger.info("[AgentInitializer] OpenAI embedding initialized")
except Exception as e:
logger.warning(f"[AgentInitializer] OpenAI embedding failed: {e}")
if embedding_provider is None:
linkai_api_key = conf().get("linkai_api_key", "") or os.environ.get("LINKAI_API_KEY", "")
linkai_api_base = conf().get("linkai_api_base", "https://api.link-ai.tech")
if linkai_api_key and linkai_api_key not in ["", "YOUR API KEY", "YOUR_API_KEY"]:
try:
embedding_provider = create_embedding_provider(
provider="linkai",
model="text-embedding-3-small",
api_key=linkai_api_key,
api_base=f"{linkai_api_base}/v1"
)
if session_id is None:
logger.info("[AgentInitializer] LinkAI embedding initialized (fallback)")
except Exception as e:
logger.warning(f"[AgentInitializer] LinkAI embedding failed: {e}")
# Create memory manager
memory_config = MemoryConfig(workspace_root=workspace_root)
embedding_provider = self._init_embedding_provider(
memory_config, session_id=session_id
)
memory_manager = MemoryManager(memory_config, embedding_provider=embedding_provider)
# Sync memory
self._sync_memory(memory_manager, session_id)
# Create memory tools
memory_tools = [
MemorySearchTool(memory_manager),
MemoryGetTool(memory_manager)
@@ -326,6 +301,190 @@ class AgentInitializer:
logger.warning(f"[AgentInitializer] Memory system not available: {e}")
return memory_manager, memory_tools
def _init_embedding_provider(self, memory_config, session_id: Optional[str] = None):
"""
Initialize the embedding provider for memory.
Two paths:
A. Default (no `embedding_provider` in config.json):
Auto-init OpenAI -> LinkAI fallback. Existing 1536-dim indices
keep working.
B. Explicit (`embedding_provider` is set):
Initialize the requested vendor with unified dim (default 1024).
If the index was built with a different dim, vector search will
quietly return no results (cosine returns 0) and keyword search
takes over until the user runs /memory rebuild-index.
"""
from agent.memory import create_embedding_provider
from config import conf
explicit_provider = (conf().get("embedding_provider") or "").strip().lower()
if not explicit_provider:
return self._init_embedding_provider_legacy(session_id=session_id)
return self._init_embedding_provider_explicit(
memory_config, explicit_provider, session_id=session_id,
)
def _init_embedding_provider_legacy(self, session_id: Optional[str] = None):
"""Legacy auto-init path: OpenAI -> LinkAI. Preserved verbatim for compat."""
from agent.memory import create_embedding_provider
from config import conf
embedding_provider = None
embedding_model = None
openai_api_key = conf().get("open_ai_api_key", "")
openai_api_base = conf().get("open_ai_api_base", "")
if openai_api_key and openai_api_key not in ["", "YOUR API KEY", "YOUR_API_KEY"]:
try:
model = "text-embedding-3-small"
embedding_provider = create_embedding_provider(
provider="openai",
model=model,
api_key=openai_api_key,
api_base=openai_api_base or "https://api.openai.com/v1"
)
embedding_model = f"openai/{model}"
except Exception as e:
logger.warning(f"[AgentInitializer] OpenAI embedding failed: {e}")
if embedding_provider is None:
linkai_api_key = conf().get("linkai_api_key", "") or os.environ.get("LINKAI_API_KEY", "")
linkai_api_base = conf().get("linkai_api_base", "https://api.link-ai.tech")
if linkai_api_key and linkai_api_key not in ["", "YOUR API KEY", "YOUR_API_KEY"]:
try:
model = "text-embedding-3-small"
embedding_provider = create_embedding_provider(
provider="linkai",
model=model,
api_key=linkai_api_key,
api_base=f"{linkai_api_base}/v1"
)
embedding_model = f"linkai/{model}"
except Exception as e:
logger.warning(f"[AgentInitializer] LinkAI embedding failed: {e}")
if embedding_provider is not None and embedding_model:
global _embedding_logged
if not _embedding_logged:
logger.info(
f"[AgentInitializer] Embedding model in use: {embedding_model} "
f"(dim={embedding_provider.dimensions})"
)
_embedding_logged = True
return embedding_provider
def _init_embedding_provider_explicit(
self,
memory_config,
provider_key: str,
session_id: Optional[str] = None,
):
"""Explicit-provider path: build the configured vendor.
If the index was built with a different dim, vector search will
silently return no results (cosine returns 0 for mismatched dims)
and keyword search takes over. Users switch vendors by running
/memory rebuild-index — see docs.
"""
from agent.memory import create_embedding_provider
from agent.memory.embedding import EMBEDDING_VENDORS
from config import conf
meta = EMBEDDING_VENDORS.get(provider_key)
if meta is None:
logger.error(
f"[AgentInitializer] Unknown embedding_provider '{provider_key}'. "
f"Supported: {sorted(EMBEDDING_VENDORS.keys())}. "
f"Memory will run in keyword-only mode."
)
return None
api_key = self._resolve_embedding_api_key(provider_key)
api_base = self._resolve_embedding_api_base(provider_key, meta["default_base_url"])
if not api_key:
logger.error(
f"[AgentInitializer] embedding_provider='{provider_key}' is set but its "
f"API key is missing. Memory will run in keyword-only mode."
)
return None
model = (conf().get("embedding_model") or "").strip() or meta["default_model"]
try:
cfg_dim = int(conf().get("embedding_dimensions") or 0)
except (TypeError, ValueError):
cfg_dim = 0
dim = cfg_dim if cfg_dim > 0 else meta["default_dimensions"]
try:
provider = create_embedding_provider(
provider=provider_key,
model=model,
api_key=api_key,
api_base=api_base,
dimensions=dim,
)
except Exception as e:
logger.error(
f"[AgentInitializer] Failed to init embedding provider "
f"'{provider_key}/{model}': {e}"
)
return None
global _embedding_logged
if not _embedding_logged:
logger.info(
f"[AgentInitializer] Embedding model in use: "
f"{provider_key}/{model} (dim={provider.dimensions})"
)
_embedding_logged = True
return provider
@staticmethod
def _resolve_embedding_api_key(provider_key: str) -> str:
"""Pick the API key for an explicit embedding provider from config."""
from config import conf
key_map = {
"openai": "open_ai_api_key",
"linkai": "linkai_api_key",
"dashscope": "dashscope_api_key",
"doubao": "ark_api_key",
"zhipu": "zhipu_ai_api_key",
}
field = key_map.get(provider_key)
if not field:
return ""
value = conf().get(field, "") or ""
if value in ["", "YOUR API KEY", "YOUR_API_KEY"]:
return ""
return value
@staticmethod
def _resolve_embedding_api_base(provider_key: str, default_base: str) -> str:
"""Pick the API base for an explicit embedding provider from config."""
from config import conf
base_map = {
"openai": "open_ai_api_base",
"linkai": "linkai_api_base",
"doubao": "ark_base_url",
"zhipu": "zhipu_ai_api_base",
}
field = base_map.get(provider_key)
if not field:
return default_base
value = (conf().get(field) or "").strip()
if not value:
return default_base
if provider_key == "linkai" and not value.rstrip("/").endswith("/v1"):
return f"{value.rstrip('/')}/v1"
return value
def _sync_memory(self, memory_manager, session_id: Optional[str] = None):
"""Sync memory database"""
@@ -362,7 +521,7 @@ class AgentInitializer:
if tool_name == "web_search":
from agent.tools.web_search.web_search import WebSearch
if not WebSearch.is_available():
logger.debug("[AgentInitializer] WebSearch skipped - no BOCHA_API_KEY or LINKAI_API_KEY")
logger.debug("[AgentInitializer] WebSearch skipped - no search provider configured")
continue
# Special handling for EnvConfig tool
@@ -373,16 +532,33 @@ class AgentInitializer:
tool = tool_manager.create_tool(tool_name)
if tool:
# Apply workspace config to file operation tools
# Apply workspace config to file operation tools.
# Merge into the existing tool.config (set by ToolManager from
# config.json's `tools.<name>` section) instead of replacing
# it, otherwise per-tool user configs (e.g. browser.cdp_endpoint)
# would be silently dropped.
if tool_name in ['read', 'write', 'edit', 'bash', 'grep', 'find', 'ls', 'web_fetch', 'send', 'browser']:
tool.config = file_config
tool.cwd = file_config.get("cwd", getattr(tool, 'cwd', None))
if 'memory_manager' in file_config:
tool.memory_manager = file_config['memory_manager']
merged_config = dict(getattr(tool, 'config', None) or {})
merged_config.update(file_config)
tool.config = merged_config
tool.cwd = merged_config.get("cwd", getattr(tool, 'cwd', None))
if 'memory_manager' in merged_config:
tool.memory_manager = merged_config['memory_manager']
tools.append(tool)
except Exception as e:
logger.warning(f"[AgentInitializer] Failed to load tool {tool_name}: {e}")
# Add MCP tools (snapshot to avoid races with the background loader)
mcp_tools_snapshot = list(tool_manager._mcp_tool_instances.items())
if mcp_tools_snapshot:
for _, mcp_tool in mcp_tools_snapshot:
tools.append(mcp_tool)
if session_id is None:
names = [name for name, _ in mcp_tools_snapshot]
logger.info(
f"[AgentInitializer] Added {len(names)} MCP tool(s): {names}"
)
# Add memory tools
if memory_tools:
tools.extend(memory_tools)
@@ -395,16 +571,23 @@ class AgentInitializer:
return tools
def _initialize_scheduler(self, tools: List, session_id: Optional[str] = None):
"""Initialize scheduler service if needed"""
"""Initialize scheduler service if needed.
Serialize the check-and-set under a module-level lock so concurrent
first-time session inits cannot each create a new SchedulerService
(which would leak background scanning threads).
"""
if not self.agent_bridge.scheduler_initialized:
try:
from agent.tools.scheduler.integration import init_scheduler
if init_scheduler(self.agent_bridge):
self.agent_bridge.scheduler_initialized = True
if session_id is None:
logger.info("[AgentInitializer] Scheduler service initialized")
except Exception as e:
logger.warning(f"[AgentInitializer] Failed to initialize scheduler: {e}")
with _scheduler_init_lock:
if not self.agent_bridge.scheduler_initialized:
try:
from agent.tools.scheduler.integration import init_scheduler
if init_scheduler(self.agent_bridge):
self.agent_bridge.scheduler_initialized = True
if session_id is None:
logger.info("[AgentInitializer] Scheduler service initialized")
except Exception as e:
logger.warning(f"[AgentInitializer] Failed to initialize scheduler: {e}")
# Inject scheduler dependencies
if self.agent_bridge.scheduler_initialized:

View File

@@ -14,7 +14,9 @@ class Bridge(object):
def __init__(self):
self.btype = {
"chat": const.OPENAI,
"voice_to_text": conf().get("voice_to_text", "openai"),
# Empty `voice_to_text` (the default in new configs) triggers
# the auto-pick below — see _auto_pick_voice_to_text for order.
"voice_to_text": conf().get("voice_to_text") or self._auto_pick_voice_to_text(),
"text_to_voice": conf().get("text_to_voice", "google"),
"translate": conf().get("translate", "baidu"),
}
@@ -84,6 +86,46 @@ class Bridge(object):
self.chat_bots = {}
self._agent_bridge = None
def refresh_voice(self):
"""Re-read voice_to_text / text_to_voice from config and drop the
cached voice bots so the next call picks up the new provider.
Used by the web console after the user edits voice settings.
Does NOT touch the agent_bridge / agent state.
"""
new_v2t = conf().get("voice_to_text") or self._auto_pick_voice_to_text()
new_t2v = conf().get("text_to_voice", "google")
if conf().get("use_linkai") and conf().get("linkai_api_key"):
if not conf().get("voice_to_text") or conf().get("voice_to_text") in ["openai"]:
new_v2t = const.LINKAI
if not conf().get("text_to_voice") or conf().get("text_to_voice") in ["openai", const.TTS_1, const.TTS_1_HD]:
new_t2v = const.LINKAI
self.btype["voice_to_text"] = new_v2t
self.btype["text_to_voice"] = new_t2v
self.bots.pop("voice_to_text", None)
self.bots.pop("text_to_voice", None)
logger.info(f"[Bridge] voice refreshed: voice_to_text={new_v2t}, text_to_voice={new_t2v}")
@staticmethod
def _auto_pick_voice_to_text() -> str:
"""Pick an ASR provider by configured api keys when voice_to_text is
unset. Order matches the web console: openai → dashscope → zhipu →
linkai. Falls back to 'openai' when nothing is configured so the
original "missing key" error is preserved.
"""
def has(k: str) -> bool:
v = (conf().get(k) or "").strip()
return v != "" and v not in ("YOUR API KEY", "YOUR_API_KEY")
for key, provider in (
("open_ai_api_key", "openai"),
("dashscope_api_key", "dashscope"),
("zhipu_ai_api_key", "zhipu"),
("linkai_api_key", "linkai"),
):
if has(key):
return provider
return "openai"
# 模型对应的接口
def get_bot(self, typename):
if self.bots.get(typename) is None:

View File

@@ -171,7 +171,13 @@ class ChatChannel(Channel):
if "desire_rtype" not in context and conf().get("always_reply_voice") and ReplyType.VOICE not in self.NOT_SUPPORT_REPLYTYPE:
context["desire_rtype"] = ReplyType.VOICE
elif context.type == ContextType.VOICE:
if "desire_rtype" not in context and conf().get("voice_reply_voice") and ReplyType.VOICE not in self.NOT_SUPPORT_REPLYTYPE:
# Voice input replies with voice when either voice_reply_voice
# (mirror voice) or the global always_reply_voice toggle is on.
if (
"desire_rtype" not in context
and (conf().get("voice_reply_voice") or conf().get("always_reply_voice"))
and ReplyType.VOICE not in self.NOT_SUPPORT_REPLYTYPE
):
context["desire_rtype"] = ReplyType.VOICE
return context
@@ -264,6 +270,8 @@ class ChatChannel(Channel):
if reply.type == ReplyType.TEXT:
reply_text = reply.content
if desire_rtype == ReplyType.VOICE and ReplyType.VOICE not in self.NOT_SUPPORT_REPLYTYPE:
# Preserve original text for the "text-then-voice" pattern in _send_reply.
context["voice_reply_text"] = reply.content
reply = super().build_text_to_voice(reply.content)
return self._decorate_reply(context, reply)
if context.get("isgroup", False):
@@ -311,6 +319,15 @@ class ChatChannel(Channel):
# 短暂延迟后发送图片
time.sleep(0.3)
self._send(reply, context)
# Send text bubble before voice, unless channel already streamed
# the text (feishu) or natively renders STT under the voice (wechatcom).
elif reply.type == ReplyType.VOICE and context.get("voice_reply_text") \
and not context.get("feishu_streamed") \
and context.get("channel_type") not in ("wechatcom_app",):
text_reply = Reply(ReplyType.TEXT, context.get("voice_reply_text"))
self._send(text_reply, context)
time.sleep(0.3)
self._send(reply, context)
else:
self._send(reply, context)

View File

@@ -86,6 +86,8 @@ def _check(func):
@singleton
class DingTalkChanel(ChatChannel, dingtalk_stream.ChatbotHandler):
NOT_SUPPORT_REPLYTYPE = []
dingtalk_client_id = conf().get('dingtalk_client_id')
dingtalk_client_secret = conf().get('dingtalk_client_secret')
@@ -870,6 +872,48 @@ class DingTalkChanel(ChatChannel, dingtalk_stream.ChatbotHandler):
self.reply_text("抱歉,文件上传失败", incoming_message)
return
# Native sampleAudio. Upload only accepts ogg/amr, so convert TTS mp3/wav to amr.
elif reply.type == ReplyType.VOICE:
logger.info(f"[DingTalk] Sending voice: {reply.content}")
access_token = self.get_access_token()
if not access_token:
logger.error("[DingTalk] Cannot get access token for voice")
self.reply_text("抱歉语音发送失败无法获取token", incoming_message)
return
voice_path = reply.content
if voice_path.startswith("file://"):
voice_path = voice_path[7:]
amr_path = voice_path
duration_ms = 0
if not voice_path.lower().endswith((".amr", ".ogg")):
try:
from voice.audio_convert import any_to_amr
amr_path = os.path.splitext(voice_path)[0] + ".amr"
duration_ms = int(any_to_amr(voice_path, amr_path) or 0)
except Exception as e:
logger.error(f"[DingTalk] Failed to convert voice to amr: {e}")
self.reply_text("抱歉,语音转码失败", incoming_message)
return
media_id = self.upload_media(amr_path, media_type="voice")
if not media_id:
logger.error("[DingTalk] Failed to upload voice media")
self.reply_text("抱歉,语音上传失败", incoming_message)
return
msg_param = {
"mediaId": media_id,
"duration": str(duration_ms or 1000),
}
success = self._send_file_message(
access_token, incoming_message, "sampleAudio", msg_param, isgroup
)
if not success:
self.reply_text("抱歉,语音发送失败", incoming_message)
return
# 处理文本消息
elif reply.type == ReplyType.TEXT:
logger.info(f"[DingTalk] Sending text message, length={len(reply.content)}")

View File

@@ -542,6 +542,32 @@ class FeiShuChanel(ChatChannel):
# 单张图片不直接处理,等待用户提问
return
# 如果是文件消息,触发实际下载并缓存,等待用户后续提问时一并带上。
# 与 wecom_bot 行为对齐:发文件后静默缓存(飞书客户端会显示"已读"
# 用户下一条文本消息会自动 attach 上文件路径给 agent。
if feishu_msg.ctype == ContextType.FILE:
try:
feishu_msg.prepare()
# prepare 通过 _prepared 标记保证幂等,重复调用安全
if not os.path.exists(feishu_msg.content):
raise FileNotFoundError(feishu_msg.content)
except Exception as e:
logger.warning(f"[FeiShu] prepare file failed: {e}")
# 文件下载失败时主动通知用户,避免静默丢失
try:
err_reply = Reply(ReplyType.TEXT, f"⚠️ 文件下载失败,请重新发送:{e}")
self._send(err_reply, self._compose_context(
ContextType.TEXT, "",
isgroup=is_group, msg=feishu_msg,
receive_id_type=receive_id_type, no_need_at=True,
))
except Exception:
pass
return
file_cache.add(session_id, feishu_msg.content, file_type='file')
logger.info(f"[FeiShu] File cached for session {session_id}: {feishu_msg.content}")
return
# 如果是文本消息,检查是否有缓存的文件
if feishu_msg.ctype == ContextType.TEXT:
cached_files = file_cache.get(session_id)
@@ -1489,10 +1515,16 @@ class FeiShuChanel(ChatChannel):
else:
context.type = ContextType.TEXT
context.content = content.strip()
# Text input opts into voice replies only when the always-on toggle is set.
if "desire_rtype" not in context and conf().get("always_reply_voice"):
context["desire_rtype"] = ReplyType.VOICE
elif context.type == ContextType.VOICE:
# 2.语音请求
if "desire_rtype" not in context and conf().get("voice_reply_voice"):
# 2.语音请求: voice input replies with voice if either
# voice_reply_voice (mirror reply) or always_reply_voice is on.
if "desire_rtype" not in context and (
conf().get("voice_reply_voice") or conf().get("always_reply_voice")
):
context["desire_rtype"] = ReplyType.VOICE
return context

View File

@@ -144,7 +144,14 @@ class FeishuMessage(ChatMessage):
file_key = content.get("file_key")
file_name = content.get("file_name")
self.content = TmpDir().path() + file_key + "." + utils.get_path_suffix(file_name)
# 落到 agent_workspace/tmp 下(绝对路径),与图片处理一致;
# 否则相对路径 ./tmp 在 agent 工作区里 read 时会找不到。
workspace_root = expand_path(conf().get("agent_workspace", "~/cow"))
tmp_dir = os.path.join(workspace_root, "tmp")
os.makedirs(tmp_dir, exist_ok=True)
self.content = os.path.join(
tmp_dir, f"{file_key}.{utils.get_path_suffix(file_name)}"
)
def _download_file():
# 如果响应状态码是200则将响应内容写入本地文件
@@ -170,7 +177,11 @@ class FeishuMessage(ChatMessage):
content = json.loads(msg.get("content"))
file_key = content.get("file_key")
self.content = TmpDir().path() + file_key + ".opus"
# 落到 agent_workspace/tmp 下(绝对路径),保证语音 STT 流程可读到
workspace_root = expand_path(conf().get("agent_workspace", "~/cow"))
tmp_dir = os.path.join(workspace_root, "tmp")
os.makedirs(tmp_dir, exist_ok=True)
self.content = os.path.join(tmp_dir, f"{file_key}.opus")
logger.info(f"[FeiShu] audio message: file_key={file_key}, save_path={self.content}")
def _download_audio():

View File

@@ -5,20 +5,20 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CowAgent Console</title>
<link rel="icon" href="assets/favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/markdown-it@13.0.1/dist/markdown-it.min.js"></script>
<link id="hljs-light" rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css">
<link id="hljs-dark" rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css" disabled>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/python.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/javascript.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/java.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/go.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/bash.min.js"></script>
<!-- Vendored third-party assets (no external CDN dependency).
See channel/web/static/vendor/README.md for sources & versions. -->
<link rel="stylesheet" href="assets/vendor/fontawesome/css/all.min.css">
<link rel="stylesheet" href="assets/vendor/fonts/inter/inter.css">
<script src="assets/vendor/tailwind/tailwind.min.js"></script>
<script src="assets/vendor/markdown-it/markdown-it.min.js"></script>
<link id="hljs-light" rel="stylesheet" href="assets/vendor/highlightjs/styles/github.min.css">
<link id="hljs-dark" rel="stylesheet" href="assets/vendor/highlightjs/styles/github-dark.min.css" disabled>
<script src="assets/vendor/highlightjs/highlight.min.js"></script>
<script src="assets/vendor/highlightjs/languages/python.min.js"></script>
<script src="assets/vendor/highlightjs/languages/javascript.min.js"></script>
<script src="assets/vendor/highlightjs/languages/java.min.js"></script>
<script src="assets/vendor/highlightjs/languages/go.min.js"></script>
<script src="assets/vendor/highlightjs/languages/bash.min.js"></script>
<script>
tailwind.config = {
darkMode: 'class',
@@ -137,6 +137,11 @@
<i class="fas fa-sliders item-icon text-xs w-5 text-center"></i>
<span data-i18n="menu_config">配置</span>
</a>
<a class="sidebar-item flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 hover:bg-white/5 hover:text-neutral-200 text-[14px]"
data-view="models">
<i class="fas fa-microchip item-icon text-xs w-5 text-center"></i>
<span data-i18n="menu_models">模型</span>
</a>
<a class="sidebar-item flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 hover:bg-white/5 hover:text-neutral-200 text-[14px]"
data-view="skills">
<i class="fas fa-bolt item-icon text-xs w-5 text-center"></i>
@@ -398,22 +403,43 @@
<button id="attach-btn" class="w-9 h-10 flex items-center justify-center rounded-lg
text-slate-400 hover:text-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/20
cursor-pointer transition-colors duration-150"
onclick="document.getElementById('file-input').click()">
type="button"
onclick="toggleAttachMenu(event)">
<i class="fas fa-paperclip text-base"></i>
</button>
</div>
<input type="file" id="file-input" class="hidden" multiple
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.csv,.json,.xml,.zip,.rar,.7z,.py,.js,.ts,.java,.c,.cpp,.go,.rs,.md">
<input type="file" id="folder-input" class="hidden" multiple webkitdirectory directory>
<div id="attach-menu" class="attach-menu hidden">
<button id="attach-file-option" type="button" class="attach-menu-item" onclick="triggerFileUpload()">
<i class="fas fa-file-arrow-up"></i>
<span data-i18n="attach_menu_file">上传文件</span>
</button>
<button id="attach-folder-option" type="button" class="attach-menu-item" onclick="triggerFolderUpload()">
<i class="fas fa-folder-plus"></i>
<span data-i18n="attach_menu_folder">上传文件夹</span>
</button>
</div>
<div id="slash-menu" class="slash-menu hidden"></div>
<textarea id="chat-input"
class="flex-1 min-w-0 px-4 py-[10px] rounded-xl border border-slate-200 dark:border-slate-600
bg-slate-50 dark:bg-white/5 text-slate-800 dark:text-slate-100
placeholder:text-slate-400 dark:placeholder:text-slate-500
focus:outline-none focus:ring-0 focus:border-primary-600
text-sm leading-relaxed"
rows="1"
data-i18n-placeholder="input_placeholder"
placeholder="输入消息,或输入 / 使用指令"></textarea>
<div class="flex-1 min-w-0 relative flex items-center">
<textarea id="chat-input"
class="w-full pl-4 pr-11 py-[10px] rounded-xl border border-slate-200 dark:border-slate-600
bg-slate-50 dark:bg-white/5 text-slate-800 dark:text-slate-100
placeholder:text-slate-400 dark:placeholder:text-slate-500
focus:outline-none focus:ring-0 focus:border-primary-600
text-sm leading-relaxed"
rows="1"
data-i18n-placeholder="input_placeholder"
placeholder="输入消息,或输入 / 使用指令"></textarea>
<button id="mic-btn" type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 w-8 h-8 flex items-center justify-center rounded-lg
text-slate-400 hover:text-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/20
cursor-pointer transition-colors duration-150"
data-i18n-title="mic_idle_title" title="点击录音 / 再按一次结束">
<i class="fas fa-microphone text-sm"></i>
</button>
</div>
<button id="send-btn"
class="flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-lg
bg-primary-400 text-white hover:bg-primary-500
@@ -448,6 +474,11 @@
<i class="fas fa-microchip text-primary-500 text-sm"></i>
</div>
<h3 class="font-semibold text-slate-800 dark:text-slate-100" data-i18n="config_model">模型配置</h3>
<a class="ml-auto text-xs text-slate-500 dark:text-slate-400 hover:text-primary-500 dark:hover:text-primary-400 cursor-pointer transition-colors flex items-center gap-1"
onclick="navigateTo('models')">
<span data-i18n="config_model_advanced">高级配置</span>
<i class="fas fa-arrow-right text-[10px]"></i>
</a>
</div>
<div class="space-y-5">
<!-- Provider -->
@@ -838,6 +869,41 @@
</div>
</div>
<!-- ====================================================== -->
<!-- VIEW: Models -->
<!-- ====================================================== -->
<div id="view-models" class="view">
<!-- Tailwind JIT safelist: capability-card icon colors are
emitted from JS template strings. Listing them here
(display:none) guarantees the CDN-side compiler picks
them up regardless of render timing. -->
<div class="hidden bg-blue-50 dark:bg-blue-900/30 text-blue-500
bg-orange-50 dark:bg-orange-900/30 text-orange-500
bg-purple-50 dark:bg-purple-900/30 text-purple-500
bg-amber-50 dark:bg-amber-900/30 text-amber-500
bg-primary-50 dark:bg-primary-900/30 text-primary-500"></div>
<div class="flex-1 overflow-y-auto p-6">
<div class="max-w-4xl mx-auto">
<div class="flex items-center justify-between mb-6">
<div>
<h2 class="text-xl font-bold text-slate-800 dark:text-slate-100" data-i18n="models_title">模型管理</h2>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1" data-i18n="models_desc">统一管理对话、视觉、语音、向量、图像、搜索能力</p>
</div>
<button id="models-add-vendor-btn" onclick="openVendorModal('')"
class="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600
text-white text-sm font-medium cursor-pointer transition-colors duration-150">
<i class="fas fa-plus text-xs"></i>
<span data-i18n="models_add_vendor">添加厂商</span>
</button>
</div>
<div id="models-loading" class="flex items-center gap-2 py-12 justify-center text-slate-400 dark:text-slate-500 text-sm">
<i class="fas fa-spinner fa-spin text-xs"></i><span>Loading...</span>
</div>
<div id="models-content" class="grid gap-6 hidden"></div>
</div>
</div>
</div>
<!-- ====================================================== -->
<!-- VIEW: Channels -->
<!-- ====================================================== -->
@@ -907,6 +973,28 @@
</div>
<span class="text-xs text-slate-400 ml-2 font-mono">run.log</span>
<div class="flex-1"></div>
<div class="flex items-center gap-3 mr-2">
<label class="flex items-center gap-1 cursor-pointer select-none">
<input type="checkbox" class="log-filter-cb" data-level="debug" checked>
<span class="text-xs text-slate-400">DEBUG</span>
</label>
<label class="flex items-center gap-1 cursor-pointer select-none">
<input type="checkbox" class="log-filter-cb" data-level="info" checked>
<span class="text-xs text-blue-400">INFO</span>
</label>
<label class="flex items-center gap-1 cursor-pointer select-none">
<input type="checkbox" class="log-filter-cb" data-level="warning" checked>
<span class="text-xs text-yellow-400">WARNING</span>
</label>
<label class="flex items-center gap-1 cursor-pointer select-none">
<input type="checkbox" class="log-filter-cb" data-level="error" checked>
<span class="text-xs text-red-400">ERROR</span>
</label>
<label class="flex items-center gap-1 cursor-pointer select-none">
<input type="checkbox" class="log-filter-cb" data-level="critical" checked>
<span class="text-xs text-white font-bold">CRITICAL</span>
</label>
</div>
<div class="flex items-center gap-1.5">
<span class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>
<span class="text-xs text-slate-500" data-i18n="logs_live">实时</span>
@@ -925,7 +1013,7 @@
</div><!-- /app -->
<!-- Confirm Dialog -->
<div id="confirm-dialog-overlay" class="fixed inset-0 bg-black/50 z-[100] hidden flex items-center justify-center">
<div id="confirm-dialog-overlay" class="fixed inset-0 bg-black/50 z-[200] hidden flex items-center justify-center">
<div class="bg-white dark:bg-[#1A1A1A] rounded-2xl border border-slate-200 dark:border-white/10 shadow-xl
w-full max-w-sm mx-4 overflow-hidden">
<div class="p-6">
@@ -950,7 +1038,77 @@
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
<script src="assets/js/console.js"></script>
<!-- Vendor Credentials Modal -->
<div id="vendor-modal-overlay" class="fixed inset-0 bg-black/50 z-[100] hidden flex items-center justify-center">
<div class="bg-white dark:bg-[#1A1A1A] rounded-2xl border border-slate-200 dark:border-white/10 shadow-xl
w-full max-w-md mx-4">
<div class="p-6">
<div class="flex items-center gap-3 mb-5">
<div class="w-10 h-10 rounded-xl bg-primary-50 dark:bg-primary-900/20 flex items-center justify-center flex-shrink-0">
<i class="fas fa-key text-primary-500"></i>
</div>
<div class="min-w-0 flex-1">
<h3 id="vendor-modal-title" class="font-semibold text-slate-800 dark:text-slate-100 text-base"></h3>
<p id="vendor-modal-subtitle" class="text-xs text-slate-500 dark:text-slate-400 mt-0.5 font-mono"></p>
</div>
</div>
<!-- Provider selector (only visible when adding via top button) -->
<div id="vendor-modal-picker-wrap" class="mb-4 hidden">
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5" data-i18n="models_provider">厂商</label>
<div id="vendor-modal-picker" class="cfg-dropdown" tabindex="0">
<div class="cfg-dropdown-selected">
<span class="cfg-dropdown-text">--</span>
<i class="fas fa-chevron-down cfg-dropdown-arrow"></i>
</div>
<div class="cfg-dropdown-menu"></div>
</div>
</div>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">API Key</label>
<input id="vendor-modal-key" type="text" autocomplete="off" data-1p-ignore data-lpignore="true"
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
focus:outline-none focus:border-primary-500 font-mono transition-colors"
placeholder="sk-...">
</div>
<div id="vendor-modal-base-wrap">
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">API Base</label>
<input id="vendor-modal-base" type="text"
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
focus:outline-none focus:border-primary-500 font-mono transition-colors"
placeholder="https://...../v1">
<p id="vendor-modal-base-hint" class="mt-1.5 text-xs text-slate-400 dark:text-slate-500 hidden">
<i class="fas fa-info-circle mr-1"></i><span data-i18n="models_base_default_hint">留空将使用官方默认地址</span>
</p>
</div>
</div>
</div>
<div class="flex items-center justify-between gap-3 px-6 py-4 border-t border-slate-100 dark:border-white/5 rounded-b-2xl">
<button id="vendor-modal-clear"
class="px-3 py-2 rounded-lg text-xs
text-red-500 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20
cursor-pointer transition-colors duration-150 hidden"
data-i18n="models_clear_credential">清除凭据</button>
<span id="vendor-modal-status"
class="flex-1 text-xs text-primary-500 opacity-0 transition-opacity duration-300 text-center"></span>
<button id="vendor-modal-cancel"
class="px-4 py-2 rounded-lg border border-slate-200 dark:border-white/10
text-slate-600 dark:text-slate-300 text-sm font-medium
hover:bg-slate-50 dark:hover:bg-white/5
cursor-pointer transition-colors duration-150"
data-i18n="cancel">取消</button>
<button id="vendor-modal-save"
class="px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-sm font-medium
cursor-pointer transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed"
data-i18n="save">保存</button>
</div>
</div>
</div>
<script defer src="assets/js/console.js"></script>
</body>
</html>

View File

@@ -606,6 +606,14 @@
}
.tool-error-text { color: #f87171; }
/* Log level highlighting */
.log-line { display: block; }
.log-line-debug { color: #94a3b8; }
.log-line-info { background-color: rgba(59, 130, 246, 0.08); }
.log-line-warning { background-color: rgba(234, 179, 8, 0.15); color: #fde68a; }
.log-line-error { background-color: rgba(239, 68, 68, 0.15); color: #fca5a5; }
.log-line-critical { background-color: rgba(239, 68, 68, 0.35); color: #ff4444; font-weight: bold; }
/* Tool failed state */
.agent-tool-step.tool-failed .tool-name { color: #f87171; }
@@ -717,6 +725,58 @@
background: rgba(74, 190, 110, 0.15);
color: #74E9A4;
}
/* When an item carries a hint (e.g. brand alias next to a technical model
id), label/hint are split into two spans so the hint sits on the right in
a dim, smaller weight. Without a hint the row stays a plain text node and
uses the default ellipsis behaviour, so no layout regressions for old call
sites. */
.cfg-dropdown-label {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.cfg-dropdown-hint {
flex-shrink: 0;
margin-left: auto;
padding-left: 12px;
color: #94a3b8;
font-size: 12px;
font-weight: 400;
}
.dark .cfg-dropdown-hint {
color: #64748b;
}
.cfg-dropdown-item.active .cfg-dropdown-hint {
/* Tint the hint toward the brand colour on the active row so it doesn't
fight with the highlighted label tone. */
color: rgba(34, 133, 71, 0.65);
}
.dark .cfg-dropdown-item.active .cfg-dropdown-hint {
color: rgba(116, 233, 164, 0.6);
}
/* The active row gets a trailing brand-green checkmark via a Font Awesome
pseudo-element so every dropdown (chat / vision / image / asr / tts / etc.)
surfaces "this is what's currently selected" without per-call JS plumbing.
When a hint is present, the ✓ sits to its right with a small gap; without
a hint, margin-left:auto pushes the ✓ flush against the right edge. */
.cfg-dropdown-item.active::after {
content: '\f00c'; /* FontAwesome check glyph */
font-family: 'Font Awesome 6 Free', 'Font Awesome 5 Free', 'FontAwesome';
font-weight: 900;
margin-left: auto;
padding-left: 12px;
color: #4abe6e;
font-size: 11px;
flex-shrink: 0;
}
.cfg-dropdown-item.active:has(.cfg-dropdown-hint)::after {
/* When hint occupies the auto-margin slot, the ✓ no longer benefits
from `margin-left: auto`; replace it with a small fixed gap so the
✓ trails the hint cleanly. */
margin-left: 0;
padding-left: 10px;
}
/* API Key masking via CSS (avoids browser password prompts) */
.cfg-key-masked {
@@ -724,6 +784,77 @@
text-security: disc;
}
/* Provider logo image — vendors flagged as `provider-logo-invert-dark`
ship a black wordmark that disappears on the dark canvas; we invert their
luminance only in dark mode so the brand stays recognizable without
touching multi-color marks like Google/MiniMax. */
.provider-logo-img {
object-fit: contain;
object-position: center;
}
.dark .provider-logo-invert-dark {
filter: invert(1) brightness(1.15);
}
/* Models page — provider dropdown rows.
Configured rows look like ordinary picker entries; the .active row's
trailing brand-green ✓ already announces "this is what's selected"
(handled globally by .cfg-dropdown-item.active::after above).
Unconfigured rows are visually subdued and carry a trailing gear icon
as a "click to set up" affordance. */
.cap-provider-label {
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
}
.cap-provider-gear {
margin-left: auto;
padding-left: 12px;
color: #94a3b8;
font-size: 11px;
flex-shrink: 0;
}
.cap-provider-item.cap-provider-unconfigured {
color: #94a3b8;
}
.dark .cap-provider-item.cap-provider-unconfigured {
color: #64748b;
}
.cap-provider-item.cap-provider-unconfigured:hover {
color: #475569;
}
.dark .cap-provider-item.cap-provider-unconfigured:hover {
color: #cbd5e1;
}
.cap-provider-item.cap-provider-unconfigured:hover .cap-provider-gear {
color: #475569;
}
.dark .cap-provider-item.cap-provider-unconfigured:hover .cap-provider-gear {
color: #cbd5e1;
}
/* If the active row ever lands on an unconfigured vendor (defensive — the
click handler normally diverts to the modal), suppress the global ✓ so
the gear remains the sole trailing icon and the row keeps reading as
"needs setup" rather than "already selected". */
.cap-provider-item.cap-provider-unconfigured.active::after {
content: none;
}
/* "Add vendor" modal picker — each configured row carries a static
brand-green ✓ via decorateVendorModalPicker so users can see what's set
up at a glance. The active row's global ✓ is suppressed here to avoid
showing two checks side by side on configured + selected rows. */
.vendor-picker-item.active::after {
content: none;
}
.vendor-picker-configured-mark {
margin-left: auto;
padding-left: 12px;
color: #4abe6e;
font-size: 11px;
flex-shrink: 0;
}
/* Chat Input */
#chat-input {
resize: none; height: 42px; max-height: 180px;
@@ -740,6 +871,46 @@
}
.attachment-preview.hidden { display: none; }
.attach-menu {
position: absolute;
left: 72px;
bottom: calc(100% + 6px);
min-width: 148px;
padding: 6px;
border-radius: 12px;
background: #fff;
border: 1px solid #e2e8f0;
box-shadow: 0 8px 30px -6px rgba(0, 0, 0, 0.1), 0 2px 8px -2px rgba(0, 0, 0, 0.04);
z-index: 55;
animation: slashMenuIn 0.15s ease-out;
}
.attach-menu.hidden { display: none; }
.attach-menu-item {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border: none;
border-radius: 8px;
background: transparent;
color: #334155;
font-size: 13px;
cursor: pointer;
transition: background 0.12s ease, color 0.12s ease;
text-align: left;
}
.attach-menu-item:hover {
background: #EDFDF3;
color: #228547;
}
.attach-menu-item i {
width: 14px;
text-align: center;
color: #64748b;
}
.attach-menu-item:hover i { color: inherit; }
.att-thumb {
position: relative;
width: 64px; height: 64px;
@@ -918,6 +1089,22 @@
color: #64748b;
}
.dark .attach-menu {
background: #1A1A1A;
border-color: rgba(255, 255, 255, 0.1);
box-shadow: 0 8px 30px -6px rgba(0, 0, 0, 0.35), 0 2px 8px -2px rgba(0, 0, 0, 0.15);
}
.dark .attach-menu-item {
color: #e2e8f0;
}
.dark .attach-menu-item i {
color: #94a3b8;
}
.dark .attach-menu-item:hover {
background: rgba(74, 190, 110, 0.1);
color: #4ABE6E;
}
/* ============================================================
Knowledge View
============================================================ */
@@ -1107,3 +1294,76 @@
overflow: hidden;
min-height: 2.5em; /* ~2 lines at text-sm leading-relaxed */
}
/* --------------------------------------------------------------------
* Voice pill — compact custom audio player used by mic uploads and TTS
* replies. Replaces the bulky native <audio controls> with a play/pause
* icon + thin progress bar + duration counter so it blends into chat
* bubbles without the chrome-grey browser default look.
* ------------------------------------------------------------------ */
.voice-pill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 999px;
background: rgba(15, 23, 42, 0.05);
color: rgb(71, 85, 105);
font-size: 12px;
line-height: 1;
max-width: 240px;
user-select: none;
cursor: default;
}
.dark .voice-pill {
background: rgba(255, 255, 255, 0.08);
color: rgb(203, 213, 225);
}
.voice-pill[data-loading="1"] {
opacity: 0.65;
}
.voice-pill-btn {
width: 22px;
height: 22px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--color-primary-500, #2563eb);
color: #fff;
flex-shrink: 0;
cursor: pointer;
transition: transform 0.1s ease;
}
.voice-pill-btn:hover { transform: scale(1.05); }
.voice-pill-btn i { font-size: 9px; margin-left: 1px; }
.voice-pill-btn[data-state="play"] i { margin-left: 2px; }
.voice-pill-btn[data-state="pause"] i { margin-left: 0; }
.voice-pill-track {
flex: 1;
height: 3px;
border-radius: 999px;
background: rgba(100, 116, 139, 0.25);
overflow: hidden;
min-width: 70px;
}
.dark .voice-pill-track {
background: rgba(148, 163, 184, 0.25);
}
.voice-pill-fill {
height: 100%;
width: 0%;
background: var(--color-primary-500, #2563eb);
border-radius: inherit;
transition: width 0.1s linear;
}
.voice-pill-time {
font-variant-numeric: tabular-nums;
font-size: 11px;
color: inherit;
opacity: 0.75;
flex-shrink: 0;
min-width: 28px;
text-align: right;
}
.voice-pill audio { display: none; }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1779251656961" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="18432" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M252.8 652.8l167.893333-94.293333 2.773334-8.106667-2.773334-4.48h-8.106666l-28.16-1.706667-96-2.56-83.2-3.413333-80.64-4.266667-20.266667-4.266666L85.333333 504.746667l1.92-12.586667 17.066667-11.52 24.32 2.133333 53.973333 3.626667 81.066667 5.546667 58.666667 3.413333 87.04 9.173333h13.866666l1.92-5.546666-4.693333-3.413334-3.626667-3.413333-83.84-56.746667-90.666666-60.16-47.573334-34.56-25.813333-17.493333-13.013333-16.426667-5.546667-35.84 23.253333-25.813333 31.36 2.133333 7.893334 2.133334 31.786666 24.32 67.84 52.48L401.066667 391.466667l13.013333 10.88 5.12-3.626667 0.64-2.56-5.76-9.813333-48.213333-87.04L314.453333 210.773333l-22.826666-36.693333-5.973334-21.973333a107.861333 107.861333 0 0 1-3.626666-26.026667l26.666666-36.053333L323.413333 85.333333l35.413334 4.693334 14.933333 13.013333 21.973333 50.346667 35.626667 79.36 55.253333 107.733333 16.213334 32 8.746666 29.653333 3.2 9.173334h5.546667v-5.12l4.48-60.8 8.32-74.453334 8.106667-96 2.773333-27.093333 13.44-32.426667 26.666667-17.493333 20.693333 10.026667 17.066667 24.32-2.346667 15.786666-10.24 65.92-19.84 103.253334-13.013333 69.12h7.466666l8.746667-8.746667 34.986667-46.506667 58.666666-73.386666 26.026667-29.226667 30.293333-32.213333 19.413334-15.36h36.693333l27.093333 40.106666-12.16 41.386667-37.76 48-31.36 40.533333-45.013333 60.586667-28.16 48.426667 2.56 3.84 6.613333-0.64 101.546667-21.546667 54.826667-10.026667 65.493333-11.306666 29.653333 13.866666 3.2 14.08-11.733333 28.8-69.973333 17.28-82.133334 16.426667-122.24 29.013333-1.493333 1.066667 1.706667 2.133333 55.04 5.12 23.466666 1.28h57.6l107.306667 7.893334 28.16 18.56 16.853333 22.613333-2.773333 17.28-43.306667 21.973333-58.24-13.866666-136.106666-32.426667-46.72-11.733333h-6.4v3.84l38.826666 37.973333 71.253334 64.426667 89.173333 82.986666 4.48 20.48-11.52 16.213334-12.16-1.706667-78.506667-58.88-30.293333-26.666667-68.48-57.6h-4.48v5.973334l15.786667 23.04 83.413333 125.226666 4.266667 38.4-5.973334 12.586667-21.546666 7.466667-23.68-4.266667-48.853334-68.48-50.346666-77.226667-40.533334-69.12-4.906666 2.773334-23.893334 258.133333-11.306666 13.226667-26.026667 10.026666-21.546667-16.426666-11.52-26.666667 11.52-52.48 13.866667-68.48 11.306667-54.4 10.24-67.626667 5.973333-22.4-0.426667-1.493333-4.906666 0.64-50.986667 69.973333-77.653333 104.746667-61.44 65.706667-14.72 5.76-25.386667-13.226667 2.346667-23.466667 14.293333-20.906666 84.906667-107.946667 51.2-66.986667 33.066666-38.613333v-5.546667h-2.133333l-225.493333 146.56-40.106667 5.12-17.28-16.213333 2.133333-26.666667 8.106667-8.746666 67.84-46.72h-0.213333l0.853333 0.853333z" fill="#D97757" p-id="18433"></path></svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="200" height="200" fill="none" stroke="#475569" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<!-- Horizontal slider tracks -->
<line x1="4" y1="7" x2="20" y2="7"/>
<line x1="4" y1="12" x2="20" y2="12"/>
<line x1="4" y1="17" x2="20" y2="17"/>
<!-- Knobs (filled circles) -->
<circle cx="9" cy="7" r="2.2" fill="#475569" stroke="none"/>
<circle cx="15" cy="12" r="2.2" fill="#475569" stroke="none"/>
<circle cx="7" cy="17" r="2.2" fill="#475569" stroke="none"/>
</svg>

After

Width:  |  Height:  |  Size: 573 B

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1779251621200" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="17444" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M1019.364785 620.816931L891.797142 397.807295 946.450846 293.15069a29.097778 29.097778 0 0 0 6.399732-36.393472l-70.184053-126.586684a30.078737 30.078737 0 0 0-24.574968-13.652427H597.4945L539.171949 14.549389a27.348852 27.348852 0 0 0-20.906122-14.549389H380.628607a29.139776 29.139776 0 0 0-24.616967 14.549389v5.545767L225.797108 243.062793H100.919352a29.182775 29.182775 0 0 0-25.513928 13.653427L3.428446 384.11187a32.766624 32.766624 0 0 0 0 29.182775L132.831012 638.096205 74.508461 740.064923a32.766624 32.766624 0 0 0 0 29.05478l66.514207 116.561105a29.905744 29.905744 0 0 0 25.513929 14.505391H427.132654l62.845361 109.222414A30.078737 30.078737 0 0 0 512.762058 1024H660.382859a29.139776 29.139776 0 0 0 24.574968-14.549389l128.463606-224.843558h114.76818a31.91366 31.91366 0 0 0 24.660965-15.444352l66.471208-117.414069a28.158818 28.158818 0 0 0 0-30.9747l0.042999 0.042999z m-161.273228 14.591387L791.57735 512.490479 518.265827 993.964261l-74.748861-122.87484h-273.268525l65.618244-119.205994h139.386147L101.856313 272.244568h143.055993L380.671605 30.121735l68.34913 119.247993-70.184053 122.87484H925.501726l-69.202094 121.936879 137.594222 241.183873H858.134555z" fill="#605BEC" p-id="17445"></path><path d="M499.962596 699.320634l174.371677-274.719464H324.694955z" fill="#605BEC" p-id="17446"></path></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.1 KiB

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1779261485522" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5381" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M958.976 439.808C804.864 336.896 642.56 321.536 642.56 321.536s8.192 235.008-10.752 306.176c-0.512 9.728-11.776 75.264-43.008 157.696-10.752 28.16-24.064 55.296-39.424 81.408-40.96 74.24-89.6 127.488-89.6 127.488 119.808-48.64 205.312-92.672 309.76-175.616 122.88-96.768 229.376-254.464 189.44-378.88z" fill="#37E1BE" p-id="5382"></path><path d="M329.728 395.776c158.208-100.864 308.736-78.848 312.32-74.752 0.512 0.512 1.024 0.512 1.024 0.512 0-14.336-6.656-60.928-13.312-106.496-11.776-60.928-22.528-124.928-23.04-133.632-170.496-139.264-356.864-78.336-448 25.6-61.44 70.144-103.424 169.984-102.4 224.256V762.88c0.512-12.8 1.536-20.48 2.048-20.48 17.92-197.12 271.36-346.624 271.36-346.624z" fill="#A569FF" p-id="5383"></path><path d="M792.064 272.384c-41.984-43.52-87.552-88.576-122.368-125.44-33.28-34.816-59.392-60.928-62.976-65.536 0.512 8.704 11.264 72.704 23.04 133.632 6.656 45.568 12.8 92.672 13.312 106.496 0 0 162.304 15.36 316.416 118.272-0.512 0-83.456-80.384-167.424-167.424zM549.888 866.816c-2.56 1.024-198.656 107.008-292.352-30.72-20.992-30.72-31.744-68.096-33.28-106.496-3.072-74.752 5.12-227.84 105.472-333.824 0 0-253.44 149.504-270.848 346.624-0.512 0.512-2.048 8.192-2.048 20.48-1.024 32.768 4.608 98.304 43.008 155.136 52.224 78.336 193.024 138.752 328.192 85.504l33.28-9.728c-1.024 0.512 47.616-52.224 88.576-126.976z" fill="#1E37FC" p-id="5384"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1779251750646" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="29551" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M214.101333 512c0-32.512 5.546667-63.701333 15.36-92.928L57.173333 290.218667A491.861333 491.861333 0 0 0 4.693333 512c0 79.701333 18.858667 154.88 52.394667 221.610667l172.202667-129.066667A290.56 290.56 0 0 1 214.101333 512" fill="#FBBC05" p-id="29552"></path><path d="M516.693333 216.192c72.106667 0 137.258667 25.002667 188.458667 65.962667L854.101333 136.533333C763.349333 59.178667 646.997333 11.392 516.693333 11.392c-202.325333 0-376.234667 113.28-459.52 278.826667l172.373334 128.853333c39.68-118.016 152.832-202.88 287.146666-202.88" fill="#EA4335" p-id="29553"></path><path d="M516.693333 807.808c-134.357333 0-247.509333-84.864-287.232-202.88l-172.288 128.853333c83.242667 165.546667 257.152 278.826667 459.52 278.826667 124.842667 0 244.053333-43.392 333.568-124.757333l-163.584-123.818667c-46.122667 28.458667-104.234667 43.776-170.026666 43.776" fill="#34A853" p-id="29554"></path><path d="M1005.397333 512c0-29.568-4.693333-61.44-11.648-91.008H516.650667V614.4h274.602666c-13.696 65.962667-51.072 116.650667-104.533333 149.632l163.541333 123.818667c93.994667-85.418667 155.136-212.650667 155.136-375.850667" fill="#4285F4" p-id="29555"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1779251514432" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="11888" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M415.392 475.808v329.984c-22.304 111.744-170.56 82.944-171.2 1.92-0.672-101.824 0-202.976 0-304.064v-117.184c0-14.656-3.2-26.24-16-35.392-24.96-18.72-54.944 3.264-55.584 30.208-1.408 36.16-0.704 71.616-1.408 107.264 0 28.16 0 55.52 0.64 83.648-18.368 123.776-168.32 103.232-171.808 0.704V487.04c0-28.032 54.944-34.624 52.256 7.36-1.792 20.8-0.64 42.272-1.344 62.912-0.64 36.8 55.648 61.6 68.896 1.408 0.64-49.632 0.64-99.264 0.64-149.344 0-62.752 17.824-113.856 84.352-118.624 28.8-2.56 47.968 9.504 66.336 30.304 7.04 7.36 23.68 30.72 24.32 56.16 0 23.456 0.64 46.752 0.64 70.464 0 46.72-0.64 93.76-0.64 140.48 0 30.304 0.64 60.256 0.64 89.856 0 37.536 0 75.552-0.64 113.152-0.64 48.864 58.816 48.16 68.352-0.768 0-57.632 0.64-114.56 0.64-172.192 0-141.984-0.64-283.968-0.64-425.856 0-14.72-2.048-55.584 5.76-70.464 41.504-101.12 167.392-56.96 168.544 26.72 2.432 171.52 0 344.896 0.64 516.8 0 59.616-48.416 46.816-51.104 23.488 0-178.88 0-358.4 0.64-537.024-2.368-44.832-68.832-38.72-72.672-6.592-1.28 36.864-0.64 74.4-1.28 111.232v219.008h0.64l0.448 0.256h-0.064z" fill="#D4367A" p-id="11889"></path><path d="M610.016 473.184v242.336V143.648c21.632-112.512 169.824-83.264 170.464-2.176 0.704 101.12 0 202.912 0.704 304 0 38.784 0 77.728-0.64 116.544 0 15.36 3.776 26.176 16.64 36.032 24.32 18.24 54.24-3.2 55.584-30.592 1.344-35.488 0.64-70.976 0.64-107.328V376.96c18.56-123.776 168.128-103.232 171.264-0.704v310.592c0 28.16-54.304 34.848-51.872-7.296 1.472-21.44 0-267.104 0.768-288.64 1.28-36.16-55.712-61.664-68.928-0.768v148.576c0 63.68-17.856 113.92-84.96 119.36-63.264 1.504-88.704-42.24-90.752-86.432V271.328c0-38.24 0-75.552 0.64-113.088 0.64-48.864-58.784-48.864-68.896 0.704V831.36c0 14.592 2.048 55.52-5.184 70.432-41.44 101.056-168 56.864-169.152-26.752v-79.616c3.136-53.6 48.416-40.864 50.464-18.176v94.464c2.432 44.928 68.928 39.488 72.064 6.656 1.344-36.896 1.344-73.728 1.344-111.296v-293.824h-0.192v-0.064z" fill="#ED6D48" p-id="11890"></path></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1779251592968" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="16416" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M117.9648 684.6464l342.30272 93.57312v75.34592l209.7152 58.5728A428.99456 428.99456 0 0 1 512 942.08c-176.128 0-327.53664-105.8816-394.0352-257.4336zM83.29216 477.42976l407.30624 112.64-9.6256 37.00736-6.0416 35.0208 383.3856 104.96a432.5376 432.5376 0 0 1-65.10592 70.32832l-688.18944-185.9584A429.4656 429.4656 0 0 1 81.92 512c0-11.63264 0.47104-23.1424 1.37216-34.54976z m57.344-182.4768l429.07648 114.21696a279.94112 279.94112 0 0 0-23.06048 35.55328 201.17504 201.17504 0 0 0-14.70464 34.93888l403.08736 110.26432a426.8032 426.8032 0 0 1-23.552 81.7152L86.54848 448.7168a427.25376 427.25376 0 0 1 54.0672-153.76384z m158.47424-156.75392l404.23424 108.31872a190.2592 190.2592 0 0 0-32.80896 24.90368c-9.13408 8.8064-19.8656 21.4016-32.1536 37.74464l285.24544 77.78304c9.216 30.45376 15.03232 61.8496 17.32608 93.5936L156.61056 269.68064a432.27136 432.27136 0 0 1 142.49984-131.4816zM512 81.92c142.90944 0 269.55776 69.71392 347.7504 176.98816L337.26464 118.90688A428.50304 428.50304 0 0 1 512 81.92z" fill="#000000" p-id="16417"></path></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1779251225589" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9015" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M881.664 431.488a218.88 218.88 0 0 0-18.176-177.088A218.624 218.624 0 0 0 628.992 149.76c-40.576-45.824-100.288-71.424-162.176-71.424a219.136 219.136 0 0 0-208 150.4 215.68 215.68 0 0 0-144 104.512 218.944 218.944 0 0 0 26.688 254.912 218.752 218.752 0 0 0 19.2 177.152 217.088 217.088 0 0 0 234.624 104.512 219.136 219.136 0 0 0 162.112 72.512 219.136 219.136 0 0 0 208-150.4 215.68 215.68 0 0 0 144-104.512 219.008 219.008 0 0 0-27.712-256z m-324.288 454.4a158.08 158.08 0 0 1-103.424-37.376c1.088-1.088 4.288-2.176 5.376-3.2l171.712-99.2a28.16 28.16 0 0 0 13.824-24.512V479.488l72.576 41.6c1.024 0 1.024 1.024 1.024 2.112v200.512a160.512 160.512 0 0 1-161.088 162.112z m-347.712-148.288c-19.2-33.088-25.6-71.488-19.2-108.8 1.088 1.024 3.2 2.176 5.376 3.2l171.712 99.2a25.984 25.984 0 0 0 27.712 0l210.112-121.6v84.224c0 1.152 0 2.176-1.024 2.176L430.464 796.16c-76.8 44.8-176 18.176-220.8-58.624z m-44.736-375.424c19.2-32.64 48.896-57.856 84.224-71.488v204.8c0 9.6 5.376 19.2 13.888 24.512l210.176 121.6-72.576 41.6c-1.024 0-2.112 1.088-2.112 0L224.64 582.912a160.448 160.448 0 0 1-59.776-220.8h0.064z m597.312 138.688l-210.112-121.6 72.512-41.6c1.088 0 2.176-1.088 2.176 0l173.824 100.224a161.088 161.088 0 0 1-25.6 291.2V525.44a26.304 26.304 0 0 0-12.8-24.512z m71.488-108.8a23.232 23.232 0 0 0-5.312-3.2L656.64 289.536a26.048 26.048 0 0 0-27.712 0l-210.176 121.6V326.912c0-1.088 0-2.176 1.088-2.176l173.824-100.224a161.152 161.152 0 0 1 220.8 59.712c19.2 32 25.6 70.4 19.2 107.776z m-454.4 149.248l-72.64-41.6c-1.024 0-1.024-1.088-1.024-2.176V297.088A162.048 162.048 0 0 1 467.84 135.04a158.08 158.08 0 0 1 103.424 37.312 22.848 22.848 0 0 1-5.312 3.2L394.24 274.688a28.16 28.16 0 0 0-13.888 24.512v242.112h-1.088z m39.424-85.312l93.824-54.4 93.888 54.4v107.712l-93.888 54.4-93.824-54.4V456z" fill="#000000" p-id="9016"></path></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1779251568791" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="14450" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M96.20121136 636.3124965c-0.1472897-113.41305959-0.29457937-226.8261192-0.29457937-340.23917879 0-14.87625845 7.65906378-26.51214381 20.4732666-34.02391789 45.51251353-26.65943349 91.02502705-53.31886698 136.83211997-79.53643141 71.1409192-40.94653321 142.42912809-81.59848704 213.71733698-122.39773055 7.36448439-4.12411126 14.58167909-8.3955122 21.50429441-13.2560719 19.44223878-13.40336159 39.03176725-16.05457598 60.09419263-3.53495252 27.39588193 16.34915535 54.93905355 32.25644163 82.48222516 48.16372793 88.0792333 50.96223197 176.30575629 101.77717426 264.38498958 152.59211653 9.86840908 5.74429781 19.88410785 11.19401627 29.60522725 17.0856038 14.13981003 8.54280189 21.50429441 21.06242535 21.50429443 37.70616007 0 147.73155685 0.29457937 295.46311371-0.1472897 443.19467057 0 15.46541722-7.2171947 28.57419943-21.7988738 36.96971163-34.7603663 20.17868721-70.55176044 38.88447758-104.57567833 59.94690293-48.90017634 30.19438599-100.00969801 56.11737105-148.76258466 86.60633642-29.01606849 18.11663161-59.50503387 34.02391789-89.11026112 50.96223197-13.10878221 7.51177407-26.07027474 15.17083783-39.03176726 22.9771913-13.84523065 8.3955122-27.83775099 8.83738127-41.97756102 0.73644843-56.41195043-32.55102101-112.82390085-65.10204201-169.38314098-97.653063-61.86166887-35.64410444-123.72333775-71.1409192-185.4377169-106.78502365-11.19401627-6.48074626-22.24074286-12.81420285-32.99289009-19.88410785-11.48859565-7.65906378-17.08560379-19.14765941-17.08560378-32.69831069-0.1472897-34.7603663 0.1472897-69.52073264 0.29457938-104.28109895 1.62018657-0.58915875 1.62018657-1.62018657-0.29457938-2.65121438z m356.58833414-225.500512c2.20934532-1.76747625 4.41869063-3.68224221 6.77532565-5.15513907 68.93157389-39.62092601 137.86314777-79.24185204 206.94201135-118.86277807 2.79850407-1.62018657 6.48074626-1.62018657 6.62803594-6.18616688 0.1472897-4.8605597-4.12411126-4.71327001-6.77532564-6.18616688-40.65195383-23.56635005-81.59848704-46.83812071-122.10315117-70.84633984-16.79102442-10.01569877-32.84560039-8.54280189-48.45830728 0.58915876-45.9543826 26.51214381-91.46689612 53.61344636-137.27398903 80.42016953-31.96186226 18.70579035-64.21830387 37.11700133-96.32745581 55.67550198-18.41121097 10.60485751-27.54317163 25.33382629-27.24859225 47.72185885 0.88373813 89.55213018 0.58915875 179.10426036 0.14728969 268.65639053-0.1472897 20.17868721 9.27925033 33.58204881 25.33382629 43.15587853 31.3727035 18.70579035 63.18727606 37.11700133 95.14913832 54.93905355 10.89943689 6.03887719 21.06242535 13.99252034 35.79139414 18.41121096V505.51925374c6.48074626 19.58952848 18.55850066 34.02391789 36.67513226 44.6287754 27.83775099 16.20186565 63.18727606 12.51962347 86.31175705-10.45756784 26.95401286-26.65943349 28.72148912-62.89269668 12.81420282-90.14128893-16.34915535-28.42690974-43.59774757-37.55887038-74.38129233-38.73718787z m82.48222517 429.64401928c14.28709972-3.82953187 25.92298506-13.99252034 38.88447758-21.35700473 40.94653321-23.27177067 81.30390766-47.72185885 122.54502023-70.55176046 26.95401286-15.02354815 52.87699792-31.66728287 80.71474891-45.21793415 16.79102442-8.10093283 29.60522723-22.53532223 29.60522726-43.4504579 0.1472897-92.939793 0.29457937-185.73229631 0.14728969-278.6720893 0-11.19401627-5.15513907-13.99252034-13.84523067-7.06990501-26.51214381 20.76784598-57.29568854 34.46578693-86.16446735 51.25681135-54.49718448 31.81457257-109.14165865 63.33456576-163.78613282 95.00184862-8.54280189 4.8605597-11.78317502 10.45756784-11.63588535 20.47326662 0.29457937 96.18016613 0.1472897 192.50762194 0.1472897 288.68778806-0.29457937 3.5349525-1.47289687 7.65906378 3.38766282 10.8994369z" fill="#066AF3" p-id="14451"></path><path d="M96.20121136 636.3124965c1.91476594 1.03102783 1.91476594 2.06205563 0 3.09308345v-3.09308345z" fill="#4372E0" p-id="14452"></path><path d="M391.3697457 505.37196405c-5.44971845-44.33419602 13.84523065-74.08671296 61.4197998-94.55997955 30.93083443 1.17831749 58.03213699 10.31027814 74.38129233 38.5898982 15.75999659 27.39588193 14.13981003 63.48185543-12.81420282 90.14128893-23.27177067 22.97719129-58.47400606 26.65943349-86.31175705 10.45756783-18.11663161-10.60485751-30.34167568-25.03924691-36.67513226-44.62877541z" fill="#002A9A" p-id="14453"></path></svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1779251419020" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="10062" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M520.063496 0v77.563152c0 269.231173-144.758953 414.054122-434.212862 434.340854L86.106618 511.968002H76.827198V255.984001l443.236298-255.984001z" fill="#5B55F6" p-id="10063"></path><path d="M520.063496 1023.936004v-77.563152c0-269.231173-144.758953-414.054122-434.212862-434.340854L86.042622 511.968002H76.827198v255.984001l443.236298 255.984001z" fill="#376AF3" p-id="10064"></path><path d="M520.063496 0v77.563152c0 269.231173 144.758953 414.054122 434.276858 434.340854L954.08437 511.968002h9.215424V255.984001L520.063496 0z" fill="#5B55F6" p-id="10065"></path><path d="M520.063496 1023.936004v-77.563152c0-269.231173 144.758953-414.054122 434.276858-434.340854L954.08437 511.968002h9.27942v255.984001l-443.236298 255.984001z" fill="#376AF3" p-id="10066"></path></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

41
channel/web/static/vendor/README.md vendored Normal file
View File

@@ -0,0 +1,41 @@
# Vendor assets
Third-party frontend assets bundled locally so the Web Console can run in
fully offline / air-gapped environments (no requests to cloudflare, jsdelivr,
googleapis, gstatic, etc.).
All files here are vendored copies of upstream releases. Do not edit them by
hand; re-download from the official source if upgrading.
## Manifest
| Path | Source | Version |
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ------- |
| `fontawesome/css/all.min.css` | https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css | 6.4.0 |
| `fontawesome/webfonts/fa-{brands,regular,solid,v4compatibility}-*.woff2` | https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/webfonts/ | 6.4.0 |
| `fonts/inter/inter-latin.woff2` | https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7.woff2 | v20 |
| `fonts/inter/inter.css` | Hand-written `@font-face` declaration that maps Inter weights 300-700 to the local woff2 | - |
| `tailwind/tailwind.min.js` | https://cdn.tailwindcss.com (Play CDN runtime, JIT engine for the browser) | latest |
| `markdown-it/markdown-it.min.js` | https://cdn.jsdelivr.net/npm/markdown-it@13.0.1/dist/markdown-it.min.js | 13.0.1 |
| `highlightjs/highlight.min.js` | https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js | 11.9.0 |
| `highlightjs/styles/github{,-dark}.min.css` | https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/ | 11.9.0 |
| `highlightjs/languages/{python,javascript,java,go,bash}.min.js` | https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/ | 11.9.0 |
| `d3/d3.min.js` | https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js (loaded lazily for the knowledge graph view) | 7.x |
Notes:
- The Inter font only ships the latin subset (CJK characters fall back to the
system sans-serif via the font-family chain in `tailwind.config`).
- Only `woff2` font files are shipped (no `ttf` fallback). woff2 is supported
by all browsers released since 2014-2018 (Chrome 36+, Firefox 39+, Safari
12+, Edge, Opera 26+). The only mainstream browser that lacks woff2 support
is IE 11, which cannot run the rest of the console anyway. `all.min.css`
still references the ttf paths as a `src:` fallback — those 404s are
harmless and ignored by the browser once the woff2 loads.
- `tailwind.min.js` is the official Tailwind Play CDN build (an in-browser JIT
engine). It must be served as JS to keep the existing `tailwind.config = {}`
customization working.
- One external script remains in `channel/web/static/js/console.js`:
`wwcdn.weixin.qq.com/.../wecom-aibot-sdk` — Tencent requires the WeCom Bot
SDK to be loaded from their CDN, and it is only fetched when the user opens
the WeCom Bot QR-login flow.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,16 @@
/* Inter font (latin subset only).
* Single variable font woff2 that covers weights 300/400/500/600/700.
* Non-latin scripts (CJK, etc.) fall back to system sans-serif via the
* font-family chain defined in tailwind.config (Inter, system-ui, ...).
* Source: Google Fonts (Inter v20), redistributed locally to avoid runtime
* dependency on fonts.googleapis.com / fonts.gstatic.com.
*/
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 300 700;
font-display: swap;
src: url('./inter-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,20 @@
/*! `bash` grammar compiled for Highlight.js 11.9.0 */
(()=>{var e=(()=>{"use strict";return e=>{const s=e.regex,t={},n={begin:/\$\{/,
end:/\}/,contains:["self",{begin:/:-/,contains:[t]}]};Object.assign(t,{
className:"variable",variants:[{
begin:s.concat(/\$[\w\d#@][\w\d_]*/,"(?![\\w\\d])(?![$])")},n]});const a={
className:"subst",begin:/\$\(/,end:/\)/,contains:[e.BACKSLASH_ESCAPE]},i={
begin:/<<-?\s*(?=\w+)/,starts:{contains:[e.END_SAME_AS_BEGIN({begin:/(\w+)/,
end:/(\w+)/,className:"string"})]}},c={className:"string",begin:/"/,end:/"/,
contains:[e.BACKSLASH_ESCAPE,t,a]};a.contains.push(c);const o={begin:/\$?\(\(/,
end:/\)\)/,contains:[{begin:/\d+#[0-9a-f]+/,className:"number"},e.NUMBER_MODE,t]
},r=e.SHEBANG({binary:"(fish|bash|zsh|sh|csh|ksh|tcsh|dash|scsh)",relevance:10
}),l={className:"function",begin:/\w[\w\d_]*\s*\(\s*\)\s*\{/,returnBegin:!0,
contains:[e.inherit(e.TITLE_MODE,{begin:/\w[\w\d_]*/})],relevance:0};return{
name:"Bash",aliases:["sh"],keywords:{$pattern:/\b[a-z][a-z0-9._-]+\b/,
keyword:["if","then","else","elif","fi","for","while","until","in","do","done","case","esac","function","select"],
literal:["true","false"],
built_in:["break","cd","continue","eval","exec","exit","export","getopts","hash","pwd","readonly","return","shift","test","times","trap","umask","unset","alias","bind","builtin","caller","command","declare","echo","enable","help","let","local","logout","mapfile","printf","read","readarray","source","type","typeset","ulimit","unalias","set","shopt","autoload","bg","bindkey","bye","cap","chdir","clone","comparguments","compcall","compctl","compdescribe","compfiles","compgroups","compquote","comptags","comptry","compvalues","dirs","disable","disown","echotc","echoti","emulate","fc","fg","float","functions","getcap","getln","history","integer","jobs","kill","limit","log","noglob","popd","print","pushd","pushln","rehash","sched","setcap","setopt","stat","suspend","ttyctl","unfunction","unhash","unlimit","unsetopt","vared","wait","whence","where","which","zcompile","zformat","zftp","zle","zmodload","zparseopts","zprof","zpty","zregexparse","zsocket","zstyle","ztcp","chcon","chgrp","chown","chmod","cp","dd","df","dir","dircolors","ln","ls","mkdir","mkfifo","mknod","mktemp","mv","realpath","rm","rmdir","shred","sync","touch","truncate","vdir","b2sum","base32","base64","cat","cksum","comm","csplit","cut","expand","fmt","fold","head","join","md5sum","nl","numfmt","od","paste","ptx","pr","sha1sum","sha224sum","sha256sum","sha384sum","sha512sum","shuf","sort","split","sum","tac","tail","tr","tsort","unexpand","uniq","wc","arch","basename","chroot","date","dirname","du","echo","env","expr","factor","groups","hostid","id","link","logname","nice","nohup","nproc","pathchk","pinky","printenv","printf","pwd","readlink","runcon","seq","sleep","stat","stdbuf","stty","tee","test","timeout","tty","uname","unlink","uptime","users","who","whoami","yes"]
},contains:[r,e.SHEBANG(),l,o,e.HASH_COMMENT_MODE,i,{match:/(\/[a-z._-]+)+/},c,{
match:/\\"/},{className:"string",begin:/'/,end:/'/},{match:/\\'/},t]}}})()
;hljs.registerLanguage("bash",e)})();

View File

@@ -0,0 +1,14 @@
/*! `go` grammar compiled for Highlight.js 11.9.0 */
(()=>{var e=(()=>{"use strict";return e=>{const n={
keyword:["break","case","chan","const","continue","default","defer","else","fallthrough","for","func","go","goto","if","import","interface","map","package","range","return","select","struct","switch","type","var"],
type:["bool","byte","complex64","complex128","error","float32","float64","int8","int16","int32","int64","string","uint8","uint16","uint32","uint64","int","uint","uintptr","rune"],
literal:["true","false","iota","nil"],
built_in:["append","cap","close","complex","copy","imag","len","make","new","panic","print","println","real","recover","delete"]
};return{name:"Go",aliases:["golang"],keywords:n,illegal:"</",
contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{className:"string",
variants:[e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,{begin:"`",end:"`"}]},{
className:"number",variants:[{begin:e.C_NUMBER_RE+"[i]",relevance:1
},e.C_NUMBER_MODE]},{begin:/:=/},{className:"function",beginKeywords:"func",
end:"\\s*(\\{|$)",excludeEnd:!0,contains:[e.TITLE_MODE,{className:"params",
begin:/\(/,end:/\)/,endsParent:!0,keywords:n,illegal:/["']/}]}]}}})()
;hljs.registerLanguage("go",e)})();

View File

@@ -0,0 +1,38 @@
/*! `java` grammar compiled for Highlight.js 11.9.0 */
(()=>{var e=(()=>{"use strict"
;var e="[0-9](_*[0-9])*",a=`\\.(${e})`,n="[0-9a-fA-F](_*[0-9a-fA-F])*",s={
className:"number",variants:[{
begin:`(\\b(${e})((${a})|\\.)?|(${a}))[eE][+-]?(${e})[fFdD]?\\b`},{
begin:`\\b(${e})((${a})[fFdD]?\\b|\\.([fFdD]\\b)?)`},{begin:`(${a})[fFdD]?\\b`
},{begin:`\\b(${e})[fFdD]\\b`},{
begin:`\\b0[xX]((${n})\\.?|(${n})?\\.(${n}))[pP][+-]?(${e})[fFdD]?\\b`},{
begin:"\\b(0|[1-9](_*[0-9])*)[lL]?\\b"},{begin:`\\b0[xX](${n})[lL]?\\b`},{
begin:"\\b0(_*[0-7])*[lL]?\\b"},{begin:"\\b0[bB][01](_*[01])*[lL]?\\b"}],
relevance:0};function t(e,a,n){return-1===n?"":e.replace(a,(s=>t(e,a,n-1)))}
return e=>{
const a=e.regex,n="[\xc0-\u02b8a-zA-Z_$][\xc0-\u02b8a-zA-Z_$0-9]*",i=n+t("(?:<"+n+"~~~(?:\\s*,\\s*"+n+"~~~)*>)?",/~~~/g,2),r={
keyword:["synchronized","abstract","private","var","static","if","const ","for","while","strictfp","finally","protected","import","native","final","void","enum","else","break","transient","catch","instanceof","volatile","case","assert","package","default","public","try","switch","continue","throws","protected","public","private","module","requires","exports","do","sealed","yield","permits"],
literal:["false","true","null"],
type:["char","boolean","long","float","int","byte","short","double"],
built_in:["super","this"]},l={className:"meta",begin:"@"+n,contains:[{
begin:/\(/,end:/\)/,contains:["self"]}]},c={className:"params",begin:/\(/,
end:/\)/,keywords:r,relevance:0,contains:[e.C_BLOCK_COMMENT_MODE],endsParent:!0}
;return{name:"Java",aliases:["jsp"],keywords:r,illegal:/<\/|#/,
contains:[e.COMMENT("/\\*\\*","\\*/",{relevance:0,contains:[{begin:/\w+@/,
relevance:0},{className:"doctag",begin:"@[A-Za-z]+"}]}),{
begin:/import java\.[a-z]+\./,keywords:"import",relevance:2
},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{begin:/"""/,end:/"""/,
className:"string",contains:[e.BACKSLASH_ESCAPE]
},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{
match:[/\b(?:class|interface|enum|extends|implements|new)/,/\s+/,n],className:{
1:"keyword",3:"title.class"}},{match:/non-sealed/,scope:"keyword"},{
begin:[a.concat(/(?!else)/,n),/\s+/,n,/\s+/,/=(?!=)/],className:{1:"type",
3:"variable",5:"operator"}},{begin:[/record/,/\s+/,n],className:{1:"keyword",
3:"title.class"},contains:[c,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{
beginKeywords:"new throw return else",relevance:0},{
begin:["(?:"+i+"\\s+)",e.UNDERSCORE_IDENT_RE,/\s*(?=\()/],className:{
2:"title.function"},keywords:r,contains:[{className:"params",begin:/\(/,
end:/\)/,keywords:r,relevance:0,
contains:[l,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,s,e.C_BLOCK_COMMENT_MODE]
},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},s,l]}}})()
;hljs.registerLanguage("java",e)})();

View File

@@ -0,0 +1,80 @@
/*! `javascript` grammar compiled for Highlight.js 11.9.0 */
(()=>{var e=(()=>{"use strict"
;const e="[A-Za-z$_][0-9A-Za-z$_]*",n=["as","in","of","if","for","while","finally","var","new","function","do","return","void","else","break","catch","instanceof","with","throw","case","default","try","switch","continue","typeof","delete","let","yield","const","class","debugger","async","await","static","import","from","export","extends"],a=["true","false","null","undefined","NaN","Infinity"],t=["Object","Function","Boolean","Symbol","Math","Date","Number","BigInt","String","RegExp","Array","Float32Array","Float64Array","Int8Array","Uint8Array","Uint8ClampedArray","Int16Array","Int32Array","Uint16Array","Uint32Array","BigInt64Array","BigUint64Array","Set","Map","WeakSet","WeakMap","ArrayBuffer","SharedArrayBuffer","Atomics","DataView","JSON","Promise","Generator","GeneratorFunction","AsyncFunction","Reflect","Proxy","Intl","WebAssembly"],s=["Error","EvalError","InternalError","RangeError","ReferenceError","SyntaxError","TypeError","URIError"],r=["setInterval","setTimeout","clearInterval","clearTimeout","require","exports","eval","isFinite","isNaN","parseFloat","parseInt","decodeURI","decodeURIComponent","encodeURI","encodeURIComponent","escape","unescape"],c=["arguments","this","super","console","window","document","localStorage","sessionStorage","module","global"],i=[].concat(r,t,s)
;return o=>{const l=o.regex,b=e,d={begin:/<[A-Za-z0-9\\._:-]+/,
end:/\/[A-Za-z0-9\\._:-]+>|\/>/,isTrulyOpeningTag:(e,n)=>{
const a=e[0].length+e.index,t=e.input[a]
;if("<"===t||","===t)return void n.ignoreMatch();let s
;">"===t&&(((e,{after:n})=>{const a="</"+e[0].slice(1)
;return-1!==e.input.indexOf(a,n)})(e,{after:a})||n.ignoreMatch())
;const r=e.input.substring(a)
;((s=r.match(/^\s*=/))||(s=r.match(/^\s+extends\s+/))&&0===s.index)&&n.ignoreMatch()
}},g={$pattern:e,keyword:n,literal:a,built_in:i,"variable.language":c
},u="[0-9](_?[0-9])*",m=`\\.(${u})`,E="0|[1-9](_?[0-9])*|0[0-7]*[89][0-9]*",A={
className:"number",variants:[{
begin:`(\\b(${E})((${m})|\\.)?|(${m}))[eE][+-]?(${u})\\b`},{
begin:`\\b(${E})\\b((${m})\\b|\\.)?|(${m})\\b`},{
begin:"\\b(0|[1-9](_?[0-9])*)n\\b"},{
begin:"\\b0[xX][0-9a-fA-F](_?[0-9a-fA-F])*n?\\b"},{
begin:"\\b0[bB][0-1](_?[0-1])*n?\\b"},{begin:"\\b0[oO][0-7](_?[0-7])*n?\\b"},{
begin:"\\b0[0-7]+n?\\b"}],relevance:0},y={className:"subst",begin:"\\$\\{",
end:"\\}",keywords:g,contains:[]},h={begin:"html`",end:"",starts:{end:"`",
returnEnd:!1,contains:[o.BACKSLASH_ESCAPE,y],subLanguage:"xml"}},N={
begin:"css`",end:"",starts:{end:"`",returnEnd:!1,
contains:[o.BACKSLASH_ESCAPE,y],subLanguage:"css"}},_={begin:"gql`",end:"",
starts:{end:"`",returnEnd:!1,contains:[o.BACKSLASH_ESCAPE,y],
subLanguage:"graphql"}},f={className:"string",begin:"`",end:"`",
contains:[o.BACKSLASH_ESCAPE,y]},v={className:"comment",
variants:[o.COMMENT(/\/\*\*(?!\/)/,"\\*/",{relevance:0,contains:[{
begin:"(?=@[A-Za-z]+)",relevance:0,contains:[{className:"doctag",
begin:"@[A-Za-z]+"},{className:"type",begin:"\\{",end:"\\}",excludeEnd:!0,
excludeBegin:!0,relevance:0},{className:"variable",begin:b+"(?=\\s*(-)|$)",
endsParent:!0,relevance:0},{begin:/(?=[^\n])\s/,relevance:0}]}]
}),o.C_BLOCK_COMMENT_MODE,o.C_LINE_COMMENT_MODE]
},p=[o.APOS_STRING_MODE,o.QUOTE_STRING_MODE,h,N,_,f,{match:/\$\d+/},A]
;y.contains=p.concat({begin:/\{/,end:/\}/,keywords:g,contains:["self"].concat(p)
});const S=[].concat(v,y.contains),w=S.concat([{begin:/\(/,end:/\)/,keywords:g,
contains:["self"].concat(S)}]),R={className:"params",begin:/\(/,end:/\)/,
excludeBegin:!0,excludeEnd:!0,keywords:g,contains:w},O={variants:[{
match:[/class/,/\s+/,b,/\s+/,/extends/,/\s+/,l.concat(b,"(",l.concat(/\./,b),")*")],
scope:{1:"keyword",3:"title.class",5:"keyword",7:"title.class.inherited"}},{
match:[/class/,/\s+/,b],scope:{1:"keyword",3:"title.class"}}]},k={relevance:0,
match:l.either(/\bJSON/,/\b[A-Z][a-z]+([A-Z][a-z]*|\d)*/,/\b[A-Z]{2,}([A-Z][a-z]+|\d)+([A-Z][a-z]*)*/,/\b[A-Z]{2,}[a-z]+([A-Z][a-z]+|\d)*([A-Z][a-z]*)*/),
className:"title.class",keywords:{_:[...t,...s]}},I={variants:[{
match:[/function/,/\s+/,b,/(?=\s*\()/]},{match:[/function/,/\s*(?=\()/]}],
className:{1:"keyword",3:"title.function"},label:"func.def",contains:[R],
illegal:/%/},x={
match:l.concat(/\b/,(T=[...r,"super","import"],l.concat("(?!",T.join("|"),")")),b,l.lookahead(/\(/)),
className:"title.function",relevance:0};var T;const C={
begin:l.concat(/\./,l.lookahead(l.concat(b,/(?![0-9A-Za-z$_(])/))),end:b,
excludeBegin:!0,keywords:"prototype",className:"property",relevance:0},M={
match:[/get|set/,/\s+/,b,/(?=\()/],className:{1:"keyword",3:"title.function"},
contains:[{begin:/\(\)/},R]
},B="(\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)|"+o.UNDERSCORE_IDENT_RE+")\\s*=>",$={
match:[/const|var|let/,/\s+/,b,/\s*/,/=\s*/,/(async\s*)?/,l.lookahead(B)],
keywords:"async",className:{1:"keyword",3:"title.function"},contains:[R]}
;return{name:"JavaScript",aliases:["js","jsx","mjs","cjs"],keywords:g,exports:{
PARAMS_CONTAINS:w,CLASS_REFERENCE:k},illegal:/#(?![$_A-z])/,
contains:[o.SHEBANG({label:"shebang",binary:"node",relevance:5}),{
label:"use_strict",className:"meta",relevance:10,
begin:/^\s*['"]use (strict|asm)['"]/
},o.APOS_STRING_MODE,o.QUOTE_STRING_MODE,h,N,_,f,v,{match:/\$\d+/},A,k,{
className:"attr",begin:b+l.lookahead(":"),relevance:0},$,{
begin:"("+o.RE_STARTERS_RE+"|\\b(case|return|throw)\\b)\\s*",
keywords:"return throw case",relevance:0,contains:[v,o.REGEXP_MODE,{
className:"function",begin:B,returnBegin:!0,end:"\\s*=>",contains:[{
className:"params",variants:[{begin:o.UNDERSCORE_IDENT_RE,relevance:0},{
className:null,begin:/\(\s*\)/,skip:!0},{begin:/\(/,end:/\)/,excludeBegin:!0,
excludeEnd:!0,keywords:g,contains:w}]}]},{begin:/,/,relevance:0},{match:/\s+/,
relevance:0},{variants:[{begin:"<>",end:"</>"},{
match:/<[A-Za-z0-9\\._:-]+\s*\/>/},{begin:d.begin,
"on:begin":d.isTrulyOpeningTag,end:d.end}],subLanguage:"xml",contains:[{
begin:d.begin,end:d.end,skip:!0,contains:["self"]}]}]},I,{
beginKeywords:"while if switch catch for"},{
begin:"\\b(?!function)"+o.UNDERSCORE_IDENT_RE+"\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)\\s*\\{",
returnBegin:!0,label:"func.def",contains:[R,o.inherit(o.TITLE_MODE,{begin:b,
className:"title.function"})]},{match:/\.\.\./,relevance:0},C,{match:"\\$"+b,
relevance:0},{match:[/\bconstructor(?=\s*\()/],className:{1:"title.function"},
contains:[R]},x,{relevance:0,match:/\b[A-Z][A-Z_0-9]+\b/,
className:"variable.constant"},O,M,{match:/\$[(.]/}]}}})()
;hljs.registerLanguage("javascript",e)})();

View File

@@ -0,0 +1,41 @@
/*! `python` grammar compiled for Highlight.js 11.9.0 */
(()=>{var e=(()=>{"use strict";return e=>{
const n=e.regex,a=/[\p{XID_Start}_]\p{XID_Continue}*/u,i=["and","as","assert","async","await","break","case","class","continue","def","del","elif","else","except","finally","for","from","global","if","import","in","is","lambda","match","nonlocal|10","not","or","pass","raise","return","try","while","with","yield"],s={
$pattern:/[A-Za-z]\w+|__\w+__/,keyword:i,
built_in:["__import__","abs","all","any","ascii","bin","bool","breakpoint","bytearray","bytes","callable","chr","classmethod","compile","complex","delattr","dict","dir","divmod","enumerate","eval","exec","filter","float","format","frozenset","getattr","globals","hasattr","hash","help","hex","id","input","int","isinstance","issubclass","iter","len","list","locals","map","max","memoryview","min","next","object","oct","open","ord","pow","print","property","range","repr","reversed","round","set","setattr","slice","sorted","staticmethod","str","sum","super","tuple","type","vars","zip"],
literal:["__debug__","Ellipsis","False","None","NotImplemented","True"],
type:["Any","Callable","Coroutine","Dict","List","Literal","Generic","Optional","Sequence","Set","Tuple","Type","Union"]
},t={className:"meta",begin:/^(>>>|\.\.\.) /},r={className:"subst",begin:/\{/,
end:/\}/,keywords:s,illegal:/#/},l={begin:/\{\{/,relevance:0},b={
className:"string",contains:[e.BACKSLASH_ESCAPE],variants:[{
begin:/([uU]|[bB]|[rR]|[bB][rR]|[rR][bB])?'''/,end:/'''/,
contains:[e.BACKSLASH_ESCAPE,t],relevance:10},{
begin:/([uU]|[bB]|[rR]|[bB][rR]|[rR][bB])?"""/,end:/"""/,
contains:[e.BACKSLASH_ESCAPE,t],relevance:10},{
begin:/([fF][rR]|[rR][fF]|[fF])'''/,end:/'''/,
contains:[e.BACKSLASH_ESCAPE,t,l,r]},{begin:/([fF][rR]|[rR][fF]|[fF])"""/,
end:/"""/,contains:[e.BACKSLASH_ESCAPE,t,l,r]},{begin:/([uU]|[rR])'/,end:/'/,
relevance:10},{begin:/([uU]|[rR])"/,end:/"/,relevance:10},{
begin:/([bB]|[bB][rR]|[rR][bB])'/,end:/'/},{begin:/([bB]|[bB][rR]|[rR][bB])"/,
end:/"/},{begin:/([fF][rR]|[rR][fF]|[fF])'/,end:/'/,
contains:[e.BACKSLASH_ESCAPE,l,r]},{begin:/([fF][rR]|[rR][fF]|[fF])"/,end:/"/,
contains:[e.BACKSLASH_ESCAPE,l,r]},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]
},o="[0-9](_?[0-9])*",c=`(\\b(${o}))?\\.(${o})|\\b(${o})\\.`,d="\\b|"+i.join("|"),g={
className:"number",relevance:0,variants:[{
begin:`(\\b(${o})|(${c}))[eE][+-]?(${o})[jJ]?(?=${d})`},{begin:`(${c})[jJ]?`},{
begin:`\\b([1-9](_?[0-9])*|0+(_?0)*)[lLjJ]?(?=${d})`},{
begin:`\\b0[bB](_?[01])+[lL]?(?=${d})`},{begin:`\\b0[oO](_?[0-7])+[lL]?(?=${d})`
},{begin:`\\b0[xX](_?[0-9a-fA-F])+[lL]?(?=${d})`},{begin:`\\b(${o})[jJ](?=${d})`
}]},p={className:"comment",begin:n.lookahead(/# type:/),end:/$/,keywords:s,
contains:[{begin:/# type:/},{begin:/#/,end:/\b\B/,endsWithParent:!0}]},m={
className:"params",variants:[{className:"",begin:/\(\s*\)/,skip:!0},{begin:/\(/,
end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:s,
contains:["self",t,g,b,e.HASH_COMMENT_MODE]}]};return r.contains=[b,g,t],{
name:"Python",aliases:["py","gyp","ipython"],unicodeRegex:!0,keywords:s,
illegal:/(<\/|\?)|=>/,contains:[t,g,{begin:/\bself\b/},{beginKeywords:"if",
relevance:0},b,p,e.HASH_COMMENT_MODE,{match:[/\bdef/,/\s+/,a],scope:{
1:"keyword",3:"title.function"},contains:[m]},{variants:[{
match:[/\bclass/,/\s+/,a,/\s*/,/\(\s*/,a,/\s*\)/]},{match:[/\bclass/,/\s+/,a]}],
scope:{1:"keyword",3:"title.class",6:"title.class.inherited"}},{
className:"meta",begin:/^[\t ]*@/,end:/(?=#)|$/,contains:[g,m,b]}]}}})()
;hljs.registerLanguage("python",e)})();

View File

@@ -0,0 +1,10 @@
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*!
Theme: GitHub Dark
Description: Dark theme as seen on github.com
Author: github.com
Maintainer: @Hirse
Updated: 2021-05-15
Outdated base version: https://github.com/primer/github-syntax-dark
Current colors taken from GitHub's CSS
*/.hljs{color:#c9d1d9;background:#0d1117}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#ff7b72}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#d2a8ff}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#79c0ff}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#a5d6ff}.hljs-built_in,.hljs-symbol{color:#ffa657}.hljs-code,.hljs-comment,.hljs-formula{color:#8b949e}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#7ee787}.hljs-subst{color:#c9d1d9}.hljs-section{color:#1f6feb;font-weight:700}.hljs-bullet{color:#f2cc60}.hljs-emphasis{color:#c9d1d9;font-style:italic}.hljs-strong{color:#c9d1d9;font-weight:700}.hljs-addition{color:#aff5b4;background-color:#033a16}.hljs-deletion{color:#ffdcd7;background-color:#67060c}

View File

@@ -0,0 +1,10 @@
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*!
Theme: GitHub
Description: Light theme as seen on github.com
Author: github.com
Maintainer: @Hirse
Updated: 2021-05-15
Outdated base version: https://github.com/primer/github-syntax-light
Current colors taken from GitHub's CSS
*/.hljs{color:#24292e;background:#fff}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#d73a49}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#6f42c1}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#005cc5}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#032f62}.hljs-built_in,.hljs-symbol{color:#e36209}.hljs-code,.hljs-comment,.hljs-formula{color:#6a737d}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#22863a}.hljs-subst{color:#24292e}.hljs-section{color:#005cc5;font-weight:700}.hljs-bullet{color:#735c0f}.hljs-emphasis{color:#24292e;font-style:italic}.hljs-strong{color:#24292e;font-weight:700}.hljs-addition{color:#22863a;background-color:#f0fff4}.hljs-deletion{color:#b31d28;background-color:#ffeef0}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -34,9 +34,55 @@ HEARTBEAT_INTERVAL = 30
MEDIA_CHUNK_SIZE = 512 * 1024 # 512KB per chunk (before base64 encoding)
def _escape_control_chars_inside_json_strings(s: str) -> str:
"""Escape U+0000U+001F inside JSON string values so json.loads accepts WeCom payloads.
The server occasionally emits raw newlines/tabs inside quoted fields, which is
invalid strict JSON but recoverable without touching escapes like \\n or \\".
"""
out = []
in_string = False
escape = False
for c in s:
if escape:
out.append(c)
escape = False
continue
if in_string and c == "\\":
out.append(c)
escape = True
continue
if c == '"':
out.append(c)
in_string = not in_string
continue
if in_string and ord(c) < 32:
out.append("\\u%04x" % ord(c))
continue
out.append(c)
return "".join(out)
def _loads_wecom_ws_json(raw):
"""Parse WebSocket JSON; tolerate unescaped control characters inside strings."""
if isinstance(raw, bytes):
raw = raw.decode("utf-8", errors="replace")
if not isinstance(raw, str):
raw = str(raw)
try:
return json.loads(raw)
except json.JSONDecodeError as e:
msg = str(e).lower()
if "control character" in msg:
return json.loads(_escape_control_chars_inside_json_strings(raw))
raise
@singleton
class WecomBotChannel(ChatChannel):
NOT_SUPPORT_REPLYTYPE = []
def __init__(self):
super().__init__()
self.bot_id = ""
@@ -93,7 +139,7 @@ class WecomBotChannel(ChatChannel):
def _on_message(ws, raw):
try:
data = json.loads(raw)
data = _loads_wecom_ws_json(raw)
self._handle_ws_message(data)
except Exception as e:
logger.error(f"[WecomBot] Failed to handle ws message: {e}", exc_info=True)
@@ -428,6 +474,8 @@ class WecomBotChannel(ChatChannel):
else:
context.type = ContextType.TEXT
context.content = content.strip()
if "desire_rtype" not in context and conf().get("always_reply_voice"):
context["desire_rtype"] = ReplyType.VOICE
return context
@@ -454,6 +502,8 @@ class WecomBotChannel(ChatChannel):
self._send_file(reply.content, receiver, is_group, req_id)
elif reply.type == ReplyType.VIDEO or reply.type == ReplyType.VIDEO_URL:
self._send_file(reply.content, receiver, is_group, req_id, media_type="video")
elif reply.type == ReplyType.VOICE:
self._send_voice(reply.content, receiver, is_group, req_id)
else:
logger.warning(f"[WecomBot] Unsupported reply type: {reply.type}, falling back to text")
self._send_text(str(reply.content), receiver, is_group, req_id)
@@ -686,6 +736,65 @@ class WecomBotChannel(ChatChannel):
},
})
def _send_voice(self, voice_path: str, receiver: str, is_group: bool, req_id: str = None):
"""Send native voice reply. WeCom voice media must be amr."""
local_path = voice_path
if local_path.startswith("file://"):
local_path = local_path[7:]
if local_path.startswith(("http://", "https://")):
try:
resp = requests.get(local_path, timeout=60)
resp.raise_for_status()
ext = os.path.splitext(local_path)[1] or ".mp3"
tmp_path = f"/tmp/wecom_voice_{uuid.uuid4().hex[:8]}{ext}"
with open(tmp_path, "wb") as f:
f.write(resp.content)
local_path = tmp_path
except Exception as e:
logger.error(f"[WecomBot] Failed to download voice for sending: {e}")
return
if not os.path.exists(local_path):
logger.error(f"[WecomBot] Voice file not found: {local_path}")
return
amr_path = local_path
if not local_path.lower().endswith(".amr"):
try:
from voice.audio_convert import any_to_amr
amr_path = os.path.splitext(local_path)[0] + ".amr"
any_to_amr(local_path, amr_path)
except Exception as e:
logger.error(f"[WecomBot] Failed to convert voice to amr: {e}")
return
media_id = self._upload_media(amr_path, "voice")
if not media_id:
logger.error("[WecomBot] Failed to upload voice media")
return
if req_id:
self._ws_send({
"cmd": "aibot_respond_msg",
"headers": {"req_id": req_id},
"body": {
"msgtype": "voice",
"voice": {"media_id": media_id},
},
})
else:
self._ws_send({
"cmd": "aibot_send_msg",
"headers": {"req_id": self._gen_req_id()},
"body": {
"chatid": receiver,
"chat_type": 2 if is_group else 1,
"msgtype": "voice",
"voice": {"media_id": media_id},
},
})
def _active_send_markdown(self, content: str, receiver: str, is_group: bool):
"""Proactively send markdown message (for scheduled tasks, no req_id)."""
self._ws_send({

View File

@@ -60,6 +60,9 @@ def _save_credentials(cred_path: str, data: dict):
@singleton
class WeixinChannel(ChatChannel):
# ilink bot protocol has no outbound voice item; deliver TTS as a file.
NOT_SUPPORT_REPLYTYPE = []
LOGIN_STATUS_IDLE = "idle"
LOGIN_STATUS_WAITING = "waiting_scan"
LOGIN_STATUS_SCANNED = "scanned"
@@ -464,6 +467,14 @@ class WeixinChannel(ChatChannel):
else:
context.type = ContextType.TEXT
context.content = content.strip()
if "desire_rtype" not in context and conf().get("always_reply_voice"):
context["desire_rtype"] = ReplyType.VOICE
elif ctype == ContextType.VOICE:
if "desire_rtype" not in context and (
conf().get("voice_reply_voice") or conf().get("always_reply_voice")
):
context["desire_rtype"] = ReplyType.VOICE
return context
@@ -486,6 +497,9 @@ class WeixinChannel(ChatChannel):
self._send_file(reply.content, receiver, context_token)
elif reply.type in (ReplyType.VIDEO, ReplyType.VIDEO_URL):
self._send_video(reply.content, receiver, context_token)
elif reply.type == ReplyType.VOICE:
# ilink has no outbound voice item; deliver TTS as a file attachment.
self._send_file(reply.content, receiver, context_token)
else:
logger.warning(f"[Weixin] Unsupported reply type: {reply.type}, fallback to text")
self._send_text(str(reply.content), receiver, context_token)

View File

@@ -1 +1 @@
2.0.8
2.0.9

View File

@@ -26,7 +26,8 @@ Commands:
knowledge Manage knowledge base.
install-browser Install browser tool (Playwright + Chromium).
Tip: You can also send /help, /skill list, etc. in agent chat."""
Tip: Memory index management lives in chat — send /memory status or
/memory rebuild-index to the running agent."""
class CowCLI(click.Group):

View File

@@ -47,6 +47,7 @@ GEMINI_3_FLASH_PRE = "gemini-3-flash-preview" # Gemini 3 Flash Preview - Agent
GEMINI_3_PRO_PRE = "gemini-3-pro-preview" # Gemini 3 Pro Preview
GEMINI_31_PRO_PRE = "gemini-3.1-pro-preview" # Gemini 3.1 Pro Preview - Agent推荐模型
GEMINI_31_FLASH_LITE_PRE = "gemini-3.1-flash-lite-preview" # Gemini 3.1 Flash Lite Preview - Agent推荐模型
GEMINI_35_FLASH = "gemini-3.5-flash" # Gemini 3.5 Flash - Agent推荐模型
# OpenAI
GPT35 = "gpt-3.5-turbo"
@@ -74,6 +75,7 @@ GPT_5_NANO = "gpt-5-nano"
GPT_54 = "gpt-5.4" # GPT-5.4 - Agent recommended model
GPT_54_MINI = "gpt-5.4-mini"
GPT_54_NANO = "gpt-5.4-nano"
GPT_55 = "gpt-5.5" # GPT-5.5 - top-tier (expensive), not default
O1 = "o1-preview"
O1_MINI = "o1-mini"
WHISPER_1 = "whisper-1"
@@ -87,7 +89,8 @@ DEEPSEEK_V4_FLASH = "deepseek-v4-flash" # DeepSeek V4 Flash - 默认推荐 (思
DEEPSEEK_V4_PRO = "deepseek-v4-pro" # DeepSeek V4 Pro - 复杂任务更强 (思考模式 + 工具调用)
# Baidu Qianfan / ERNIE
ERNIE_5 = "ernie-5.0" # ERNIE 5.0 - default recommendation
ERNIE_5_1 = "ernie-5.1" # ERNIE 5.1 - default recommendation, latest flagship
ERNIE_5 = "ernie-5.0" # ERNIE 5.0
ERNIE_X1_1 = "ernie-x1.1" # ERNIE X1.1 - reasoning-focused, multimodal
ERNIE_45_TURBO_128K = "ernie-4.5-turbo-128k"
ERNIE_45_TURBO_32K = "ernie-4.5-turbo-32k"
@@ -103,10 +106,12 @@ QWEN_LONG = "qwen-long"
QWEN3_MAX = "qwen3-max" # Qwen3 Max - Agent推荐模型
QWEN35_PLUS = "qwen3.5-plus" # Qwen3.5 Plus - Omni model (MultiModalConversation)
QWEN36_PLUS = "qwen3.6-plus" # Qwen3.6 Plus - Omni model (MultiModalConversation)
QWEN37_MAX = "qwen3.7-max" # Qwen3.7 Max - Agent推荐模型
QWQ_PLUS = "qwq-plus"
# MiniMax
MINIMAX_M2_7 = "MiniMax-M2.7" # MiniMax M2.7 - Latest
MINIMAX_TEXT_01 = "MiniMax-Text-01" # MiniMax 多模态 (vision)
MINIMAX_M2_7_HIGHSPEED = "MiniMax-M2.7-highspeed" # MiniMax M2.7 highspeed
MINIMAX_M2_5 = "MiniMax-M2.5" # MiniMax M2.5
MINIMAX_M2_1 = "MiniMax-M2.1" # MiniMax M2.1
@@ -118,6 +123,7 @@ MINIMAX_ABAB6_5 = "abab6.5-chat" # MiniMax abab6.5
GLM_5_1 = "glm-5.1" # 智谱 GLM-5.1 - Agent recommended model (default)
GLM_5_TURBO = "glm-5-turbo" # 智谱 GLM-5-Turbo
GLM_5 = "glm-5" # 智谱 GLM-5
GLM_5V_TURBO = "glm-5v-turbo" # 智谱多模态 (vision)
GLM_4 = "glm-4"
GLM_4_PLUS = "glm-4-plus"
GLM_4_flash = "glm-4-flash"
@@ -170,7 +176,7 @@ MODEL_LIST = [
DEEPSEEK_V4_FLASH, DEEPSEEK_V4_PRO, DEEPSEEK_CHAT, DEEPSEEK_REASONER,
# Baidu Qianfan / ERNIE
QIANFAN, ERNIE_5, ERNIE_X1_1, ERNIE_45_TURBO_128K, ERNIE_45_TURBO_32K, ERNIE_4_TURBO_8K,
QIANFAN, ERNIE_5_1, ERNIE_5, ERNIE_X1_1, ERNIE_45_TURBO_128K, ERNIE_45_TURBO_32K, ERNIE_4_TURBO_8K,
ERNIE_45_TURBO_VL, ERNIE_45_TURBO_VL_32K,
# MiniMax
@@ -182,7 +188,7 @@ MODEL_LIST = [
"claude", "claude-3-haiku", "claude-3-sonnet", "claude-3-opus", "claude-3.5-sonnet",
# Gemini
GEMINI_31_FLASH_LITE_PRE, GEMINI_31_PRO_PRE, GEMINI_3_PRO_PRE, GEMINI_3_FLASH_PRE, GEMINI_25_PRO_PRE, GEMINI_25_FLASH_PRE,
GEMINI_35_FLASH, GEMINI_31_FLASH_LITE_PRE, GEMINI_31_PRO_PRE, GEMINI_3_PRO_PRE, GEMINI_3_FLASH_PRE, GEMINI_25_PRO_PRE, GEMINI_25_FLASH_PRE,
GEMINI_20_FLASH, GEMINI_20_flash_exp, GEMINI_15_PRO, GEMINI_15_flash, GEMINI_PRO, GEMINI,
# OpenAI
@@ -192,7 +198,7 @@ MODEL_LIST = [
GPT_4o, GPT_4O_0806, GPT_4o_MINI,
GPT_41, GPT_41_MINI, GPT_41_NANO,
GPT_5, GPT_5_MINI, GPT_5_NANO,
GPT_54, GPT_54_MINI, GPT_54_NANO,
GPT_54, GPT_55, GPT_54_MINI, GPT_54_NANO,
O1, O1_MINI,
# GLM (智谱AI)
@@ -200,7 +206,7 @@ MODEL_LIST = [
GLM_4_0520, GLM_4_AIR, GLM_4_AIRX, GLM_4_7,
# Qwen (通义千问)
QWEN36_PLUS, QWEN35_PLUS, QWEN3_MAX, QWEN_MAX, QWEN_PLUS, QWEN_TURBO, QWEN_LONG,
QWEN37_MAX, QWEN36_PLUS, QWEN35_PLUS, QWEN3_MAX, QWEN_MAX, QWEN_PLUS, QWEN_TURBO, QWEN_LONG,
# Doubao (豆包)
DOUBAO, DOUBAO_SEED_2_CODE, DOUBAO_SEED_2_PRO, DOUBAO_SEED_2_LITE, DOUBAO_SEED_2_MINI,

View File

@@ -16,8 +16,8 @@
"open_ai_api_base": "https://api.openai.com/v1",
"gemini_api_key": "",
"gemini_api_base": "https://generativelanguage.googleapis.com",
"voice_to_text": "openai",
"text_to_voice": "openai",
"voice_to_text": "",
"text_to_voice": "",
"voice_reply_voice": false,
"speech_recognition": true,
"group_speech_recognition": false,
@@ -31,11 +31,13 @@
"dingtalk_client_secret": "",
"wecom_bot_id": "",
"wecom_bot_secret": "",
"web_host": "",
"web_password": "",
"agent": true,
"agent_max_context_tokens": 50000,
"agent_max_context_turns": 20,
"agent_max_steps": 20,
"enable_thinking": false,
"reasoning_effort": "high",
"knowledge": true
}

119
config.py
View File

@@ -100,6 +100,10 @@ available_setting = {
"dashscope_api_key": "",
# Google Gemini Api Key
"gemini_api_key": "",
# Embedding 模型设置
"embedding_provider": "", # 显式指定厂商openai / linkai / dashscope / doubao / zhipu (与 bot_type 命名一致)
"embedding_model": "", # 留空使用厂商默认 model
"embedding_dimensions": 0, # 留空/0 使用厂商默认维度(推荐统一 1024
# 语音设置
"speech_recognition": True, # 是否开启语音识别
"group_speech_recognition": False, # 是否开启群组语音识别
@@ -205,6 +209,7 @@ available_setting = {
"Minimax_base_url": "",
"deepseek_api_key": "",
"deepseek_api_base": "https://api.deepseek.com/v1",
"web_host": "", # Web console bind address; empty means auto
"web_port": 9899,
"web_password": "", # Web console password; empty means no authentication required
"web_session_expire_days": 30, # Auth session expiry in days
@@ -214,11 +219,10 @@ available_setting = {
"agent_max_context_turns": 20, # Agent模式下最大上下文记忆轮次
"agent_max_steps": 20, # Agent模式下单次运行最大决策步数
"enable_thinking": False, # Enable deep-thinking mode for thinking-capable models
"reasoning_effort": "high", # Reasoning depth under thinking mode: "high" or "max"
"knowledge": True, # 是否开启知识库功能
# Per-skill runtime config. Nested keys are flattened to env vars at startup
# using the rule: skill[<name>][<key>] -> SKILL_<NAME>_<KEY>
# (e.g. skill["image-generation"].model -> SKILL_IMAGE_GENERATION_MODEL).
"skill": {},
"skill": {}, # Per-skill runtime config; nested keys flatten to SKILL_<NAME>_<KEY> env vars at startup
"mcp_servers": [], # MCP server list; each entry supports type "stdio" (local process) or "sse" (remote URL)
}
@@ -233,15 +237,9 @@ class Config(dict):
self.user_datas = {}
def __getitem__(self, key):
# 跳过以下划线开头的注释字段
if not key.startswith("_") and key not in available_setting:
logger.debug("[Config] key '{}' not in available_setting, may not take effect".format(key))
return super().__getitem__(key)
def __setitem__(self, key, value):
# 跳过以下划线开头的注释字段
if not key.startswith("_") and key not in available_setting:
logger.debug("[Config] key '{}' not in available_setting, may not take effect".format(key))
return super().__setitem__(key, value)
def get(self, key, default=None):
@@ -249,7 +247,7 @@ class Config(dict):
if key.startswith("_"):
return super().get(key, default)
# 如果key不在available_setting中直接返回default
# 如果key不在available_setting中直接走dict的get返回config.json中实际加载的值如不存在则返回default
if key not in available_setting:
return super().get(key, default)
@@ -332,8 +330,18 @@ def load_config():
config_str = read_file(config_path)
logger.debug("[INIT] config str: {}".format(drag_sensitive(config_str)))
# 将json字符串反序列化为dict类型
config = Config(json.loads(config_str))
# 将json字符串反序列化为dict类型
# `object_pairs_hook` lets us catch users who accidentally typed the
# same key twice (e.g. two `"tools"` blocks) — json.loads would
# otherwise silently drop all but the last occurrence.
config = Config(json.loads(config_str, object_pairs_hook=_merge_duplicate_keys))
# Migrate legacy singular keys (`tool`, `skill`) into the canonical
# plural buckets so the rest of the codebase only reads one schema.
# Deep-merge so existing `tools`/`skills` entries are preserved and
# only missing namespaces are filled in from the legacy section.
_merge_legacy_namespace(config, legacy="tool", canonical="tools")
_merge_legacy_namespace(config, legacy="skill", canonical="skills")
# override config with environment variables.
# Some online deployment platforms (e.g. Railway) deploy project from github directly. So you shouldn't put your secrets like api key in a config file, instead use environment variables to override the default config.
@@ -424,7 +432,7 @@ def load_config():
os.environ[env_key] = str(val)
injected += 1
injected += _sync_skill_config_to_env(config.get("skill", {}))
injected += _sync_skill_config_to_env(config.get("skills", {}))
if injected:
logger.info("[INIT] Synced {} config values to environment variables".format(injected))
@@ -432,11 +440,90 @@ def load_config():
config.load_user_datas()
def _deep_merge_dicts(base: dict, incoming: dict) -> dict:
"""Recursively merge ``incoming`` into ``base`` (incoming wins on leaves)."""
for key, val in incoming.items():
if (
key in base
and isinstance(base[key], dict)
and isinstance(val, dict)
):
_deep_merge_dicts(base[key], val)
else:
base[key] = val
return base
def _merge_duplicate_keys(pairs):
"""object_pairs_hook for json.loads: deep-merge duplicate top-level keys
(lists concat, dicts merge, scalars take the latter) instead of dropping."""
out = {}
duplicates = []
for key, val in pairs:
if key not in out:
out[key] = val
continue
duplicates.append(key)
prev = out[key]
if isinstance(prev, dict) and isinstance(val, dict):
_deep_merge_dicts(prev, val)
elif isinstance(prev, list) and isinstance(val, list):
prev.extend(val)
else:
out[key] = val
if duplicates:
# logger may not be wired yet — fall back to print so we never lose the warning.
unique = sorted(set(duplicates))
try:
logger.warning("[INIT] config.json has duplicate keys (merged): %s", unique)
except Exception:
print("[INIT] config.json has duplicate keys (merged):", unique)
return out
def _merge_legacy_namespace(cfg, legacy: str, canonical: str) -> None:
"""Fold deprecated singular keys (``tool`` / ``skill``) into their plural
canonical counterparts at load time. Canonical entries always win."""
legacy_section = cfg.get(legacy)
if not isinstance(legacy_section, dict) or not legacy_section:
cfg.pop(legacy, None)
return
canonical_section = cfg.get(canonical)
if not isinstance(canonical_section, dict):
canonical_section = {}
merged_keys = []
for name, val in legacy_section.items():
if name in canonical_section:
if isinstance(canonical_section[name], dict) and isinstance(val, dict):
for sub_key, sub_val in val.items():
if (
sub_key in canonical_section[name]
and isinstance(canonical_section[name][sub_key], dict)
and isinstance(sub_val, dict)
):
_deep_merge_dicts(sub_val, canonical_section[name][sub_key])
canonical_section[name][sub_key] = sub_val
else:
canonical_section[name].setdefault(sub_key, sub_val)
continue
canonical_section[name] = val
merged_keys.append(name)
cfg[canonical] = canonical_section
cfg.pop(legacy, None)
if merged_keys:
logger.warning(
"[INIT] Legacy config key '{}' is deprecated; merged into '{}': {}. "
"Please rename '{}' to '{}' in your config.json.".format(
legacy, canonical, merged_keys, legacy, canonical,
)
)
def _sync_skill_config_to_env(skill_section) -> int:
"""Flatten skill-namespaced config into environment variables.
Mapping rule: ``config["skill"][<name>][<key>]`` -> ``SKILL_<NAME>_<KEY>``
(e.g. ``skill["image-generation"].model`` -> ``SKILL_IMAGE_GENERATION_MODEL``).
Mapping rule: ``config["skills"][<name>][<key>]`` -> ``SKILL_<NAME>_<KEY>``
(e.g. ``skills["image-generation"].model`` -> ``SKILL_IMAGE_GENERATION_MODEL``).
This lets subprocess-based skill scripts read their own settings without
importing project code. Existing env vars are NOT overwritten so the

View File

@@ -37,6 +37,8 @@ services:
DINGTALK_CLIENT_SECRET: ''
WECOM_BOT_ID: ''
WECOM_BOT_SECRET: ''
# 如需通过宿主机访问 Web 控制台,改为 '0.0.0.0' 并设置 WEB_PASSWORD
WEB_HOST: '127.0.0.1'
WEB_PASSWORD: ''
AGENT: 'True'
AGENT_MAX_CONTEXT_TOKENS: 50000

39
docs/channels/index.mdx Normal file
View File

@@ -0,0 +1,39 @@
---
title: 通道概览
description: CowAgent 支持的通道及能力矩阵
---
CowAgent 支持接入多种聊天通道,启动时通过 `channel_type` 切换。Web 控制台默认开启,可与其他接入通道并行运行。
## 能力矩阵
下表汇总各通道支持的入站消息类型、机器人回复类型与群聊能力,方便按场景选择。
| 通道 | 文本 | 图片 | 文件 | 语音 | 群聊 |
| --- | :-: | :-: | :-: | :-: | :-: |
| [微信](/channels/weixin) | ✅ | ✅ | ✅ | ✅ | |
| [Web 控制台](/channels/web) | ✅ | ✅ | ✅ | ✅ | |
| [飞书](/channels/feishu) | ✅ | ✅ | ✅ | ✅ | ✅ |
| [钉钉](/channels/dingtalk) | ✅ | ✅ | ✅ | ✅ | ✅ |
| [企微智能机器人](/channels/wecom-bot) | ✅ | ✅ | ✅ | ✅ | ✅ |
| [QQ](/channels/qq) | ✅ | ✅ | ✅ | | ✅ |
| [企业微信应用](/channels/wecom) | ✅ | ✅ | ✅ | ✅ | |
| [公众号](/channels/wechatmp) | ✅ | ✅ | | ✅ | |
- **图片 / 文件 / 语音**列表示通道支持收发对应消息类型,具体细节详见各通道文档
- **群聊**列指可识别并响应群消息
<Tip>
每个通道的语音 / 图像能力依赖对应模型厂商的配置,详见 [模型概览](/models)。
</Tip>
## 通道一览
- [Web 控制台](/channels/web) — 内置浏览器对话和管理面板,默认开启
- [微信](/channels/weixin) — 通过个人微信扫码登录
- [飞书](/channels/feishu) — 飞书自建机器人
- [钉钉](/channels/dingtalk) — 钉钉自建机器人
- [企微智能机器人](/channels/wecom-bot) — 企业微信智能机器人
- [QQ](/channels/qq) — QQ 官方机器人开放平台
- [企业微信应用](/channels/wecom) — 企业微信自建应用接入
- [公众号](/channels/wechatmp) — 微信公众号(订阅号 / 服务号)

View File

@@ -10,6 +10,7 @@ Web 控制台是 CowAgent 的默认通道,启动后会自动运行,通过浏
```json
{
"channel_type": "web",
"web_host": "0.0.0.0",
"web_port": 9899,
"web_password": "",
"enable_thinking": false
@@ -19,8 +20,9 @@ Web 控制台是 CowAgent 的默认通道,启动后会自动运行,通过浏
| 参数 | 说明 | 默认值 |
| --- | --- | --- |
| `channel_type` | 设为 `web` | `web` |
| `web_host` | Web 服务监听地址,默认监听 `127.0.0.1`(仅本机),如需公网访问请改为 `0.0.0.0` 并设置密码 | `""` |
| `web_port` | Web 服务监听端口 | `9899` |
| `web_password` | 访问密码,留空表示不启用密码保护 | `""` |
| `web_password` | 访问密码,留空表示不启用密码保护;监听 `0.0.0.0` 时建议设置 | `""` |
| `web_session_expire_days` | 登录会话有效天数 | `30` |
| `enable_thinking` | 是否启用深度思考模式 | `false` |
@@ -57,9 +59,9 @@ Web 控制台是 CowAgent 的默认通道,启动后会自动运行,通过浏
### 模型管理
支持在线管理模型配置,无需手动编辑配置文件:
支持在线管理不同模型厂商的文本、图像、语音、向量模型配置,无需手动编辑配置文件:
<img width="850" src="https://cdn.link-ai.tech/doc/20260227173811.png" />
<img width="850" src="https://cdn.link-ai.tech/doc/20260521212949.png" />
### 技能管理

View File

@@ -120,6 +120,12 @@
"tools/vision",
"tools/browser"
]
},
{
"group": "MCP 工具",
"pages": [
"tools/mcp"
]
}
]
},
@@ -175,6 +181,7 @@
{
"group": "接入渠道",
"pages": [
"channels/index",
"channels/weixin",
"channels/web",
"channels/feishu",
@@ -307,6 +314,12 @@
"en/tools/vision",
"en/tools/browser"
]
},
{
"group": "MCP Tools",
"pages": [
"en/tools/mcp"
]
}
]
},
@@ -494,6 +507,12 @@
"ja/tools/vision",
"ja/tools/browser"
]
},
{
"group": "MCP Tool",
"pages": [
"ja/tools/mcp"
]
}
]
},

View File

@@ -42,6 +42,24 @@ Controlled by the global `enable_thinking` setting:
- `true`: thinking is on across all channels. The Web console renders the reasoning trace; IM channels (WeChat / WeCom / DingTalk / Feishu) don't render it but still benefit from higher answer quality.
- `false`: thinking off, faster responses with lower first-token latency.
### Reasoning Effort
Under thinking mode, `reasoning_effort` controls how hard the model thinks:
```json
{
"enable_thinking": true,
"reasoning_effort": "high"
}
```
| Value | Use Case |
| --- | --- |
| `high` (default) | Day-to-day agent tasks; balanced thinking depth and latency |
| `max` | Complex coding, long-horizon planning, strict-constraint tasks. Deeper reasoning at the cost of more output tokens and higher latency |
`reasoning_effort` only takes effect when `enable_thinking` is `true`. It is silently ignored on models that do not support thinking mode.
### Notes
- **Sampling parameters**: under thinking mode, `temperature`, `top_p`, `presence_penalty`, and `frequency_penalty` are silently ignored by the server (no error). CowAgent skips sending them automatically.

View File

@@ -6,7 +6,7 @@ description: Supported models and recommended choices for CowAgent
CowAgent supports mainstream LLMs from domestic and international providers. Model interfaces are implemented in the project's `models/` directory.
<Note>
For Agent mode, the following models are recommended based on quality and cost: deepseek-v4-flash, MiniMax-M2.7, claude-sonnet-4-6, gemini-3.1-pro-preview, glm-5.1, qwen3.6-plus, kimi-k2.6, ernie-5.0
For Agent mode, the following models are recommended based on quality and cost: deepseek-v4-flash, MiniMax-M2.7, claude-sonnet-4-6, gemini-3.1-pro-preview, glm-5.1, qwen3.6-plus, kimi-k2.6, ernie-5.1
</Note>
## Configuration
@@ -22,7 +22,7 @@ You can also use the [LinkAI](https://link-ai.tech) platform interface to flexib
deepseek-v4-flash, deepseek-v4-pro, and more
</Card>
<Card title="Baidu Qianfan / ERNIE" href="/en/models/qianfan">
ernie-5.0, ernie-4.5-turbo-128k, and more
ernie-5.1, ernie-5.0, ernie-4.5-turbo-128k, and more
</Card>
<Card title="MiniMax" href="/en/models/minimax">
MiniMax-M2.7 and other series models

View File

@@ -7,7 +7,7 @@ Option 1: Native integration (recommended):
```json
{
"model": "ernie-5.0",
"model": "ernie-5.1",
"qianfan_api_key": "",
"qianfan_api_base": "https://qianfan.baidubce.com/v2"
}
@@ -15,7 +15,7 @@ Option 1: Native integration (recommended):
| Parameter | Description |
| --- | --- |
| `model` | Default recommendation: `ernie-5.0`; also supports `ernie-x1.1`, `ernie-4.5-turbo-128k`, `ernie-4.5-turbo-32k` |
| `model` | Default recommendation: `ernie-5.1`; also supports `ernie-5.0`, `ernie-x1.1`, `ernie-4.5-turbo-128k`, `ernie-4.5-turbo-32k` |
| `qianfan_api_key` | Qianfan API key, usually starting with `bce-v3/` |
| `qianfan_api_base` | Optional, defaults to `https://qianfan.baidubce.com/v2` |
@@ -23,7 +23,8 @@ Option 1: Native integration (recommended):
| Model | Use Case |
| --- | --- |
| `ernie-5.0` | Default recommendation; latest ERNIE flagship with the strongest overall capability |
| `ernie-5.1` | Default recommendation; latest ERNIE flagship with the strongest overall capability |
| `ernie-5.0` | Previous-generation flagship with excellent overall capability |
| `ernie-x1.1` | Deep-thinking reasoning model with lower hallucination and stronger instruction following / tool calling |
| `ernie-4.5-turbo-128k` | Long-context and general chat |
| `ernie-4.5-turbo-32k` | General chat with a balanced context window and cost |
@@ -32,14 +33,14 @@ Option 1: Native integration (recommended):
Once `qianfan_api_key` is configured, Agent mode can auto-discover Qianfan for the Vision tool:
- When the main model itself is multimodal (e.g. `ernie-5.0`, `ernie-x1.1`, `ernie-4.5-turbo-vl`), images are handled directly by the main model with no extra setup.
- When the main model itself is multimodal (e.g. `ernie-5.1`, `ernie-5.0`, `ernie-x1.1`, `ernie-4.5-turbo-vl`), images are handled directly by the main model with no extra setup.
- When the main model is text-only (e.g. `ernie-4.5-turbo-128k`), the Vision tool automatically falls back to `ernie-4.5-turbo-vl`.
To force a specific Vision model, set it explicitly in `config.json`:
```json
{
"tool": {
"tools": {
"vision": {
"model": "ernie-4.5-turbo-vl"
}
@@ -51,7 +52,7 @@ Option 2: OpenAI-compatible configuration:
```json
{
"model": "ernie-5.0",
"model": "ernie-5.1",
"bot_type": "openai",
"open_ai_api_key": "",
"open_ai_api_base": "https://qianfan.baidubce.com/v2"

View File

@@ -11,7 +11,7 @@ New built-in `image-generation` skill supporting text-to-image, image-to-image,
- **Zero model selection**: Just configure an API key and it works — no need to manually specify a model. You can also name a specific model in conversation (e.g. "draw a cat with seedream")
- **Flexible control**: Supports `quality`, `size` (512/1K4K), and `aspect_ratio` parameters, with each provider automatically mapping to its supported values
- **Image editing**: Pass existing images for editing, style transfer, or multi-image fusion (Seedream supports up to 14 reference images)
- **Skill-level config**: Pin a default model via `skill.image-generation.model` in `config.json`
- **Skill-level config**: Pin a default model via `skills.image-generation.model` in `config.json`
- **Image lightbox**: All images in the Web console now support click-to-enlarge preview
Docs: [Image Generation Skill](https://docs.cowagent.ai/en/skills/image-generation)

View File

@@ -24,7 +24,7 @@ Integrated with Feishu CardKit streaming cards, **enabled by default**, matching
- Falls back to plain text replies automatically when not supported, no manual config needed
- Requires Feishu client ≥ 7.20
The voice and streaming building blocks come from a community contribution #2791. Thanks [@ooaaooaa123](https://github.com/ooaaooaa123)
The voice and streaming building blocks come from a community contribution #2791. Thanks [@yangluxin613](https://github.com/yangluxin613)
## 🤖 New Model Support
@@ -51,7 +51,7 @@ The voice and streaming building blocks come from a community contribution #2791
## 🔧 Tools and Safety
- **Vision model selection**: `tool.vision.model` config now actually takes effect, with automatic fallback when unconfigured #2792
- **Vision model selection**: `tools.vision.model` config now actually takes effect, with automatic fallback when unconfigured #2792
- **Bash safety prompt**: The destructive-deletion confirm prompt is now scoped to paths outside the workspace — routine in-workspace operations are no longer interrupted
## 🐛 Other Fixes

View File

@@ -87,7 +87,7 @@ Configure ARK_API_KEY as xxx
To force all image generation through a specific provider's model, add this to `config.json`:
```json
"skill": {
"skills": {
"image-generation": {
"model": "seedream-5.0-lite"
}

View File

@@ -1,25 +1,172 @@
---
title: browser - Browser
description: Access and interact with web pages
description: Control a browser to access and interact with web pages
---
Use a browser to access and interact with web pages, supports JavaScript-rendered dynamic pages.
Control a Chromium browser for web navigation, element interaction and content extraction. Supports JavaScript-rendered pages and uses a compact DOM snapshot so the Agent can efficiently understand page structure.
## Dependencies
## Installation
| Dependency | Install Command |
| --- | --- |
| `browser-use` ≥ 0.1.40 | `pip install browser-use` |
| `markdownify` | `pip install markdownify` |
| `playwright` + chromium | `pip install playwright && playwright install chromium` |
<Tabs>
<Tab title="CLI install (recommended)">
```bash
cow install-browser
```
This command will:
- Install the `playwright` Python package (with auto-fallback for older systems)
- Install system dependencies on Linux
- Download the Chromium browser (Linux servers automatically use the headless build)
- Detect China-mainland networks and use mirror acceleration
</Tab>
<Tab title="Manual install">
```bash
pip install playwright
playwright install chromium
```
On Linux servers, install system dependencies as well:
```bash
sudo playwright install-deps chromium
```
On older systems (e.g. Ubuntu 18.04, glibc < 2.28), install a compatible version:
```bash
pip install playwright==1.28.0
python -m playwright install chromium
```
To accelerate the Chromium download from China:
```bash
export PLAYWRIGHT_DOWNLOAD_HOST=https://registry.npmmirror.com/-/binary/playwright
python -m playwright install chromium
```
</Tab>
</Tabs>
<Note>
1. Supported on Ubuntu 20.04+, Debian 10+, macOS and Windows. Older systems such as Ubuntu 18.04 will fall back to a compatible version automatically.
2. The browser tool has heavy dependencies (~300MB) and is optional. For lightweight web content retrieval, use the `web_fetch` tool.
</Note>
## Workflow
A typical browser workflow for the Agent:
1. **`navigate`** — Open the target URL
2. **`snapshot`** — Get a compact DOM with auto-numbered interactive elements (`ref`)
3. **`click` / `fill` / `select`** — Operate elements by `ref`
4. **`snapshot`** — Snapshot again to verify the result
## Supported Actions
| Action | Description | Key parameters |
| --- | --- | --- |
| `navigate` | Open URL | `url` |
| `snapshot` | Get structured page text (primary way) | `selector` (optional) |
| `click` | Click an element | `ref` or `selector` |
| `fill` | Fill text into an input | `ref` or `selector`, `text` |
| `select` | Select a dropdown option | `ref` or `selector`, `value` |
| `scroll` | Scroll the page | `direction` (up/down/left/right) |
| `screenshot` | Save a screenshot to the workspace | `full_page` |
| `wait` | Wait for an element or timeout | `selector`, `timeout` |
| `press` | Press a key (Enter, Tab, etc.) | `key` |
| `back` / `forward` | Browser back / forward | - |
| `get_text` | Get an element's text content | `selector` |
| `evaluate` | Run JavaScript | `script` |
## Use Cases
- Access specific URLs to get page content
- Interact with web page elements (click, type, etc.)
- Verify deployed web pages
- Scrape dynamic content requiring JS rendering
- Access a URL to retrieve dynamic page content
- Fill in forms and log in
- Operate web elements (click buttons, select options, etc.)
- Verify the result of a deployed web page
- Scrape content that requires JS rendering
## Run Mode
The browser picks a mode based on the runtime environment:
| Environment | Mode |
| --- | --- |
| macOS / Windows | Headed (browser window visible) |
| Linux desktop (with DISPLAY) | Headed |
| Linux server (no DISPLAY) | Headless |
You can override it in `config.json`:
```json
{
"tools": {
"browser": {
"headless": true
}
}
}
```
## Persistent Login
**Log in to a target site once and the Agent can keep using it.** Two ways are supported:
### Option 1: Persistent mode (default)
Works out of the box. Login state is saved under `~/.cow/browser_profile`. No configuration needed.
To disable persistence and start with a clean environment every time:
```json
{
"tools": {
"browser": {
"persistent": false
}
}
}
```
### Option 2: CDP mode (attach to real Chrome)
Have the Agent connect to a separately launched real Chrome (instead of the Chromium bundled with Playwright) for full browser fingerprints. Useful for sites with strict bot detection.
Launch Chrome with a debugging port and a dedicated user data directory:
<Tabs>
<Tab title="macOS">
```bash
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
--remote-debugging-port=9222 \
--user-data-dir="$HOME/.cow/chrome-cdp"
```
</Tab>
<Tab title="Linux">
```bash
google-chrome \
--remote-debugging-port=9222 \
--user-data-dir="$HOME/.cow/chrome-cdp"
```
</Tab>
<Tab title="Windows">
```powershell
& "C:\Program Files\Google\Chrome\Application\chrome.exe" `
--remote-debugging-port=9222 `
--user-data-dir="$env:USERPROFILE\.cow\chrome-cdp"
```
</Tab>
</Tabs>
Then point the Agent at the endpoint in `config.json`:
```json
{
"tools": {
"browser": {
"cdp_endpoint": "http://localhost:9222"
}
}
}
```
<Note>
The browser tool has heavy dependencies. If not needed, skip installation. For lightweight web content retrieval, use the `web-fetch` skill instead.
Chrome 137+ requires `--remote-debugging-port` to be paired with a dedicated `--user-data-dir`. As a result, the CDP-launched Chrome **cannot directly reuse the login state of your daily Chrome**; you'll need to log in once inside this dedicated profile.
</Note>

View File

@@ -48,3 +48,13 @@ The following tools require additional dependencies or API key configuration:
Search the internet for real-time information
</Card>
</CardGroup>
## MCP Tools
Integrate thousands of community tools (maps, GitHub, Notion, etc.) via the [Model Context Protocol](https://modelcontextprotocol.io). Configure `mcp.json` once, ready to use:
<CardGroup cols={1}>
<Card title="MCP - External Tool Ecosystem" icon="plug" href="/en/tools/mcp">
Supports standard stdio / SSE transports. Hot-reload, zero code changes.
</Card>
</CardGroup>

109
docs/en/tools/mcp.mdx Normal file
View File

@@ -0,0 +1,109 @@
---
title: MCP Tools
description: Integrate external tool ecosystems via the Model Context Protocol
---
CowAgent supports the [Model Context Protocol (MCP)](https://modelcontextprotocol.io), allowing the Agent to directly invoke tens of thousands of community MCP tools. Configure `mcp.json` once and the tools are exposed to the LLM in exactly the same way as built-in tools — automatically selected and invoked.
## Configuration File
CowAgent reads `~/cow/mcp.json`. If the file does not exist, no MCP tools are loaded — and no error is raised.
For Docker deployments, the official `docker-compose.yml` already mounts the host's `./cow` directory to `/home/agent/cow` inside the container (i.e. the container user's `~/cow`). Just drop `mcp.json` into the host's `./cow/` directory and it will take effect.
### Standard Format
Fully compatible with the MCP community standard, identical to Claude Desktop / Cursor:
```json
{
"mcpServers": {
"<server-name>": {
"command": "npx",
"args": ["-y", "some-mcp-package"],
"env": {
"API_KEY": "your-key-here"
}
}
}
}
```
| Field | Required | Description |
| --- | --- | --- |
| `command` | stdio | Executable to launch the server (e.g. `npx`, `python`, `uvx`) |
| `args` | No | Arguments passed to `command` |
| `env` | No | Environment variables for the subprocess, commonly used for API keys |
| `url` | SSE | SSE endpoint URL (alternative to `command`) |
| `disabled` | No | When `true`, this server is skipped — handy for temporary disabling |
### Full Example
```json
{
"mcpServers": {
"fetch": {
"command": "uvx",
"args": ["mcp-server-fetch"]
},
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "<YOUR_TOKEN>"
}
}
}
}
```
- **fetch**: Generic web page fetcher that returns page text content. No API key required.
- **github**: Access GitHub repos, issues, PRs, etc. Requires a Personal Access Token.
## Let the Agent Configure It for You
CowAgent ships with `read` / `write` / `edit` tools, so **you can simply send the MCP config to the Agent and ask it to write the file**:
For example:
```markdown
Add this MCP to ~/cow/mcp.json:
{"mcpServers":{"fetch":{"command":"uvx","args":["mcp-server-fetch"]}}}
```
The Agent will:
1. Read the existing MCP config and merge the new server entry, preserving existing ones
2. Hot-reload the new MCP server, so the corresponding tools become available on the next message
## How It Works
- **Async loading at startup**: All servers configured in `mcp.json` are loaded asynchronously in the background, never blocking the main loop — chat is usable immediately.
- **Hot reload**: When you or the Agent modifies `mcp.json`, changed servers are automatically reloaded after the current message — no need to restart cow.
- **Flat exposure**: Each method exposed by an MCP server appears as an individual tool. The LLM picks one directly without a second-stage decision.
## Supported Transports
| Transport | Description | Config Field |
| --- | --- | --- |
| **stdio** | Subprocess communication. The most common option, with the richest community ecosystem. | `command` + `args` |
| **SSE** | HTTP Server-Sent Events, suitable for remotely hosted MCP services. | `url` |
## Troubleshooting
| Symptom | What to Check |
| --- | --- |
| Agent has no MCP tools after startup | Verify that `~/cow/mcp.json` exists and contains valid JSON |
| A specific server fails to load | Look for `[MCP] Server 'xxx' load failed` in startup logs — usually missing dependencies or API keys |
| Changes to `mcp.json` aren't applied | Changes take effect on **the next message**. If the server config didn't actually change (e.g. only comments edited), no restart is triggered |
| Docker deployment | Make sure host's `./cow` is mounted to `/home/agent/cow` in the container, then just drop `mcp.json` into host's `./cow/`. Or just ask the Agent to do it |
## Recommended MCP Marketplaces
You can browse third-party MCP marketplaces and copy a JSON config to use directly, for example:
- [mcp.so](https://mcp.so) — Global MCP service index
- [ModelScope MCP Hub](https://modelscope.cn/mcp) — ModelScope's MCP hub, more reliable from mainland China
Any MCP server that follows the standard protocol (stdio / SSE) integrates with CowAgent out of the box.

View File

@@ -23,7 +23,7 @@ If the current provider fails, the tool automatically tries the next one until i
| Vendor | Vision Model | Notes |
| --- | --- | --- |
| OpenAI / Compatible | Main model | All OpenAI-compatible multimodal models |
| Baidu Qianfan | Main model | Multimodal main models (e.g. `ernie-5.0`) handle images directly; falls back to `ernie-4.5-turbo-vl` for text-only main models |
| Baidu Qianfan | Main model | Multimodal main models (e.g. `ernie-5.1`) handle images directly; falls back to `ernie-4.5-turbo-vl` for text-only main models |
| Qwen (DashScope) | Main model | Via MultiModalConversation API |
| Claude | Main model | Anthropic native image format |
| Gemini | Main model | inlineData format |
@@ -51,7 +51,7 @@ To specify a particular model for the vision tool, add to `config.json`:
```json
{
"tool": {
"tools": {
"vision": {
"model": "ernie-4.5-turbo-vl"
}

View File

@@ -42,6 +42,24 @@ V4シリーズ`deepseek-v4-flash` / `deepseek-v4-pro`)は明示的な「思
- `true`すべてのチャネルで思考モードがオン。Webコンソールでは思考過程を表示し、IMチャネルWeChat / WeCom / DingTalk / Feishuでは表示されないものの、回答品質の向上というメリットを得られます。
- `false`:思考オフ、応答が速く、初回トークンの遅延も低くなります。
### 推論強度
思考モード下では `reasoning_effort` で推論の深さを制御できます:
```json
{
"enable_thinking": true,
"reasoning_effort": "high"
}
```
| 値 | 適用シーン |
| --- | --- |
| `high`(デフォルト) | 通常の Agent タスク、思考の深さとレスポンス速度のバランス |
| `max` | 複雑なコーディング、長いプランニング、厳密な制約のあるタスク。より深い推論と引き換えに出力トークンとレイテンシが増加 |
`reasoning_effort` は `enable_thinking` が `true` の場合のみ有効になります。思考モードをサポートしないモデルでは自動的に無視されます。
### 注意事項
- **サンプリングパラメータ**:思考モード時は `temperature`、`top_p`、`presence_penalty`、`frequency_penalty` がサーバ側で無視されますエラーにはなりません。CowAgentは自動的に送信をスキップします。

View File

@@ -6,7 +6,7 @@ description: CowAgentがサポートするモデルとおすすめの選択肢
CowAgentは国内外の主要なLLMをサポートしています。モデルインターフェースはプロジェクトの`models/`ディレクトリに実装されています。
<Note>
Agent モードでは、品質とコストのバランスから以下のモデルをおすすめします: deepseek-v4-flash、MiniMax-M2.7、claude-sonnet-4-6、gemini-3.1-pro-preview、glm-5.1、qwen3.6-plus、kimi-k2.6、ernie-5.0
Agent モードでは、品質とコストのバランスから以下のモデルをおすすめします: deepseek-v4-flash、MiniMax-M2.7、claude-sonnet-4-6、gemini-3.1-pro-preview、glm-5.1、qwen3.6-plus、kimi-k2.6、ernie-5.1
</Note>
## 設定
@@ -22,7 +22,7 @@ CowAgentは国内外の主要なLLMをサポートしています。モデルイ
deepseek-v4-flash、deepseek-v4-pro など
</Card>
<Card title="Baidu Qianfan / ERNIE" href="/ja/models/qianfan">
ernie-5.0、ernie-4.5-turbo-128k など
ernie-5.1、ernie-5.0、ernie-4.5-turbo-128k など
</Card>
<Card title="MiniMax" href="/ja/models/minimax">
MiniMax-M2.7およびその他のシリーズモデル

View File

@@ -7,7 +7,7 @@ description: Baidu Qianfan ERNIE モデル設定
```json
{
"model": "ernie-5.0",
"model": "ernie-5.1",
"qianfan_api_key": "",
"qianfan_api_base": "https://qianfan.baidubce.com/v2"
}
@@ -15,7 +15,7 @@ description: Baidu Qianfan ERNIE モデル設定
| パラメータ | 説明 |
| --- | --- |
| `model` | デフォルトの推奨は `ernie-5.0`。`ernie-x1.1`、`ernie-4.5-turbo-128k`、`ernie-4.5-turbo-32k` も利用できます |
| `model` | デフォルトの推奨は `ernie-5.1`。`ernie-5.0`、`ernie-x1.1`、`ernie-4.5-turbo-128k`、`ernie-4.5-turbo-32k` も利用できます |
| `qianfan_api_key` | Qianfan API Key。通常は `bce-v3/` で始まります |
| `qianfan_api_base` | 任意。デフォルトは `https://qianfan.baidubce.com/v2` |
@@ -23,7 +23,8 @@ description: Baidu Qianfan ERNIE モデル設定
| モデル | 用途 |
| --- | --- |
| `ernie-5.0` | デフォルト推奨。文心の最新フラッグシップモデルで、総合性能が最も強い |
| `ernie-5.1` | デフォルト推奨。文心の最新フラッグシップモデルで、総合性能が最も強い |
| `ernie-5.0` | 前世代フラッグシップ。総合性能に優れる |
| `ernie-x1.1` | 深層推論モデル。ハルシネーションが少なく、指示追従とツール呼び出しが強化 |
| `ernie-4.5-turbo-128k` | 長いコンテキストと一般的なチャット向け |
| `ernie-4.5-turbo-32k` | コンテキスト長とコストのバランスが良い一般チャット向け |
@@ -32,14 +33,14 @@ description: Baidu Qianfan ERNIE モデル設定
`qianfan_api_key` を設定すると、Agent モードの Vision ツールは Qianfan を自動検出します:
- 主モデルが多モーダル(`ernie-5.0`、`ernie-x1.1`、`ernie-4.5-turbo-vl` など)の場合は、追加設定なしで主モデルがそのまま画像を処理します。
- 主モデルが多モーダル(`ernie-5.1`、`ernie-5.0`、`ernie-x1.1`、`ernie-4.5-turbo-vl` など)の場合は、追加設定なしで主モデルがそのまま画像を処理します。
- 主モデルがテキスト専用(`ernie-4.5-turbo-128k` などの場合は、Vision ツールが自動的に `ernie-4.5-turbo-vl` にフォールバックします。
特定の Vision モデルを強制したい場合は、`config.json` で明示的に指定できます:
```json
{
"tool": {
"tools": {
"vision": {
"model": "ernie-4.5-turbo-vl"
}
@@ -51,7 +52,7 @@ description: Baidu Qianfan ERNIE モデル設定
```json
{
"model": "ernie-5.0",
"model": "ernie-5.1",
"bot_type": "openai",
"open_ai_api_key": "",
"open_ai_api_base": "https://qianfan.baidubce.com/v2"

View File

@@ -11,7 +11,7 @@ description: CowAgent 2.0.7 - 画像生成スキル6プロバイダー自動
- **モデル選択不要**API Key を設定するだけで使用可能、モデルを手動で指定する必要なし。会話で特定モデルを指名することも可能「seedream で猫を描いて」)
- **柔軟な制御**`quality`(画質)、`size`解像度、512/1K〜4K、`aspect_ratio`(アスペクト比)パラメータ対応、各プロバイダーが自動的に有効な値にマッピング
- **画像編集**既存の画像を渡して編集・スタイル変換・複数画像融合が可能Seedream は最大 14 枚の参照画像をサポート)
- **スキルレベル設定**`config.json` の `skill.image-generation.model` でデフォルトモデルを固定可能
- **スキルレベル設定**`config.json` の `skills.image-generation.model` でデフォルトモデルを固定可能
- **画像ライトボックス**Web コンソールのすべての画像がクリックで拡大プレビュー対応
ドキュメント:[画像生成スキル](https://docs.cowagent.ai/ja/skills/image-generation)

View File

@@ -24,7 +24,7 @@ description: CowAgent 2.0.8 - 飛書チャネル全面アップグレード(
- 非対応時は自動的に通常のテキスト返信にフォールバック、手動設定不要
- 飛書クライアント ≥ 7.20 が必要
飛書の音声メッセージ送受信とストリーミングタイプライターのベース機能はコミュニティ貢献 #2791 によるものです。Thanks [@ooaaooaa123](https://github.com/ooaaooaa123)
飛書の音声メッセージ送受信とストリーミングタイプライターのベース機能はコミュニティ貢献 #2791 によるものです。Thanks [@yangluxin613](https://github.com/yangluxin613)
## 🤖 新モデルサポート
@@ -51,7 +51,7 @@ description: CowAgent 2.0.8 - 飛書チャネル全面アップグレード(
## 🔧 ツールと安全性
- **Vision モデル選択**`tool.vision.model` 設定が実際に反映されるようになり、未設定時は自動フォールバック #2792
- **Vision モデル選択**`tools.vision.model` 設定が実際に反映されるようになり、未設定時は自動フォールバック #2792
- **Bash セーフティ確認**:破壊的削除の確認プロンプトをワークスペース外のパスに限定。ワークスペース内の通常操作は中断されません
## 🐛 その他の修正

View File

@@ -87,7 +87,7 @@ ARK_API_KEY を xxx に設定して
すべての画像生成を特定のプロバイダーのモデルで固定したい場合、`config.json` に以下を追加:
```json
"skill": {
"skills": {
"image-generation": {
"model": "seedream-5.0-lite"
}

View File

@@ -1,25 +1,172 @@
---
title: browser - ブラウザ
description: Webページへのアクセス操作
description: ブラウザを操作してWebページアクセス操作する
---
ブラウザを使用してWebページにアクセス・操作します。JavaScriptでレンダリングされる動的ページに対応しています。
Chromiumブラウザを操作してWebページのナビゲーション、要素操作、コンテンツ取得を行います。JavaScriptでレンダリングされる動的ページに対応し、簡略化したDOMスナップショットによりAgentが効率的にページ構造を理解できます。
## 依存関係
## インストール
| 依存関係 | インストールコマンド |
| --- | --- |
| `browser-use` ≥ 0.1.40 | `pip install browser-use` |
| `markdownify` | `pip install markdownify` |
| `playwright` + chromium | `pip install playwright && playwright install chromium` |
<Tabs>
<Tab title="CLIインストール推奨">
```bash
cow install-browser
```
このコマンドは以下を自動で実行します:
- `playwright` Pythonパッケージのインストール古いシステムでは互換バージョンに自動フォールバック
- Linuxにおけるシステム依存のインストール
- ChromiumブラウザのダウンロードLinuxサーバーでは自動的にヘッドレス軽量版を使用
- 中国本土ネットワークの自動検知とミラー高速化
</Tab>
<Tab title="手動インストール">
```bash
pip install playwright
playwright install chromium
```
Linuxサーバーではシステム依存も必要
```bash
sudo playwright install-deps chromium
```
古いシステム(例: Ubuntu 18.04、glibc < 2.28)では互換バージョンをインストール:
```bash
pip install playwright==1.28.0
python -m playwright install chromium
```
中国からChromiumのダウンロードを高速化したい場合
```bash
export PLAYWRIGHT_DOWNLOAD_HOST=https://registry.npmmirror.com/-/binary/playwright
python -m playwright install chromium
```
</Tab>
</Tabs>
<Note>
1. Ubuntu 20.04+、Debian 10+、macOS、Windowsをサポート。Ubuntu 18.04などの古いシステムでは互換バージョンに自動フォールバックします。
2. ブラウザToolは依存関係が大きい約300MBため、不要な場合はインストールを省略できます。軽量なWebコンテンツ取得には `web_fetch` Toolをご利用ください。
</Note>
## ワークフロー
Agentがブラウザを使う典型的な流れ
1. **`navigate`** — 対象URLを開く
2. **`snapshot`** — 簡略化したDOMを取得し、操作可能な要素には自動で番号`ref`)が付く
3. **`click` / `fill` / `select`** — `ref`で要素を操作する
4. **`snapshot`** — 再度スナップショットを取得して結果を確認
## サポートされる操作
| 操作 | 説明 | 主なパラメータ |
| --- | --- | --- |
| `navigate` | URLを開く | `url` |
| `snapshot` | 構造化されたページテキストを取得(主な利用方法) | `selector`(任意) |
| `click` | 要素をクリック | `ref` または `selector` |
| `fill` | 入力欄にテキストを入力 | `ref` または `selector`、`text` |
| `select` | プルダウンから選択 | `ref` または `selector`、`value` |
| `scroll` | ページをスクロール | `direction`up/down/left/right |
| `screenshot` | スクリーンショットをワークスペースに保存 | `full_page` |
| `wait` | 要素または時間を待機 | `selector`、`timeout` |
| `press` | キー入力Enter、Tabなど | `key` |
| `back` / `forward` | ブラウザの戻る/進む | - |
| `get_text` | 要素のテキストを取得 | `selector` |
| `evaluate` | JavaScriptを実行 | `script` |
## ユースケース
- 特定のURLにアクセスしてページ内容を取得
- Webページの要素を操作クリック、入力など
- デプロイされたWebページの検証
- 指定URLにアクセスして動的コンテンツを取得
- フォーム入力やログイン操作
- Web要素の操作ボタンクリック、項目選択など
- デプロイ後のWebページ動作確認
- JSレンダリングが必要な動的コンテンツのスクレイピング
## 動作モード
実行環境に応じてブラウザのモードが自動選択されます:
| 環境 | モード |
| --- | --- |
| macOS / Windows | ヘッドモード(ブラウザウィンドウを表示) |
| LinuxデスクトップDISPLAYあり | ヘッドモード |
| LinuxサーバーDISPLAYなし | ヘッドレスモード |
`config.json`で手動上書き可能:
```json
{
"tools": {
"browser": {
"headless": true
}
}
}
```
## ログイン状態の永続化
**対象サイトに一度ログインすれば、Agentは以降そのまま利用できます。** 2つの方法があります
### 方法1: Persistentモードデフォルト
設定不要、すぐに利用可能。ログイン情報は `~/.cow/browser_profile` に保存されます。
毎回クリーンな環境で起動したい場合は、永続化を無効化:
```json
{
"tools": {
"browser": {
"persistent": false
}
}
}
```
### 方法2: CDPモード既存のChromeに接続
Playwright付属のChromiumではなく、別途起動した本物のChromeにAgentを接続させることで、完全なブラウザフィンガープリントが得られます。Bot検知が厳しいサイトに有効です。
Chromeをデバッグポートと専用のユーザーデータディレクトリ付きで起動します
<Tabs>
<Tab title="macOS">
```bash
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
--remote-debugging-port=9222 \
--user-data-dir="$HOME/.cow/chrome-cdp"
```
</Tab>
<Tab title="Linux">
```bash
google-chrome \
--remote-debugging-port=9222 \
--user-data-dir="$HOME/.cow/chrome-cdp"
```
</Tab>
<Tab title="Windows">
```powershell
& "C:\Program Files\Google\Chrome\Application\chrome.exe" `
--remote-debugging-port=9222 `
--user-data-dir="$env:USERPROFILE\.cow\chrome-cdp"
```
</Tab>
</Tabs>
`config.json` で接続先を指定:
```json
{
"tools": {
"browser": {
"cdp_endpoint": "http://localhost:9222"
}
}
}
```
<Note>
ブラウザToolは依存関係が大きいため、不要な場合はインストールを省略できます。軽量なWebコンテンツ取得には、代わりに `web-fetch` Skillをご利用ください
Chrome 137以降では `--remote-debugging-port` を専用の `--user-data-dir` と組み合わせる必要があるため、CDPで起動するChromeは**普段使いのChromeのログイン状態をそのまま流用できません**。専用プロファイル内で一度ログインし直す必要があります
</Note>

View File

@@ -48,3 +48,13 @@ Toolは、AgentがOSリソースにアクセスするための中核機能です
インターネットからリアルタイム情報を検索
</Card>
</CardGroup>
## MCP Tool
[Model Context Protocol](https://modelcontextprotocol.io) を介して、コミュニティの既製 Tool地図、GitHub、Notion など数千種類)を統合できます。`mcp.json` を一度設定するだけで利用可能です:
<CardGroup cols={1}>
<Card title="MCP - 外部Toolエコシステム" icon="plug" href="/ja/tools/mcp">
標準の stdio / SSE トランスポートをサポート。ホットリロードで、コード変更不要
</Card>
</CardGroup>

109
docs/ja/tools/mcp.mdx Normal file
View File

@@ -0,0 +1,109 @@
---
title: MCP Tool
description: Model Context Protocol を介して外部Toolエコシステムを統合
---
CowAgent は [Model Context Protocol (MCP)](https://modelcontextprotocol.io) をサポートしており、コミュニティで提供されている数万種類の MCP Tool を Agent から直接呼び出せます。`mcp.json` を一度設定すれば、組み込みToolとまったく同じ形で LLM に公開され、自動的に選択・呼び出されます。
## 設定ファイル
CowAgent は `~/cow/mcp.json` を読み込みます。ファイルが存在しない場合は MCP Tool は読み込まれず、エラーにもなりません。
Docker デプロイの場合、公式の `docker-compose.yml` はホスト側の `./cow` をコンテナ内の `/home/agent/cow`(コンテナユーザーの `~/cow`)にマウント済みです。ホスト側の `./cow/` に `mcp.json` を置くだけで反映されます。
### 標準フォーマット
MCP コミュニティ標準に完全準拠しており、Claude Desktop / Cursor と同じです:
```json
{
"mcpServers": {
"<server-name>": {
"command": "npx",
"args": ["-y", "some-mcp-package"],
"env": {
"API_KEY": "your-key-here"
}
}
}
}
```
| フィールド | 必須 | 説明 |
| --- | --- | --- |
| `command` | stdio | サーバーを起動する実行コマンド(`npx`、`python`、`uvx` など) |
| `args` | 任意 | `command` に渡す引数 |
| `env` | 任意 | サブプロセスの環境変数。API Key などに利用 |
| `url` | SSE | SSE エンドポイントの URL`command` と二者択一) |
| `disabled` | 任意 | `true` のとき該当サーバーをスキップ。一時的に無効化したいときに便利 |
### 完全な例
```json
{
"mcpServers": {
"fetch": {
"command": "uvx",
"args": ["mcp-server-fetch"]
},
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "<YOUR_TOKEN>"
}
}
}
}
```
- **fetch**:汎用 Web ページ取得。ページ本文を返す。API Key 不要
- **github**GitHub のリポジトリ、Issue、PR などにアクセス。Personal Access Token が必要
## Agent に設定を任せる
CowAgent には `read` / `write` / `edit` Tool が組み込まれているため、**MCP の設定をそのまま Agent に渡して、ファイルに書き込んでもらえます**
例:
```markdown
この MCP を ~/cow/mcp.json に追加してください:
{"mcpServers":{"fetch":{"command":"uvx","args":["mcp-server-fetch"]}}}
```
Agent は次のように動作します:
1. 既存の MCP 設定ファイルを読み込み、新しい server エントリをマージ(既存の項目は保持)
2. 増分の MCP Server を自動でリロードし、次のメッセージから対応する Tool が利用可能に
## 動作の仕組み
- **起動時の非同期ロード**`mcp.json` に設定された全 server はバックグラウンドで非同期に読み込まれ、メインループをブロックしません。会話はすぐに開始できます
- **ホットリロード**:ユーザーまたは Agent が `mcp.json` を変更すると、メッセージ処理完了時に変更された server のみが自動でリロードされます。cow の再起動は不要です
- **フラットな公開**MCP server が公開する各メソッドは独立した Tool として並列に公開され、LLM が直接選択して呼び出します。二段階の判断は不要です
## サポートされるトランスポート
| トランスポート | 説明 | 設定フィールド |
| --- | --- | --- |
| **stdio** | サブプロセス通信。最も一般的で、コミュニティのエコシステムが最も豊富 | `command` + `args` |
| **SSE** | HTTP Server-Sent Events。リモートホスト型の MCP サービス向け | `url` |
## トラブルシューティング
| 症状 | 確認ポイント |
| --- | --- |
| 起動後に MCP Tool が一つもない | `~/cow/mcp.json` が存在し、JSON が正しいか確認 |
| 特定の server が読み込みに失敗する | 起動ログの `[MCP] Server 'xxx' load failed` を確認。多くは依存関係の不足や API Key 未設定 |
| `mcp.json` の変更が反映されない | 変更は **次のメッセージ** から有効になる。server の設定が実質的に変わっていない(コメントだけ変更など)場合は再起動されない |
| Docker デプロイ | ホストの `./cow` がコンテナ内の `/home/agent/cow` にマウントされていることを確認し、ホスト側の `./cow/` に `mcp.json` を配置。または Agent に直接インストールを依頼 |
## おすすめ MCP マーケットプレイス
各種サードパーティのマーケットプレイスから既製の MCP server を探し、JSON 設定をコピーしてそのまま利用できます。例:
- [mcp.so](https://mcp.so) — グローバル MCP サービスインデックス
- [ModelScope MCP 広場](https://modelscope.cn/mcp) — 魔搭コミュニティの MCP 広場、中国本土からのアクセスが安定
MCP 標準プロトコルstdio / SSEに準拠していれば、コードを一切変更せずに CowAgent に統合できます。

View File

@@ -23,7 +23,7 @@ Vision ツールは多段階の自動選択+自動フォールバック戦略
| ベンダー | ビジョンモデル | 説明 |
| --- | --- | --- |
| OpenAI / 互換プロトコル | メインモデル | すべての OpenAI 互換マルチモーダルモデルに対応 |
| Baidu Qianfan | メインモデル | 多モーダルの主モデル(`ernie-5.0` など)は直接画像を処理。テキスト専用主モデルの場合は `ernie-4.5-turbo-vl` に自動フォールバック |
| Baidu Qianfan | メインモデル | 多モーダルの主モデル(`ernie-5.1` など)は直接画像を処理。テキスト専用主モデルの場合は `ernie-4.5-turbo-vl` に自動フォールバック |
| 通義千問 (DashScope) | メインモデル | MultiModalConversation API 経由 |
| Claude | メインモデル | Anthropic ネイティブ画像形式 |
| Gemini | メインモデル | inlineData 形式 |
@@ -51,7 +51,7 @@ Vision ツールで使用するモデルを指定するには、`config.json`
```json
{
"tool": {
"tools": {
"vision": {
"model": "ernie-4.5-turbo-vl"
}

View File

@@ -1,8 +1,16 @@
---
title: Claude
description: Claude 模型配置
description: Anthropic Claude 模型配置(文本对话 + 图像理解)
---
Claude 由 Anthropic 提供,支持文本对话与图像理解,主流 Sonnet / Opus 模型均原生支持视觉,无需额外指定 Vision 模型。
<Tip>
通过 Web 控制台的「模型管理」页面可一站式配置以下全部能力,无需手动改配置文件。
</Tip>
## 文本对话
```json
{
"model": "claude-sonnet-4-6",
@@ -14,4 +22,28 @@ description: Claude 模型配置
| --- | --- |
| `model` | 支持 `claude-sonnet-4-6`、`claude-opus-4-7`、`claude-opus-4-6`、`claude-sonnet-4-5`、`claude-sonnet-4-0`、`claude-3-5-sonnet-latest` 等,参考 [官方模型](https://docs.anthropic.com/en/docs/about-claude/models/overview) |
| `claude_api_key` | 在 [Claude 控制台](https://console.anthropic.com/settings/keys) 创建 |
| `claude_api_base` | 可选,默认为 `https://api.anthropic.com/v1`修改可接入第三方代理 |
| `claude_api_base` | 可选,默认为 `https://api.anthropic.com/v1`可改为第三方代理 |
### 模型选择
| 模型 | 适用场景 |
| --- | --- |
| `claude-sonnet-4-6` | 默认推荐,性价比与速度平衡 |
| `claude-opus-4-7` | 复杂推理与长链路任务,效果最佳但成本更高 |
| `claude-sonnet-4-5` / `claude-sonnet-4-0` | 上一代旗舰,价格更低 |
## 图像理解
配置 `claude_api_key` 后 Agent 的 Vision 工具会自动使用 Claude 主模型识别图像,无需额外配置。
如需手动指定 Vision 模型,可在配置文件中显式配置:
```json
{
"tools": {
"vision": {
"model": "claude-sonnet-4-6"
}
}
}
```

View File

@@ -13,7 +13,7 @@ description: 自定义厂商配置,适用于第三方 API 代理和本地模
与 `openai` 厂商的区别:选择自定义厂商后,通过 `/config model` 切换模型时,不会自动切换厂商类型,始终使用自定义的 API 地址。
</Note>
## 配置方式
## 文本对话
### 第三方 API 代理
@@ -35,7 +35,7 @@ description: 自定义厂商配置,适用于第三方 API 代理和本地模
### 本地模型
本地模型通常不需要 API Key只需填写 API Base 即可
本地模型通常不需要 API Key只需填写 API Base
```json
{
@@ -53,7 +53,7 @@ description: 自定义厂商配置,适用于第三方 API 代理和本地模
| [vLLM](https://docs.vllm.ai) | `http://localhost:8000/v1` |
| [LocalAI](https://localai.io) | `http://localhost:8080/v1` |
## 切换模型
### 切换模型
自定义厂商下切换模型时,只会修改 `model`,不会改变 `bot_type` 和 API 地址:

View File

@@ -1,9 +1,11 @@
---
title: DeepSeek
description: DeepSeek 模型配置
description: DeepSeek 模型配置(文本对话 + 思考模式)
---
方式一:官方接入(推荐):
DeepSeek 是当前 Agent 模式默认推荐的厂商之一,主打高性价比的文本对话和任务规划能力。
## 文本对话
```json
{
@@ -18,20 +20,20 @@ description: DeepSeek 模型配置
| `deepseek_api_key` | 在 [DeepSeek 平台](https://platform.deepseek.com/api_keys) 创建 |
| `deepseek_api_base` | 可选,默认为 `https://api.deepseek.com/v1`,可修改为第三方代理地址 |
## 模型选择
### 模型选择
| 模型 | 适用场景 |
| --- | --- |
| `deepseek-v4-flash` | 默认推荐,速度快、成本低 |
| `deepseek-v4-pro` | 更智能复杂任务效果更强 |
| `deepseek-v4-pro` | 更智能复杂任务效果更强 |
## 思考模式
V4 系列(`deepseek-v4-flash` / `deepseek-v4-pro`)支持显式的"思考模式":模型在输出最终回答前,先输出一段思维链(`reasoning_content`),从而提升答案质量。
V4 系列(`deepseek-v4-flash` / `deepseek-v4-pro`)支持显式的思考模式:模型在输出最终回答前,先输出一段思维链(`reasoning_content`),从而提升答案质量。
### 开关
通过全局配置 `enable_thinking` 控制:
通过全局配置 `enable_thinking` 控制,也可在 web控制台 - 配置页面中进行切换
```json
{
@@ -42,22 +44,29 @@ V4 系列(`deepseek-v4-flash` / `deepseek-v4-pro`)支持显式的"思考模
- `true`所有渠道下模型都会先思考再作答。Web 控制台会展示思考过程IM 渠道(微信 / 企微 / 钉钉 / 飞书)虽不展示但同样获得更好答案。
- `false`:关闭思考,响应更快,首字延迟更低。
### 推理强度
思考模式下可通过 `reasoning_effort` 控制推理强度:
```json
{
"enable_thinking": true,
"reasoning_effort": "high"
}
```
| 取值 | 适用场景 |
| --- | --- |
| `high`(默认) | 日常 Agent 任务,思考与速度的平衡 |
| `max` | 复杂编码、长链路规划、严格约束的任务,推理更深但耗时与输出 token 更多 |
`reasoning_effort` 仅在 `enable_thinking` 为 `true` 时生效;模型不支持思考模式时该字段自动忽略。
### 行为说明
- **采样参数**:思考模式下 `temperature`、`top_p`、`presence_penalty`、`frequency_penalty` 会被服务端忽略不会报错CowAgent 会自动跳过传入。
- **多轮工具调用**当历史中包含工具调用时DeepSeek 要求所有 assistant 消息必须回传 `reasoning_content`。CowAgent 会自动处理回传逻辑,跨轮次切换思考开关也不会出错。
<Tip>
默认使用 `deepseek-v4-flash`;复杂任务可使用 `deepseek-v4-pro`;需要深度思考可开启 `enable_thinking`。
默认使用 `deepseek-v4-flash`;复杂任务可使用 `deepseek-v4-pro`;需要深度推理可开启 `enable_thinking`。
</Tip>
方式二OpenAI 兼容方式接入:
```json
{
"model": "deepseek-v4-flash",
"bot_type": "openai",
"open_ai_api_key": "YOUR_API_KEY",
"open_ai_api_base": "https://api.deepseek.com/v1"
}
```

View File

@@ -1,17 +1,66 @@
---
title: 豆包 Doubao
description: 豆包 (火山方舟) 模型配置
description: 豆包火山方舟模型配置(文本 / 图像理解 / 图像生成 / 向量)
---
豆包火山方舟支持文本对话、图像理解、图像生成Seedream和向量能力一份 `ark_api_key` 即可启用全部能力。
<Tip>
通过 Web 控制台的「模型管理」页面可一站式配置以下全部能力,无需手动改配置文件。
</Tip>
## 文本对话
```json
{
"model": "doubao-seed-2-0-code-preview-260215",
"model": "doubao-seed-2-0-pro-260215",
"ark_api_key": "YOUR_API_KEY"
}
```
| 参数 | 说明 |
| --- | --- |
| `model` | 可填 `doubao-seed-2-0-code-preview-260215`、`doubao-seed-2-0-pro-260215`、`doubao-seed-2-0-lite-260215` 等 |
| `model` | 可填 `doubao-seed-2-0-pro-260215`、`doubao-seed-2-0-code-preview-260215`、`doubao-seed-2-0-lite-260215` 等 |
| `ark_api_key` | 在 [火山方舟控制台](https://console.volcengine.com/ark/region:ark+cn-beijing/apikey) 创建 |
| `ark_base_url` | 可选,默认为 `https://ark.cn-beijing.volces.com/api/v3` |
## 图像理解
配置 `ark_api_key` 后 Agent 的 Vision 工具会自动使用 `doubao-seed-2-0-pro-260215` 识别图像,无需额外配置。
如需手动指定 Vision 模型:
```json
{
"tools": {
"vision": {
"model": "doubao-seed-2-0-pro-260215"
}
}
}
```
## 图像生成
```json
{
"skills": {
"image-generation": {
"model": "seedream-5.0-lite"
}
}
}
```
可选模型:`seedream-5.0-lite`、`seedream-4.5`。
## 向量
```json
{
"embedding_provider": "doubao",
"embedding_model": "doubao-embedding-vision-251215"
}
```
默认模型 `doubao-embedding-vision-251215`(多模态 embedding可在配置文件中通过 `embedding_dimensions` 指定 1024 或 2048 维。修改 embedding 后需执行 `/memory rebuild-index` 命令重建索引。

Some files were not shown because too many files have changed in this diff Show More