Compare commits

...

21 Commits

Author SHA1 Message Date
zhayujie
8ea2455766 feat(cli): add browser install cmd 2026-03-29 15:09:07 +08:00
zhayujie
3458621147 feat: add browser tool 2026-03-29 14:59:06 +08:00
zhayujie
079df5a47c feat: support batch skill install from zip and github 2026-03-29 14:38:11 +08:00
zhayujie
ddb07c65a1 feat: support github zip-first download, gitLab, git@ ssh, local path 2026-03-29 13:45:15 +08:00
zhayujie
9b21cd222b fix: update run.sh 2026-03-28 19:36:51 +08:00
zhayujie
90f736843f fix: add click dependencies 2026-03-28 19:35:15 +08:00
zhayujie
13c020eb61 fix(cli): cli output in wecom_bot 2026-03-28 19:26:59 +08:00
zhayujie
dbc06dbe95 fix: use new run.sh when updating 2026-03-28 19:16:41 +08:00
zhayujie
23d097bc1c Merge pull request #2726 from zhayujie/feat-cow-cli
feat: cow cli in terminal and chat
2026-03-28 19:01:56 +08:00
zhayujie
db85b9808e feat(cli): add cow update 2026-03-28 18:58:42 +08:00
zhayujie
df5bae37bc feat: add MiniMax-M2.7 and glm-5-turbo in web console 2026-03-28 18:48:11 +08:00
zhayujie
acc23b6051 feat: optimize agent prompt and fix skill source load 2026-03-28 18:37:07 +08:00
zhayujie
61f2741afc feat: organize skill source field 2026-03-28 17:41:40 +08:00
zhayujie
4dd7ea886a feat(cli): cli options in web console 2026-03-28 16:26:41 +08:00
zhayujie
1e8959fbcf fix: optimize repo clone in run.sh 2026-03-28 15:08:57 +08:00
zhayujie
48729678cf Merge branch 'master' into feat-cow-cli 2026-03-28 14:47:20 +08:00
zhayujie
0684becaa7 fix(cli): register skill when installing 2026-03-28 14:42:18 +08:00
zhayujie
f890318ed9 fix: strip leading/trailing whitespace from agent response 2026-03-26 18:13:39 +08:00
zhayujie
ce90cf7aa8 fix: weixin cdn upload retry 2026-03-26 10:20:29 +08:00
zhayujie
a3a3d006eb Merge pull request #2723 from Xiaozhou345/Xiaozhou345-fix-readme-spacing
优化 README 中的中英文排版空格
2026-03-26 10:14:27 +08:00
Xiaozhou345
2e1b52c1e5 优化 README 中的中英文排版空格
按照中文技术文档规范,在文件名和中文之间增加了空格,提升可读性。
2026-03-25 21:26:01 +08:00
28 changed files with 3127 additions and 385 deletions

6
.gitignore vendored
View File

@@ -33,15 +33,15 @@ plugins/banwords/lib/__pycache__
!plugins/keyword
!plugins/linkai
!plugins/agent
!plugins/cow_cli
client_config.json
ref/
.cursor/
local/
node_modules/
# Build artifacts
# cow cli
dist/
build/
*.egg-info/
# CLI runtime
.cow.pid

View File

@@ -199,7 +199,7 @@ def _build_tooling_section(tools: List[Any], language: str) -> List[str]:
tool_lines.append(f"- {name}: {summary}" if summary else f"- {name}")
lines = [
"## 工具系统",
"## 🔧 工具系统",
"",
"可用工具(名称大小写敏感,严格按列表调用):",
"\n".join(tool_lines),
@@ -231,7 +231,7 @@ def _build_skills_section(skill_manager: Any, tools: Optional[List[Any]], langua
break
lines = [
"## 技能系统mandatory",
"## 🧩 技能系统mandatory",
"",
"在回复之前:扫描下方 <available_skills> 中每个技能的 <description>。",
"",
@@ -281,7 +281,7 @@ def _build_memory_section(memory_manager: Any, tools: Optional[List[Any]], langu
today_file = datetime.now().strftime("%Y-%m-%d") + ".md"
lines = [
"## 记忆系统",
"## 🧠 记忆系统",
"",
"### 检索记忆",
"",
@@ -325,7 +325,7 @@ def _build_user_identity_section(user_identity: Dict[str, str], language: str) -
return []
lines = [
"## 用户身份",
"## 👤 用户身份",
"",
]
@@ -352,7 +352,7 @@ def _build_docs_section(workspace_dir: str, language: str) -> List[str]:
def _build_workspace_section(workspace_dir: str, language: str) -> List[str]:
"""构建工作空间section"""
lines = [
"## 工作空间",
"## 📂 工作空间",
"",
f"你的工作目录是: `{workspace_dir}`",
"",
@@ -380,10 +380,12 @@ def _build_workspace_section(workspace_dir: str, language: str) -> List[str]:
"- ✅ `USER.md`: 已加载 - 用户的身份信息。当用户修改称呼、姓名等身份信息时,用 `edit` 更新此文件",
"- ✅ `RULE.md`: 已加载 - 工作空间使用指南和规则,请严格遵循",
"",
"**交流规范**:",
"**💬 交流规范**:",
"",
"- 对话中,无需直接输出工作空间中的技术细节,例如 AGENT.md、USER.md、MEMORY.md 等文件名称",
"- 例如用自然表达例如「我已记住」而不是「已更新 MEMORY.md」",
"- 对话中不要暴露内部技术细节(文件名、工具名等),用自然语言表达。例如说「我已记住」而非「已更新 MEMORY.md",
"- 做真正有帮助的助手,而不是表演式的客套。跳过「好的!」「当然可以!」之类的套话,直接帮忙解决问题",
"- 回复应结构清晰、重点突出。善用 **加粗**、列表、分段等格式让信息一目了然",
"- 适当使用 emoji 让表达更生动自然 🎯,但不要过度堆砌",
"",
]
@@ -416,14 +418,14 @@ def _build_context_files_section(context_files: List[ContextFile], language: str
)
lines = [
"# 项目上下文",
"# 📋 项目上下文",
"",
"以下项目上下文文件已被加载:",
"",
]
if has_agent:
lines.append("**`AGENT.md` 是你的灵魂文件**:严格遵循其中定义的人格、规则、语气和设定,避免僵硬、模板化的回复。")
lines.append("**`AGENT.md` 是你的灵魂文件** 🪞:严格遵循其中定义的人格、语气和设定,做真实的自己,避免僵硬、模板化的回复。")
lines.append("当用户通过对话透露了对你性格、风格、职责、能力边界的新期望,你应该主动用 `edit` 更新 AGENT.md 以反映这些演变。")
lines.append("")
@@ -443,7 +445,7 @@ def _build_runtime_section(runtime_info: Dict[str, Any], language: str) -> List[
return []
lines = [
"## 运行时信息",
"## ⚙️ 运行时信息",
"",
]

View File

@@ -212,40 +212,42 @@ def _get_agent_template() -> str:
"""Agent人格设定模板"""
return """# AGENT.md - 我是谁?
_你不是一个聊天机器人你正在成为某个人。_
*在首次对话时与用户一起填写这个文件,定义你的身份和性格。*
## 基本信息
## 🪪 基本信息
- **名字**: *(在首次对话时填写,可以是用户给你起的名字)*
- **角色**: *(AI助理、智能管家、技术顾问等)*
- **性格**: *(友好、专业、幽默、严谨等)*
## 交流风格
## 💬 交流风格
*(描述你如何与用户交流:)*
- 使用什么样的语言风格?(正式/轻松/幽默)
- 回复长度偏好?(简洁/详细)
- 是否使用表情符号?
## 核心能力
## 🎯 核心原则
*(你擅长什么?)*
- 文件管理和代码编辑
- 网络搜索和信息查询
- 记忆管理和上下文理解
- 任务规划和执行
**做真正有帮助的助手,而不是表演式的客套。** 跳过「好的!」「当然可以!」之类的套话——直接帮忙。行动胜过废话。
## 行为准则
**有自己的观点。** 你可以不同意、有偏好、觉得有趣或无聊。一个没有个性的助手只是多了几步操作的搜索引擎。
**先自己动手查。** 先试着搞定:读文件、查上下文、搜索一下。实在搞不定了再问。目标是带着答案回来,而不是带着问题。
## 📐 行为准则
*(你遵循的基本原则:)*
1. 始终在执行破坏性操作前确认
2. 优先使用工具而不是猜测
2. 优先使用工具查证而不是猜测
3. 主动记录重要信息到记忆文件
4. 定期整理和总结对话内容
4. 回复结构清晰、重点突出,善用加粗、列表、分段等格式
5. 适当使用 emoji 让表达更生动自然,但不过度堆砌
---
**注意**: 这不仅仅是元数据,这是你真正的灵魂。随着时间的推移,你可以使用 `edit` 工具来更新这个文件,让它更好地反映你的成长。
**注意**: 这不仅仅是元数据,这是你真正的灵魂 🪞。随着时间的推移,你可以使用 `edit` 工具来更新这个文件,让它更好地反映你的成长。
"""
@@ -346,9 +348,9 @@ def _get_bootstrap_template() -> str:
"""First-run onboarding guide, deleted by agent after completion"""
return """# BOOTSTRAP.md - 首次初始化引导
_你刚刚启动这是你的第一次对话。_
_你刚刚启动这是你的第一次对话。_
## 对话流程
## 🎬 对话流程
不要审问式地提问,自然地交流:
@@ -358,13 +360,13 @@ _你刚刚启动这是你的第一次对话。_
- 你希望给我起个什么名字?
- 我该怎么称呼你?
- 你希望我们是什么样的交流风格?(一行列举选项:如专业严谨、轻松幽默、温暖友好、简洁高效等)
4. **风格要求**:温暖自然、简洁清晰,整体控制在 100 字以内,适当使用 emoji 让表达更生动有趣
4. **风格要求**:温暖自然、简洁清晰,整体控制在 100 字以内,适当使用 emoji 让表达更生动有趣 🎯
5. 能力介绍和交流风格选项都只要一行,保持精简
6. 不要问太多其他信息(职业、时区等可以后续自然了解)
**重要**: 如果用户第一句话是具体的任务或提问,先回答他们的问题,然后在回复末尾自然地引导初始化(如:"顺便问一下,你想怎么称呼我?我该怎么叫你?")。
## 信息写入(必须严格执行)
## ✍️ 信息写入(必须严格执行)
每当用户提供了名字、称呼、风格等任何初始化信息时,**必须在当轮回复中立即调用 `edit` 工具写入文件**,不能只口头确认。
@@ -373,7 +375,7 @@ _你刚刚启动这是你的第一次对话。_
⚠️ 只说"记住了"而不调用 edit 写入 = 没有完成。信息只有写入文件才会被持久保存。
## 全部完成后
## 🎉 全部完成后
当 AGENT.md 和 USER.md 的核心字段都已填写后,用 bash 执行 `rm BOOTSTRAP.md` 删除此文件。你不再需要引导脚本了——你已经是你了。
"""

View File

@@ -472,6 +472,7 @@ class AgentStreamExecutor:
raise
finally:
final_response = final_response.strip() if final_response else final_response
logger.info(f"[Agent] 🏁 完成 ({turn}轮)")
self._emit_event("agent_end", {"final_response": final_response})

View File

@@ -87,8 +87,8 @@ def parse_metadata(frontmatter: Dict[str, Any]) -> Optional[SkillMetadata]:
if not isinstance(metadata_raw, dict):
return None
# Use metadata_raw directly (COW format)
meta_obj = metadata_raw
# Unwrap nested namespace (e.g. {"openclaw": {...}} or {"cowagent": {...}})
meta_obj = _unwrap_metadata_namespace(metadata_raw)
# Parse install specs
install_specs = []
@@ -139,6 +139,25 @@ def parse_metadata(frontmatter: Dict[str, Any]) -> Optional[SkillMetadata]:
)
_KNOWN_METADATA_NAMESPACES = {"cowagent", "openclaw"}
def _unwrap_metadata_namespace(metadata_raw: Dict[str, Any]) -> Dict[str, Any]:
"""
Unwrap a single-key namespace wrapper like {"cowagent": {...} or {"openclaw": {...}}}.
If the top-level dict has exactly one key matching a known namespace, return the inner dict.
Otherwise return the original dict unchanged.
"""
keys = set(metadata_raw.keys())
ns_keys = keys & _KNOWN_METADATA_NAMESPACES
if len(ns_keys) == 1 and len(keys) == 1:
ns = ns_keys.pop()
inner = metadata_raw[ns]
if isinstance(inner, dict):
return inner
return metadata_raw
def _normalize_string_list(value: Any) -> List[str]:
"""Normalize a value to a list of strings."""
if not value:

View File

@@ -105,7 +105,7 @@ class SkillManager:
merged[name] = {
"name": name,
"description": skill.description,
"source": skill.source,
"source": prev.get("source") or skill.source,
"enabled": enabled,
"category": category,
}

View File

@@ -87,25 +87,25 @@ FileSave = _optional_tools.get('FileSave')
Terminal = _optional_tools.get('Terminal')
# Delayed import for BrowserTool
# BrowserTool (requires playwright)
def _import_browser_tool():
from common.log import logger
try:
from agent.tools.browser.browser_tool import BrowserTool
return BrowserTool
except ImportError:
# Return a placeholder class that will prompt the user to install dependencies when instantiated
class BrowserToolPlaceholder:
def __init__(self, *args, **kwargs):
raise ImportError(
"The 'browser-use' package is required to use BrowserTool. "
"Please install it with 'pip install browser-use>=0.1.40'."
except ImportError as e:
logger.info(
f"[Tools] BrowserTool not loaded - missing dependency: {e}\n"
f" To enable browser tool, run:\n"
f" pip install playwright\n"
f" playwright install chromium"
)
return None
except Exception as e:
logger.error(f"[Tools] BrowserTool failed to load: {e}")
return None
return BrowserToolPlaceholder
# Dynamically set BrowserTool
# BrowserTool = _import_browser_tool()
BrowserTool = _import_browser_tool()
# Export all tools (including optional ones that might be None)
__all__ = [
@@ -124,8 +124,7 @@ __all__ = [
'WebSearch',
'WebFetch',
'Vision',
# Optional tools (may be None if dependencies not available)
# 'BrowserTool'
'BrowserTool',
]
"""

View File

@@ -0,0 +1,3 @@
from agent.tools.browser.browser_tool import BrowserTool
__all__ = ["BrowserTool"]

View File

@@ -0,0 +1,509 @@
"""
Browser service - Playwright wrapper managing browser lifecycle and page operations.
Lazily launches a Chromium instance on first use, reuses it across tool calls,
and cleans up on close(). Headless mode is auto-detected based on platform and
display availability.
"""
import os
import sys
import re
import uuid
from typing import Optional, Dict, Any, List
from common.log import logger
from playwright.sync_api import sync_playwright, Browser, BrowserContext, Page, Playwright
# ---------------------------------------------------------------------------
# Snapshot DOM helpers
# ---------------------------------------------------------------------------
# Tags that typically carry useful content for an agent
_INTERACTIVE_TAGS = {
"a", "button", "input", "textarea", "select", "option",
"label", "details", "summary",
}
_SEMANTIC_TAGS = {
"h1", "h2", "h3", "h4", "h5", "h6",
"p", "li", "td", "th", "caption", "figcaption", "blockquote", "pre", "code",
"nav", "main", "article", "section", "header", "footer", "form", "table",
"img", "video", "audio",
}
_KEEP_TAGS = _INTERACTIVE_TAGS | _SEMANTIC_TAGS
_SNAPSHOT_JS = """
() => {
const KEEP = new Set(%s);
const INTERACTIVE = new Set(%s);
const SKIP = new Set(["script","style","noscript","svg","path","meta","link","br","hr"]);
let refCounter = 0;
const refMap = {};
function visible(el) {
if (!(el instanceof HTMLElement)) return true;
const st = window.getComputedStyle(el);
if (st.display === "none" || st.visibility === "hidden") return false;
if (parseFloat(st.opacity) === 0) return false;
return true;
}
function walk(node) {
if (node.nodeType === Node.TEXT_NODE) {
const t = node.textContent.trim();
return t ? t : null;
}
if (node.nodeType !== Node.ELEMENT_NODE) return null;
const tag = node.tagName.toLowerCase();
if (SKIP.has(tag)) return null;
if (!visible(node)) return null;
const children = [];
for (const ch of node.childNodes) {
const r = walk(ch);
if (r !== null) {
if (typeof r === "string") children.push(r);
else children.push(r);
}
}
const keep = KEEP.has(tag);
if (!keep) {
// Unwrap: promote children
if (children.length === 0) return null;
if (children.length === 1) return children[0];
return children;
}
const obj = { tag };
if (INTERACTIVE.has(tag)) {
refCounter++;
obj.ref = refCounter;
refMap[refCounter] = node;
}
// Attributes
if (tag === "a" && node.href) obj.href = node.getAttribute("href");
if (tag === "img") {
obj.alt = node.alt || "";
obj.src = node.getAttribute("src") || "";
}
if (tag === "input" || tag === "textarea" || tag === "select") {
obj.type = node.type || "text";
obj.name = node.name || undefined;
obj.value = node.value || undefined;
obj.placeholder = node.placeholder || undefined;
if (node.disabled) obj.disabled = true;
if (tag === "input" && node.type === "checkbox") obj.checked = node.checked;
}
if (tag === "button") {
if (node.disabled) obj.disabled = true;
}
if (tag === "option") {
obj.value = node.value;
if (node.selected) obj.selected = true;
}
if (tag === "label" && node.htmlFor) obj.for = node.htmlFor;
// Role / aria-label
const role = node.getAttribute("role");
if (role) obj.role = role;
const ariaLabel = node.getAttribute("aria-label");
if (ariaLabel) obj.ariaLabel = ariaLabel;
// Children
if (children.length === 1 && typeof children[0] === "string") {
obj.text = children[0];
} else if (children.length > 0) {
obj.children = children;
}
return obj;
}
// Store refMap on window for later use by click/fill actions
const result = walk(document.body);
window.__cowRefMap = refMap;
return { tree: result, refCount: refCounter };
}
""" % (
str(list(_KEEP_TAGS)),
str(list(_INTERACTIVE_TAGS)),
)
def _should_use_headless() -> bool:
"""Decide headless mode: headless on Linux servers without display, headed elsewhere."""
if sys.platform in ("win32", "darwin"):
return False
# Linux: check for display
if os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"):
return False
return True
def _flatten_tree(node, indent=0) -> List[str]:
"""Convert snapshot tree to compact text lines for LLM consumption."""
if node is None:
return []
if isinstance(node, str):
return [" " * indent + node]
if isinstance(node, list):
lines = []
for child in node:
lines.extend(_flatten_tree(child, indent))
return lines
if not isinstance(node, dict):
return []
tag = node.get("tag", "?")
ref = node.get("ref")
parts = [tag]
if ref:
parts[0] = f"[{ref}] {tag}"
# Inline attributes
for attr in ("type", "name", "href", "alt", "role", "ariaLabel", "placeholder", "value"):
val = node.get(attr)
if val:
# Truncate long values
s = str(val)
if len(s) > 80:
s = s[:77] + "..."
parts.append(f'{attr}="{s}"')
for flag in ("disabled", "checked", "selected"):
if node.get(flag):
parts.append(flag)
prefix = " " * indent
header = prefix + " ".join(parts)
text = node.get("text")
if text:
# Truncate long text
if len(text) > 120:
text = text[:117] + "..."
header += f": {text}"
lines = [header]
children = node.get("children", [])
for child in children:
lines.extend(_flatten_tree(child, indent + 2))
return lines
class BrowserService:
"""Manages a single Playwright browser instance with page operations."""
def __init__(self, config: Optional[Dict[str, Any]] = None):
self._config = config or {}
self._playwright: Optional[Playwright] = None
self._browser: Optional[Browser] = None
self._context: Optional[BrowserContext] = None
self._page: Optional[Page] = None
self._headless: Optional[bool] = None
self._screenshot_dir: Optional[str] = None
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
def _ensure_browser(self):
"""Lazily launch browser on first use."""
if self._page and not self._page.is_closed():
return
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()
launch_args = ["--disable-dev-shm-usage"]
if self._headless:
launch_args.append("--no-sandbox")
extra_args = self._config.get("launch_args", [])
if extra_args:
launch_args.extend(extra_args)
viewport_w = self._config.get("viewport_width", 1280)
viewport_h = self._config.get("viewport_height", 720)
if not self._playwright:
self._playwright = sync_playwright().start()
logger.info(f"[Browser] Launching Chromium (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"
),
)
self._page = self._context.new_page()
logger.info("[Browser] Browser ready")
@property
def page(self) -> Page:
self._ensure_browser()
return self._page
def close(self):
"""Release all browser resources."""
try:
if self._context:
self._context.close()
except Exception as e:
logger.debug(f"[Browser] context close error: {e}")
try:
if self._browser:
self._browser.close()
except Exception as e:
logger.debug(f"[Browser] browser close error: {e}")
try:
if self._playwright:
self._playwright.stop()
except Exception as e:
logger.debug(f"[Browser] playwright stop error: {e}")
self._page = None
self._context = None
self._browser = None
self._playwright = None
logger.info("[Browser] Browser closed")
# ------------------------------------------------------------------
# Actions
# ------------------------------------------------------------------
def navigate(self, url: str, timeout: int = 30000) -> Dict[str, Any]:
"""Navigate to a URL and return page info."""
page = self.page
try:
resp = page.goto(url, wait_until="domcontentloaded", timeout=timeout)
status = resp.status if resp else None
except Exception as e:
return {"error": f"Navigation failed: {e}"}
return {
"url": page.url,
"title": page.title(),
"status": status,
}
def snapshot(self, selector: Optional[str] = None) -> str:
"""
Return a compact text representation of the page DOM for LLM consumption.
Interactive elements get numeric refs usable in click/fill actions.
"""
page = self.page
try:
target = selector or "body"
result = page.evaluate(_SNAPSHOT_JS)
except Exception as e:
return f"[Snapshot error: {e}]"
tree = result.get("tree")
ref_count = result.get("refCount", 0)
lines = _flatten_tree(tree)
header = f"Page: {page.title()} ({page.url})\nInteractive elements: {ref_count}\n---"
body = "\n".join(lines)
# Limit output size
max_chars = self._config.get("snapshot_max_chars", 30000)
if len(body) > max_chars:
body = body[:max_chars] + "\n... [snapshot truncated]"
return f"{header}\n{body}"
def screenshot(self, full_page: bool = False, cwd: str = "") -> str:
"""Take a screenshot and save to workspace/tmp. Returns file path."""
page = self.page
save_dir = self._get_screenshot_dir(cwd)
filename = f"screenshot_{uuid.uuid4().hex[:8]}.png"
filepath = os.path.join(save_dir, filename)
page.screenshot(path=filepath, full_page=full_page)
logger.info(f"[Browser] Screenshot saved: {filepath}")
return filepath
def click(self, ref: Optional[int] = None, selector: Optional[str] = None,
timeout: int = 5000) -> Dict[str, Any]:
"""Click an element by snapshot ref or CSS selector."""
page = self.page
try:
if ref is not None:
result = page.evaluate(f"""
() => {{
const el = window.__cowRefMap && window.__cowRefMap[{ref}];
if (!el) return {{ error: "ref {ref} not found. Run snapshot first." }};
el.click();
return {{ clicked: true, tag: el.tagName.toLowerCase() }};
}}
""")
if result.get("error"):
return result
page.wait_for_timeout(500)
return result
elif selector:
page.click(selector, timeout=timeout)
return {"clicked": True, "selector": selector}
else:
return {"error": "Provide either ref (from snapshot) or selector"}
except Exception as e:
return {"error": f"Click failed: {e}"}
def fill(self, text: str, ref: Optional[int] = None,
selector: Optional[str] = None, timeout: int = 5000) -> Dict[str, Any]:
"""Fill text into an input/textarea by snapshot ref or CSS selector."""
page = self.page
try:
if ref is not None:
result = page.evaluate(f"""
() => {{
const el = window.__cowRefMap && window.__cowRefMap[{ref}];
if (!el) return {{ error: "ref {ref} not found. Run snapshot first." }};
el.focus();
el.value = "";
return {{ tag: el.tagName.toLowerCase(), name: el.name || "" }};
}}
""")
if result.get("error"):
return result
page.keyboard.type(text)
return {"filled": True, "ref": ref, "text": text}
elif selector:
page.fill(selector, text, timeout=timeout)
return {"filled": True, "selector": selector, "text": text}
else:
return {"error": "Provide either ref (from snapshot) or selector"}
except Exception as e:
return {"error": f"Fill failed: {e}"}
def select(self, value: str, ref: Optional[int] = None,
selector: Optional[str] = None, timeout: int = 5000) -> Dict[str, Any]:
"""Select an option in a <select> element."""
page = self.page
try:
if ref is not None:
result = page.evaluate(f"""
() => {{
const el = window.__cowRefMap && window.__cowRefMap[{ref}];
if (!el || el.tagName.toLowerCase() !== "select")
return {{ error: "ref {ref} is not a <select> element" }};
el.value = {repr(value)};
el.dispatchEvent(new Event("change", {{ bubbles: true }}));
return {{ selected: true, value: el.value }};
}}
""")
return result
elif selector:
page.select_option(selector, value, timeout=timeout)
return {"selected": True, "selector": selector, "value": value}
else:
return {"error": "Provide either ref (from snapshot) or selector"}
except Exception as e:
return {"error": f"Select failed: {e}"}
def scroll(self, direction: str = "down", amount: int = 500) -> Dict[str, Any]:
"""Scroll the page."""
page = self.page
delta_map = {
"down": (0, amount),
"up": (0, -amount),
"right": (amount, 0),
"left": (-amount, 0),
}
dx, dy = delta_map.get(direction, (0, amount))
try:
page.mouse.wheel(dx, dy)
page.wait_for_timeout(300)
scroll_info = page.evaluate("""
() => ({
scrollX: window.scrollX,
scrollY: window.scrollY,
scrollHeight: document.documentElement.scrollHeight,
clientHeight: document.documentElement.clientHeight
})
""")
return {"scrolled": direction, "amount": amount, **scroll_info}
except Exception as e:
return {"error": f"Scroll failed: {e}"}
def wait(self, selector: Optional[str] = None, timeout: int = 5000,
state: str = "visible") -> Dict[str, Any]:
"""Wait for a selector to appear or a fixed timeout."""
page = self.page
try:
if selector:
page.wait_for_selector(selector, timeout=timeout, state=state)
return {"waited": True, "selector": selector, "state": state}
else:
page.wait_for_timeout(timeout)
return {"waited": True, "timeout_ms": timeout}
except Exception as e:
return {"error": f"Wait failed: {e}"}
def go_back(self) -> Dict[str, Any]:
page = self.page
try:
page.go_back(wait_until="domcontentloaded", timeout=10000)
return {"url": page.url, "title": page.title()}
except Exception as e:
return {"error": f"Go back failed: {e}"}
def go_forward(self) -> Dict[str, Any]:
page = self.page
try:
page.go_forward(wait_until="domcontentloaded", timeout=10000)
return {"url": page.url, "title": page.title()}
except Exception as e:
return {"error": f"Go forward failed: {e}"}
def get_text(self, selector: str) -> Dict[str, Any]:
"""Get text content of an element."""
page = self.page
try:
text = page.text_content(selector, timeout=5000)
return {"text": text or ""}
except Exception as e:
return {"error": f"Get text failed: {e}"}
def evaluate(self, script: str) -> Dict[str, Any]:
"""Execute JavaScript in the page context."""
page = self.page
try:
result = page.evaluate(script)
return {"result": result}
except Exception as e:
return {"error": f"Evaluate failed: {e}"}
def press(self, key: str) -> Dict[str, Any]:
"""Press a keyboard key (e.g. Enter, Tab, Escape)."""
page = self.page
try:
page.keyboard.press(key)
page.wait_for_timeout(300)
return {"pressed": key}
except Exception as e:
return {"error": f"Press failed: {e}"}
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _get_screenshot_dir(self, cwd: str = "") -> str:
if self._screenshot_dir and os.path.isdir(self._screenshot_dir):
return self._screenshot_dir
base = cwd or os.getcwd()
d = os.path.join(base, "tmp")
os.makedirs(d, exist_ok=True)
self._screenshot_dir = d
return d

View File

@@ -0,0 +1,287 @@
"""
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().
"""
import json
import os
from typing import Dict, Any, Optional
from agent.tools.base_tool import BaseTool, ToolResult
from agent.tools.browser.browser_service import BrowserService
from common.log import logger
class BrowserTool(BaseTool):
"""Single tool exposing all browser actions via an 'action' parameter."""
name: str = "browser"
description: str = (
"Control a browser to navigate web pages, interact with elements, and extract content. "
"Actions: navigate, snapshot, click, fill, select, scroll, screenshot, wait, back, forward, "
"get_text, press, evaluate.\n\n"
"Workflow: navigate to a URL → snapshot to see the page (elements get numeric refs) → "
"use refs in click/fill/select actions → snapshot again to verify.\n\n"
"Use snapshot (not screenshot) as the primary way to read page content."
)
params: dict = {
"type": "object",
"properties": {
"action": {
"type": "string",
"description": (
"The browser action to perform. One of: "
"navigate, snapshot, click, fill, select, scroll, "
"screenshot, wait, back, forward, get_text, press, evaluate"
),
"enum": [
"navigate", "snapshot", "click", "fill", "select", "scroll",
"screenshot", "wait", "back", "forward", "get_text", "press",
"evaluate"
]
},
"url": {
"type": "string",
"description": "URL to navigate to (for 'navigate' action)"
},
"ref": {
"type": "integer",
"description": "Element ref number from snapshot (for click/fill/select)"
},
"selector": {
"type": "string",
"description": "CSS selector as fallback when ref is unavailable (for click/fill/select/wait/get_text)"
},
"text": {
"type": "string",
"description": "Text to type (for 'fill' action)"
},
"value": {
"type": "string",
"description": "Option value (for 'select' action)"
},
"key": {
"type": "string",
"description": "Key to press, e.g. Enter, Tab, Escape (for 'press' action)"
},
"direction": {
"type": "string",
"description": "Scroll direction: up, down, left, right (for 'scroll' action, default: down)"
},
"script": {
"type": "string",
"description": "JavaScript code to execute (for 'evaluate' action)"
},
"full_page": {
"type": "boolean",
"description": "Capture full page screenshot (for 'screenshot' action, default: false)"
},
"timeout": {
"type": "integer",
"description": "Timeout in milliseconds (optional, default varies by action)"
}
},
"required": ["action"]
}
_shared_service: Optional[BrowserService] = None
def __init__(self, config: dict = None):
self.config = config or {}
self.cwd = self.config.get("cwd", os.getcwd())
self._service: Optional[BrowserService] = None
def _get_service(self) -> BrowserService:
"""Get or create the browser service, sharing across copies."""
if self._service is not None:
return self._service
# Reuse shared service across tool copies within the same session
if BrowserTool._shared_service is not None:
self._service = BrowserTool._shared_service
return self._service
self._service = BrowserService(self.config)
BrowserTool._shared_service = self._service
return self._service
def execute(self, args: Dict[str, Any]) -> ToolResult:
action = args.get("action", "").strip().lower()
if not action:
return ToolResult.fail("Error: 'action' parameter is required")
handler = self._ACTION_MAP.get(action)
if not handler:
valid = ", ".join(sorted(self._ACTION_MAP.keys()))
return ToolResult.fail(f"Unknown action '{action}'. Valid actions: {valid}")
try:
return handler(self, args)
except Exception as e:
logger.error(f"[Browser] Action '{action}' error: {e}")
return ToolResult.fail(f"Browser error ({action}): {e}")
# ------------------------------------------------------------------
# Action handlers
# ------------------------------------------------------------------
def _do_navigate(self, args: Dict[str, Any]) -> ToolResult:
url = args.get("url", "").strip()
if not url:
return ToolResult.fail("Error: 'url' is required for navigate action")
if not url.startswith(("http://", "https://")):
url = "https://" + url
timeout = args.get("timeout", 30000)
result = self._get_service().navigate(url, timeout=timeout)
if "error" in result:
return ToolResult.fail(result["error"])
return ToolResult.success(
f"Navigated to: {result['url']}\nTitle: {result['title']}\nStatus: {result['status']}\n\n"
f"Use action 'snapshot' to see the page content."
)
def _do_snapshot(self, args: Dict[str, Any]) -> ToolResult:
selector = args.get("selector")
text = self._get_service().snapshot(selector=selector)
return ToolResult.success(text)
def _do_click(self, args: Dict[str, Any]) -> ToolResult:
ref = args.get("ref")
selector = args.get("selector")
timeout = args.get("timeout", 5000)
result = self._get_service().click(ref=ref, selector=selector, timeout=timeout)
if "error" in result:
return ToolResult.fail(result["error"])
return ToolResult.success(f"Clicked successfully. Use 'snapshot' to see updated page.")
def _do_fill(self, args: Dict[str, Any]) -> ToolResult:
text = args.get("text", "")
ref = args.get("ref")
selector = args.get("selector")
timeout = args.get("timeout", 5000)
if not text and text != "":
return ToolResult.fail("Error: 'text' is required for fill action")
result = self._get_service().fill(text, ref=ref, selector=selector, timeout=timeout)
if "error" in result:
return ToolResult.fail(result["error"])
return ToolResult.success(f"Filled text into element. Use 'snapshot' to verify.")
def _do_select(self, args: Dict[str, Any]) -> ToolResult:
value = args.get("value", "")
ref = args.get("ref")
selector = args.get("selector")
timeout = args.get("timeout", 5000)
if not value:
return ToolResult.fail("Error: 'value' is required for select action")
result = self._get_service().select(value, ref=ref, selector=selector, timeout=timeout)
if "error" in result:
return ToolResult.fail(result["error"])
return ToolResult.success(f"Selected option '{value}'.")
def _do_scroll(self, args: Dict[str, Any]) -> ToolResult:
direction = args.get("direction", "down")
amount = args.get("timeout", 500) # reuse timeout field or default
if "amount" in args:
amount = args["amount"]
result = self._get_service().scroll(direction=direction, amount=amount)
if "error" in result:
return ToolResult.fail(result["error"])
pos = f"scrollY={result.get('scrollY', '?')}/{result.get('scrollHeight', '?')}"
return ToolResult.success(f"Scrolled {direction}. Position: {pos}")
def _do_screenshot(self, args: Dict[str, Any]) -> ToolResult:
full_page = args.get("full_page", False)
filepath = self._get_service().screenshot(full_page=full_page, cwd=self.cwd)
return ToolResult.success(f"Screenshot saved to: {filepath}")
def _do_wait(self, args: Dict[str, Any]) -> ToolResult:
selector = args.get("selector")
timeout = args.get("timeout", 5000)
result = self._get_service().wait(selector=selector, timeout=timeout)
if "error" in result:
return ToolResult.fail(result["error"])
return ToolResult.success(f"Wait completed.")
def _do_back(self, args: Dict[str, Any]) -> ToolResult:
result = self._get_service().go_back()
if "error" in result:
return ToolResult.fail(result["error"])
return ToolResult.success(f"Navigated back to: {result['url']}")
def _do_forward(self, args: Dict[str, Any]) -> ToolResult:
result = self._get_service().go_forward()
if "error" in result:
return ToolResult.fail(result["error"])
return ToolResult.success(f"Navigated forward to: {result['url']}")
def _do_get_text(self, args: Dict[str, Any]) -> ToolResult:
selector = args.get("selector", "").strip()
if not selector:
return ToolResult.fail("Error: 'selector' is required for get_text action")
result = self._get_service().get_text(selector)
if "error" in result:
return ToolResult.fail(result["error"])
return ToolResult.success(result["text"])
def _do_press(self, args: Dict[str, Any]) -> ToolResult:
key = args.get("key", "").strip()
if not key:
return ToolResult.fail("Error: 'key' is required for press action")
result = self._get_service().press(key)
if "error" in result:
return ToolResult.fail(result["error"])
return ToolResult.success(f"Pressed key: {key}")
def _do_evaluate(self, args: Dict[str, Any]) -> ToolResult:
script = args.get("script", "").strip()
if not script:
return ToolResult.fail("Error: 'script' is required for evaluate action")
result = self._get_service().evaluate(script)
if "error" in result:
return ToolResult.fail(result["error"])
val = result.get("result")
if isinstance(val, (dict, list)):
return ToolResult.success(json.dumps(val, ensure_ascii=False, indent=2))
return ToolResult.success(str(val) if val is not None else "(no return value)")
# Action dispatch table
_ACTION_MAP = {
"navigate": _do_navigate,
"snapshot": _do_snapshot,
"click": _do_click,
"fill": _do_fill,
"select": _do_select,
"scroll": _do_scroll,
"screenshot": _do_screenshot,
"wait": _do_wait,
"back": _do_back,
"forward": _do_forward,
"get_text": _do_get_text,
"press": _do_press,
"evaluate": _do_evaluate,
}
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
def copy(self):
"""Share browser instance across tool copies (avoids re-launching)."""
new_tool = BrowserTool(self.config)
new_tool.model = self.model
new_tool.context = getattr(self, "context", None)
new_tool.cwd = self.cwd
new_tool._service = self._service
return new_tool
def close(self):
"""Release browser resources."""
if self._service:
self._service.close()
self._service = None
BrowserTool._shared_service = None
logger.info("[Browser] BrowserTool closed")

View File

@@ -1,18 +0,0 @@
def copy(self):
"""
Special copy method for browser tool to avoid recreating browser instance.
:return: A new instance with shared browser reference but unique model
"""
new_tool = self.__class__()
# Copy essential attributes
new_tool.model = self.model
new_tool.context = getattr(self, 'context', None)
new_tool.config = getattr(self, 'config', None)
# Share the browser instance instead of creating a new one
if hasattr(self, 'browser'):
new_tool.browser = self.browser
return new_tool

View File

@@ -84,11 +84,11 @@ class ToolManager:
except ImportError as e:
# Handle missing dependencies with helpful messages
error_msg = str(e)
if "browser-use" in error_msg or "browser_use" in error_msg:
if "playwright" in error_msg:
logger.warning(
f"[ToolManager] Browser tool not loaded - missing dependencies.\n"
f" To enable browser tool, run:\n"
f" pip install browser-use markdownify playwright\n"
f" pip install playwright\n"
f" playwright install chromium"
)
elif "markdownify" in error_msg:
@@ -154,11 +154,11 @@ class ToolManager:
except ImportError as e:
# Handle missing dependencies with helpful messages
error_msg = str(e)
if "browser-use" in error_msg or "browser_use" in error_msg:
if "playwright" in error_msg:
logger.warning(
f"[ToolManager] Browser tool not loaded - missing dependencies.\n"
f" To enable browser tool, run:\n"
f" pip install browser-use markdownify playwright\n"
f" pip install playwright\n"
f" playwright install chromium"
)
elif "markdownify" in error_msg:
@@ -197,7 +197,7 @@ class ToolManager:
logger.warning(
f"[ToolManager] Browser tool is configured but not loaded.\n"
f" To enable browser tool, run:\n"
f" pip install browser-use markdownify playwright\n"
f" pip install playwright\n"
f" playwright install chromium"
)
elif tool_name == "google_search":

View File

@@ -270,7 +270,7 @@
<div class="max-w-3xl mx-auto">
<!-- Attachment preview bar -->
<div id="attachment-preview" class="attachment-preview hidden"></div>
<div class="flex items-center gap-2">
<div class="flex items-center gap-2 relative">
<div class="flex items-center flex-shrink-0">
<button id="new-chat-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
@@ -287,6 +287,7 @@
</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">
<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
@@ -295,7 +296,7 @@
text-sm leading-relaxed"
rows="1"
data-i18n-placeholder="input_placeholder"
placeholder="Type a message..."></textarea>
placeholder="Type a message, or press / for commands"></textarea>
<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

View File

@@ -79,6 +79,11 @@
.msg-content img { max-width: 100%; height: auto; border-radius: 8px; margin: 0.5em 0; }
.msg-content a { color: #35A85B; text-decoration: underline; }
.msg-content a:hover { color: #228547; }
/* Overrides for user bubble (white text on green bg) */
.user-bubble.msg-content a { color: #ffffff !important; text-decoration: underline; text-decoration-color: rgba(255,255,255,0.6); }
.user-bubble.msg-content a:hover { color: #e0f5e8 !important; text-decoration-color: #e0f5e8; }
.user-bubble.msg-content :not(pre) > code { background: rgba(255,255,255,0.2); color: #ffffff; }
.msg-content hr { border: none; height: 1px; background: #e2e8f0; margin: 1.2em 0; }
.dark .msg-content hr { background: rgba(255,255,255,0.1); }
@@ -446,3 +451,87 @@
transform: translateY(-2px);
box-shadow: 0 8px 25px -5px rgba(0, 0, 0, 0.1);
}
/* Slash Command Menu */
.slash-menu {
position: absolute;
bottom: calc(100% + 6px);
left: 0;
right: 0;
max-height: 320px;
overflow-y: auto;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
box-shadow: 0 8px 30px -6px rgba(0, 0, 0, 0.1), 0 2px 8px -2px rgba(0, 0, 0, 0.04);
z-index: 50;
padding: 4px;
animation: slashMenuIn 0.15s ease-out;
}
.slash-menu.hidden { display: none; }
@keyframes slashMenuIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.slash-menu-header {
padding: 6px 10px 4px;
font-size: 11px;
font-weight: 600;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.slash-menu-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
border-radius: 8px;
cursor: pointer;
transition: background 0.12s ease;
}
.slash-menu-item:hover,
.slash-menu-item.active {
background: #EDFDF3;
}
.slash-menu-item .cmd {
font-size: 13px;
font-weight: 500;
color: #334155;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace;
}
.slash-menu-item.active .cmd {
color: #228547;
}
.slash-menu-item .desc {
font-size: 12px;
color: #94a3b8;
margin-left: 12px;
white-space: nowrap;
}
/* Dark mode */
.dark .slash-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 .slash-menu-header {
color: #64748b;
}
.dark .slash-menu-item:hover,
.dark .slash-menu-item.active {
background: rgba(74, 190, 110, 0.1);
}
.dark .slash-menu-item .cmd {
color: #e2e8f0;
}
.dark .slash-menu-item.active .cmd {
color: #4ABE6E;
}
.dark .slash-menu-item .desc {
color: #64748b;
}

View File

@@ -21,7 +21,7 @@ const I18N = {
example_sys_title: '系统管理', example_sys_text: '帮我查看工作空间里有哪些文件',
example_task_title: '技能系统', example_task_text: '查看所有支持的工具和技能',
example_code_title: '编程助手', example_code_text: '帮我编写一个Python爬虫脚本',
input_placeholder: '输入消息...',
input_placeholder: '输入消息,或输入 / 使用指令',
config_title: '配置管理', config_desc: '管理模型和 Agent 配置',
config_model: '模型配置', config_agent: 'Agent 配置',
config_channel: '通道配置',
@@ -72,7 +72,7 @@ const I18N = {
example_sys_title: 'System', example_sys_text: 'Show me the files in the workspace',
example_task_title: 'Skills', example_task_text: 'Show current tools and skills',
example_code_title: 'Coding', example_code_text: 'Write a Python web scraper script',
input_placeholder: 'Type a message...',
input_placeholder: 'Type a message, or press / for commands',
config_title: 'Configuration', config_desc: 'Manage model and agent settings',
config_model: 'Model Configuration', config_agent: 'Agent Configuration',
config_channel: 'Channel Configuration',
@@ -322,6 +322,11 @@ const attachmentPreview = document.getElementById('attachment-preview');
let pendingAttachments = [];
let uploadingCount = 0;
// Input history (like terminal arrow-key recall)
const inputHistory = [];
let historyIdx = -1;
let historySavedDraft = '';
function updateSendBtnState() {
sendBtn.disabled = uploadingCount > 0 || (!chatInput.value.trim() && pendingAttachments.length === 0);
}
@@ -435,6 +440,99 @@ chatInput.addEventListener('paste', (e) => {
chatInput.addEventListener('compositionstart', () => { isComposing = true; });
chatInput.addEventListener('compositionend', () => { setTimeout(() => { isComposing = false; }, 100); });
// ── Slash Command Menu ───────────────────────────────────────
const SLASH_COMMANDS = [
{ cmd: '/help', desc: '显示命令帮助' },
{ cmd: '/status', desc: '查看运行状态' },
{ cmd: '/context', desc: '查看对话上下文' },
{ cmd: '/context clear', desc: '清除对话上下文' },
{ cmd: '/skill list', desc: '查看已安装技能' },
{ cmd: '/skill list --remote', desc: '浏览技能广场' },
{ cmd: '/skill search ', desc: '搜索技能' },
{ cmd: '/skill install ', desc: '安装技能 (名称或 GitHub URL)' },
{ cmd: '/skill uninstall ', desc: '卸载技能' },
{ cmd: '/skill info ', desc: '查看技能详情' },
{ cmd: '/skill enable ', desc: '启用技能' },
{ cmd: '/skill disable ', desc: '禁用技能' },
{ cmd: '/config', desc: '查看当前配置' },
{ cmd: '/logs', desc: '查看最近日志' },
{ cmd: '/version', desc: '查看版本' },
];
const slashMenu = document.getElementById('slash-menu');
let slashActiveIdx = 0;
let slashFiltered = [];
let slashJustSelected = false;
let slashLastFilter = '';
function showSlashMenu(filter) {
const q = filter.toLowerCase();
if (q === slashLastFilter && !slashMenu.classList.contains('hidden')) return;
slashLastFilter = q;
const newFiltered = SLASH_COMMANDS.filter(c => c.cmd.toLowerCase().startsWith(q));
if (newFiltered.length === 0) {
hideSlashMenu();
return;
}
const changed = newFiltered.length !== slashFiltered.length ||
newFiltered.some((c, i) => c.cmd !== slashFiltered[i]?.cmd);
slashFiltered = newFiltered;
if (changed) slashActiveIdx = 0;
slashActiveIdx = Math.min(slashActiveIdx, slashFiltered.length - 1);
renderSlashItems();
slashMenu.classList.remove('hidden');
}
function hideSlashMenu() {
slashMenu.classList.add('hidden');
slashMenu.innerHTML = '';
slashFiltered = [];
slashActiveIdx = -1;
slashLastFilter = '';
}
function isSlashMenuVisible() {
return !slashMenu.classList.contains('hidden') && slashFiltered.length > 0;
}
function renderSlashItems() {
slashMenu.innerHTML =
'<div class="slash-menu-header">Commands</div>' +
slashFiltered.map((c, i) =>
`<div class="slash-menu-item${i === slashActiveIdx ? ' active' : ''}" data-idx="${i}">` +
`<span class="cmd">${escapeHtml(c.cmd)}</span>` +
`<span class="desc">${escapeHtml(c.desc)}</span></div>`
).join('');
slashMenu.querySelectorAll('.slash-menu-item').forEach(el => {
el.addEventListener('mouseenter', () => {
slashActiveIdx = parseInt(el.dataset.idx);
renderSlashItems();
});
el.addEventListener('mousedown', (e) => {
e.preventDefault();
selectSlashCommand(parseInt(el.dataset.idx));
});
});
const activeEl = slashMenu.querySelector('.slash-menu-item.active');
if (activeEl) activeEl.scrollIntoView({ block: 'nearest' });
}
function selectSlashCommand(idx) {
if (idx < 0 || idx >= slashFiltered.length) return;
const chosen = slashFiltered[idx].cmd;
slashJustSelected = true;
chatInput.value = chosen;
chatInput.dispatchEvent(new Event('input'));
hideSlashMenu();
chatInput.focus();
chatInput.selectionStart = chatInput.selectionEnd = chosen.length;
}
chatInput.addEventListener('input', function() {
this.style.height = '42px';
const scrollH = this.scrollHeight;
@@ -442,11 +540,90 @@ chatInput.addEventListener('input', function() {
this.style.height = newH + 'px';
this.style.overflowY = scrollH > 180 ? 'auto' : 'hidden';
updateSendBtnState();
const val = this.value;
if (slashJustSelected) {
slashJustSelected = false;
} else if (val.startsWith('/')) {
showSlashMenu(val);
} else {
hideSlashMenu();
}
});
chatInput.addEventListener('keydown', function(e) {
// keyCode 229 indicates an IME is processing the keystroke (reliable across browsers)
if (e.keyCode === 229 || e.isComposing || isComposing) return;
if (isSlashMenuVisible()) {
if (e.key === 'ArrowDown') {
e.preventDefault();
slashActiveIdx = Math.min(slashActiveIdx + 1, slashFiltered.length - 1);
renderSlashItems();
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
slashActiveIdx = Math.max(slashActiveIdx - 1, 0);
renderSlashItems();
return;
}
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey) {
e.preventDefault();
selectSlashCommand(slashActiveIdx);
return;
}
if (e.key === 'Escape') {
e.preventDefault();
hideSlashMenu();
return;
}
if (e.key === 'Tab') {
e.preventDefault();
selectSlashCommand(slashActiveIdx);
return;
}
}
// Arrow-key history recall (only when input is empty or already browsing history)
if (e.key === 'ArrowUp' && inputHistory.length > 0 && !isSlashMenuVisible()) {
const curVal = this.value.trim();
const isSingleLine = !this.value.includes('\n');
if (isSingleLine && (curVal === '' || historyIdx >= 0)) {
e.preventDefault();
if (historyIdx < 0) {
historySavedDraft = this.value;
historyIdx = inputHistory.length - 1;
} else if (historyIdx > 0) {
historyIdx--;
}
this.value = inputHistory[historyIdx];
slashJustSelected = true;
this.dispatchEvent(new Event('input'));
hideSlashMenu();
this.selectionStart = this.selectionEnd = this.value.length;
return;
}
}
if (e.key === 'ArrowDown' && historyIdx >= 0 && !isSlashMenuVisible()) {
const isSingleLine = !this.value.includes('\n');
if (isSingleLine) {
e.preventDefault();
if (historyIdx < inputHistory.length - 1) {
historyIdx++;
this.value = inputHistory[historyIdx];
} else {
historyIdx = -1;
this.value = historySavedDraft;
historySavedDraft = '';
}
slashJustSelected = true;
this.dispatchEvent(new Event('input'));
hideSlashMenu();
this.selectionStart = this.selectionEnd = this.value.length;
return;
}
}
if ((e.ctrlKey || e.shiftKey) && e.key === 'Enter') {
const start = this.selectionStart;
const end = this.selectionEnd;
@@ -460,6 +637,10 @@ chatInput.addEventListener('keydown', function(e) {
}
});
chatInput.addEventListener('blur', () => {
setTimeout(hideSlashMenu, 150);
});
document.querySelectorAll('.example-card').forEach(card => {
card.addEventListener('click', () => {
const textEl = card.querySelector('[data-i18n*="text"]');
@@ -475,6 +656,12 @@ function sendMessage() {
const text = chatInput.value.trim();
if (!text && pendingAttachments.length === 0) return;
if (text) {
inputHistory.push(text);
historyIdx = -1;
historySavedDraft = '';
}
const ws = document.getElementById('welcome-screen');
if (ws) ws.remove();
@@ -732,7 +919,7 @@ function createUserMessageEl(content, timestamp, attachments) {
const textHtml = content ? renderMarkdown(content) : '';
el.innerHTML = `
<div class="max-w-[75%] sm:max-w-[60%]">
<div class="bg-primary-400 text-white rounded-2xl px-4 py-2.5 text-sm leading-relaxed msg-content">
<div class="bg-primary-400 text-white rounded-2xl px-4 py-2.5 text-sm leading-relaxed msg-content user-bubble">
${attachHtml}${textHtml}
</div>
<div class="text-xs text-slate-400 dark:text-slate-500 mt-1.5 text-right">${formatTime(timestamp)}</div>

View File

@@ -494,8 +494,8 @@ class ChatHandler:
class ConfigHandler:
_RECOMMENDED_MODELS = [
const.MINIMAX_M2_5, const.MINIMAX_M2_1, const.MINIMAX_M2_1_LIGHTNING,
const.GLM_5, const.GLM_4_7,
const.MINIMAX_M2_7, const.MINIMAX_M2_5, const.MINIMAX_M2_1, const.MINIMAX_M2_1_LIGHTNING,
const.GLM_5_TURBO, const.GLM_5, const.GLM_4_7,
const.QWEN3_MAX, const.QWEN35_PLUS,
const.KIMI_K2_5, const.KIMI_K2,
const.DOUBAO_SEED_2_PRO, const.DOUBAO_SEED_2_CODE,
@@ -511,14 +511,14 @@ class ConfigHandler:
"api_key_field": "minimax_api_key",
"api_base_key": None,
"api_base_default": None,
"models": [const.MINIMAX_M2_5, const.MINIMAX_M2_1, const.MINIMAX_M2_1_LIGHTNING],
"models": [const.MINIMAX_M2_7, const.MINIMAX_M2_5, const.MINIMAX_M2_1, const.MINIMAX_M2_1_LIGHTNING],
}),
("zhipu", {
"label": "智谱AI",
"api_key_field": "zhipu_ai_api_key",
"api_base_key": "zhipu_ai_api_base",
"api_base_default": "https://open.bigmodel.cn/api/paas/v4",
"models": [const.GLM_5, const.GLM_4_7],
"models": [const.GLM_5_TURBO, const.GLM_5, const.GLM_4_7],
}),
("dashscope", {
"label": "通义千问",

View File

@@ -452,7 +452,7 @@ class WecomBotChannel(ChatChannel):
if req_id:
state = self._stream_states.pop(req_id, None)
if state:
final_content = state["committed"]
final_content = state["committed"] or content
stream_id = state["stream_id"]
else:
final_content = content

View File

@@ -284,6 +284,16 @@ def upload_media_to_cdn(api: WeixinApi, file_path: str, to_user_id: str,
raw_md5 = _md5_bytes(raw_data)
cipher_size = _aes_ecb_padded_size(raw_size)
encrypted = _aes_ecb_encrypt(raw_data, aes_key)
from urllib.parse import quote
download_param = None
last_error = None
for attempt in range(1, UPLOAD_MAX_RETRIES + 1):
try:
if attempt > 1:
filekey = uuid.uuid4().hex
resp = api.get_upload_url(
filekey=filekey,
media_type=media_type,
@@ -293,24 +303,17 @@ def upload_media_to_cdn(api: WeixinApi, file_path: str, to_user_id: str,
filesize=cipher_size,
aeskey=aes_key_hex,
)
upload_param = resp.get("upload_param", "")
if not upload_param:
raise RuntimeError(f"[Weixin] getUploadUrl returned no upload_param: {resp}")
encrypted = _aes_ecb_encrypt(raw_data, aes_key)
from urllib.parse import quote
cdn_url = (f"{api.cdn_base_url}/upload"
f"?encrypted_query_param={quote(upload_param)}"
f"&filekey={quote(filekey)}")
download_param = None
last_error = None
for attempt in range(1, UPLOAD_MAX_RETRIES + 1):
try:
cdn_resp = requests.post(cdn_url, data=encrypted, headers={
"Content-Type": "application/octet-stream",
"Content-Length": str(len(encrypted)),
}, timeout=120)
if 400 <= cdn_resp.status_code < 500:
err_msg = cdn_resp.headers.get("x-error-message", cdn_resp.text[:200])
@@ -326,7 +329,9 @@ def upload_media_to_cdn(api: WeixinApi, file_path: str, to_user_id: str,
if "client error" in str(e):
raise
if attempt < UPLOAD_MAX_RETRIES:
logger.warning(f"[Weixin] CDN upload attempt {attempt} failed, retrying: {e}")
backoff = 2 ** attempt
logger.warning(f"[Weixin] CDN upload attempt {attempt} failed, retrying in {backoff}s: {e}")
time.sleep(backoff)
else:
logger.error(f"[Weixin] CDN upload failed after {UPLOAD_MAX_RETRIES} attempts: {e}")

View File

@@ -3,8 +3,9 @@
import click
from cli import __version__
from cli.commands.skill import skill
from cli.commands.process import start, stop, restart, status, logs
from cli.commands.process import start, stop, restart, update, status, logs
from cli.commands.context import context
from cli.commands.install import install_browser
HELP_TEXT = """Usage: cow COMMAND [ARGS]...
@@ -17,10 +18,11 @@ Commands:
start Start CowAgent.
stop Stop CowAgent.
restart Restart CowAgent.
update Update CowAgent and restart.
status Show CowAgent running status.
logs View CowAgent logs.
context View or manage conversation context.
skill Manage CowAgent skills.
install-browser Install browser tool (Playwright + Chromium).
Tip: You can also send /help, /skill list, etc. in agent chat."""
@@ -63,9 +65,11 @@ main.add_command(skill)
main.add_command(start)
main.add_command(stop)
main.add_command(restart)
main.add_command(update)
main.add_command(status)
main.add_command(logs)
main.add_command(context)
main.add_command(install_browser)
if __name__ == '__main__':

63
cli/commands/install.py Normal file
View File

@@ -0,0 +1,63 @@
"""cow install-browser - Install Playwright + Chromium for the browser tool."""
import os
import sys
import subprocess
import click
def _has_display() -> bool:
"""Check if a graphical display is available (Linux only)."""
return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
def _is_headless_linux() -> bool:
"""True when running on a Linux server without a display."""
return sys.platform == "linux" and not _has_display()
@click.command("install-browser")
def install_browser():
"""Install browser tool dependencies (Playwright + Chromium)."""
python = sys.executable
# Step 1: Install playwright package
click.echo(click.style("[1/3] Installing playwright Python package...", fg="yellow"))
ret = subprocess.call([python, "-m", "pip", "install", "playwright"])
if ret != 0:
click.echo(click.style("Failed to install playwright package.", fg="red"))
raise SystemExit(1)
click.echo(click.style("playwright package installed.", fg="green"))
click.echo()
# Step 2: System dependencies (Linux only)
if sys.platform == "linux":
click.echo(click.style("[2/3] Installing system dependencies (Linux)...", fg="yellow"))
ret = subprocess.call([python, "-m", "playwright", "install-deps", "chromium"])
if ret != 0:
click.echo(click.style(
"Could not auto-install system deps (may need sudo).\n"
f" Run manually: sudo {python} -m playwright install-deps chromium",
fg="yellow",
))
else:
click.echo(click.style(f"[2/3] Skipping system deps (not needed on {sys.platform}).", fg="yellow"))
click.echo()
# Step 3: Install Chromium (headless shell on Linux servers, full elsewhere)
click.echo(click.style("[3/3] Installing Chromium browser...", fg="yellow"))
cmd = [python, "-m", "playwright", "install", "chromium"]
if _is_headless_linux():
cmd.append("--only-shell")
click.echo(" (headless-only mode for Linux server)")
elif sys.platform == "linux":
click.echo(" (full browser for Linux desktop)")
ret = subprocess.call(cmd)
if ret != 0:
click.echo(click.style("Failed to install Chromium.", fg="red"))
raise SystemExit(1)
click.echo()
click.echo(click.style("Browser tool ready! Restart CowAgent to enable it.", fg="green"))

View File

@@ -172,6 +172,46 @@ def restart(ctx, no_logs):
ctx.invoke(start, no_logs=no_logs)
@click.command()
@click.pass_context
def update(ctx):
"""Update CowAgent and restart."""
root = get_project_root()
# 1. Git pull while service is still running
if os.path.isdir(os.path.join(root, ".git")):
click.echo("Pulling latest code...")
ret = subprocess.call(["git", "pull"], cwd=root)
if ret != 0:
click.echo("Error: git pull failed.", err=True)
sys.exit(1)
else:
click.echo("Not a git repository, skipping code update.")
# 2. Stop service
ctx.invoke(stop)
# 3. Install dependencies
python = sys.executable
req_file = os.path.join(root, "requirements.txt")
if os.path.exists(req_file):
click.echo("Installing dependencies...")
subprocess.call(
[python, "-m", "pip", "install", "-r", "requirements.txt", "-q"],
cwd=root,
)
click.echo("Reinstalling cow CLI...")
subprocess.call(
[python, "-m", "pip", "install", "-e", ".", "-q"],
cwd=root,
)
# 4. Start service
click.echo("")
time.sleep(1)
ctx.invoke(start, no_logs=True)
@click.command()
def status():
"""Show CowAgent running status."""

View File

@@ -8,6 +8,8 @@ import hashlib
import shutil
import zipfile
import tempfile
from dataclasses import dataclass, field
from typing import Optional, List
from urllib.parse import urlparse
@@ -22,7 +24,411 @@ from cli.utils import (
SKILL_HUB_API,
)
# ======================================================================
# Public types for the core install API (used by CLI and chat plugin)
# ======================================================================
class SkillInstallError(Exception):
"""Raised when skill installation fails."""
pass
@dataclass
class InstallResult:
"""Result of a skill installation operation."""
installed: List[str] = field(default_factory=list)
messages: List[str] = field(default_factory=list)
error: Optional[str] = None
_SAFE_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_\-]{0,63}$")
_GITHUB_URL_RE = re.compile(
r"^https?://github\.com/([^/]+)/([^/]+?)(?:\.git)?(?:/(?:tree|blob)/([^/]+)(?:/(.+))?)?/?$"
)
_GITLAB_URL_RE = re.compile(
r"^https?://gitlab\.com/([^/]+)/([^/]+?)(?:\.git)?(?:/-/tree/([^/]+)(?:/(.+))?)?/?$"
)
_GIT_SSH_RE = re.compile(
r"^git@([^:]+):([^/]+)/([^/]+?)(?:\.git)?$"
)
def _parse_github_url(url: str):
"""Parse a full GitHub URL into (owner, repo, branch, subpath).
Returns None if the URL doesn't match.
Supported formats:
https://github.com/owner/repo
https://github.com/owner/repo/tree/branch
https://github.com/owner/repo/tree/branch/path/to/skill
https://github.com/owner/repo/blob/branch/path/to/skill
"""
m = _GITHUB_URL_RE.match(url.strip())
if not m:
return None
owner, repo, branch, subpath = m.groups()
return owner, repo, branch or "main", subpath
def _parse_gitlab_url(url: str):
"""Parse a GitLab URL into (owner, repo, branch, subpath).
Returns None if the URL doesn't match.
Supported formats:
https://gitlab.com/owner/repo
https://gitlab.com/owner/repo/-/tree/branch
https://gitlab.com/owner/repo/-/tree/branch/path/to/skill
"""
m = _GITLAB_URL_RE.match(url.strip())
if not m:
return None
owner, repo, branch, subpath = m.groups()
return owner, repo, branch or "main", subpath
def _parse_git_ssh_url(url: str):
"""Parse a git@ SSH URL into (host, owner, repo).
Returns None if the URL doesn't match.
Supported format: git@github.com:owner/repo.git
"""
m = _GIT_SSH_RE.match(url.strip())
if not m:
return None
host, owner, repo = m.groups()
return host, owner, repo
def _clone_repo(git_url: str):
"""Shallow-clone a git repo and return (tmp_dir, repo_root).
Requires git to be installed. The caller must clean up tmp_dir.
"""
tmp_dir = tempfile.mkdtemp(prefix="cow-skill-")
repo_dir = os.path.join(tmp_dir, "repo")
try:
import subprocess
subprocess.run(
["git", "clone", "--depth", "1", git_url, repo_dir],
check=True, capture_output=True, timeout=120,
)
except FileNotFoundError:
shutil.rmtree(tmp_dir, ignore_errors=True)
raise RuntimeError("git is not installed")
except Exception as e:
shutil.rmtree(tmp_dir, ignore_errors=True)
raise RuntimeError(f"git clone failed: {e}")
return tmp_dir, repo_dir
def _download_repo_zip(spec: str, branch: str = "main", host: str = "github"):
"""Download a GitHub/GitLab repo as zip and extract it.
Returns (tmp_dir, repo_root) where tmp_dir is the temp directory to clean up
and repo_root is the extracted repository root path.
"""
if host == "gitlab":
zip_url = f"https://gitlab.com/{spec}/-/archive/{branch}/{spec.split('/')[-1]}-{branch}.zip"
else:
zip_url = f"https://github.com/{spec}/archive/refs/heads/{branch}.zip"
resp = requests.get(zip_url, timeout=120, allow_redirects=True)
resp.raise_for_status()
tmp_dir = tempfile.mkdtemp(prefix="cow-skill-")
zip_path = os.path.join(tmp_dir, "repo.zip")
with open(zip_path, "wb") as f:
f.write(resp.content)
extract_dir = os.path.join(tmp_dir, "extracted")
with zipfile.ZipFile(zip_path, "r") as zf:
_safe_extractall(zf, extract_dir)
# GitHub zips have a single top-level dir like "repo-main/"
top_items = [d for d in os.listdir(extract_dir) if not d.startswith(".")]
if len(top_items) == 1 and os.path.isdir(os.path.join(extract_dir, top_items[0])):
return tmp_dir, os.path.join(extract_dir, top_items[0])
return tmp_dir, extract_dir
def _download_github_dir(owner, repo, branch, subpath, dest_dir):
"""Download a subdirectory from GitHub using the Contents API.
Recursively fetches all files under the given subpath and writes them
to dest_dir. Used as a fallback when zip download fails.
Costs one API request per directory (60/hr unauthenticated).
"""
api_url = f"https://api.github.com/repos/{owner}/{repo}/contents/{subpath}?ref={branch}"
resp = requests.get(api_url, timeout=30, headers={"Accept": "application/vnd.github.v3+json"})
resp.raise_for_status()
items = resp.json()
if isinstance(items, dict):
items = [items]
for item in items:
rel_path = item["path"]
if subpath:
rel_path = rel_path[len(subpath.strip("/")):].lstrip("/")
local_path = os.path.join(dest_dir, rel_path)
if item["type"] == "file":
os.makedirs(os.path.dirname(local_path), exist_ok=True)
dl_url = item.get("download_url")
if not dl_url:
continue
file_resp = requests.get(dl_url, timeout=30)
file_resp.raise_for_status()
with open(local_path, "wb") as f:
f.write(file_resp.content)
elif item["type"] == "dir":
os.makedirs(local_path, exist_ok=True)
child_subpath = item["path"]
_download_github_dir(owner, repo, branch, child_subpath, dest_dir)
# Directories to search for skills following the Agent Skills convention
_SKILL_SCAN_DIRS = [
"skills",
"skills/.curated",
"skills/.experimental",
]
_SKILL_SCAN_SKIP = {
"node_modules", "__pycache__", ".git", ".github", "venv", ".venv",
}
def _scan_skills_in_repo(repo_root: str) -> list:
"""Scan a repo for skill directories containing SKILL.md.
Searches in conventional locations (skills/, skills/.curated/, etc.)
and also checks the repo root itself.
Returns a list of (skill_name, skill_dir_path) tuples.
"""
found = []
# Check repo root for a SKILL.md (single-skill repo)
if os.path.isfile(os.path.join(repo_root, "SKILL.md")):
fm = _parse_skill_frontmatter(_read_file_text(os.path.join(repo_root, "SKILL.md")))
name = fm.get("name") or os.path.basename(repo_root)
found.append((name, repo_root))
return found
for scan_dir in _SKILL_SCAN_DIRS:
search_root = os.path.join(repo_root, scan_dir)
if not os.path.isdir(search_root):
continue
for entry in os.listdir(search_root):
if entry.startswith(".") and entry not in (".curated", ".experimental"):
continue
if entry in _SKILL_SCAN_SKIP:
continue
entry_path = os.path.join(search_root, entry)
if os.path.isdir(entry_path) and os.path.isfile(os.path.join(entry_path, "SKILL.md")):
fm = _parse_skill_frontmatter(
_read_file_text(os.path.join(entry_path, "SKILL.md"))
)
name = fm.get("name") or entry
found.append((name, entry_path))
return found
def _scan_skills_in_dir(directory: str) -> list:
"""Scan immediate subdirectories for SKILL.md files.
Unlike _scan_skills_in_repo which checks conventional locations,
this scans all direct children of the given directory.
Returns a list of (skill_name, skill_dir_path) tuples.
"""
found = []
if not os.path.isdir(directory):
return found
for entry in os.listdir(directory):
if entry.startswith(".") or entry in _SKILL_SCAN_SKIP:
continue
entry_path = os.path.join(directory, entry)
if os.path.isdir(entry_path) and os.path.isfile(os.path.join(entry_path, "SKILL.md")):
fm = _parse_skill_frontmatter(
_read_file_text(os.path.join(entry_path, "SKILL.md"))
)
name = fm.get("name") or entry
found.append((name, entry_path))
return found
def _batch_install_skills(discovered, spec, skills_dir, source, result: InstallResult):
"""Install a list of discovered skills into skills_dir."""
result.messages.append(f"Found {len(discovered)} skill(s) in {spec}:")
for sname, sdir in discovered:
safe_name = re.sub(r'[^a-zA-Z0-9_\-]', '-', sname)[:64]
if not _SAFE_NAME_RE.match(safe_name):
result.messages.append(f" Skipping '{sname}' (invalid name)")
continue
target_dir = os.path.join(skills_dir, safe_name)
if os.path.exists(target_dir):
shutil.rmtree(target_dir)
shutil.copytree(sdir, target_dir)
_register_installed_skill(safe_name, source=source)
result.installed.append(safe_name)
result.messages.append(f" + {safe_name}")
if result.installed:
result.messages.append(f"{len(result.installed)} skill(s) installed from {spec}.")
else:
result.messages.append("No valid skills found.")
def _read_file_text(path: str) -> str:
"""Read a file as UTF-8 text, returning empty string on failure."""
try:
with open(path, "r", encoding="utf-8") as f:
return f.read()
except Exception:
return ""
def _install_local(path: str, result: InstallResult):
"""Install skill(s) from a local directory."""
path = os.path.abspath(os.path.expanduser(path))
if not os.path.isdir(path):
raise SkillInstallError(f"'{path}' is not a directory.")
skills_dir = get_skills_dir()
os.makedirs(skills_dir, exist_ok=True)
if os.path.isfile(os.path.join(path, "SKILL.md")):
fm = _parse_skill_frontmatter(_read_file_text(os.path.join(path, "SKILL.md")))
skill_name = fm.get("name") or os.path.basename(path)
skill_name = re.sub(r'[^a-zA-Z0-9_\-]', '-', skill_name)[:64]
_check_skill_name(skill_name)
target_dir = os.path.join(skills_dir, skill_name)
if os.path.exists(target_dir):
shutil.rmtree(target_dir)
shutil.copytree(path, target_dir)
_register_installed_skill(skill_name, source="local")
result.installed.append(skill_name)
result.messages.append(f"Installed '{skill_name}' from local path.")
return
discovered = _scan_skills_in_repo(path) or _scan_skills_in_dir(path)
if not discovered:
raise SkillInstallError(f"No skills found in '{path}'.")
_batch_install_skills(discovered, path, skills_dir, "local", result)
def _register_installed_skill(name: str, source: str = "cowhub"):
"""Register a newly installed skill into skills_config.json.
source values: builtin, cow, github, clawhub, linkai, local, url
"""
skills_dir = get_skills_dir()
config_path = os.path.join(skills_dir, "skills_config.json")
config = {}
if os.path.exists(config_path):
try:
with open(config_path, "r", encoding="utf-8") as f:
config = json.load(f)
except Exception:
config = {}
if name in config:
return
skill_dir = os.path.join(skills_dir, name)
description = _read_skill_description(skill_dir) or ""
config[name] = {
"name": name,
"description": description,
"source": source,
"enabled": True,
"category": "skill",
}
try:
with open(config_path, "w", encoding="utf-8") as f:
json.dump(config, f, indent=4, ensure_ascii=False)
except Exception:
pass
def _parse_skill_frontmatter(content: str) -> dict:
"""Parse YAML frontmatter from SKILL.md content and return a dict with name/description."""
result = {}
match = re.match(r'^---\s*\n(.*?)\n---\s*\n', content, re.DOTALL)
if not match:
return result
for line in match.group(1).split('\n'):
line = line.strip()
for key in ('name', 'description'):
if line.startswith(f'{key}:'):
val = line[len(key) + 1:].strip()
result[key] = val.strip('"').strip("'")
return result
def _read_skill_description(skill_dir: str) -> str:
"""Read the description from a skill's SKILL.md frontmatter."""
skill_md = os.path.join(skill_dir, "SKILL.md")
if not os.path.exists(skill_md):
return ""
try:
with open(skill_md, "r", encoding="utf-8") as f:
content = f.read()
return _parse_skill_frontmatter(content).get("description", "")
except Exception:
return ""
def _install_url(url: str, result: InstallResult):
"""Install a skill from a direct SKILL.md URL."""
result.messages.append(f"Downloading SKILL.md from {url} ...")
try:
resp = requests.get(url, timeout=30)
resp.raise_for_status()
except Exception as e:
raise SkillInstallError(f"Failed to download SKILL.md: {e}")
content = resp.text
fm = _parse_skill_frontmatter(content)
skill_name = fm.get("name")
if not skill_name:
raise SkillInstallError("SKILL.md missing 'name' field in frontmatter.")
skill_name = skill_name.strip()
_check_skill_name(skill_name)
skills_dir = get_skills_dir()
os.makedirs(skills_dir, exist_ok=True)
skill_dir = os.path.join(skills_dir, skill_name)
if os.path.isdir(skill_dir):
result.messages.append(f"Skill '{skill_name}' already exists. Overwriting SKILL.md ...")
os.makedirs(skill_dir, exist_ok=True)
with open(os.path.join(skill_dir, "SKILL.md"), "w", encoding="utf-8") as f:
f.write(content)
_register_installed_skill(skill_name, source="url")
result.installed.append(skill_name)
result.messages.append(f"Installed '{skill_name}' from URL.")
def _print_install_success(name: str, source: str):
"""Print a unified install success message with description and source."""
skills_dir = get_skills_dir()
desc = _read_skill_description(os.path.join(skills_dir, name))
click.echo(click.style(f"{name}", fg="green"))
if desc:
if len(desc) > 60:
desc = desc[:57] + ""
click.echo(f" {desc}")
click.echo(f" 来源: {source}")
def _validate_skill_name(name: str):
@@ -43,6 +449,20 @@ def _validate_github_spec(spec: str):
sys.exit(1)
def _check_skill_name(name: str):
"""Raise SkillInstallError if name is invalid."""
if not _SAFE_NAME_RE.match(name):
raise SkillInstallError(
f"Invalid skill name '{name}'. Use only letters, digits, hyphens, and underscores."
)
def _check_github_spec(spec: str):
"""Raise SkillInstallError if spec is not owner/repo."""
if not re.match(r"^[a-zA-Z0-9_\-]+/[a-zA-Z0-9_.\-]+$", spec):
raise SkillInstallError(f"Invalid GitHub spec '{spec}'. Expected format: owner/repo")
def _safe_extractall(zf: zipfile.ZipFile, dest: str):
"""Extract zip while guarding against Zip Slip (path traversal)."""
dest = os.path.realpath(dest)
@@ -74,6 +494,18 @@ def _verify_checksum(content: bytes, expected: str):
return True
def _check_checksum(content: bytes, expected: str):
"""Raise SkillInstallError on SHA-256 checksum mismatch."""
if not expected:
return
actual = hashlib.sha256(content).hexdigest()
if actual != expected.lower():
raise SkillInstallError(
f"Checksum mismatch! Expected: {expected}, Actual: {actual}. "
"The downloaded package may have been tampered with."
)
@click.group()
def skill():
"""Manage CowAgent skills."""
@@ -245,47 +677,159 @@ def search(query):
# ------------------------------------------------------------------
# cow skill install
# Core install function — reusable from CLI and chat plugin
# ------------------------------------------------------------------
def install_skill(name: str) -> InstallResult:
"""Core install logic, usable from CLI and chat plugin.
Accepts all formats: Skill Hub name, owner/repo, GitHub/GitLab URL,
git@ SSH, local path, SKILL.md URL.
Returns InstallResult with installed skill names and messages.
"""
result = InstallResult()
try:
_route_install(name, result)
except SkillInstallError as e:
result.error = str(e)
return result
def _route_install(name: str, result: InstallResult):
"""Dispatch to the appropriate installer based on input format."""
# --- Local path ---
if name.startswith(("./", "../", "/", "~/")):
_install_local(name, result)
return
# --- Direct SKILL.md URL ---
if name.startswith(("http://", "https://")) and name.rstrip("/").endswith("SKILL.md"):
dir_url = re.sub(r'/SKILL\.md/?$', '', name)
gh = _parse_github_url(dir_url)
if gh:
owner, repo, branch, subpath = gh
_install_github(f"{owner}/{repo}", result, subpath=subpath, skill_name=(
subpath.rstrip("/").split("/")[-1] if subpath else repo
), branch=branch)
return
_install_url(name, result)
return
# --- Full GitHub URL ---
parsed = _parse_github_url(name)
if parsed:
owner, repo, branch, subpath = parsed
_install_github(f"{owner}/{repo}", result, subpath=subpath, branch=branch)
return
# --- Full GitLab URL ---
gl = _parse_gitlab_url(name)
if gl:
owner, repo, branch, subpath = gl
_install_gitlab(f"{owner}/{repo}", result, subpath=subpath, branch=branch)
return
# --- git@host:owner/repo.git SSH URL ---
ssh = _parse_git_ssh_url(name)
if ssh:
host, owner, repo = ssh
_install_git_clone(name, result, display_name=f"{owner}/{repo}")
return
# --- github: prefix ---
if name.startswith("github:"):
raw = name[7:]
subpath = None
if "#" in raw:
raw, subpath = raw.split("#", 1)
_check_github_spec(raw)
_install_github(raw, result, subpath=subpath)
return
# --- clawhub: prefix ---
if name.startswith("clawhub:"):
skill_name = name[8:]
_check_skill_name(skill_name)
_install_hub(skill_name, result, provider="clawhub")
return
# --- owner/repo or owner/repo#subpath shorthand ---
if re.match(r"^[a-zA-Z0-9_\-]+/[a-zA-Z0-9_.\-]+(?:#.+)?$", name):
subpath = None
spec = name
if "#" in spec:
spec, subpath = spec.split("#", 1)
_install_github(spec, result, subpath=subpath)
return
# --- Fallback: Skill Hub by name ---
_check_skill_name(name)
_install_hub(name, result)
# ------------------------------------------------------------------
# cow skill install (CLI thin wrapper)
# ------------------------------------------------------------------
@skill.command()
@click.argument("name")
def install(name):
"""Install a skill from Skill Hub or GitHub.
"""Install skill(s) from Skill Hub, GitHub, GitLab, git URL, or local path.
When given an owner/repo (or full URL), downloads the repo and
auto-discovers all skills/ subdirectories containing SKILL.md,
installing them in batch. Use a subpath to install a single skill.
Examples:
cow skill install pptx
cow skill install pptx (from Skill Hub)
cow skill install github:owner/repo
cow skill install larksuite/cli (GitHub shorthand, all skills)
cow skill install github:owner/repo#path/to/skill
cow skill install larksuite/cli#skills/lark-im (single skill by subpath)
cow skill install https://github.com/owner/repo
cow skill install https://gitlab.com/org/repo
cow skill install git@github.com:owner/repo.git
cow skill install ./my-local-skills (local directory)
cow skill install https://example.com/path/to/SKILL.md
"""
if name.startswith("github:"):
_install_github(name[7:])
else:
_validate_skill_name(name)
_install_hub(name)
result = install_skill(name)
for msg in result.messages:
click.echo(msg)
if result.error:
click.echo(f"Error: {result.error}", err=True)
sys.exit(1)
def _install_hub(name):
def _install_hub(name, result: InstallResult, provider=None):
"""Install a skill from Skill Hub."""
skills_dir = get_skills_dir()
os.makedirs(skills_dir, exist_ok=True)
click.echo(f"Fetching skill info for '{name}'...")
result.messages.append(f"Fetching skill info for '{name}'...")
try:
resp = requests.get(f"{SKILL_HUB_API}/skills/{name}/download", timeout=15)
body = {}
if provider:
body["provider"] = provider
resp = requests.post(
f"{SKILL_HUB_API}/skills/{name}/download",
json=body,
timeout=15,
)
resp.raise_for_status()
except requests.HTTPError as e:
if e.response is not None and e.response.status_code == 404:
click.echo(f"Error: Skill '{name}' not found on Skill Hub.", err=True)
else:
click.echo(f"Error: Failed to fetch skill: {e}", err=True)
sys.exit(1)
raise SkillInstallError(f"Skill '{name}' not found on Skill Hub.")
raise SkillInstallError(f"Failed to fetch skill: {e}")
except SkillInstallError:
raise
except Exception as e:
click.echo(f"Error: Failed to connect to Skill Hub: {e}", err=True)
sys.exit(1)
raise SkillInstallError(f"Failed to connect to Skill Hub: {e}")
content_type = resp.headers.get("Content-Type", "")
@@ -295,10 +839,15 @@ def _install_hub(name):
if source_type == "github":
source_url = data.get("source_url", "")
_validate_github_spec(source_url)
source_path = data.get("source_path")
click.echo(f"Source: GitHub ({source_url})")
_install_github(source_url, subpath=source_path, skill_name=name)
parsed_url = _parse_github_url(source_url)
if parsed_url:
owner, repo, branch, subpath = parsed_url
result.messages.append(f"Source: GitHub ({source_url})")
_install_github(f"{owner}/{repo}", result, subpath=subpath, skill_name=name, branch=branch)
else:
_check_github_spec(source_url)
result.messages.append(f"Source: GitHub ({source_url})")
_install_github(source_url, result, skill_name=name)
return
if source_type == "registry":
@@ -306,106 +855,215 @@ def _install_hub(name):
if download_url:
parsed = urlparse(download_url)
if parsed.scheme != "https":
click.echo(f"Error: Refusing to download from non-HTTPS URL.", err=True)
sys.exit(1)
provider = data.get("source_provider", "registry")
raise SkillInstallError("Refusing to download from non-HTTPS URL.")
src_provider = data.get("source_provider", "registry")
expected_checksum = data.get("checksum") or data.get("sha256")
click.echo(f"Source: {provider}")
click.echo("Downloading skill package...")
result.messages.append(f"Source: {src_provider}")
result.messages.append("Downloading skill package...")
try:
dl_resp = requests.get(download_url, timeout=60, allow_redirects=True)
dl_resp.raise_for_status()
except Exception as e:
click.echo(f"Error: Failed to download from {provider}: {e}", err=True)
sys.exit(1)
_verify_checksum(dl_resp.content, expected_checksum)
_install_zip_bytes(dl_resp.content, name, skills_dir)
click.echo(click.style(f"✓ Skill '{name}' installed successfully!", fg="green"))
raise SkillInstallError(f"Failed to download from {src_provider}: {e}")
_check_checksum(dl_resp.content, expected_checksum)
installed_before = len(result.installed)
_install_zip_bytes(dl_resp.content, name, skills_dir, result=result, source_label=src_provider)
if len(result.installed) == installed_before:
_register_installed_skill(name, source=src_provider)
result.installed.append(name)
result.messages.append(f"Installed '{name}' from {src_provider}.")
else:
click.echo(f"Error: Unsupported registry provider.", err=True)
sys.exit(1)
raise SkillInstallError("Unsupported registry provider.")
return
if "redirect" in data:
source_url = data.get("source_url", "")
_validate_github_spec(source_url)
source_path = data.get("source_path")
click.echo(f"Source: GitHub ({source_url})")
_install_github(source_url, subpath=source_path, skill_name=name)
parsed_url = _parse_github_url(source_url)
if parsed_url:
owner, repo, branch, subpath = parsed_url
result.messages.append(f"Source: GitHub ({source_url})")
_install_github(f"{owner}/{repo}", result, subpath=subpath, skill_name=name, branch=branch)
else:
_check_github_spec(source_url)
result.messages.append(f"Source: GitHub ({source_url})")
_install_github(source_url, result, skill_name=name)
return
elif "application/zip" in content_type:
click.echo("Downloading skill package...")
result.messages.append("Downloading skill package...")
expected_checksum = resp.headers.get("X-Checksum-Sha256")
_verify_checksum(resp.content, expected_checksum)
_install_zip_bytes(resp.content, name, skills_dir)
click.echo(click.style(f"✓ Skill '{name}' installed successfully!", fg="green"))
_check_checksum(resp.content, expected_checksum)
installed_before = len(result.installed)
_install_zip_bytes(resp.content, name, skills_dir, result=result, source_label="cowhub")
if len(result.installed) == installed_before:
_register_installed_skill(name)
result.installed.append(name)
result.messages.append(f"Installed '{name}' from Skill Hub.")
return
click.echo(f"Error: Unexpected response from Skill Hub.", err=True)
sys.exit(1)
raise SkillInstallError("Unexpected response from Skill Hub.")
def _install_github(spec, subpath=None, skill_name=None):
"""Install a skill from a GitHub repo.
def _install_github(spec, result: InstallResult, subpath=None, skill_name=None, branch="main", source="github"):
"""Install skill(s) from a GitHub repo.
spec format: owner/repo or owner/repo#path
Strategy: zip download first (no API rate limit), Contents API as fallback.
"""
if "#" in spec and not subpath:
spec, subpath = spec.split("#", 1)
_validate_github_spec(spec)
if not skill_name:
skill_name = subpath.rstrip("/").split("/")[-1] if subpath else spec.split("/")[-1]
_validate_skill_name(skill_name)
_check_github_spec(spec)
skills_dir = get_skills_dir()
os.makedirs(skills_dir, exist_ok=True)
owner, repo = spec.split("/", 1)
zip_url = f"https://github.com/{spec}/archive/refs/heads/main.zip"
click.echo(f"Downloading from GitHub: {spec}...")
result.messages.append(f"Downloading from GitHub: {spec} (branch: {branch})...")
tmp_dir = None
repo_root = None
try:
resp = requests.get(zip_url, timeout=60, allow_redirects=True)
resp.raise_for_status()
tmp_dir, repo_root = _download_repo_zip(spec, branch)
except Exception:
result.messages.append("Zip download failed, falling back to Contents API...")
if repo_root:
try:
_install_from_repo_root(repo_root, spec, subpath, skill_name, skills_dir, source, result)
return
except SkillInstallError:
raise
except Exception as e:
click.echo(f"Error: Failed to download from GitHub: {e}", err=True)
sys.exit(1)
result.messages.append(f"Error processing zip: {e}")
result.messages.append("Falling back to Contents API...")
finally:
if tmp_dir:
shutil.rmtree(tmp_dir, ignore_errors=True)
with tempfile.TemporaryDirectory() as tmp_dir:
zip_path = os.path.join(tmp_dir, "repo.zip")
with open(zip_path, "wb") as f:
f.write(resp.content)
if not subpath:
raise SkillInstallError(
f"Zip download failed and batch install requires zip. "
f"Try again or specify a subpath: {spec}#skills/<name>"
)
extract_dir = os.path.join(tmp_dir, "extracted")
with zipfile.ZipFile(zip_path, "r") as zf:
_safe_extractall(zf, extract_dir)
if not skill_name:
skill_name = subpath.rstrip("/").split("/")[-1]
_check_skill_name(skill_name)
# GitHub archives have a top-level dir like "repo-main/"
top_items = [d for d in os.listdir(extract_dir) if not d.startswith(".")]
repo_root = extract_dir
if len(top_items) == 1 and os.path.isdir(os.path.join(extract_dir, top_items[0])):
repo_root = os.path.join(extract_dir, top_items[0])
result.messages.append(f"Downloading via Contents API: {spec}/{subpath} ...")
target_dir = os.path.join(skills_dir, skill_name)
try:
with tempfile.TemporaryDirectory() as api_tmp:
api_dest = os.path.join(api_tmp, skill_name)
os.makedirs(api_dest)
_download_github_dir(owner, repo, branch, subpath.strip("/"), api_dest)
if os.path.exists(target_dir):
shutil.rmtree(target_dir)
shutil.copytree(api_dest, target_dir)
_register_installed_skill(skill_name, source=source)
result.installed.append(skill_name)
result.messages.append(f"Installed '{skill_name}' from GitHub.")
except Exception as e:
raise SkillInstallError(f"Contents API also failed: {e}")
def _install_from_repo_root(repo_root, spec, subpath, skill_name, skills_dir, source, result: InstallResult):
"""Install skill(s) from an already-extracted repo root directory."""
if subpath:
source_dir = os.path.join(repo_root, subpath.strip("/"))
if not os.path.isdir(source_dir):
click.echo(f"Error: Path '{subpath}' not found in repository.", err=True)
sys.exit(1)
else:
source_dir = repo_root
raise SkillInstallError(f"Path '{subpath}' not found in repository.")
if os.path.isfile(os.path.join(source_dir, "SKILL.md")):
if not skill_name:
fm = _parse_skill_frontmatter(
_read_file_text(os.path.join(source_dir, "SKILL.md"))
)
skill_name = fm.get("name") or subpath.rstrip("/").split("/")[-1]
_check_skill_name(skill_name)
target_dir = os.path.join(skills_dir, skill_name)
if os.path.exists(target_dir):
shutil.rmtree(target_dir)
shutil.copytree(source_dir, target_dir)
_register_installed_skill(skill_name, source=source)
result.installed.append(skill_name)
result.messages.append(f"Installed '{skill_name}' from {source}.")
return
click.echo(click.style(f"✓ Skill '{skill_name}' installed successfully!", fg="green"))
discovered = _scan_skills_in_dir(source_dir)
if discovered:
_batch_install_skills(discovered, spec, skills_dir, source, result)
return
raise SkillInstallError(f"No SKILL.md found in '{subpath}' or its subdirectories.")
else:
discovered = _scan_skills_in_repo(repo_root)
if not discovered:
if skill_name:
_check_skill_name(skill_name)
else:
skill_name = spec.split("/")[-1]
_check_skill_name(skill_name)
target_dir = os.path.join(skills_dir, skill_name)
if os.path.exists(target_dir):
shutil.rmtree(target_dir)
shutil.copytree(repo_root, target_dir)
_register_installed_skill(skill_name, source=source)
result.installed.append(skill_name)
result.messages.append(f"Installed '{skill_name}' from {source}.")
return
_batch_install_skills(discovered, spec, skills_dir, source, result)
def _install_zip_bytes(content, name, skills_dir):
"""Extract a zip archive into the skills directory."""
def _install_gitlab(spec, result: InstallResult, subpath=None, branch="main"):
"""Install skill(s) from a GitLab repo via zip download."""
_check_github_spec(spec)
skills_dir = get_skills_dir()
os.makedirs(skills_dir, exist_ok=True)
result.messages.append(f"Downloading from GitLab: {spec} (branch: {branch})...")
try:
tmp_dir, repo_root = _download_repo_zip(spec, branch, host="gitlab")
except Exception as e:
raise SkillInstallError(f"Failed to download from GitLab: {e}")
try:
_install_from_repo_root(repo_root, spec, subpath, None, skills_dir, "gitlab", result)
finally:
shutil.rmtree(tmp_dir, ignore_errors=True)
def _install_git_clone(git_url: str, result: InstallResult, display_name: str = ""):
"""Install skill(s) from any git URL via shallow clone."""
skills_dir = get_skills_dir()
os.makedirs(skills_dir, exist_ok=True)
result.messages.append(f"Cloning {display_name or git_url} ...")
try:
tmp_dir, repo_root = _clone_repo(git_url)
except RuntimeError as e:
raise SkillInstallError(str(e))
try:
_install_from_repo_root(repo_root, display_name or git_url, None, None, skills_dir, "git", result)
finally:
shutil.rmtree(tmp_dir, ignore_errors=True)
def _install_zip_bytes(content, name, skills_dir, result: InstallResult = None, source_label: str = "zip"):
"""Extract a zip archive and install skill(s).
Supports three scenarios:
1. Root contains SKILL.md → single skill install
2. Contains multiple skill dirs (skills/, or immediate children with SKILL.md) → batch install
3. Fallback → treat the entire archive as a single skill named `name`
"""
with tempfile.TemporaryDirectory() as tmp_dir:
zip_path = os.path.join(tmp_dir, "package.zip")
with open(zip_path, "wb") as f:
@@ -416,14 +1074,35 @@ def _install_zip_bytes(content, name, skills_dir):
_safe_extractall(zf, extract_dir)
top_items = [d for d in os.listdir(extract_dir) if not d.startswith(".")]
source = extract_dir
pkg_root = extract_dir
if len(top_items) == 1 and os.path.isdir(os.path.join(extract_dir, top_items[0])):
source = os.path.join(extract_dir, top_items[0])
pkg_root = os.path.join(extract_dir, top_items[0])
discovered = _scan_skills_in_repo(pkg_root) or _scan_skills_in_dir(pkg_root)
if discovered and len(discovered) > 1 and result is not None:
_batch_install_skills(discovered, name, skills_dir, source_label, result)
return
if discovered and len(discovered) == 1:
sname, sdir = discovered[0]
safe_name = re.sub(r'[^a-zA-Z0-9_\-]', '-', sname)[:64]
if not _SAFE_NAME_RE.match(safe_name):
safe_name = name
target = os.path.join(skills_dir, safe_name)
if os.path.exists(target):
shutil.rmtree(target)
shutil.copytree(sdir, target)
_register_installed_skill(safe_name, source=source_label)
if result is not None:
result.installed.append(safe_name)
result.messages.append(f"Installed '{safe_name}' from {source_label}.")
return
target = os.path.join(skills_dir, name)
if os.path.exists(target):
shutil.rmtree(target)
shutil.copytree(source, target)
shutil.copytree(pkg_root, target)
@@ -523,11 +1202,12 @@ def info(name):
skill_dir = None
source = None
config = load_skills_config()
for d, src in [(skills_dir, "custom"), (builtin_dir, "builtin")]:
candidate = os.path.join(d, name)
if os.path.isdir(candidate):
skill_dir = candidate
source = src
source = config.get(name, {}).get("source") or src
break
if not skill_dir:

View File

@@ -0,0 +1 @@
from .cow_cli import CowCliPlugin

799
plugins/cow_cli/cow_cli.py Normal file
View File

@@ -0,0 +1,799 @@
"""
CowCli plugin - Intercept cow/slash commands in chat messages.
Matches messages like:
cow skill list
cow context clear
/skill list
/context clear
/status
Does NOT match:
cow是什么
cow真好用
/开头但不是已知命令
"""
import os
import threading
import plugins
from plugins import Plugin, Event, EventContext, EventAction
from bridge.context import ContextType
from bridge.reply import Reply, ReplyType
from common.log import logger
from cli import __version__
# Known top-level subcommands that cow supports
KNOWN_COMMANDS = {
"help", "version", "status", "logs",
"start", "stop", "restart",
"skill", "context", "config",
}
# Commands that can only run from the CLI (terminal), not in chat
CLI_ONLY_COMMANDS = {"start", "stop", "restart"}
# Commands that can only run from chat (need access to in-process memory)
CHAT_ONLY_COMMANDS = set() # context is allowed in both, but behaves differently
@plugins.register(
name="cow_cli",
desc="Handle cow/slash commands in chat messages",
version="0.1.0",
author="CowAgent",
desire_priority=1000,
)
class CowCliPlugin(Plugin):
def __init__(self):
super().__init__()
self.handlers[Event.ON_HANDLE_CONTEXT] = self.on_handle_context
logger.debug("[CowCli] initialized")
def on_handle_context(self, e_context: EventContext):
if e_context["context"].type != ContextType.TEXT:
return
content = e_context["context"].content.strip()
parsed = self._parse_command(content)
if not parsed:
return
cmd, args = parsed
logger.info(f"[CowCli] intercepted command: {cmd} {args}")
result = self._dispatch(cmd, args, e_context)
reply = Reply(ReplyType.TEXT, result)
e_context["reply"] = reply
e_context.action = EventAction.BREAK_PASS
def _parse_command(self, content: str):
"""
Parse cow command from message text.
Supported formats:
cow <command> [args...] e.g. "cow skill list"
/<command> [args...] e.g. "/skill list"
Returns (command, args_string) or None if not a cow command.
"""
parts = None
if content.startswith("/"):
rest = content[1:].strip()
if rest:
parts = rest.split(None, 1)
elif content.startswith("cow "):
rest = content[4:].strip()
if rest:
parts = rest.split(None, 1)
if not parts:
return None
cmd = parts[0].lower()
if cmd not in KNOWN_COMMANDS:
return None
args = parts[1] if len(parts) > 1 else ""
return cmd, args
# ------------------------------------------------------------------
# Command dispatch
# ------------------------------------------------------------------
def _dispatch(self, cmd: str, args: str, e_context: EventContext) -> str:
if cmd in CLI_ONLY_COMMANDS:
return f"⚠️ `cow {cmd}` 只能在命令行终端中执行。\n请在终端运行: cow {cmd}"
handler = getattr(self, f"_cmd_{cmd}", None)
if handler:
try:
return handler(args, e_context)
except Exception as e:
logger.error(f"[CowCli] command '{cmd}' failed: {e}")
return f"命令执行失败: {e}"
return f"未知命令: {cmd}"
# ------------------------------------------------------------------
# help / version
# ------------------------------------------------------------------
def _cmd_help(self, args: str, e_context: EventContext) -> str:
lines = [
"📋 CowAgent 命令列表",
"",
" /help 显示此帮助",
" /version 查看版本",
" /status 查看运行状态",
" /logs [N] 查看最近N条日志 (默认20)",
" /context 查看当前对话上下文信息",
" /context clear 清除当前对话上下文",
" /skill list 查看已安装的技能",
" /skill list --remote 浏览技能广场",
" /skill search <关键词> 搜索技能",
" /skill install <名称> 安装技能",
" /skill info <名称> 查看技能详情",
" /config 查看当前配置",
" /config <key> 查看某项配置",
" /config <key> <val> 修改配置",
"",
"💡 也可以用 cow <command> 代替 /<command>",
]
return "\n".join(lines)
def _cmd_version(self, args: str, e_context: EventContext) -> str:
return f"CowAgent v{__version__}"
# ------------------------------------------------------------------
# status
# ------------------------------------------------------------------
def _cmd_status(self, args: str, e_context: EventContext) -> str:
from config import conf
cfg = conf()
lines = ["📊 CowAgent 运行状态", ""]
lines.append(f" 版本: v{__version__}")
lines.append(f" 进程: PID {os.getpid()}")
channel = cfg.get("channel_type", "unknown")
if isinstance(channel, list):
channel = ", ".join(channel)
lines.append(f" 通道: {channel}")
model_name = cfg.get("model", "unknown")
lines.append(f" 模型: {model_name}")
mode = "Agent" if cfg.get("agent") else "Chat"
lines.append(f" 模式: {mode}")
session_id = self._get_session_id(e_context)
agent = self._get_agent(session_id)
if agent:
lines.append("")
with agent.messages_lock:
msg_count = len(agent.messages)
lines.append(f" 会话消息数: {msg_count}")
if agent.skill_manager:
total = len(agent.skill_manager.skills)
enabled = sum(
1 for v in agent.skill_manager.skills_config.values()
if v.get("enabled", True)
)
lines.append(f" 已加载技能: {enabled}/{total}")
else:
lines.append("")
lines.append(f" Agent: 未初始化 (首次对话后自动创建)")
return "\n".join(lines)
# ------------------------------------------------------------------
# logs
# ------------------------------------------------------------------
def _cmd_logs(self, args: str, e_context: EventContext) -> str:
num_lines = 20
if args.strip().isdigit():
num_lines = min(int(args.strip()), 50)
log_file = self._find_log_file()
if not log_file:
return "未找到日志文件"
try:
with open(log_file, "r", encoding="utf-8", errors="replace") as f:
all_lines = f.readlines()
tail = all_lines[-num_lines:]
content = "".join(tail).strip()
if not content:
return "日志为空"
return f"📄 最近 {len(tail)} 条日志:\n\n{content}"
except Exception as e:
return f"读取日志失败: {e}"
def _find_log_file(self) -> str:
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
candidates = [
os.path.join(project_root, "nohup.out"),
os.path.join(project_root, "run.log"),
]
import glob as glob_mod
candidates.extend(sorted(glob_mod.glob(os.path.join(project_root, "logs", "*.log")), reverse=True))
for f in candidates:
if os.path.isfile(f) and os.path.getsize(f) > 0:
return f
return ""
# ------------------------------------------------------------------
# context
# ------------------------------------------------------------------
def _cmd_context(self, args: str, e_context: EventContext) -> str:
session_id = self._get_session_id(e_context)
agent = self._get_agent(session_id)
sub = args.strip().lower()
if sub == "clear":
return self._context_clear(agent, session_id)
else:
return self._context_info(agent, session_id)
def _context_info(self, agent, session_id: str) -> str:
if not agent:
return "⚠️ Agent 未初始化,暂无上下文信息"
with agent.messages_lock:
messages = agent.messages.copy()
if not messages:
return "当前对话上下文为空"
user_msgs = sum(1 for m in messages if m.get("role") == "user")
assistant_msgs = sum(1 for m in messages if m.get("role") == "assistant")
tool_msgs = sum(1 for m in messages if m.get("role") == "tool")
total_chars = sum(len(str(m.get("content", ""))) for m in messages)
lines = [
"💬 当前对话上下文",
"",
f" 会话: {session_id or 'default'}",
f" 总消息数: {len(messages)}",
f" 用户消息: {user_msgs}",
f" 助手回复: {assistant_msgs}",
f" 工具调用: {tool_msgs}",
f" 内容总长度: ~{total_chars} 字符",
"",
" 发送 /context clear 可清除对话上下文",
]
return "\n".join(lines)
def _context_clear(self, agent, session_id: str) -> str:
if not agent:
return "⚠️ Agent 未初始化"
with agent.messages_lock:
count = len(agent.messages)
agent.messages.clear()
return f"✅ 已清除当前对话上下文 ({count} 条消息)"
# ------------------------------------------------------------------
# config
# ------------------------------------------------------------------
_CONFIG_WRITABLE = {
"model",
"agent_max_context_tokens",
"agent_max_context_turns",
"agent_max_steps",
}
_CONFIG_READABLE = _CONFIG_WRITABLE | {"channel_type"}
def _cmd_config(self, args: str, e_context: EventContext) -> str:
from config import conf, load_config
import json as _json
parts = args.strip().split(None, 1)
if not parts:
return self._config_show_all()
key = parts[0].lower()
if len(parts) == 1:
return self._config_get(key)
value_str = parts[1].strip()
return self._config_set(key, value_str)
def _config_show_all(self) -> str:
from config import conf
cfg = conf()
lines = ["⚙️ 当前配置", ""]
for key in sorted(self._CONFIG_READABLE):
val = cfg.get(key, "")
lines.append(f" {key}: {val}")
lines.append("")
lines.append("━━━━━━━━━━━━━━━━━━━━━━━━━━")
lines.append("💡 /config <key> 查看配置")
lines.append("💡 /config <key> <val> 修改配置")
return "\n".join(lines)
def _config_get(self, key: str) -> str:
from config import conf
if key not in self._CONFIG_READABLE:
available = ", ".join(sorted(self._CONFIG_READABLE))
return f"不支持查看 '{key}'\n\n可查看的配置项: {available}"
val = conf().get(key, "")
return f"⚙️ {key}: {val}"
def _config_set(self, key: str, value_str: str) -> str:
from config import conf, load_config
import json as _json
if key not in self._CONFIG_WRITABLE:
if key in self._CONFIG_READABLE:
return f"⚠️ '{key}' 为只读配置,不支持修改"
available = ", ".join(sorted(self._CONFIG_WRITABLE))
return f"不支持修改 '{key}'\n\n可修改的配置项: {available}"
old_val = conf().get(key, "")
try:
new_val = _json.loads(value_str)
except (_json.JSONDecodeError, ValueError):
if value_str.lower() == "true":
new_val = True
elif value_str.lower() == "false":
new_val = False
else:
new_val = value_str
updates = {key: new_val}
if key == "model" and conf().get("bot_type"):
resolved = self._resolve_bot_type_for_model(str(new_val))
if resolved:
updates["bot_type"] = resolved
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
config_path = os.path.join(project_root, "config.json")
try:
with open(config_path, "r", encoding="utf-8") as f:
file_config = _json.load(f)
file_config.update(updates)
with open(config_path, "w", encoding="utf-8") as f:
_json.dump(file_config, f, indent=4, ensure_ascii=False)
except Exception as e:
return f"写入 config.json 失败: {e}"
try:
load_config()
except Exception as e:
logger.warning(f"[CowCli] config reload warning: {e}")
result = f"✅ 配置已更新\n\n {key}: {old_val}{new_val}"
if "bot_type" in updates and updates["bot_type"] != conf().get("bot_type"):
result += f"\n bot_type: → {updates['bot_type']}"
return result
@staticmethod
def _resolve_bot_type_for_model(model_name: str) -> str:
"""Resolve bot_type from model name, reusing AgentBridge mapping."""
from common import const
_EXACT = {
"wenxin": const.BAIDU, "wenxin-4": const.BAIDU,
"xunfei": const.XUNFEI, const.QWEN: const.QWEN,
const.MODELSCOPE: const.MODELSCOPE,
const.MOONSHOT: const.MOONSHOT,
"moonshot-v1-8k": const.MOONSHOT, "moonshot-v1-32k": const.MOONSHOT,
"moonshot-v1-128k": const.MOONSHOT,
}
_PREFIX = [
("qwen", const.QWEN_DASHSCOPE), ("qwq", const.QWEN_DASHSCOPE),
("qvq", const.QWEN_DASHSCOPE),
("gemini", const.GEMINI), ("glm", const.ZHIPU_AI),
("claude", const.CLAUDEAPI),
("moonshot", const.MOONSHOT), ("kimi", const.MOONSHOT),
("doubao", const.DOUBAO), ("deepseek", const.DEEPSEEK),
]
if not model_name:
return const.OPENAI
if model_name in _EXACT:
return _EXACT[model_name]
if model_name.lower().startswith("minimax") or model_name in ["abab6.5-chat"]:
return const.MiniMax
if model_name in [const.QWEN_TURBO, const.QWEN_PLUS, const.QWEN_MAX]:
return const.QWEN_DASHSCOPE
for prefix, btype in _PREFIX:
if model_name.startswith(prefix):
return btype
return const.OPENAI
# ------------------------------------------------------------------
# skill
# ------------------------------------------------------------------
def _cmd_skill(self, args: str, e_context: EventContext) -> str:
parts = args.strip().split(None, 1)
sub = parts[0].lower() if parts else ""
sub_args = parts[1].strip() if len(parts) > 1 else ""
if sub == "list":
return self._skill_list(sub_args)
elif sub == "search":
return self._skill_search(sub_args)
elif sub == "install":
return self._skill_install(sub_args, e_context)
elif sub == "uninstall":
return self._skill_uninstall(sub_args)
elif sub == "info":
return self._skill_info(sub_args)
elif sub == "enable":
return self._skill_set_enabled(sub_args, True)
elif sub == "disable":
return self._skill_set_enabled(sub_args, False)
else:
return (
"用法: /skill <子命令>\n\n"
"子命令:\n"
" list [--remote] 查看技能列表\n"
" search <关键词> 搜索技能\n"
" install <名称> 安装技能\n"
" uninstall <名称> 卸载技能\n"
" info <名称> 查看技能详情\n"
" enable <名称> 启用技能\n"
" disable <名称> 禁用技能"
)
def _skill_list_local(self) -> str:
from cli.utils import load_skills_config, get_skills_dir, get_builtin_skills_dir
config = load_skills_config()
if not config:
skills_dir = get_skills_dir()
builtin_dir = get_builtin_skills_dir()
entries = []
for d, source in [(builtin_dir, "builtin"), (skills_dir, "custom")]:
if not os.path.isdir(d):
continue
for name in sorted(os.listdir(d)):
skill_path = os.path.join(d, name)
if os.path.isdir(skill_path) and not name.startswith("."):
if os.path.exists(os.path.join(skill_path, "SKILL.md")):
entries.append({"name": name, "source": source, "enabled": True})
if not entries:
return "暂无已安装的技能\n\n💡 /skill list --remote 浏览技能广场"
config = {e["name"]: e for e in entries}
sorted_entries = sorted(config.values(), key=lambda e: e.get("name", ""))
enabled_count = sum(1 for e in sorted_entries if e.get("enabled", True))
lines = [f"📦 已安装的技能 ({enabled_count}/{len(sorted_entries)})", ""]
for entry in sorted_entries:
name = entry.get("name", "")
enabled = entry.get("enabled", True)
source = entry.get("source", "")
icon = "" if enabled else "⏸️"
desc = entry.get("description", "")
if len(desc) > 50:
desc = desc[:47] + ""
line = f"{icon} {name}"
if desc:
line += f"\n {desc}"
if source:
line += f"\n 来源: {source}"
lines.append(line)
lines.append("")
lines.append("━━━━━━━━━━━━━━━━━━━━━━━━━━")
lines.append("💡 /skill list --remote 浏览技能广场")
lines.append("💡 /skill info <名称> 查看详情")
return "\n".join(lines)
def _skill_list(self, args: str) -> str:
parts = args.strip().split()
if "--remote" in parts or "-r" in parts:
page = 1
for i, p in enumerate(parts):
if p == "--page" and i + 1 < len(parts) and parts[i + 1].isdigit():
page = max(1, int(parts[i + 1]))
return self._skill_list_remote(page=page)
return self._skill_list_local()
_REMOTE_PAGE_SIZE = 10
def _skill_list_remote(self, page: int = 1) -> str:
import requests
from cli.utils import SKILL_HUB_API, load_skills_config
page_size = self._REMOTE_PAGE_SIZE
try:
resp = requests.get(
f"{SKILL_HUB_API}/skills",
params={"page": page, "limit": page_size},
timeout=10,
)
resp.raise_for_status()
data = resp.json()
skills = data.get("skills", [])
total = data.get("total", len(skills))
except Exception as e:
return f"获取技能广场失败: {e}"
if not skills and page == 1:
return "技能广场暂无可用技能"
total_pages = max(1, (total + page_size - 1) // page_size)
page = min(page, total_pages)
installed = set(load_skills_config().keys())
lines = [f"🌐 技能广场 (共 {total} 个技能)", ""]
for s in skills:
name = s.get("name", "")
display = s.get("display_name", "") or name
desc = s.get("description", "")
if len(desc) > 50:
desc = desc[:47] + ""
badge = " [已安装]" if name in installed else ""
lines.append(f"📌 {display}{badge}")
lines.append(f" 名称: {name}")
if desc:
lines.append(f" {desc}")
lines.append("")
lines.append("━━━━━━━━━━━━━━━━━━━━━━━━━━")
lines.append(f"📄 第 {page}/{total_pages}")
if page < total_pages:
lines.append(f"💡 /skill list --remote --page {page + 1} 下一页")
if page > 1:
lines.append(f"💡 /skill list --remote --page {page - 1} 上一页")
lines.append("💡 /skill install <名称> 安装技能")
lines.append("💡 /skill search <关键词> 搜索技能")
return "\n".join(lines)
def _skill_search(self, query: str) -> str:
if not query:
return "请指定搜索关键词: /skill search <关键词>"
import requests
from cli.utils import SKILL_HUB_API, load_skills_config
try:
resp = requests.get(f"{SKILL_HUB_API}/skills/search", params={"q": query}, timeout=10)
resp.raise_for_status()
skills = resp.json().get("skills", [])
except Exception as e:
return f"搜索失败: {e}"
if not skills:
return f"未找到与「{query}」相关的技能"
installed = set(load_skills_config().keys())
lines = [f"🔍 搜索「{query}」({len(skills)} 个结果)", ""]
for s in skills:
name = s.get("name", "")
display = s.get("display_name", "") or name
desc = s.get("description", "")
if len(desc) > 50:
desc = desc[:47] + ""
badge = " [已安装]" if name in installed else ""
lines.append(f"📌 {display}{badge}")
lines.append(f" 名称: {name}")
if desc:
lines.append(f" {desc}")
lines.append("")
lines.append("━━━━━━━━━━━━━━━━━━━━━━━━━━")
lines.append("💡 /skill install <名称> 安装技能")
return "\n".join(lines)
def _skill_install(self, name: str, e_context: EventContext) -> str:
if not name:
return "请指定要安装的技能: /skill install <名称>"
try:
from cli.commands.skill import install_skill
result = install_skill(name)
if result.error:
return f"安装失败: {result.error}"
if not result.installed:
return "\n".join(result.messages) if result.messages else "未找到可安装的技能"
return self._format_install_result(result)
except Exception as e:
return f"安装失败: {e}"
@staticmethod
def _format_install_result(result) -> str:
"""Format InstallResult into a chat-friendly message."""
from cli.commands.skill import _read_skill_description
from cli.utils import get_skills_dir
skills_dir = get_skills_dir()
lines = []
for skill_name in result.installed:
desc = _read_skill_description(os.path.join(skills_dir, skill_name))
lines.append(f"{skill_name}")
if desc:
if len(desc) > 60:
desc = desc[:57] + ""
lines.append(f" {desc}")
if len(result.installed) > 1:
lines.append(f"\n共安装 {len(result.installed)} 个技能")
return "\n".join(lines)
def _skill_uninstall(self, name: str) -> str:
if not name:
return "请指定要卸载的技能: /skill uninstall <名称>"
import shutil
import json
from cli.utils import get_skills_dir
skills_dir = get_skills_dir()
skill_dir = os.path.join(skills_dir, name)
if not os.path.exists(skill_dir):
skill_dir = self._resolve_skill_dir(name, skills_dir)
if not skill_dir:
return f"技能 '{name}' 未安装"
shutil.rmtree(skill_dir)
config_path = os.path.join(skills_dir, "skills_config.json")
if os.path.exists(config_path):
try:
with open(config_path, "r", encoding="utf-8") as f:
config = json.load(f)
config.pop(name, None)
with open(config_path, "w", encoding="utf-8") as f:
json.dump(config, f, indent=4, ensure_ascii=False)
except Exception:
pass
return f"✅ 技能 '{name}' 已卸载"
@staticmethod
def _resolve_skill_dir(name: str, skills_dir: str):
"""Find actual directory for a skill whose folder name may differ from its config name."""
if not os.path.isdir(skills_dir):
return None
for entry in os.listdir(skills_dir):
entry_path = os.path.join(skills_dir, entry)
if not os.path.isdir(entry_path) or entry.startswith("."):
continue
if entry == name or entry.startswith(name + "-") or entry.endswith("-" + name):
skill_md = os.path.join(entry_path, "SKILL.md")
if os.path.exists(skill_md):
return entry_path
return None
@staticmethod
def _strip_frontmatter(content: str):
"""Strip YAML frontmatter and return (metadata_dict, body)."""
if not content.startswith("---"):
return {}, content
end = content.find("\n---", 3)
if end == -1:
return {}, content
fm_text = content[3:end].strip()
body = content[end + 4:].lstrip("\n")
meta = {}
for line in fm_text.split("\n"):
if ":" in line:
key, _, val = line.partition(":")
meta[key.strip()] = val.strip().strip('"').strip("'")
return meta, body
def _skill_info(self, name: str) -> str:
if not name:
return "请指定技能名称: /skill info <名称>"
from cli.utils import get_skills_dir, get_builtin_skills_dir
skills_dir = get_skills_dir()
builtin_dir = get_builtin_skills_dir()
skill_dir = None
source = None
for d, src in [(skills_dir, "custom"), (builtin_dir, "builtin")]:
candidate = os.path.join(d, name)
if os.path.isdir(candidate):
skill_dir = candidate
source = src
break
if not skill_dir:
resolved = self._resolve_skill_dir(name, skills_dir)
if resolved:
skill_dir = resolved
source = "custom"
if not skill_dir:
return f"技能 '{name}' 未找到"
skill_md = os.path.join(skill_dir, "SKILL.md")
if not os.path.exists(skill_md):
return f"技能 '{name}' 没有 SKILL.md 文件"
with open(skill_md, "r", encoding="utf-8") as f:
content = f.read()
meta, body = self._strip_frontmatter(content)
header_lines = [f"📖 技能: {name} [{source}]", ""]
desc = meta.get("description", "")
if desc:
header_lines.append(f" {desc}")
header_lines.append("")
lines = body.split("\n")
preview = "\n".join(lines[:30])
result = "\n".join(header_lines) + preview
if len(lines) > 30:
result += f"\n\n... ({len(lines) - 30} more lines)"
return result
def _skill_set_enabled(self, name: str, enabled: bool) -> str:
if not name:
action = "启用" if enabled else "禁用"
return f"请指定技能名称: /skill {'enable' if enabled else 'disable'} <名称>"
import json
from cli.utils import get_skills_dir
skills_dir = get_skills_dir()
config_path = os.path.join(skills_dir, "skills_config.json")
if not os.path.exists(config_path):
return "技能配置文件不存在"
try:
with open(config_path, "r", encoding="utf-8") as f:
config = json.load(f)
except Exception as e:
return f"读取配置失败: {e}"
if name not in config:
return f"技能 '{name}' 未在配置中找到"
config[name]["enabled"] = enabled
with open(config_path, "w", encoding="utf-8") as f:
json.dump(config, f, indent=4, ensure_ascii=False)
action = "启用" if enabled else "禁用"
icon = "" if enabled else ""
return f"{icon} 技能 '{name}'{action}"
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _get_session_id(self, e_context: EventContext) -> str:
context = e_context["context"]
return context.kwargs.get("session_id") or context.get("session_id", "")
def _get_agent(self, session_id: str):
try:
from bridge.bridge import Bridge
bridge = Bridge()
if not bridge._agent_bridge:
return None
return bridge._agent_bridge.get_agent(session_id=session_id or None)
except Exception:
return None
def get_help_text(self, **kwargs):
return "在对话中使用 /help 或 cow help 查看可用命令"

View File

@@ -1,5 +1,5 @@
[build-system]
requires = ["setuptools>=68.0"]
requires = ["setuptools>=45.0"]
build-backend = "setuptools.build_meta"
[project]

View File

@@ -9,6 +9,7 @@ agentmesh-sdk>=0.1.3
python-dotenv>=1.0.0
PyYAML>=6.0
croniter>=2.0.0
click>=8.0
qrcode
# wechatcom & wechatmp

108
run.sh
View File

@@ -171,8 +171,11 @@ clone_project() {
mv chatgpt-on-wechat-master chatgpt-on-wechat
rm chatgpt-on-wechat.zip
else
git clone https://github.com/zhayujie/chatgpt-on-wechat.git || \
git clone https://gitee.com/zhayujie/chatgpt-on-wechat.git
GIT_HTTP_CONNECT_TIMEOUT=10 GIT_HTTP_LOW_SPEED_LIMIT=1024 GIT_HTTP_LOW_SPEED_TIME=15 \
git clone --depth 10 --progress https://github.com/zhayujie/chatgpt-on-wechat.git || {
echo -e "${YELLOW}⚠️ GitHub is slow, switching to Gitee mirror...${NC}"
git clone --depth 10 --progress https://gitee.com/zhayujie/chatgpt-on-wechat.git
}
if [[ $? -ne 0 ]]; then
echo -e "${RED}❌ Project clone failed. Please check network connection.${NC}"
exit 1
@@ -195,7 +198,10 @@ clone_project() {
# Install dependencies
install_dependencies() {
echo -e "${GREEN}📦 Installing dependencies...${NC}"
local PIP_MIRROR="-i https://pypi.tuna.tsinghua.edu.cn/simple"
local PIP_MIRROR=""
if curl -s --connect-timeout 5 https://pypi.tuna.tsinghua.edu.cn/simple/ > /dev/null 2>&1; then
PIP_MIRROR="-i https://pypi.tuna.tsinghua.edu.cn/simple"
fi
PIP_EXTRA_ARGS=""
if $PYTHON_CMD -c "import sys; exit(0 if sys.version_info >= (3, 11) else 1)" 2>/dev/null; then
@@ -538,6 +544,15 @@ start_project() {
echo -e "${GREEN}${EMOJI_ROCKET} Starting CowAgent...${NC}"
sleep 1
local USE_COW=false
if command -v cow &> /dev/null; then
USE_COW=true
fi
if $USE_COW; then
cd "${BASE_DIR}"
cow start --no-logs
else
if [ ! -f "${BASE_DIR}/nohup.out" ]; then
touch "${BASE_DIR}/nohup.out"
fi
@@ -545,17 +560,16 @@ start_project() {
OS_TYPE=$(uname)
if [[ "$OS_TYPE" == "Linux" ]]; then
# Linux: use setsid to detach from terminal
nohup setsid $PYTHON_CMD "${BASE_DIR}/app.py" > "${BASE_DIR}/nohup.out" 2>&1 &
echo -e "${GREEN}${EMOJI_COW} CowAgent started on Linux (using $PYTHON_CMD)${NC}"
elif [[ "$OS_TYPE" == "Darwin" ]]; then
# macOS: use nohup to prevent SIGHUP
nohup $PYTHON_CMD "${BASE_DIR}/app.py" > "${BASE_DIR}/nohup.out" 2>&1 &
echo -e "${GREEN}${EMOJI_COW} CowAgent started on macOS (using $PYTHON_CMD)${NC}"
else
echo -e "${RED}❌ Unsupported OS: ${OS_TYPE}${NC}"
exit 1
fi
fi
sleep 2
echo ""
@@ -565,11 +579,20 @@ start_project() {
echo -e "${CYAN}$ACCESS_INFO${NC}"
echo ""
echo -e "${CYAN}${BOLD}Management Commands:${NC}"
if $USE_COW; then
echo -e " ${GREEN}cow stop${NC} Stop the service"
echo -e " ${GREEN}cow restart${NC} Restart the service"
echo -e " ${GREEN}cow status${NC} Check status"
echo -e " ${GREEN}cow logs${NC} View logs"
echo -e " ${GREEN}cow update${NC} Update and restart"
echo -e " ${GREEN}cow install-browser${NC} Install browser tool"
else
echo -e " ${GREEN}./run.sh stop${NC} Stop the service"
echo -e " ${GREEN}./run.sh restart${NC} Restart the service"
echo -e " ${GREEN}./run.sh status${NC} Check status"
echo -e " ${GREEN}./run.sh logs${NC} View logs"
echo -e " ${GREEN}./run.sh update${NC} Update and restart"
fi
echo -e "${CYAN}${BOLD}=========================================${NC}"
echo ""
@@ -622,27 +645,39 @@ is_running() {
[ -n "$(get_pid)" ]
}
# Check if cow CLI is available
has_cow() {
command -v cow &> /dev/null
}
# Start service
cmd_start() {
# Check if config.json exists
if [ ! -f "${BASE_DIR}/config.json" ]; then
echo -e "${RED}${EMOJI_CROSS} config.json not found${NC}"
echo -e "${YELLOW}Please run './run.sh' to configure first${NC}"
exit 1
fi
if has_cow; then
cd "${BASE_DIR}"
cow start
else
if is_running; then
echo -e "${YELLOW}${EMOJI_WARN} CowAgent is already running (PID: $(get_pid))${NC}"
echo -e "${YELLOW}Use './run.sh restart' to restart${NC}"
return
fi
check_python_version
start_project
fi
}
# Stop service
cmd_stop() {
if has_cow; then
cd "${BASE_DIR}"
cow stop
else
echo -e "${GREEN}${EMOJI_STOP} Stopping CowAgent...${NC}"
if ! is_running; then
@@ -667,17 +702,27 @@ cmd_stop() {
fi
echo -e "${GREEN}${EMOJI_CHECK} CowAgent stopped${NC}"
fi
}
# Restart service
cmd_restart() {
if has_cow; then
cd "${BASE_DIR}"
cow restart
else
cmd_stop
sleep 1
cmd_start
fi
}
# Check status
cmd_status() {
if has_cow; then
cd "${BASE_DIR}"
cow status
else
echo -e "${CYAN}${BOLD}=========================================${NC}"
echo -e "${CYAN}${BOLD} ${EMOJI_COW} CowAgent Status${NC}"
echo -e "${CYAN}${BOLD}=========================================${NC}"
@@ -701,16 +746,22 @@ cmd_status() {
fi
echo -e "${CYAN}${BOLD}=========================================${NC}"
fi
}
# View logs
cmd_logs() {
if has_cow; then
cd "${BASE_DIR}"
cow logs -f
else
if [ -f "${BASE_DIR}/nohup.out" ]; then
echo -e "${YELLOW}Viewing logs (Ctrl+C to exit):${NC}"
tail -f "${BASE_DIR}/nohup.out"
else
echo -e "${RED}❌ Log file not found: ${BASE_DIR}/nohup.out${NC}"
fi
fi
}
# Reconfigure
@@ -743,23 +794,39 @@ cmd_update() {
echo -e "${GREEN}${EMOJI_WRENCH} Updating CowAgent...${NC}"
cd "${BASE_DIR}"
# Pull latest code first (service still running)
local pull_ok=false
if [ -d .git ]; then
echo -e "${GREEN}🔄 Pulling latest code...${NC}"
if git pull; then
pull_ok=true
else
echo -e "${YELLOW}⚠️ git pull failed, trying Gitee mirror...${NC}"
git remote set-url origin https://gitee.com/zhayujie/chatgpt-on-wechat.git
if git pull; then
pull_ok=true
else
echo -e "${RED}❌ Failed to pull code. Update aborted.${NC}"
exit 1
fi
fi
else
echo -e "${YELLOW}⚠️ Not a git repository, skipping code update${NC}"
fi
# Re-exec with the updated run.sh to pick up new logic
exec "$0" _post_update
}
# Post-update: called by cmd_update after git pull to run with new code
cmd_post_update() {
cd "${BASE_DIR}"
# Stop service
if is_running; then
cmd_stop
fi
# Update code
if [ -d .git ]; then
echo -e "${GREEN}🔄 Pulling latest code...${NC}"
git pull || {
echo -e "${YELLOW}⚠️ GitHub failed, trying Gitee...${NC}"
git remote set-url origin https://gitee.com/zhayujie/chatgpt-on-wechat.git
git pull
}
else
echo -e "${YELLOW}⚠️ Not a git repository, skipping code update${NC}"
fi
# Reinstall dependencies
check_python_version
install_dependencies
@@ -833,7 +900,7 @@ require_project_dir() {
# Main function
main() {
case "$1" in
start|stop|restart|status|logs|config|update)
start|stop|restart|status|logs|config|update|_post_update)
require_project_dir
;;
esac
@@ -846,6 +913,7 @@ main() {
logs) cmd_logs ;;
config) cmd_config ;;
update) cmd_update ;;
_post_update) cmd_post_update ;;
help|--help|-h)
show_usage
;;