diff --git a/.gitignore b/.gitignore index 1aee851f..44fc64fc 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ plugins/banwords/lib/__pycache__ !plugins/keyword !plugins/linkai !plugins/agent +!plugins/cow_cli client_config.json ref/ .cursor/ diff --git a/channel/web/chat.html b/channel/web/chat.html index 80cb9319..f9dd1eee 100644 --- a/channel/web/chat.html +++ b/channel/web/chat.html @@ -270,7 +270,7 @@
-
+
' + + slashFiltered.map((c, i) => + `
` + + `${escapeHtml(c.cmd)}` + + `${escapeHtml(c.desc)}
` + ).join(''); + + slashMenu.querySelectorAll('.slash-menu-item').forEach(el => { + el.addEventListener('mouseenter', () => { + slashActiveIdx = parseInt(el.dataset.idx); + renderSlashItems(); + }); + el.addEventListener('mousedown', (e) => { + e.preventDefault(); + selectSlashCommand(parseInt(el.dataset.idx)); + }); + }); + + const activeEl = slashMenu.querySelector('.slash-menu-item.active'); + if (activeEl) activeEl.scrollIntoView({ block: 'nearest' }); +} + +function selectSlashCommand(idx) { + if (idx < 0 || idx >= slashFiltered.length) return; + const chosen = slashFiltered[idx].cmd; + slashJustSelected = true; + chatInput.value = chosen; + chatInput.dispatchEvent(new Event('input')); + hideSlashMenu(); + chatInput.focus(); + chatInput.selectionStart = chatInput.selectionEnd = chosen.length; +} + chatInput.addEventListener('input', function() { this.style.height = '42px'; const scrollH = this.scrollHeight; @@ -442,11 +535,50 @@ chatInput.addEventListener('input', function() { this.style.height = newH + 'px'; this.style.overflowY = scrollH > 180 ? 'auto' : 'hidden'; updateSendBtnState(); + + const val = this.value; + if (slashJustSelected) { + slashJustSelected = false; + } else if (val.startsWith('/')) { + showSlashMenu(val); + } else { + hideSlashMenu(); + } }); chatInput.addEventListener('keydown', function(e) { - // keyCode 229 indicates an IME is processing the keystroke (reliable across browsers) if (e.keyCode === 229 || e.isComposing || isComposing) return; + + if (isSlashMenuVisible()) { + if (e.key === 'ArrowDown') { + e.preventDefault(); + slashActiveIdx = Math.min(slashActiveIdx + 1, slashFiltered.length - 1); + renderSlashItems(); + return; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + slashActiveIdx = Math.max(slashActiveIdx - 1, 0); + renderSlashItems(); + return; + } + if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey) { + e.preventDefault(); + selectSlashCommand(slashActiveIdx); + return; + } + if (e.key === 'Escape') { + e.preventDefault(); + hideSlashMenu(); + return; + } + if (e.key === 'Tab') { + e.preventDefault(); + selectSlashCommand(slashActiveIdx); + return; + } + } + if ((e.ctrlKey || e.shiftKey) && e.key === 'Enter') { const start = this.selectionStart; const end = this.selectionEnd; @@ -460,6 +592,10 @@ chatInput.addEventListener('keydown', function(e) { } }); +chatInput.addEventListener('blur', () => { + setTimeout(hideSlashMenu, 150); +}); + document.querySelectorAll('.example-card').forEach(card => { card.addEventListener('click', () => { const textEl = card.querySelector('[data-i18n*="text"]'); diff --git a/cli/cli.py b/cli/cli.py index 9e01af98..14830747 100644 --- a/cli/cli.py +++ b/cli/cli.py @@ -19,7 +19,6 @@ Commands: restart Restart CowAgent. status Show CowAgent running status. logs View CowAgent logs. - context View or manage conversation context. skill Manage CowAgent skills. Tip: You can also send /help, /skill list, etc. in agent chat.""" diff --git a/plugins/cow_cli/__init__.py b/plugins/cow_cli/__init__.py new file mode 100644 index 00000000..a535239f --- /dev/null +++ b/plugins/cow_cli/__init__.py @@ -0,0 +1 @@ +from .cow_cli import CowCliPlugin diff --git a/plugins/cow_cli/cow_cli.py b/plugins/cow_cli/cow_cli.py new file mode 100644 index 00000000..ffbe5a4f --- /dev/null +++ b/plugins/cow_cli/cow_cli.py @@ -0,0 +1,904 @@ +""" +CowCli plugin - Intercept cow/slash commands in chat messages. + +Matches messages like: + cow skill list + cow context clear + /skill list + /context clear + /status + +Does NOT match: + cow是什么 + cow真好用 + /开头但不是已知命令 +""" + +import os +import threading + +import plugins +from plugins import Plugin, Event, EventContext, EventAction +from bridge.context import ContextType +from bridge.reply import Reply, ReplyType +from common.log import logger +from cli import __version__ + + +# Known top-level subcommands that cow supports +KNOWN_COMMANDS = { + "help", "version", "status", "logs", + "start", "stop", "restart", + "skill", "context", "config", +} + +# Commands that can only run from the CLI (terminal), not in chat +CLI_ONLY_COMMANDS = {"start", "stop", "restart"} + +# Commands that can only run from chat (need access to in-process memory) +CHAT_ONLY_COMMANDS = set() # context is allowed in both, but behaves differently + + +@plugins.register( + name="cow_cli", + desc="Handle cow/slash commands in chat messages", + version="0.1.0", + author="CowAgent", + desire_priority=1000, +) +class CowCliPlugin(Plugin): + + def __init__(self): + super().__init__() + self.handlers[Event.ON_HANDLE_CONTEXT] = self.on_handle_context + logger.debug("[CowCli] initialized") + + def on_handle_context(self, e_context: EventContext): + if e_context["context"].type != ContextType.TEXT: + return + + content = e_context["context"].content.strip() + parsed = self._parse_command(content) + if not parsed: + return + + cmd, args = parsed + logger.info(f"[CowCli] intercepted command: {cmd} {args}") + + result = self._dispatch(cmd, args, e_context) + + reply = Reply(ReplyType.TEXT, result) + e_context["reply"] = reply + e_context.action = EventAction.BREAK_PASS + + def _parse_command(self, content: str): + """ + Parse cow command from message text. + + Supported formats: + cow [args...] e.g. "cow skill list" + / [args...] e.g. "/skill list" + + Returns (command, args_string) or None if not a cow command. + """ + parts = None + + if content.startswith("/"): + rest = content[1:].strip() + if rest: + parts = rest.split(None, 1) + elif content.startswith("cow "): + rest = content[4:].strip() + if rest: + parts = rest.split(None, 1) + + if not parts: + return None + + cmd = parts[0].lower() + if cmd not in KNOWN_COMMANDS: + return None + + args = parts[1] if len(parts) > 1 else "" + return cmd, args + + # ------------------------------------------------------------------ + # Command dispatch + # ------------------------------------------------------------------ + + def _dispatch(self, cmd: str, args: str, e_context: EventContext) -> str: + if cmd in CLI_ONLY_COMMANDS: + return f"⚠️ `cow {cmd}` 只能在命令行终端中执行。\n请在终端运行: cow {cmd}" + + handler = getattr(self, f"_cmd_{cmd}", None) + if handler: + try: + return handler(args, e_context) + except Exception as e: + logger.error(f"[CowCli] command '{cmd}' failed: {e}") + return f"命令执行失败: {e}" + + return f"未知命令: {cmd}" + + # ------------------------------------------------------------------ + # help / version + # ------------------------------------------------------------------ + + def _cmd_help(self, args: str, e_context: EventContext) -> str: + lines = [ + "📋 CowAgent 命令列表", + "", + " /help 显示此帮助", + " /version 查看版本", + " /status 查看运行状态", + " /logs [N] 查看最近N条日志 (默认20)", + " /context 查看当前对话上下文信息", + " /context clear 清除当前对话上下文", + " /skill list 查看已安装的技能", + " /skill list --remote 浏览技能广场", + " /skill search <关键词> 搜索技能", + " /skill install <名称> 安装技能", + " /skill info <名称> 查看技能详情", + " /config 查看当前配置", + " /config 查看某项配置", + " /config 修改配置", + "", + "💡 也可以用 cow 代替 /", + ] + return "\n".join(lines) + + def _cmd_version(self, args: str, e_context: EventContext) -> str: + return f"CowAgent v{__version__}" + + # ------------------------------------------------------------------ + # status + # ------------------------------------------------------------------ + + def _cmd_status(self, args: str, e_context: EventContext) -> str: + from config import conf + + cfg = conf() + lines = ["📊 CowAgent 运行状态", ""] + + lines.append(f" 版本: v{__version__}") + lines.append(f" 进程: PID {os.getpid()}") + + channel = cfg.get("channel_type", "unknown") + if isinstance(channel, list): + channel = ", ".join(channel) + lines.append(f" 通道: {channel}") + + model_name = cfg.get("model", "unknown") + lines.append(f" 模型: {model_name}") + + mode = "Agent" if cfg.get("agent") else "Chat" + lines.append(f" 模式: {mode}") + + session_id = self._get_session_id(e_context) + agent = self._get_agent(session_id) + if agent: + lines.append("") + with agent.messages_lock: + msg_count = len(agent.messages) + lines.append(f" 会话消息数: {msg_count}") + + if agent.skill_manager: + total = len(agent.skill_manager.skills) + enabled = sum( + 1 for v in agent.skill_manager.skills_config.values() + if v.get("enabled", True) + ) + lines.append(f" 已加载技能: {enabled}/{total}") + else: + lines.append("") + lines.append(f" Agent: 未初始化 (首次对话后自动创建)") + + return "\n".join(lines) + + # ------------------------------------------------------------------ + # logs + # ------------------------------------------------------------------ + + def _cmd_logs(self, args: str, e_context: EventContext) -> str: + num_lines = 20 + if args.strip().isdigit(): + num_lines = min(int(args.strip()), 50) + + log_file = self._find_log_file() + if not log_file: + return "未找到日志文件" + + try: + with open(log_file, "r", encoding="utf-8", errors="replace") as f: + all_lines = f.readlines() + tail = all_lines[-num_lines:] + content = "".join(tail).strip() + if not content: + return "日志为空" + return f"📄 最近 {len(tail)} 条日志:\n\n{content}" + except Exception as e: + return f"读取日志失败: {e}" + + def _find_log_file(self) -> str: + project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + candidates = [ + os.path.join(project_root, "nohup.out"), + os.path.join(project_root, "run.log"), + ] + import glob as glob_mod + candidates.extend(sorted(glob_mod.glob(os.path.join(project_root, "logs", "*.log")), reverse=True)) + for f in candidates: + if os.path.isfile(f) and os.path.getsize(f) > 0: + return f + return "" + + # ------------------------------------------------------------------ + # context + # ------------------------------------------------------------------ + + def _cmd_context(self, args: str, e_context: EventContext) -> str: + session_id = self._get_session_id(e_context) + agent = self._get_agent(session_id) + + sub = args.strip().lower() + if sub == "clear": + return self._context_clear(agent, session_id) + else: + return self._context_info(agent, session_id) + + def _context_info(self, agent, session_id: str) -> str: + if not agent: + return "⚠️ Agent 未初始化,暂无上下文信息" + + with agent.messages_lock: + messages = agent.messages.copy() + + if not messages: + return "当前对话上下文为空" + + user_msgs = sum(1 for m in messages if m.get("role") == "user") + assistant_msgs = sum(1 for m in messages if m.get("role") == "assistant") + tool_msgs = sum(1 for m in messages if m.get("role") == "tool") + + total_chars = sum(len(str(m.get("content", ""))) for m in messages) + + lines = [ + "💬 当前对话上下文", + "", + f" 会话: {session_id or 'default'}", + f" 总消息数: {len(messages)}", + f" 用户消息: {user_msgs}", + f" 助手回复: {assistant_msgs}", + f" 工具调用: {tool_msgs}", + f" 内容总长度: ~{total_chars} 字符", + "", + " 发送 /context clear 可清除对话上下文", + ] + return "\n".join(lines) + + def _context_clear(self, agent, session_id: str) -> str: + if not agent: + return "⚠️ Agent 未初始化" + + with agent.messages_lock: + count = len(agent.messages) + agent.messages.clear() + + return f"✅ 已清除当前对话上下文 ({count} 条消息)" + + # ------------------------------------------------------------------ + # config + # ------------------------------------------------------------------ + + _CONFIG_WRITABLE = { + "model", + "agent_max_context_tokens", + "agent_max_context_turns", + "agent_max_steps", + } + + _CONFIG_READABLE = _CONFIG_WRITABLE | {"channel_type"} + + def _cmd_config(self, args: str, e_context: EventContext) -> str: + from config import conf, load_config + import json as _json + + parts = args.strip().split(None, 1) + if not parts: + return self._config_show_all() + + key = parts[0].lower() + if len(parts) == 1: + return self._config_get(key) + + value_str = parts[1].strip() + return self._config_set(key, value_str) + + def _config_show_all(self) -> str: + from config import conf + cfg = conf() + lines = ["⚙️ 当前配置", ""] + for key in sorted(self._CONFIG_READABLE): + val = cfg.get(key, "") + lines.append(f" {key}: {val}") + lines.append("") + lines.append("━━━━━━━━━━━━━━━━━━━━━━━━━━") + lines.append("💡 /config 查看配置") + lines.append("💡 /config 修改配置") + return "\n".join(lines) + + def _config_get(self, key: str) -> str: + from config import conf + if key not in self._CONFIG_READABLE: + available = ", ".join(sorted(self._CONFIG_READABLE)) + return f"不支持查看 '{key}'\n\n可查看的配置项: {available}" + val = conf().get(key, "") + return f"⚙️ {key}: {val}" + + def _config_set(self, key: str, value_str: str) -> str: + from config import conf, load_config + import json as _json + + if key not in self._CONFIG_WRITABLE: + if key in self._CONFIG_READABLE: + return f"⚠️ '{key}' 为只读配置,不支持修改" + available = ", ".join(sorted(self._CONFIG_WRITABLE)) + return f"不支持修改 '{key}'\n\n可修改的配置项: {available}" + + old_val = conf().get(key, "") + + try: + new_val = _json.loads(value_str) + except (_json.JSONDecodeError, ValueError): + if value_str.lower() == "true": + new_val = True + elif value_str.lower() == "false": + new_val = False + else: + new_val = value_str + + updates = {key: new_val} + + if key == "model" and conf().get("bot_type"): + resolved = self._resolve_bot_type_for_model(str(new_val)) + if resolved: + updates["bot_type"] = resolved + + project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + config_path = os.path.join(project_root, "config.json") + try: + with open(config_path, "r", encoding="utf-8") as f: + file_config = _json.load(f) + file_config.update(updates) + with open(config_path, "w", encoding="utf-8") as f: + _json.dump(file_config, f, indent=4, ensure_ascii=False) + except Exception as e: + return f"写入 config.json 失败: {e}" + + try: + load_config() + except Exception as e: + logger.warning(f"[CowCli] config reload warning: {e}") + + result = f"✅ 配置已更新\n\n {key}: {old_val} → {new_val}" + if "bot_type" in updates and updates["bot_type"] != conf().get("bot_type"): + result += f"\n bot_type: → {updates['bot_type']}" + return result + + @staticmethod + def _resolve_bot_type_for_model(model_name: str) -> str: + """Resolve bot_type from model name, reusing AgentBridge mapping.""" + from common import const + _EXACT = { + "wenxin": const.BAIDU, "wenxin-4": const.BAIDU, + "xunfei": const.XUNFEI, const.QWEN: const.QWEN, + const.MODELSCOPE: const.MODELSCOPE, + const.MOONSHOT: const.MOONSHOT, + "moonshot-v1-8k": const.MOONSHOT, "moonshot-v1-32k": const.MOONSHOT, + "moonshot-v1-128k": const.MOONSHOT, + } + _PREFIX = [ + ("qwen", const.QWEN_DASHSCOPE), ("qwq", const.QWEN_DASHSCOPE), + ("qvq", const.QWEN_DASHSCOPE), + ("gemini", const.GEMINI), ("glm", const.ZHIPU_AI), + ("claude", const.CLAUDEAPI), + ("moonshot", const.MOONSHOT), ("kimi", const.MOONSHOT), + ("doubao", const.DOUBAO), ("deepseek", const.DEEPSEEK), + ] + if not model_name: + return const.OPENAI + if model_name in _EXACT: + return _EXACT[model_name] + if model_name.lower().startswith("minimax") or model_name in ["abab6.5-chat"]: + return const.MiniMax + if model_name in [const.QWEN_TURBO, const.QWEN_PLUS, const.QWEN_MAX]: + return const.QWEN_DASHSCOPE + for prefix, btype in _PREFIX: + if model_name.startswith(prefix): + return btype + return const.OPENAI + + # ------------------------------------------------------------------ + # skill + # ------------------------------------------------------------------ + + def _cmd_skill(self, args: str, e_context: EventContext) -> str: + parts = args.strip().split(None, 1) + sub = parts[0].lower() if parts else "" + sub_args = parts[1].strip() if len(parts) > 1 else "" + + if sub == "list": + return self._skill_list(sub_args) + elif sub == "search": + return self._skill_search(sub_args) + elif sub == "install": + return self._skill_install(sub_args, e_context) + elif sub == "uninstall": + return self._skill_uninstall(sub_args) + elif sub == "info": + return self._skill_info(sub_args) + elif sub == "enable": + return self._skill_set_enabled(sub_args, True) + elif sub == "disable": + return self._skill_set_enabled(sub_args, False) + else: + return ( + "用法: /skill <子命令>\n\n" + "子命令:\n" + " list [--remote] 查看技能列表\n" + " search <关键词> 搜索技能\n" + " install <名称> 安装技能\n" + " uninstall <名称> 卸载技能\n" + " info <名称> 查看技能详情\n" + " enable <名称> 启用技能\n" + " disable <名称> 禁用技能" + ) + + def _skill_list_local(self) -> str: + from cli.utils import load_skills_config, get_skills_dir, get_builtin_skills_dir + config = load_skills_config() + + if not config: + skills_dir = get_skills_dir() + builtin_dir = get_builtin_skills_dir() + entries = [] + for d, source in [(builtin_dir, "builtin"), (skills_dir, "custom")]: + if not os.path.isdir(d): + continue + for name in sorted(os.listdir(d)): + skill_path = os.path.join(d, name) + if os.path.isdir(skill_path) and not name.startswith("."): + if os.path.exists(os.path.join(skill_path, "SKILL.md")): + entries.append({"name": name, "source": source, "enabled": True}) + if not entries: + return "暂无已安装的技能\n\n💡 /skill list --remote 浏览技能广场" + config = {e["name"]: e for e in entries} + + sorted_entries = sorted(config.values(), key=lambda e: e.get("name", "")) + enabled_count = sum(1 for e in sorted_entries if e.get("enabled", True)) + + lines = [f"📦 已安装的技能 ({enabled_count}/{len(sorted_entries)})", ""] + for entry in sorted_entries: + name = entry.get("name", "") + enabled = entry.get("enabled", True) + source = entry.get("source", "") + icon = "✅" if enabled else "⏸️" + desc = entry.get("description", "") + if len(desc) > 50: + desc = desc[:47] + "…" + source_tag = f" · {source}" if source else "" + line = f"{icon} {name}{source_tag}" + if desc: + line += f"\n {desc}" + lines.append(line) + lines.append("") + + lines.append("") + lines.append("━━━━━━━━━━━━━━━━━━━━━━━━━━") + lines.append("💡 /skill list --remote 浏览技能广场") + lines.append("💡 /skill info <名称> 查看详情") + return "\n".join(lines) + + def _skill_list(self, args: str) -> str: + parts = args.strip().split() + if "--remote" in parts or "-r" in parts: + page = 1 + for i, p in enumerate(parts): + if p == "--page" and i + 1 < len(parts) and parts[i + 1].isdigit(): + page = max(1, int(parts[i + 1])) + return self._skill_list_remote(page=page) + return self._skill_list_local() + + _REMOTE_PAGE_SIZE = 10 + + def _skill_list_remote(self, page: int = 1) -> str: + import requests + from cli.utils import SKILL_HUB_API, load_skills_config + page_size = self._REMOTE_PAGE_SIZE + try: + resp = requests.get( + f"{SKILL_HUB_API}/skills", + params={"page": page, "limit": page_size}, + timeout=10, + ) + resp.raise_for_status() + data = resp.json() + skills = data.get("skills", []) + total = data.get("total", len(skills)) + except Exception as e: + return f"获取技能广场失败: {e}" + + if not skills and page == 1: + return "技能广场暂无可用技能" + + total_pages = max(1, (total + page_size - 1) // page_size) + page = min(page, total_pages) + installed = set(load_skills_config().keys()) + + lines = [f"🌐 技能广场 (共 {total} 个技能)", ""] + for s in skills: + name = s.get("name", "") + display = s.get("display_name", "") or name + desc = s.get("description", "") + if len(desc) > 50: + desc = desc[:47] + "…" + badge = " [已安装]" if name in installed else "" + lines.append(f"📌 {display}{badge}") + lines.append(f" 名称: {name}") + if desc: + lines.append(f" {desc}") + lines.append("") + + lines.append("━━━━━━━━━━━━━━━━━━━━━━━━━━") + lines.append(f"📄 第 {page}/{total_pages} 页") + if page < total_pages: + lines.append(f"💡 /skill list --remote --page {page + 1} 下一页") + if page > 1: + lines.append(f"💡 /skill list --remote --page {page - 1} 上一页") + lines.append("💡 /skill install <名称> 安装技能") + lines.append("💡 /skill search <关键词> 搜索技能") + return "\n".join(lines) + + def _skill_search(self, query: str) -> str: + if not query: + return "请指定搜索关键词: /skill search <关键词>" + + import requests + from cli.utils import SKILL_HUB_API, load_skills_config + try: + resp = requests.get(f"{SKILL_HUB_API}/skills/search", params={"q": query}, timeout=10) + resp.raise_for_status() + skills = resp.json().get("skills", []) + except Exception as e: + return f"搜索失败: {e}" + + if not skills: + return f"未找到与「{query}」相关的技能" + + installed = set(load_skills_config().keys()) + lines = [f"🔍 搜索「{query}」({len(skills)} 个结果)", ""] + for s in skills: + name = s.get("name", "") + display = s.get("display_name", "") or name + desc = s.get("description", "") + if len(desc) > 50: + desc = desc[:47] + "…" + badge = " [已安装]" if name in installed else "" + lines.append(f"📌 {display}{badge}") + lines.append(f" 名称: {name}") + if desc: + lines.append(f" {desc}") + lines.append("") + + lines.append("━━━━━━━━━━━━━━━━━━━━━━━━━━") + lines.append("💡 /skill install <名称> 安装技能") + return "\n".join(lines) + + def _skill_install(self, name: str, e_context: EventContext) -> str: + if not name: + return "请指定要安装的技能: /skill install <名称>" + + # Run installation in a thread to avoid blocking + # For now, invoke the CLI logic directly + try: + from cli.utils import get_skills_dir, SKILL_HUB_API + import requests + import shutil + import zipfile + import tempfile + + skills_dir = get_skills_dir() + os.makedirs(skills_dir, exist_ok=True) + + if name.startswith("github:"): + return self._skill_install_github(name[7:], skills_dir) + + resp = requests.get(f"{SKILL_HUB_API}/skills/{name}/download", timeout=15) + resp.raise_for_status() + + content_type = resp.headers.get("Content-Type", "") + + if "application/json" in content_type: + data = resp.json() + source_type = data.get("source_type") + if source_type == "github" or "redirect" in data: + source_url = data.get("source_url", "") + source_path = data.get("source_path") + return self._skill_install_github(source_url, skills_dir, subpath=source_path, skill_name=name) + if source_type == "registry": + download_url = data.get("download_url") + if not download_url: + return f"此技能来自不支持的注册表,无法自动安装。" + from urllib.parse import urlparse + if urlparse(download_url).scheme != "https": + return "安装失败: 下载地址不安全 (非 HTTPS)" + provider = data.get("source_provider", "registry") + try: + dl_resp = requests.get(download_url, timeout=60, allow_redirects=True) + dl_resp.raise_for_status() + except Exception as e: + return f"从 {provider} 下载失败: {e}" + self._extract_zip(dl_resp.content, name, skills_dir) + self._report_install(name) + return f"✅ 技能 '{name}' 安装成功!" + + elif "application/zip" in content_type: + self._extract_zip(resp.content, name, skills_dir) + self._report_install(name) + return f"✅ 技能 '{name}' 安装成功!" + + return "技能商店返回了未预期的响应格式" + + except requests.HTTPError as e: + if e.response is not None and e.response.status_code == 404: + return f"技能 '{name}' 未在技能商店中找到" + return f"安装失败: {e}" + except Exception as e: + return f"安装失败: {e}" + + def _skill_install_github(self, spec: str, skills_dir: str, + subpath: str = None, skill_name: str = None) -> str: + import requests + import shutil + import zipfile + import tempfile + + if "#" in spec and not subpath: + spec, subpath = spec.split("#", 1) + if not skill_name: + skill_name = subpath.rstrip("/").split("/")[-1] if subpath else spec.split("/")[-1] + + zip_url = f"https://github.com/{spec}/archive/refs/heads/main.zip" + try: + resp = requests.get(zip_url, timeout=60, allow_redirects=True) + resp.raise_for_status() + except Exception as e: + return f"从 GitHub 下载失败: {e}" + + with tempfile.TemporaryDirectory() as tmp_dir: + zip_path = os.path.join(tmp_dir, "repo.zip") + with open(zip_path, "wb") as f: + f.write(resp.content) + + extract_dir = os.path.join(tmp_dir, "extracted") + with zipfile.ZipFile(zip_path, "r") as zf: + zf.extractall(extract_dir) + + top_items = [d for d in os.listdir(extract_dir) if not d.startswith(".")] + repo_root = extract_dir + if len(top_items) == 1 and os.path.isdir(os.path.join(extract_dir, top_items[0])): + repo_root = os.path.join(extract_dir, top_items[0]) + + if subpath: + source_dir = os.path.join(repo_root, subpath.strip("/")) + if not os.path.isdir(source_dir): + return f"路径 '{subpath}' 在仓库中不存在" + else: + source_dir = repo_root + + target_dir = os.path.join(skills_dir, skill_name) + if os.path.exists(target_dir): + import shutil + shutil.rmtree(target_dir) + import shutil + shutil.copytree(source_dir, target_dir) + + self._report_install(skill_name) + return f"✅ 技能 '{skill_name}' 安装成功!" + + def _extract_zip(self, content: bytes, name: str, skills_dir: str): + import zipfile + import tempfile + import shutil + + with tempfile.TemporaryDirectory() as tmp_dir: + zip_path = os.path.join(tmp_dir, "package.zip") + with open(zip_path, "wb") as f: + f.write(content) + + extract_dir = os.path.join(tmp_dir, "extracted") + with zipfile.ZipFile(zip_path, "r") as zf: + zf.extractall(extract_dir) + + top_items = [d for d in os.listdir(extract_dir) if not d.startswith(".")] + source = extract_dir + if len(top_items) == 1 and os.path.isdir(os.path.join(extract_dir, top_items[0])): + source = os.path.join(extract_dir, top_items[0]) + + target = os.path.join(skills_dir, name) + if os.path.exists(target): + shutil.rmtree(target) + shutil.copytree(source, target) + + def _report_install(self, name: str): + try: + import requests + from cli.utils import SKILL_HUB_API + requests.post(f"{SKILL_HUB_API}/skills/{name}/install", json={}, timeout=5) + except Exception: + pass + + def _skill_uninstall(self, name: str) -> str: + if not name: + return "请指定要卸载的技能: /skill uninstall <名称>" + + import shutil + import json + from cli.utils import get_skills_dir + + skills_dir = get_skills_dir() + skill_dir = os.path.join(skills_dir, name) + + if not os.path.exists(skill_dir): + skill_dir = self._resolve_skill_dir(name, skills_dir) + + if not skill_dir: + return f"技能 '{name}' 未安装" + + shutil.rmtree(skill_dir) + + config_path = os.path.join(skills_dir, "skills_config.json") + if os.path.exists(config_path): + try: + with open(config_path, "r", encoding="utf-8") as f: + config = json.load(f) + config.pop(name, None) + with open(config_path, "w", encoding="utf-8") as f: + json.dump(config, f, indent=4, ensure_ascii=False) + except Exception: + pass + + return f"✅ 技能 '{name}' 已卸载" + + @staticmethod + def _resolve_skill_dir(name: str, skills_dir: str): + """Find actual directory for a skill whose folder name may differ from its config name.""" + if not os.path.isdir(skills_dir): + return None + for entry in os.listdir(skills_dir): + entry_path = os.path.join(skills_dir, entry) + if not os.path.isdir(entry_path) or entry.startswith("."): + continue + if entry == name or entry.startswith(name + "-") or entry.endswith("-" + name): + skill_md = os.path.join(entry_path, "SKILL.md") + if os.path.exists(skill_md): + return entry_path + return None + + @staticmethod + def _strip_frontmatter(content: str): + """Strip YAML frontmatter and return (metadata_dict, body).""" + if not content.startswith("---"): + return {}, content + end = content.find("\n---", 3) + if end == -1: + return {}, content + fm_text = content[3:end].strip() + body = content[end + 4:].lstrip("\n") + meta = {} + for line in fm_text.split("\n"): + if ":" in line: + key, _, val = line.partition(":") + meta[key.strip()] = val.strip().strip('"').strip("'") + return meta, body + + def _skill_info(self, name: str) -> str: + if not name: + return "请指定技能名称: /skill info <名称>" + + from cli.utils import get_skills_dir, get_builtin_skills_dir + + skills_dir = get_skills_dir() + builtin_dir = get_builtin_skills_dir() + + skill_dir = None + source = None + for d, src in [(skills_dir, "custom"), (builtin_dir, "builtin")]: + candidate = os.path.join(d, name) + if os.path.isdir(candidate): + skill_dir = candidate + source = src + break + + if not skill_dir: + resolved = self._resolve_skill_dir(name, skills_dir) + if resolved: + skill_dir = resolved + source = "custom" + + if not skill_dir: + return f"技能 '{name}' 未找到" + + skill_md = os.path.join(skill_dir, "SKILL.md") + if not os.path.exists(skill_md): + return f"技能 '{name}' 没有 SKILL.md 文件" + + with open(skill_md, "r", encoding="utf-8") as f: + content = f.read() + + meta, body = self._strip_frontmatter(content) + + header_lines = [f"📖 技能: {name} [{source}]", ""] + desc = meta.get("description", "") + if desc: + header_lines.append(f" {desc}") + header_lines.append("") + + lines = body.split("\n") + preview = "\n".join(lines[:30]) + result = "\n".join(header_lines) + preview + if len(lines) > 30: + result += f"\n\n... ({len(lines) - 30} more lines)" + return result + + def _skill_set_enabled(self, name: str, enabled: bool) -> str: + if not name: + action = "启用" if enabled else "禁用" + return f"请指定技能名称: /skill {'enable' if enabled else 'disable'} <名称>" + + import json + from cli.utils import get_skills_dir + + skills_dir = get_skills_dir() + config_path = os.path.join(skills_dir, "skills_config.json") + + if not os.path.exists(config_path): + return "技能配置文件不存在" + + try: + with open(config_path, "r", encoding="utf-8") as f: + config = json.load(f) + except Exception as e: + return f"读取配置失败: {e}" + + if name not in config: + return f"技能 '{name}' 未在配置中找到" + + config[name]["enabled"] = enabled + with open(config_path, "w", encoding="utf-8") as f: + json.dump(config, f, indent=4, ensure_ascii=False) + + action = "启用" if enabled else "禁用" + icon = "✅" if enabled else "⬚" + return f"{icon} 技能 '{name}' 已{action}" + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _get_session_id(self, e_context: EventContext) -> str: + context = e_context["context"] + return context.kwargs.get("session_id") or context.get("session_id", "") + + def _get_agent(self, session_id: str): + try: + from bridge.bridge import Bridge + bridge = Bridge() + if not bridge._agent_bridge: + return None + return bridge._agent_bridge.get_agent(session_id=session_id or None) + except Exception: + return None + + def get_help_text(self, **kwargs): + return "在对话中使用 /help 或 cow help 查看可用命令" diff --git a/run.sh b/run.sh index 7fa00c73..9a2eb088 100755 --- a/run.sh +++ b/run.sh @@ -198,7 +198,10 @@ clone_project() { # Install dependencies install_dependencies() { echo -e "${GREEN}📦 Installing dependencies...${NC}" - local PIP_MIRROR="-i https://pypi.tuna.tsinghua.edu.cn/simple" + local PIP_MIRROR="" + if curl -s --connect-timeout 5 https://pypi.tuna.tsinghua.edu.cn/simple/ > /dev/null 2>&1; then + PIP_MIRROR="-i https://pypi.tuna.tsinghua.edu.cn/simple" + fi PIP_EXTRA_ARGS="" if $PYTHON_CMD -c "import sys; exit(0 if sys.version_info >= (3, 11) else 1)" 2>/dev/null; then @@ -541,23 +544,31 @@ start_project() { echo -e "${GREEN}${EMOJI_ROCKET} Starting CowAgent...${NC}" sleep 1 - if [ ! -f "${BASE_DIR}/nohup.out" ]; then - touch "${BASE_DIR}/nohup.out" + local USE_COW=false + if command -v cow &> /dev/null; then + USE_COW=true fi - OS_TYPE=$(uname) - - if [[ "$OS_TYPE" == "Linux" ]]; then - # Linux: use setsid to detach from terminal - nohup setsid $PYTHON_CMD "${BASE_DIR}/app.py" > "${BASE_DIR}/nohup.out" 2>&1 & - echo -e "${GREEN}${EMOJI_COW} CowAgent started on Linux (using $PYTHON_CMD)${NC}" - elif [[ "$OS_TYPE" == "Darwin" ]]; then - # macOS: use nohup to prevent SIGHUP - nohup $PYTHON_CMD "${BASE_DIR}/app.py" > "${BASE_DIR}/nohup.out" 2>&1 & - echo -e "${GREEN}${EMOJI_COW} CowAgent started on macOS (using $PYTHON_CMD)${NC}" + if $USE_COW; then + cd "${BASE_DIR}" + cow start --no-logs else - echo -e "${RED}❌ Unsupported OS: ${OS_TYPE}${NC}" - exit 1 + if [ ! -f "${BASE_DIR}/nohup.out" ]; then + touch "${BASE_DIR}/nohup.out" + fi + + OS_TYPE=$(uname) + + if [[ "$OS_TYPE" == "Linux" ]]; then + nohup setsid $PYTHON_CMD "${BASE_DIR}/app.py" > "${BASE_DIR}/nohup.out" 2>&1 & + echo -e "${GREEN}${EMOJI_COW} CowAgent started on Linux (using $PYTHON_CMD)${NC}" + elif [[ "$OS_TYPE" == "Darwin" ]]; then + nohup $PYTHON_CMD "${BASE_DIR}/app.py" > "${BASE_DIR}/nohup.out" 2>&1 & + echo -e "${GREEN}${EMOJI_COW} CowAgent started on macOS (using $PYTHON_CMD)${NC}" + else + echo -e "${RED}❌ Unsupported OS: ${OS_TYPE}${NC}" + exit 1 + fi fi sleep 2 @@ -568,14 +579,21 @@ start_project() { echo -e "${CYAN}$ACCESS_INFO${NC}" echo "" echo -e "${CYAN}${BOLD}Management Commands:${NC}" - echo -e " ${GREEN}./run.sh stop${NC} Stop the service" - echo -e " ${GREEN}./run.sh restart${NC} Restart the service" - echo -e " ${GREEN}./run.sh status${NC} Check status" - echo -e " ${GREEN}./run.sh logs${NC} View logs" + 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 restart${NC} Restart the service" + echo -e " ${GREEN}./run.sh status${NC} Check status" + echo -e " ${GREEN}./run.sh logs${NC} View logs" + fi echo -e " ${GREEN}./run.sh update${NC} Update and restart" echo -e "${CYAN}${BOLD}=========================================${NC}" echo "" - + echo -e "${YELLOW}Showing recent logs (Ctrl+C to exit, agent keeps running):${NC}" sleep 2 tail -n 30 -f "${BASE_DIR}/nohup.out" @@ -625,94 +643,122 @@ is_running() { [ -n "$(get_pid)" ] } +# Check if cow CLI is available +has_cow() { + command -v cow &> /dev/null +} + # Start service cmd_start() { - # Check if config.json exists if [ ! -f "${BASE_DIR}/config.json" ]; then echo -e "${RED}${EMOJI_CROSS} config.json not found${NC}" echo -e "${YELLOW}Please run './run.sh' to configure first${NC}" exit 1 fi - - if is_running; then - echo -e "${YELLOW}${EMOJI_WARN} CowAgent is already running (PID: $(get_pid))${NC}" - echo -e "${YELLOW}Use './run.sh restart' to restart${NC}" - return + + if has_cow; then + cd "${BASE_DIR}" + cow start + else + if is_running; then + echo -e "${YELLOW}${EMOJI_WARN} CowAgent is already running (PID: $(get_pid))${NC}" + echo -e "${YELLOW}Use './run.sh restart' to restart${NC}" + return + fi + check_python_version + start_project fi - - check_python_version - start_project } # Stop service cmd_stop() { - echo -e "${GREEN}${EMOJI_STOP} Stopping CowAgent...${NC}" + if has_cow; then + cd "${BASE_DIR}" + cow stop + else + echo -e "${GREEN}${EMOJI_STOP} Stopping CowAgent...${NC}" - if ! is_running; then - echo -e "${YELLOW}${EMOJI_WARN} CowAgent is not running${NC}" - return + if ! is_running; then + echo -e "${YELLOW}${EMOJI_WARN} CowAgent is not running${NC}" + return + fi + + pid=$(get_pid) + if [ -z "$pid" ] || ! echo "$pid" | grep -qE '^[0-9]+$'; then + echo -e "${RED}❌ Failed to get valid PID (got: ${pid})${NC}" + return 1 + fi + + echo -e "${GREEN}Found running process (PID: ${pid})${NC}" + + kill ${pid} + sleep 3 + + if ps -p ${pid} > /dev/null 2>&1; then + echo -e "${YELLOW}⚠️ Process not stopped, forcing termination...${NC}" + kill -9 ${pid} + fi + + echo -e "${GREEN}${EMOJI_CHECK} CowAgent stopped${NC}" fi - - pid=$(get_pid) - if [ -z "$pid" ] || ! echo "$pid" | grep -qE '^[0-9]+$'; then - echo -e "${RED}❌ Failed to get valid PID (got: ${pid})${NC}" - return 1 - fi - - echo -e "${GREEN}Found running process (PID: ${pid})${NC}" - - kill ${pid} - sleep 3 - - if ps -p ${pid} > /dev/null 2>&1; then - echo -e "${YELLOW}⚠️ Process not stopped, forcing termination...${NC}" - kill -9 ${pid} - fi - - echo -e "${GREEN}${EMOJI_CHECK} CowAgent stopped${NC}" } # Restart service cmd_restart() { - cmd_stop - sleep 1 - cmd_start + if has_cow; then + cd "${BASE_DIR}" + cow restart + else + cmd_stop + sleep 1 + cmd_start + fi } # Check status cmd_status() { - echo -e "${CYAN}${BOLD}=========================================${NC}" - echo -e "${CYAN}${BOLD} ${EMOJI_COW} CowAgent Status${NC}" - echo -e "${CYAN}${BOLD}=========================================${NC}" - - if is_running; then - pid=$(get_pid) - echo -e "${GREEN}Status:${NC} ✅ Running" - echo -e "${GREEN}PID:${NC} ${pid}" - if [ -f "${BASE_DIR}/nohup.out" ]; then - echo -e "${GREEN}Logs:${NC} ${BASE_DIR}/nohup.out" - fi + if has_cow; then + cd "${BASE_DIR}" + cow status else - echo -e "${YELLOW}Status:${NC} ⭐ Stopped" + echo -e "${CYAN}${BOLD}=========================================${NC}" + echo -e "${CYAN}${BOLD} ${EMOJI_COW} CowAgent Status${NC}" + echo -e "${CYAN}${BOLD}=========================================${NC}" + + if is_running; then + pid=$(get_pid) + echo -e "${GREEN}Status:${NC} ✅ Running" + echo -e "${GREEN}PID:${NC} ${pid}" + if [ -f "${BASE_DIR}/nohup.out" ]; then + echo -e "${GREEN}Logs:${NC} ${BASE_DIR}/nohup.out" + fi + else + echo -e "${YELLOW}Status:${NC} ⭐ Stopped" + fi + + if [ -f "${BASE_DIR}/config.json" ]; then + model=$(grep -o '"model"[[:space:]]*:[[:space:]]*"[^"]*"' "${BASE_DIR}/config.json" | cut -d'"' -f4) + channel=$(grep -o '"channel_type"[[:space:]]*:[[:space:]]*"[^"]*"' "${BASE_DIR}/config.json" | cut -d'"' -f4) + echo -e "${GREEN}Model:${NC} ${model}" + echo -e "${GREEN}Channel:${NC} ${channel}" + fi + + echo -e "${CYAN}${BOLD}=========================================${NC}" fi - - if [ -f "${BASE_DIR}/config.json" ]; then - model=$(grep -o '"model"[[:space:]]*:[[:space:]]*"[^"]*"' "${BASE_DIR}/config.json" | cut -d'"' -f4) - channel=$(grep -o '"channel_type"[[:space:]]*:[[:space:]]*"[^"]*"' "${BASE_DIR}/config.json" | cut -d'"' -f4) - echo -e "${GREEN}Model:${NC} ${model}" - echo -e "${GREEN}Channel:${NC} ${channel}" - fi - - echo -e "${CYAN}${BOLD}=========================================${NC}" } # View logs cmd_logs() { - if [ -f "${BASE_DIR}/nohup.out" ]; then - echo -e "${YELLOW}Viewing logs (Ctrl+C to exit):${NC}" - tail -f "${BASE_DIR}/nohup.out" + if has_cow; then + cd "${BASE_DIR}" + cow logs -f else - echo -e "${RED}❌ Log file not found: ${BASE_DIR}/nohup.out${NC}" + if [ -f "${BASE_DIR}/nohup.out" ]; then + echo -e "${YELLOW}Viewing logs (Ctrl+C to exit):${NC}" + tail -f "${BASE_DIR}/nohup.out" + else + echo -e "${RED}❌ Log file not found: ${BASE_DIR}/nohup.out${NC}" + fi fi }