Compare commits

..

12 Commits

Author SHA1 Message Date
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
19 changed files with 1895 additions and 262 deletions

6
.gitignore vendored
View File

@@ -33,15 +33,15 @@ plugins/banwords/lib/__pycache__
!plugins/keyword !plugins/keyword
!plugins/linkai !plugins/linkai
!plugins/agent !plugins/agent
!plugins/cow_cli
client_config.json client_config.json
ref/ ref/
.cursor/ .cursor/
local/ local/
node_modules/
# Build artifacts # cow cli
dist/ dist/
build/ build/
*.egg-info/ *.egg-info/
# CLI runtime
.cow.pid .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}") tool_lines.append(f"- {name}: {summary}" if summary else f"- {name}")
lines = [ lines = [
"## 工具系统", "## 🔧 工具系统",
"", "",
"可用工具(名称大小写敏感,严格按列表调用):", "可用工具(名称大小写敏感,严格按列表调用):",
"\n".join(tool_lines), "\n".join(tool_lines),
@@ -231,7 +231,7 @@ def _build_skills_section(skill_manager: Any, tools: Optional[List[Any]], langua
break break
lines = [ lines = [
"## 技能系统mandatory", "## 🧩 技能系统mandatory",
"", "",
"在回复之前:扫描下方 <available_skills> 中每个技能的 <description>。", "在回复之前:扫描下方 <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" today_file = datetime.now().strftime("%Y-%m-%d") + ".md"
lines = [ lines = [
"## 记忆系统", "## 🧠 记忆系统",
"", "",
"### 检索记忆", "### 检索记忆",
"", "",
@@ -325,7 +325,7 @@ def _build_user_identity_section(user_identity: Dict[str, str], language: str) -
return [] return []
lines = [ 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]: def _build_workspace_section(workspace_dir: str, language: str) -> List[str]:
"""构建工作空间section""" """构建工作空间section"""
lines = [ lines = [
"## 工作空间", "## 📂 工作空间",
"", "",
f"你的工作目录是: `{workspace_dir}`", f"你的工作目录是: `{workspace_dir}`",
"", "",
@@ -380,10 +380,12 @@ def _build_workspace_section(workspace_dir: str, language: str) -> List[str]:
"- ✅ `USER.md`: 已加载 - 用户的身份信息。当用户修改称呼、姓名等身份信息时,用 `edit` 更新此文件", "- ✅ `USER.md`: 已加载 - 用户的身份信息。当用户修改称呼、姓名等身份信息时,用 `edit` 更新此文件",
"- ✅ `RULE.md`: 已加载 - 工作空间使用指南和规则,请严格遵循", "- ✅ `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 = [ lines = [
"# 项目上下文", "# 📋 项目上下文",
"", "",
"以下项目上下文文件已被加载:", "以下项目上下文文件已被加载:",
"", "",
] ]
if has_agent: if has_agent:
lines.append("**`AGENT.md` 是你的灵魂文件**:严格遵循其中定义的人格、规则、语气和设定,避免僵硬、模板化的回复。") lines.append("**`AGENT.md` 是你的灵魂文件** 🪞:严格遵循其中定义的人格、语气和设定,做真实的自己,避免僵硬、模板化的回复。")
lines.append("当用户通过对话透露了对你性格、风格、职责、能力边界的新期望,你应该主动用 `edit` 更新 AGENT.md 以反映这些演变。") lines.append("当用户通过对话透露了对你性格、风格、职责、能力边界的新期望,你应该主动用 `edit` 更新 AGENT.md 以反映这些演变。")
lines.append("") lines.append("")
@@ -443,7 +445,7 @@ def _build_runtime_section(runtime_info: Dict[str, Any], language: str) -> List[
return [] return []
lines = [ lines = [
"## 运行时信息", "## ⚙️ 运行时信息",
"", "",
] ]

View File

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

View File

@@ -472,6 +472,7 @@ class AgentStreamExecutor:
raise raise
finally: finally:
final_response = final_response.strip() if final_response else final_response
logger.info(f"[Agent] 🏁 完成 ({turn}轮)") logger.info(f"[Agent] 🏁 完成 ({turn}轮)")
self._emit_event("agent_end", {"final_response": final_response}) 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): if not isinstance(metadata_raw, dict):
return None return None
# Use metadata_raw directly (COW format) # Unwrap nested namespace (e.g. {"openclaw": {...}} or {"cowagent": {...}})
meta_obj = metadata_raw meta_obj = _unwrap_metadata_namespace(metadata_raw)
# Parse install specs # Parse install specs
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]: def _normalize_string_list(value: Any) -> List[str]:
"""Normalize a value to a list of strings.""" """Normalize a value to a list of strings."""
if not value: if not value:

View File

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

View File

@@ -270,7 +270,7 @@
<div class="max-w-3xl mx-auto"> <div class="max-w-3xl mx-auto">
<!-- Attachment preview bar --> <!-- Attachment preview bar -->
<div id="attachment-preview" class="attachment-preview hidden"></div> <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"> <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 <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 text-slate-400 hover:text-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/20
@@ -287,6 +287,7 @@
</div> </div>
<input type="file" id="file-input" class="hidden" multiple <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"> 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" <textarea id="chat-input"
class="flex-1 min-w-0 px-4 py-[10px] rounded-xl border border-slate-200 dark:border-slate-600 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 bg-slate-50 dark:bg-white/5 text-slate-800 dark:text-slate-100
@@ -295,7 +296,7 @@
text-sm leading-relaxed" text-sm leading-relaxed"
rows="1" rows="1"
data-i18n-placeholder="input_placeholder" data-i18n-placeholder="input_placeholder"
placeholder="Type a message..."></textarea> placeholder="Type a message, or press / for commands"></textarea>
<button id="send-btn" <button id="send-btn"
class="flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-lg class="flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-lg
bg-primary-400 text-white hover:bg-primary-500 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 img { max-width: 100%; height: auto; border-radius: 8px; margin: 0.5em 0; }
.msg-content a { color: #35A85B; text-decoration: underline; } .msg-content a { color: #35A85B; text-decoration: underline; }
.msg-content a:hover { color: #228547; } .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; } .msg-content hr { border: none; height: 1px; background: #e2e8f0; margin: 1.2em 0; }
.dark .msg-content hr { background: rgba(255,255,255,0.1); } .dark .msg-content hr { background: rgba(255,255,255,0.1); }
@@ -446,3 +451,87 @@
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 8px 25px -5px rgba(0, 0, 0, 0.1); 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_sys_title: '系统管理', example_sys_text: '帮我查看工作空间里有哪些文件',
example_task_title: '技能系统', example_task_text: '查看所有支持的工具和技能', example_task_title: '技能系统', example_task_text: '查看所有支持的工具和技能',
example_code_title: '编程助手', example_code_text: '帮我编写一个Python爬虫脚本', example_code_title: '编程助手', example_code_text: '帮我编写一个Python爬虫脚本',
input_placeholder: '输入消息...', input_placeholder: '输入消息,或输入 / 使用指令',
config_title: '配置管理', config_desc: '管理模型和 Agent 配置', config_title: '配置管理', config_desc: '管理模型和 Agent 配置',
config_model: '模型配置', config_agent: 'Agent 配置', config_model: '模型配置', config_agent: 'Agent 配置',
config_channel: '通道配置', config_channel: '通道配置',
@@ -72,7 +72,7 @@ const I18N = {
example_sys_title: 'System', example_sys_text: 'Show me the files in the workspace', 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_task_title: 'Skills', example_task_text: 'Show current tools and skills',
example_code_title: 'Coding', example_code_text: 'Write a Python web scraper script', 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_title: 'Configuration', config_desc: 'Manage model and agent settings',
config_model: 'Model Configuration', config_agent: 'Agent Configuration', config_model: 'Model Configuration', config_agent: 'Agent Configuration',
config_channel: 'Channel Configuration', config_channel: 'Channel Configuration',
@@ -322,6 +322,11 @@ const attachmentPreview = document.getElementById('attachment-preview');
let pendingAttachments = []; let pendingAttachments = [];
let uploadingCount = 0; let uploadingCount = 0;
// Input history (like terminal arrow-key recall)
const inputHistory = [];
let historyIdx = -1;
let historySavedDraft = '';
function updateSendBtnState() { function updateSendBtnState() {
sendBtn.disabled = uploadingCount > 0 || (!chatInput.value.trim() && pendingAttachments.length === 0); 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('compositionstart', () => { isComposing = true; });
chatInput.addEventListener('compositionend', () => { setTimeout(() => { isComposing = false; }, 100); }); 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() { chatInput.addEventListener('input', function() {
this.style.height = '42px'; this.style.height = '42px';
const scrollH = this.scrollHeight; const scrollH = this.scrollHeight;
@@ -442,11 +540,90 @@ chatInput.addEventListener('input', function() {
this.style.height = newH + 'px'; this.style.height = newH + 'px';
this.style.overflowY = scrollH > 180 ? 'auto' : 'hidden'; this.style.overflowY = scrollH > 180 ? 'auto' : 'hidden';
updateSendBtnState(); updateSendBtnState();
const val = this.value;
if (slashJustSelected) {
slashJustSelected = false;
} else if (val.startsWith('/')) {
showSlashMenu(val);
} else {
hideSlashMenu();
}
}); });
chatInput.addEventListener('keydown', function(e) { 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 (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') { if ((e.ctrlKey || e.shiftKey) && e.key === 'Enter') {
const start = this.selectionStart; const start = this.selectionStart;
const end = this.selectionEnd; 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 => { document.querySelectorAll('.example-card').forEach(card => {
card.addEventListener('click', () => { card.addEventListener('click', () => {
const textEl = card.querySelector('[data-i18n*="text"]'); const textEl = card.querySelector('[data-i18n*="text"]');
@@ -475,6 +656,12 @@ function sendMessage() {
const text = chatInput.value.trim(); const text = chatInput.value.trim();
if (!text && pendingAttachments.length === 0) return; if (!text && pendingAttachments.length === 0) return;
if (text) {
inputHistory.push(text);
historyIdx = -1;
historySavedDraft = '';
}
const ws = document.getElementById('welcome-screen'); const ws = document.getElementById('welcome-screen');
if (ws) ws.remove(); if (ws) ws.remove();
@@ -732,7 +919,7 @@ function createUserMessageEl(content, timestamp, attachments) {
const textHtml = content ? renderMarkdown(content) : ''; const textHtml = content ? renderMarkdown(content) : '';
el.innerHTML = ` el.innerHTML = `
<div class="max-w-[75%] sm:max-w-[60%]"> <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} ${attachHtml}${textHtml}
</div> </div>
<div class="text-xs text-slate-400 dark:text-slate-500 mt-1.5 text-right">${formatTime(timestamp)}</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: class ConfigHandler:
_RECOMMENDED_MODELS = [ _RECOMMENDED_MODELS = [
const.MINIMAX_M2_5, const.MINIMAX_M2_1, const.MINIMAX_M2_1_LIGHTNING, const.MINIMAX_M2_7, const.MINIMAX_M2_5, const.MINIMAX_M2_1, const.MINIMAX_M2_1_LIGHTNING,
const.GLM_5, const.GLM_4_7, const.GLM_5_TURBO, const.GLM_5, const.GLM_4_7,
const.QWEN3_MAX, const.QWEN35_PLUS, const.QWEN3_MAX, const.QWEN35_PLUS,
const.KIMI_K2_5, const.KIMI_K2, const.KIMI_K2_5, const.KIMI_K2,
const.DOUBAO_SEED_2_PRO, const.DOUBAO_SEED_2_CODE, const.DOUBAO_SEED_2_PRO, const.DOUBAO_SEED_2_CODE,
@@ -511,14 +511,14 @@ class ConfigHandler:
"api_key_field": "minimax_api_key", "api_key_field": "minimax_api_key",
"api_base_key": None, "api_base_key": None,
"api_base_default": 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", { ("zhipu", {
"label": "智谱AI", "label": "智谱AI",
"api_key_field": "zhipu_ai_api_key", "api_key_field": "zhipu_ai_api_key",
"api_base_key": "zhipu_ai_api_base", "api_base_key": "zhipu_ai_api_base",
"api_base_default": "https://open.bigmodel.cn/api/paas/v4", "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", { ("dashscope", {
"label": "通义千问", "label": "通义千问",

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) raw_md5 = _md5_bytes(raw_data)
cipher_size = _aes_ecb_padded_size(raw_size) 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( resp = api.get_upload_url(
filekey=filekey, filekey=filekey,
media_type=media_type, 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, filesize=cipher_size,
aeskey=aes_key_hex, aeskey=aes_key_hex,
) )
upload_param = resp.get("upload_param", "") upload_param = resp.get("upload_param", "")
if not upload_param: if not upload_param:
raise RuntimeError(f"[Weixin] getUploadUrl returned no upload_param: {resp}") 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" cdn_url = (f"{api.cdn_base_url}/upload"
f"?encrypted_query_param={quote(upload_param)}" f"?encrypted_query_param={quote(upload_param)}"
f"&filekey={quote(filekey)}") 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={ cdn_resp = requests.post(cdn_url, data=encrypted, headers={
"Content-Type": "application/octet-stream", "Content-Type": "application/octet-stream",
"Content-Length": str(len(encrypted)),
}, timeout=120) }, timeout=120)
if 400 <= cdn_resp.status_code < 500: if 400 <= cdn_resp.status_code < 500:
err_msg = cdn_resp.headers.get("x-error-message", cdn_resp.text[:200]) 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): if "client error" in str(e):
raise raise
if attempt < UPLOAD_MAX_RETRIES: 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: else:
logger.error(f"[Weixin] CDN upload failed after {UPLOAD_MAX_RETRIES} attempts: {e}") logger.error(f"[Weixin] CDN upload failed after {UPLOAD_MAX_RETRIES} attempts: {e}")

View File

@@ -3,7 +3,7 @@
import click import click
from cli import __version__ from cli import __version__
from cli.commands.skill import skill 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.context import context
@@ -17,9 +17,9 @@ Commands:
start Start CowAgent. start Start CowAgent.
stop Stop CowAgent. stop Stop CowAgent.
restart Restart CowAgent. restart Restart CowAgent.
update Update CowAgent and restart.
status Show CowAgent running status. status Show CowAgent running status.
logs View CowAgent logs. logs View CowAgent logs.
context View or manage conversation context.
skill Manage CowAgent skills. skill Manage CowAgent skills.
Tip: You can also send /help, /skill list, etc. in agent chat.""" Tip: You can also send /help, /skill list, etc. in agent chat."""
@@ -63,6 +63,7 @@ main.add_command(skill)
main.add_command(start) main.add_command(start)
main.add_command(stop) main.add_command(stop)
main.add_command(restart) main.add_command(restart)
main.add_command(update)
main.add_command(status) main.add_command(status)
main.add_command(logs) main.add_command(logs)
main.add_command(context) main.add_command(context)

View File

@@ -172,6 +172,46 @@ def restart(ctx, no_logs):
ctx.invoke(start, no_logs=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() @click.command()
def status(): def status():
"""Show CowAgent running status.""" """Show CowAgent running status."""

View File

@@ -23,6 +23,173 @@ from cli.utils import (
) )
_SAFE_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_\-]{0,63}$") _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)/([^/]+)(?:/(.+))?)?/?$"
)
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 _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. Raises on any network or API error.
"""
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)
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):
"""Install a skill from a direct SKILL.md URL."""
click.echo(f"Downloading SKILL.md from {url} ...")
try:
resp = requests.get(url, timeout=30)
resp.raise_for_status()
except Exception as e:
click.echo(f"Error: Failed to download SKILL.md: {e}", err=True)
sys.exit(1)
content = resp.text
fm = _parse_skill_frontmatter(content)
skill_name = fm.get("name")
if not skill_name:
click.echo("Error: SKILL.md missing 'name' field in frontmatter.", err=True)
sys.exit(1)
skill_name = skill_name.strip()
_validate_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):
click.echo(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")
_print_install_success(skill_name, "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): def _validate_skill_name(name: str):
@@ -250,7 +417,7 @@ def search(query):
@skill.command() @skill.command()
@click.argument("name") @click.argument("name")
def install(name): def install(name):
"""Install a skill from Skill Hub or GitHub. """Install a skill from Skill Hub, GitHub, or a SKILL.md URL.
Examples: Examples:
@@ -259,15 +426,44 @@ def install(name):
cow skill install github:owner/repo cow skill install github:owner/repo
cow skill install github:owner/repo#path/to/skill cow skill install github:owner/repo#path/to/skill
cow skill install https://github.com/owner/repo/tree/main/path/to/skill
cow skill install https://example.com/path/to/SKILL.md
""" """
if name.startswith("github:"): if name.startswith(("http://", "https://")) and name.rstrip("/").endswith("SKILL.md"):
_install_github(name[7:]) # GitHub SKILL.md → strip filename and install the whole directory
dir_url = re.sub(r'/SKILL\.md/?$', '', name)
gh = _parse_github_url(dir_url)
if gh:
owner, repo, branch, subpath = gh
spec = f"{owner}/{repo}"
skill_name = subpath.rstrip("/").split("/")[-1] if subpath else repo
_install_github(spec, subpath=subpath, skill_name=skill_name, branch=branch)
return
_install_url(name)
return
parsed = _parse_github_url(name)
if parsed:
owner, repo, branch, subpath = parsed
spec = f"{owner}/{repo}"
skill_name = subpath.rstrip("/").split("/")[-1] if subpath else repo
_install_github(spec, subpath=subpath, skill_name=skill_name, branch=branch)
elif name.startswith("github:"):
skill_name = name[7:]
_validate_skill_name(skill_name)
_install_hub(skill_name)
elif name.startswith("clawhub:"):
skill_name = name[8:]
_validate_skill_name(skill_name)
_install_hub(skill_name, provider="clawhub")
else: else:
_validate_skill_name(name) _validate_skill_name(name)
_install_hub(name) _install_hub(name)
def _install_hub(name): def _install_hub(name, provider=None):
"""Install a skill from Skill Hub.""" """Install a skill from Skill Hub."""
skills_dir = get_skills_dir() skills_dir = get_skills_dir()
os.makedirs(skills_dir, exist_ok=True) os.makedirs(skills_dir, exist_ok=True)
@@ -275,7 +471,14 @@ def _install_hub(name):
click.echo(f"Fetching skill info for '{name}'...") click.echo(f"Fetching skill info for '{name}'...")
try: 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() resp.raise_for_status()
except requests.HTTPError as e: except requests.HTTPError as e:
if e.response is not None and e.response.status_code == 404: if e.response is not None and e.response.status_code == 404:
@@ -295,10 +498,15 @@ def _install_hub(name):
if source_type == "github": if source_type == "github":
source_url = data.get("source_url", "") source_url = data.get("source_url", "")
_validate_github_spec(source_url) parsed_url = _parse_github_url(source_url)
source_path = data.get("source_path") if parsed_url:
owner, repo, branch, subpath = parsed_url
click.echo(f"Source: GitHub ({source_url})") click.echo(f"Source: GitHub ({source_url})")
_install_github(source_url, subpath=source_path, skill_name=name) _install_github(f"{owner}/{repo}", subpath=subpath, skill_name=name, branch=branch)
else:
_validate_github_spec(source_url)
click.echo(f"Source: GitHub ({source_url})")
_install_github(source_url, skill_name=name)
return return
if source_type == "registry": if source_type == "registry":
@@ -320,7 +528,8 @@ def _install_hub(name):
sys.exit(1) sys.exit(1)
_verify_checksum(dl_resp.content, expected_checksum) _verify_checksum(dl_resp.content, expected_checksum)
_install_zip_bytes(dl_resp.content, name, skills_dir) _install_zip_bytes(dl_resp.content, name, skills_dir)
click.echo(click.style(f"✓ Skill '{name}' installed successfully!", fg="green")) _register_installed_skill(name, source=provider)
_print_install_success(name, provider)
else: else:
click.echo(f"Error: Unsupported registry provider.", err=True) click.echo(f"Error: Unsupported registry provider.", err=True)
sys.exit(1) sys.exit(1)
@@ -328,10 +537,15 @@ def _install_hub(name):
if "redirect" in data: if "redirect" in data:
source_url = data.get("source_url", "") source_url = data.get("source_url", "")
_validate_github_spec(source_url) parsed_url = _parse_github_url(source_url)
source_path = data.get("source_path") if parsed_url:
owner, repo, branch, subpath = parsed_url
click.echo(f"Source: GitHub ({source_url})") click.echo(f"Source: GitHub ({source_url})")
_install_github(source_url, subpath=source_path, skill_name=name) _install_github(f"{owner}/{repo}", subpath=subpath, skill_name=name, branch=branch)
else:
_validate_github_spec(source_url)
click.echo(f"Source: GitHub ({source_url})")
_install_github(source_url, skill_name=name)
return return
elif "application/zip" in content_type: elif "application/zip" in content_type:
@@ -339,14 +553,15 @@ def _install_hub(name):
expected_checksum = resp.headers.get("X-Checksum-Sha256") expected_checksum = resp.headers.get("X-Checksum-Sha256")
_verify_checksum(resp.content, expected_checksum) _verify_checksum(resp.content, expected_checksum)
_install_zip_bytes(resp.content, name, skills_dir) _install_zip_bytes(resp.content, name, skills_dir)
click.echo(click.style(f"✓ Skill '{name}' installed successfully!", fg="green")) _register_installed_skill(name)
_print_install_success(name, "cowhub")
return return
click.echo(f"Error: Unexpected response from Skill Hub.", err=True) click.echo(f"Error: Unexpected response from Skill Hub.", err=True)
sys.exit(1) sys.exit(1)
def _install_github(spec, subpath=None, skill_name=None): def _install_github(spec, subpath=None, skill_name=None, branch="main", source="github"):
"""Install a skill from a GitHub repo. """Install a skill from a GitHub repo.
spec format: owner/repo or owner/repo#path spec format: owner/repo or owner/repo#path
@@ -362,9 +577,30 @@ def _install_github(spec, subpath=None, skill_name=None):
skills_dir = get_skills_dir() skills_dir = get_skills_dir()
os.makedirs(skills_dir, exist_ok=True) os.makedirs(skills_dir, exist_ok=True)
target_dir = os.path.join(skills_dir, skill_name)
zip_url = f"https://github.com/{spec}/archive/refs/heads/main.zip" owner, repo = spec.split("/", 1)
click.echo(f"Downloading from GitHub: {spec}...")
# For subpath installs, try GitHub Contents API first (avoids downloading entire repo)
if subpath:
click.echo(f"Downloading from GitHub: {spec}/{subpath} (branch: {branch})...")
try:
with tempfile.TemporaryDirectory() as tmp_dir:
api_dest = os.path.join(tmp_dir, 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)
_print_install_success(skill_name, source)
return
except Exception:
click.echo("Contents API unavailable, falling back to zip download...")
# Fallback: download full repo zip
zip_url = f"https://github.com/{spec}/archive/refs/heads/{branch}.zip"
click.echo(f"Downloading from GitHub: {spec} (branch: {branch})...")
try: try:
resp = requests.get(zip_url, timeout=60, allow_redirects=True) resp = requests.get(zip_url, timeout=60, allow_redirects=True)
@@ -382,7 +618,6 @@ def _install_github(spec, subpath=None, skill_name=None):
with zipfile.ZipFile(zip_path, "r") as zf: with zipfile.ZipFile(zip_path, "r") as zf:
_safe_extractall(zf, extract_dir) _safe_extractall(zf, extract_dir)
# GitHub archives have a top-level dir like "repo-main/"
top_items = [d for d in os.listdir(extract_dir) if not d.startswith(".")] top_items = [d for d in os.listdir(extract_dir) if not d.startswith(".")]
repo_root = extract_dir repo_root = extract_dir
if len(top_items) == 1 and os.path.isdir(os.path.join(extract_dir, top_items[0])): if len(top_items) == 1 and os.path.isdir(os.path.join(extract_dir, top_items[0])):
@@ -396,12 +631,12 @@ def _install_github(spec, subpath=None, skill_name=None):
else: else:
source_dir = repo_root source_dir = repo_root
target_dir = os.path.join(skills_dir, skill_name)
if os.path.exists(target_dir): if os.path.exists(target_dir):
shutil.rmtree(target_dir) shutil.rmtree(target_dir)
shutil.copytree(source_dir, target_dir) shutil.copytree(source_dir, target_dir)
click.echo(click.style(f"✓ Skill '{skill_name}' installed successfully!", fg="green")) _register_installed_skill(skill_name, source=source)
_print_install_success(skill_name, source)
def _install_zip_bytes(content, name, skills_dir): def _install_zip_bytes(content, name, skills_dir):
@@ -523,11 +758,12 @@ def info(name):
skill_dir = None skill_dir = None
source = None source = None
config = load_skills_config()
for d, src in [(skills_dir, "custom"), (builtin_dir, "builtin")]: for d, src in [(skills_dir, "custom"), (builtin_dir, "builtin")]:
candidate = os.path.join(d, name) candidate = os.path.join(d, name)
if os.path.isdir(candidate): if os.path.isdir(candidate):
skill_dir = candidate skill_dir = candidate
source = src source = config.get(name, {}).get("source") or src
break break
if not skill_dir: if not skill_dir:

View File

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

1000
plugins/cow_cli/cow_cli.py Normal file

File diff suppressed because it is too large Load Diff

View File

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

63
run.sh
View File

@@ -171,8 +171,11 @@ clone_project() {
mv chatgpt-on-wechat-master chatgpt-on-wechat mv chatgpt-on-wechat-master chatgpt-on-wechat
rm chatgpt-on-wechat.zip rm chatgpt-on-wechat.zip
else else
git clone https://github.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 https://gitee.com/zhayujie/chatgpt-on-wechat.git 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 if [[ $? -ne 0 ]]; then
echo -e "${RED}❌ Project clone failed. Please check network connection.${NC}" echo -e "${RED}❌ Project clone failed. Please check network connection.${NC}"
exit 1 exit 1
@@ -195,7 +198,10 @@ clone_project() {
# Install dependencies # Install dependencies
install_dependencies() { install_dependencies() {
echo -e "${GREEN}📦 Installing dependencies...${NC}" 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="" PIP_EXTRA_ARGS=""
if $PYTHON_CMD -c "import sys; exit(0 if sys.version_info >= (3, 11) else 1)" 2>/dev/null; then 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}" echo -e "${GREEN}${EMOJI_ROCKET} Starting CowAgent...${NC}"
sleep 1 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 if [ ! -f "${BASE_DIR}/nohup.out" ]; then
touch "${BASE_DIR}/nohup.out" touch "${BASE_DIR}/nohup.out"
fi fi
@@ -545,17 +560,16 @@ start_project() {
OS_TYPE=$(uname) OS_TYPE=$(uname)
if [[ "$OS_TYPE" == "Linux" ]]; then 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 & 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}" echo -e "${GREEN}${EMOJI_COW} CowAgent started on Linux (using $PYTHON_CMD)${NC}"
elif [[ "$OS_TYPE" == "Darwin" ]]; then elif [[ "$OS_TYPE" == "Darwin" ]]; then
# macOS: use nohup to prevent SIGHUP
nohup $PYTHON_CMD "${BASE_DIR}/app.py" > "${BASE_DIR}/nohup.out" 2>&1 & 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}" echo -e "${GREEN}${EMOJI_COW} CowAgent started on macOS (using $PYTHON_CMD)${NC}"
else else
echo -e "${RED}❌ Unsupported OS: ${OS_TYPE}${NC}" echo -e "${RED}❌ Unsupported OS: ${OS_TYPE}${NC}"
exit 1 exit 1
fi fi
fi
sleep 2 sleep 2
echo "" echo ""
@@ -565,10 +579,17 @@ start_project() {
echo -e "${CYAN}$ACCESS_INFO${NC}" echo -e "${CYAN}$ACCESS_INFO${NC}"
echo "" echo ""
echo -e "${CYAN}${BOLD}Management Commands:${NC}" 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"
else
echo -e " ${GREEN}./run.sh stop${NC} Stop the service" 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 restart${NC} Restart the service"
echo -e " ${GREEN}./run.sh status${NC} Check status" echo -e " ${GREEN}./run.sh status${NC} Check status"
echo -e " ${GREEN}./run.sh logs${NC} View logs" echo -e " ${GREEN}./run.sh logs${NC} View logs"
fi
echo -e " ${GREEN}./run.sh update${NC} Update and restart" echo -e " ${GREEN}./run.sh update${NC} Update and restart"
echo -e "${CYAN}${BOLD}=========================================${NC}" echo -e "${CYAN}${BOLD}=========================================${NC}"
echo "" echo ""
@@ -622,27 +643,39 @@ is_running() {
[ -n "$(get_pid)" ] [ -n "$(get_pid)" ]
} }
# Check if cow CLI is available
has_cow() {
command -v cow &> /dev/null
}
# Start service # Start service
cmd_start() { cmd_start() {
# Check if config.json exists
if [ ! -f "${BASE_DIR}/config.json" ]; then if [ ! -f "${BASE_DIR}/config.json" ]; then
echo -e "${RED}${EMOJI_CROSS} config.json not found${NC}" echo -e "${RED}${EMOJI_CROSS} config.json not found${NC}"
echo -e "${YELLOW}Please run './run.sh' to configure first${NC}" echo -e "${YELLOW}Please run './run.sh' to configure first${NC}"
exit 1 exit 1
fi fi
if has_cow; then
cd "${BASE_DIR}"
cow start
else
if is_running; then if is_running; then
echo -e "${YELLOW}${EMOJI_WARN} CowAgent is already running (PID: $(get_pid))${NC}" echo -e "${YELLOW}${EMOJI_WARN} CowAgent is already running (PID: $(get_pid))${NC}"
echo -e "${YELLOW}Use './run.sh restart' to restart${NC}" echo -e "${YELLOW}Use './run.sh restart' to restart${NC}"
return return
fi fi
check_python_version check_python_version
start_project start_project
fi
} }
# Stop service # Stop service
cmd_stop() { cmd_stop() {
if has_cow; then
cd "${BASE_DIR}"
cow stop
else
echo -e "${GREEN}${EMOJI_STOP} Stopping CowAgent...${NC}" echo -e "${GREEN}${EMOJI_STOP} Stopping CowAgent...${NC}"
if ! is_running; then if ! is_running; then
@@ -667,17 +700,27 @@ cmd_stop() {
fi fi
echo -e "${GREEN}${EMOJI_CHECK} CowAgent stopped${NC}" echo -e "${GREEN}${EMOJI_CHECK} CowAgent stopped${NC}"
fi
} }
# Restart service # Restart service
cmd_restart() { cmd_restart() {
if has_cow; then
cd "${BASE_DIR}"
cow restart
else
cmd_stop cmd_stop
sleep 1 sleep 1
cmd_start cmd_start
fi
} }
# Check status # Check status
cmd_status() { cmd_status() {
if has_cow; then
cd "${BASE_DIR}"
cow status
else
echo -e "${CYAN}${BOLD}=========================================${NC}" echo -e "${CYAN}${BOLD}=========================================${NC}"
echo -e "${CYAN}${BOLD} ${EMOJI_COW} CowAgent Status${NC}" echo -e "${CYAN}${BOLD} ${EMOJI_COW} CowAgent Status${NC}"
echo -e "${CYAN}${BOLD}=========================================${NC}" echo -e "${CYAN}${BOLD}=========================================${NC}"
@@ -701,16 +744,22 @@ cmd_status() {
fi fi
echo -e "${CYAN}${BOLD}=========================================${NC}" echo -e "${CYAN}${BOLD}=========================================${NC}"
fi
} }
# View logs # View logs
cmd_logs() { cmd_logs() {
if has_cow; then
cd "${BASE_DIR}"
cow logs -f
else
if [ -f "${BASE_DIR}/nohup.out" ]; then if [ -f "${BASE_DIR}/nohup.out" ]; then
echo -e "${YELLOW}Viewing logs (Ctrl+C to exit):${NC}" echo -e "${YELLOW}Viewing logs (Ctrl+C to exit):${NC}"
tail -f "${BASE_DIR}/nohup.out" tail -f "${BASE_DIR}/nohup.out"
else else
echo -e "${RED}❌ Log file not found: ${BASE_DIR}/nohup.out${NC}" echo -e "${RED}❌ Log file not found: ${BASE_DIR}/nohup.out${NC}"
fi fi
fi
} }
# Reconfigure # Reconfigure