mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-21 14:17:11 +08:00
Compare commits
12 Commits
feat-skill
...
feat-cow-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db85b9808e | ||
|
|
df5bae37bc | ||
|
|
acc23b6051 | ||
|
|
61f2741afc | ||
|
|
4dd7ea886a | ||
|
|
1e8959fbcf | ||
|
|
48729678cf | ||
|
|
0684becaa7 | ||
|
|
f890318ed9 | ||
|
|
ce90cf7aa8 | ||
|
|
a3a3d006eb | ||
|
|
2e1b52c1e5 |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
@@ -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 = [
|
||||||
"## 运行时信息",
|
"## ⚙️ 运行时信息",
|
||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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` 删除此文件。你不再需要引导脚本了——你已经是你了。
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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": "通义千问",
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
1
plugins/cow_cli/__init__.py
Normal file
1
plugins/cow_cli/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from .cow_cli import CowCliPlugin
|
||||||
1000
plugins/cow_cli/cow_cli.py
Normal file
1000
plugins/cow_cli/cow_cli.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
63
run.sh
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user