mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-05-06 11:12:39 +08:00
feat(cli): cli options in web console
This commit is contained in:
@@ -33,6 +33,7 @@ plugins/banwords/lib/__pycache__
|
||||
!plugins/keyword
|
||||
!plugins/linkai
|
||||
!plugins/agent
|
||||
!plugins/cow_cli
|
||||
client_config.json
|
||||
ref/
|
||||
.cursor/
|
||||
|
||||
@@ -270,7 +270,7 @@
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<!-- Attachment preview bar -->
|
||||
<div id="attachment-preview" class="attachment-preview hidden"></div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-2 relative">
|
||||
<div class="flex items-center flex-shrink-0">
|
||||
<button id="new-chat-btn" class="w-9 h-10 flex items-center justify-center rounded-lg
|
||||
text-slate-400 hover:text-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||
@@ -287,6 +287,7 @@
|
||||
</div>
|
||||
<input type="file" id="file-input" class="hidden" multiple
|
||||
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.csv,.json,.xml,.zip,.rar,.7z,.py,.js,.ts,.java,.c,.cpp,.go,.rs,.md">
|
||||
<div id="slash-menu" class="slash-menu hidden"></div>
|
||||
<textarea id="chat-input"
|
||||
class="flex-1 min-w-0 px-4 py-[10px] rounded-xl border border-slate-200 dark:border-slate-600
|
||||
bg-slate-50 dark:bg-white/5 text-slate-800 dark:text-slate-100
|
||||
@@ -295,7 +296,7 @@
|
||||
text-sm leading-relaxed"
|
||||
rows="1"
|
||||
data-i18n-placeholder="input_placeholder"
|
||||
placeholder="Type a message..."></textarea>
|
||||
placeholder="Type a message, or press / for commands"></textarea>
|
||||
<button id="send-btn"
|
||||
class="flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-lg
|
||||
bg-primary-400 text-white hover:bg-primary-500
|
||||
|
||||
@@ -446,3 +446,87 @@
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px -5px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Slash Command Menu */
|
||||
.slash-menu {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 6px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 30px -6px rgba(0, 0, 0, 0.1), 0 2px 8px -2px rgba(0, 0, 0, 0.04);
|
||||
z-index: 50;
|
||||
padding: 4px;
|
||||
animation: slashMenuIn 0.15s ease-out;
|
||||
}
|
||||
.slash-menu.hidden { display: none; }
|
||||
|
||||
@keyframes slashMenuIn {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.slash-menu-header {
|
||||
padding: 6px 10px 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.slash-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
.slash-menu-item:hover,
|
||||
.slash-menu-item.active {
|
||||
background: #EDFDF3;
|
||||
}
|
||||
.slash-menu-item .cmd {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #334155;
|
||||
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace;
|
||||
}
|
||||
.slash-menu-item.active .cmd {
|
||||
color: #228547;
|
||||
}
|
||||
.slash-menu-item .desc {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
margin-left: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
.dark .slash-menu {
|
||||
background: #1A1A1A;
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 8px 30px -6px rgba(0, 0, 0, 0.35), 0 2px 8px -2px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.dark .slash-menu-header {
|
||||
color: #64748b;
|
||||
}
|
||||
.dark .slash-menu-item:hover,
|
||||
.dark .slash-menu-item.active {
|
||||
background: rgba(74, 190, 110, 0.1);
|
||||
}
|
||||
.dark .slash-menu-item .cmd {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.dark .slash-menu-item.active .cmd {
|
||||
color: #4ABE6E;
|
||||
}
|
||||
.dark .slash-menu-item .desc {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ const I18N = {
|
||||
example_sys_title: '系统管理', example_sys_text: '帮我查看工作空间里有哪些文件',
|
||||
example_task_title: '技能系统', example_task_text: '查看所有支持的工具和技能',
|
||||
example_code_title: '编程助手', example_code_text: '帮我编写一个Python爬虫脚本',
|
||||
input_placeholder: '输入消息...',
|
||||
input_placeholder: '输入消息,或输入 / 使用指令',
|
||||
config_title: '配置管理', config_desc: '管理模型和 Agent 配置',
|
||||
config_model: '模型配置', config_agent: 'Agent 配置',
|
||||
config_channel: '通道配置',
|
||||
@@ -72,7 +72,7 @@ const I18N = {
|
||||
example_sys_title: 'System', example_sys_text: 'Show me the files in the workspace',
|
||||
example_task_title: 'Skills', example_task_text: 'Show current tools and skills',
|
||||
example_code_title: 'Coding', example_code_text: 'Write a Python web scraper script',
|
||||
input_placeholder: 'Type a message...',
|
||||
input_placeholder: 'Type a message, or press / for commands',
|
||||
config_title: 'Configuration', config_desc: 'Manage model and agent settings',
|
||||
config_model: 'Model Configuration', config_agent: 'Agent Configuration',
|
||||
config_channel: 'Channel Configuration',
|
||||
@@ -435,6 +435,99 @@ chatInput.addEventListener('paste', (e) => {
|
||||
chatInput.addEventListener('compositionstart', () => { isComposing = true; });
|
||||
chatInput.addEventListener('compositionend', () => { setTimeout(() => { isComposing = false; }, 100); });
|
||||
|
||||
// ── Slash Command Menu ───────────────────────────────────────
|
||||
const SLASH_COMMANDS = [
|
||||
{ cmd: '/help', desc: '显示命令帮助' },
|
||||
{ cmd: '/status', desc: '查看运行状态' },
|
||||
{ cmd: '/context', desc: '查看对话上下文' },
|
||||
{ cmd: '/context clear', desc: '清除对话上下文' },
|
||||
{ cmd: '/skill list', desc: '查看已安装技能' },
|
||||
{ cmd: '/skill list --remote', desc: '浏览技能广场' },
|
||||
{ cmd: '/skill search ', desc: '搜索技能' },
|
||||
{ cmd: '/skill install ', desc: '安装技能' },
|
||||
{ cmd: '/skill uninstall ', desc: '卸载技能' },
|
||||
{ cmd: '/skill info ', desc: '查看技能详情' },
|
||||
{ cmd: '/skill enable ', desc: '启用技能' },
|
||||
{ cmd: '/skill disable ', desc: '禁用技能' },
|
||||
{ cmd: '/config', desc: '查看当前配置' },
|
||||
{ cmd: '/logs', desc: '查看最近日志' },
|
||||
{ cmd: '/version', desc: '查看版本' },
|
||||
];
|
||||
|
||||
const slashMenu = document.getElementById('slash-menu');
|
||||
let slashActiveIdx = 0;
|
||||
let slashFiltered = [];
|
||||
let slashJustSelected = false;
|
||||
let slashLastFilter = '';
|
||||
|
||||
function showSlashMenu(filter) {
|
||||
const q = filter.toLowerCase();
|
||||
if (q === slashLastFilter && !slashMenu.classList.contains('hidden')) return;
|
||||
slashLastFilter = q;
|
||||
|
||||
const newFiltered = SLASH_COMMANDS.filter(c => c.cmd.toLowerCase().startsWith(q));
|
||||
if (newFiltered.length === 0) {
|
||||
hideSlashMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
const changed = newFiltered.length !== slashFiltered.length ||
|
||||
newFiltered.some((c, i) => c.cmd !== slashFiltered[i]?.cmd);
|
||||
slashFiltered = newFiltered;
|
||||
if (changed) slashActiveIdx = 0;
|
||||
slashActiveIdx = Math.min(slashActiveIdx, slashFiltered.length - 1);
|
||||
|
||||
renderSlashItems();
|
||||
slashMenu.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideSlashMenu() {
|
||||
slashMenu.classList.add('hidden');
|
||||
slashMenu.innerHTML = '';
|
||||
slashFiltered = [];
|
||||
slashActiveIdx = -1;
|
||||
slashLastFilter = '';
|
||||
}
|
||||
|
||||
function isSlashMenuVisible() {
|
||||
return !slashMenu.classList.contains('hidden') && slashFiltered.length > 0;
|
||||
}
|
||||
|
||||
function renderSlashItems() {
|
||||
slashMenu.innerHTML =
|
||||
'<div class="slash-menu-header">Commands</div>' +
|
||||
slashFiltered.map((c, i) =>
|
||||
`<div class="slash-menu-item${i === slashActiveIdx ? ' active' : ''}" data-idx="${i}">` +
|
||||
`<span class="cmd">${escapeHtml(c.cmd)}</span>` +
|
||||
`<span class="desc">${escapeHtml(c.desc)}</span></div>`
|
||||
).join('');
|
||||
|
||||
slashMenu.querySelectorAll('.slash-menu-item').forEach(el => {
|
||||
el.addEventListener('mouseenter', () => {
|
||||
slashActiveIdx = parseInt(el.dataset.idx);
|
||||
renderSlashItems();
|
||||
});
|
||||
el.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
selectSlashCommand(parseInt(el.dataset.idx));
|
||||
});
|
||||
});
|
||||
|
||||
const activeEl = slashMenu.querySelector('.slash-menu-item.active');
|
||||
if (activeEl) activeEl.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
function selectSlashCommand(idx) {
|
||||
if (idx < 0 || idx >= slashFiltered.length) return;
|
||||
const chosen = slashFiltered[idx].cmd;
|
||||
slashJustSelected = true;
|
||||
chatInput.value = chosen;
|
||||
chatInput.dispatchEvent(new Event('input'));
|
||||
hideSlashMenu();
|
||||
chatInput.focus();
|
||||
chatInput.selectionStart = chatInput.selectionEnd = chosen.length;
|
||||
}
|
||||
|
||||
chatInput.addEventListener('input', function() {
|
||||
this.style.height = '42px';
|
||||
const scrollH = this.scrollHeight;
|
||||
@@ -442,11 +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"]');
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from .cow_cli import CowCliPlugin
|
||||
@@ -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 <command> [args...] e.g. "cow skill list"
|
||||
/<command> [args...] e.g. "/skill list"
|
||||
|
||||
Returns (command, args_string) or None if not a cow command.
|
||||
"""
|
||||
parts = None
|
||||
|
||||
if content.startswith("/"):
|
||||
rest = content[1:].strip()
|
||||
if rest:
|
||||
parts = rest.split(None, 1)
|
||||
elif content.startswith("cow "):
|
||||
rest = content[4:].strip()
|
||||
if rest:
|
||||
parts = rest.split(None, 1)
|
||||
|
||||
if not parts:
|
||||
return None
|
||||
|
||||
cmd = parts[0].lower()
|
||||
if cmd not in KNOWN_COMMANDS:
|
||||
return None
|
||||
|
||||
args = parts[1] if len(parts) > 1 else ""
|
||||
return cmd, args
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Command dispatch
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _dispatch(self, cmd: str, args: str, e_context: EventContext) -> str:
|
||||
if cmd in CLI_ONLY_COMMANDS:
|
||||
return f"⚠️ `cow {cmd}` 只能在命令行终端中执行。\n请在终端运行: cow {cmd}"
|
||||
|
||||
handler = getattr(self, f"_cmd_{cmd}", None)
|
||||
if handler:
|
||||
try:
|
||||
return handler(args, e_context)
|
||||
except Exception as e:
|
||||
logger.error(f"[CowCli] command '{cmd}' failed: {e}")
|
||||
return f"命令执行失败: {e}"
|
||||
|
||||
return f"未知命令: {cmd}"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# help / version
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _cmd_help(self, args: str, e_context: EventContext) -> str:
|
||||
lines = [
|
||||
"📋 CowAgent 命令列表",
|
||||
"",
|
||||
" /help 显示此帮助",
|
||||
" /version 查看版本",
|
||||
" /status 查看运行状态",
|
||||
" /logs [N] 查看最近N条日志 (默认20)",
|
||||
" /context 查看当前对话上下文信息",
|
||||
" /context clear 清除当前对话上下文",
|
||||
" /skill list 查看已安装的技能",
|
||||
" /skill list --remote 浏览技能广场",
|
||||
" /skill search <关键词> 搜索技能",
|
||||
" /skill install <名称> 安装技能",
|
||||
" /skill info <名称> 查看技能详情",
|
||||
" /config 查看当前配置",
|
||||
" /config <key> 查看某项配置",
|
||||
" /config <key> <val> 修改配置",
|
||||
"",
|
||||
"💡 也可以用 cow <command> 代替 /<command>",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
def _cmd_version(self, args: str, e_context: EventContext) -> str:
|
||||
return f"CowAgent v{__version__}"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# status
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _cmd_status(self, args: str, e_context: EventContext) -> str:
|
||||
from config import conf
|
||||
|
||||
cfg = conf()
|
||||
lines = ["📊 CowAgent 运行状态", ""]
|
||||
|
||||
lines.append(f" 版本: v{__version__}")
|
||||
lines.append(f" 进程: PID {os.getpid()}")
|
||||
|
||||
channel = cfg.get("channel_type", "unknown")
|
||||
if isinstance(channel, list):
|
||||
channel = ", ".join(channel)
|
||||
lines.append(f" 通道: {channel}")
|
||||
|
||||
model_name = cfg.get("model", "unknown")
|
||||
lines.append(f" 模型: {model_name}")
|
||||
|
||||
mode = "Agent" if cfg.get("agent") else "Chat"
|
||||
lines.append(f" 模式: {mode}")
|
||||
|
||||
session_id = self._get_session_id(e_context)
|
||||
agent = self._get_agent(session_id)
|
||||
if agent:
|
||||
lines.append("")
|
||||
with agent.messages_lock:
|
||||
msg_count = len(agent.messages)
|
||||
lines.append(f" 会话消息数: {msg_count}")
|
||||
|
||||
if agent.skill_manager:
|
||||
total = len(agent.skill_manager.skills)
|
||||
enabled = sum(
|
||||
1 for v in agent.skill_manager.skills_config.values()
|
||||
if v.get("enabled", True)
|
||||
)
|
||||
lines.append(f" 已加载技能: {enabled}/{total}")
|
||||
else:
|
||||
lines.append("")
|
||||
lines.append(f" Agent: 未初始化 (首次对话后自动创建)")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# logs
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _cmd_logs(self, args: str, e_context: EventContext) -> str:
|
||||
num_lines = 20
|
||||
if args.strip().isdigit():
|
||||
num_lines = min(int(args.strip()), 50)
|
||||
|
||||
log_file = self._find_log_file()
|
||||
if not log_file:
|
||||
return "未找到日志文件"
|
||||
|
||||
try:
|
||||
with open(log_file, "r", encoding="utf-8", errors="replace") as f:
|
||||
all_lines = f.readlines()
|
||||
tail = all_lines[-num_lines:]
|
||||
content = "".join(tail).strip()
|
||||
if not content:
|
||||
return "日志为空"
|
||||
return f"📄 最近 {len(tail)} 条日志:\n\n{content}"
|
||||
except Exception as e:
|
||||
return f"读取日志失败: {e}"
|
||||
|
||||
def _find_log_file(self) -> str:
|
||||
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
candidates = [
|
||||
os.path.join(project_root, "nohup.out"),
|
||||
os.path.join(project_root, "run.log"),
|
||||
]
|
||||
import glob as glob_mod
|
||||
candidates.extend(sorted(glob_mod.glob(os.path.join(project_root, "logs", "*.log")), reverse=True))
|
||||
for f in candidates:
|
||||
if os.path.isfile(f) and os.path.getsize(f) > 0:
|
||||
return f
|
||||
return ""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# context
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _cmd_context(self, args: str, e_context: EventContext) -> str:
|
||||
session_id = self._get_session_id(e_context)
|
||||
agent = self._get_agent(session_id)
|
||||
|
||||
sub = args.strip().lower()
|
||||
if sub == "clear":
|
||||
return self._context_clear(agent, session_id)
|
||||
else:
|
||||
return self._context_info(agent, session_id)
|
||||
|
||||
def _context_info(self, agent, session_id: str) -> str:
|
||||
if not agent:
|
||||
return "⚠️ Agent 未初始化,暂无上下文信息"
|
||||
|
||||
with agent.messages_lock:
|
||||
messages = agent.messages.copy()
|
||||
|
||||
if not messages:
|
||||
return "当前对话上下文为空"
|
||||
|
||||
user_msgs = sum(1 for m in messages if m.get("role") == "user")
|
||||
assistant_msgs = sum(1 for m in messages if m.get("role") == "assistant")
|
||||
tool_msgs = sum(1 for m in messages if m.get("role") == "tool")
|
||||
|
||||
total_chars = sum(len(str(m.get("content", ""))) for m in messages)
|
||||
|
||||
lines = [
|
||||
"💬 当前对话上下文",
|
||||
"",
|
||||
f" 会话: {session_id or 'default'}",
|
||||
f" 总消息数: {len(messages)}",
|
||||
f" 用户消息: {user_msgs}",
|
||||
f" 助手回复: {assistant_msgs}",
|
||||
f" 工具调用: {tool_msgs}",
|
||||
f" 内容总长度: ~{total_chars} 字符",
|
||||
"",
|
||||
" 发送 /context clear 可清除对话上下文",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
def _context_clear(self, agent, session_id: str) -> str:
|
||||
if not agent:
|
||||
return "⚠️ Agent 未初始化"
|
||||
|
||||
with agent.messages_lock:
|
||||
count = len(agent.messages)
|
||||
agent.messages.clear()
|
||||
|
||||
return f"✅ 已清除当前对话上下文 ({count} 条消息)"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# config
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
_CONFIG_WRITABLE = {
|
||||
"model",
|
||||
"agent_max_context_tokens",
|
||||
"agent_max_context_turns",
|
||||
"agent_max_steps",
|
||||
}
|
||||
|
||||
_CONFIG_READABLE = _CONFIG_WRITABLE | {"channel_type"}
|
||||
|
||||
def _cmd_config(self, args: str, e_context: EventContext) -> str:
|
||||
from config import conf, load_config
|
||||
import json as _json
|
||||
|
||||
parts = args.strip().split(None, 1)
|
||||
if not parts:
|
||||
return self._config_show_all()
|
||||
|
||||
key = parts[0].lower()
|
||||
if len(parts) == 1:
|
||||
return self._config_get(key)
|
||||
|
||||
value_str = parts[1].strip()
|
||||
return self._config_set(key, value_str)
|
||||
|
||||
def _config_show_all(self) -> str:
|
||||
from config import conf
|
||||
cfg = conf()
|
||||
lines = ["⚙️ 当前配置", ""]
|
||||
for key in sorted(self._CONFIG_READABLE):
|
||||
val = cfg.get(key, "")
|
||||
lines.append(f" {key}: {val}")
|
||||
lines.append("")
|
||||
lines.append("━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
||||
lines.append("💡 /config <key> 查看配置")
|
||||
lines.append("💡 /config <key> <val> 修改配置")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _config_get(self, key: str) -> str:
|
||||
from config import conf
|
||||
if key not in self._CONFIG_READABLE:
|
||||
available = ", ".join(sorted(self._CONFIG_READABLE))
|
||||
return f"不支持查看 '{key}'\n\n可查看的配置项: {available}"
|
||||
val = conf().get(key, "")
|
||||
return f"⚙️ {key}: {val}"
|
||||
|
||||
def _config_set(self, key: str, value_str: str) -> str:
|
||||
from config import conf, load_config
|
||||
import json as _json
|
||||
|
||||
if key not in self._CONFIG_WRITABLE:
|
||||
if key in self._CONFIG_READABLE:
|
||||
return f"⚠️ '{key}' 为只读配置,不支持修改"
|
||||
available = ", ".join(sorted(self._CONFIG_WRITABLE))
|
||||
return f"不支持修改 '{key}'\n\n可修改的配置项: {available}"
|
||||
|
||||
old_val = conf().get(key, "")
|
||||
|
||||
try:
|
||||
new_val = _json.loads(value_str)
|
||||
except (_json.JSONDecodeError, ValueError):
|
||||
if value_str.lower() == "true":
|
||||
new_val = True
|
||||
elif value_str.lower() == "false":
|
||||
new_val = False
|
||||
else:
|
||||
new_val = value_str
|
||||
|
||||
updates = {key: new_val}
|
||||
|
||||
if key == "model" and conf().get("bot_type"):
|
||||
resolved = self._resolve_bot_type_for_model(str(new_val))
|
||||
if resolved:
|
||||
updates["bot_type"] = resolved
|
||||
|
||||
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
config_path = os.path.join(project_root, "config.json")
|
||||
try:
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
file_config = _json.load(f)
|
||||
file_config.update(updates)
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
_json.dump(file_config, f, indent=4, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
return f"写入 config.json 失败: {e}"
|
||||
|
||||
try:
|
||||
load_config()
|
||||
except Exception as e:
|
||||
logger.warning(f"[CowCli] config reload warning: {e}")
|
||||
|
||||
result = f"✅ 配置已更新\n\n {key}: {old_val} → {new_val}"
|
||||
if "bot_type" in updates and updates["bot_type"] != conf().get("bot_type"):
|
||||
result += f"\n bot_type: → {updates['bot_type']}"
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _resolve_bot_type_for_model(model_name: str) -> str:
|
||||
"""Resolve bot_type from model name, reusing AgentBridge mapping."""
|
||||
from common import const
|
||||
_EXACT = {
|
||||
"wenxin": const.BAIDU, "wenxin-4": const.BAIDU,
|
||||
"xunfei": const.XUNFEI, const.QWEN: const.QWEN,
|
||||
const.MODELSCOPE: const.MODELSCOPE,
|
||||
const.MOONSHOT: const.MOONSHOT,
|
||||
"moonshot-v1-8k": const.MOONSHOT, "moonshot-v1-32k": const.MOONSHOT,
|
||||
"moonshot-v1-128k": const.MOONSHOT,
|
||||
}
|
||||
_PREFIX = [
|
||||
("qwen", const.QWEN_DASHSCOPE), ("qwq", const.QWEN_DASHSCOPE),
|
||||
("qvq", const.QWEN_DASHSCOPE),
|
||||
("gemini", const.GEMINI), ("glm", const.ZHIPU_AI),
|
||||
("claude", const.CLAUDEAPI),
|
||||
("moonshot", const.MOONSHOT), ("kimi", const.MOONSHOT),
|
||||
("doubao", const.DOUBAO), ("deepseek", const.DEEPSEEK),
|
||||
]
|
||||
if not model_name:
|
||||
return const.OPENAI
|
||||
if model_name in _EXACT:
|
||||
return _EXACT[model_name]
|
||||
if model_name.lower().startswith("minimax") or model_name in ["abab6.5-chat"]:
|
||||
return const.MiniMax
|
||||
if model_name in [const.QWEN_TURBO, const.QWEN_PLUS, const.QWEN_MAX]:
|
||||
return const.QWEN_DASHSCOPE
|
||||
for prefix, btype in _PREFIX:
|
||||
if model_name.startswith(prefix):
|
||||
return btype
|
||||
return const.OPENAI
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# skill
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _cmd_skill(self, args: str, e_context: EventContext) -> str:
|
||||
parts = args.strip().split(None, 1)
|
||||
sub = parts[0].lower() if parts else ""
|
||||
sub_args = parts[1].strip() if len(parts) > 1 else ""
|
||||
|
||||
if sub == "list":
|
||||
return self._skill_list(sub_args)
|
||||
elif sub == "search":
|
||||
return self._skill_search(sub_args)
|
||||
elif sub == "install":
|
||||
return self._skill_install(sub_args, e_context)
|
||||
elif sub == "uninstall":
|
||||
return self._skill_uninstall(sub_args)
|
||||
elif sub == "info":
|
||||
return self._skill_info(sub_args)
|
||||
elif sub == "enable":
|
||||
return self._skill_set_enabled(sub_args, True)
|
||||
elif sub == "disable":
|
||||
return self._skill_set_enabled(sub_args, False)
|
||||
else:
|
||||
return (
|
||||
"用法: /skill <子命令>\n\n"
|
||||
"子命令:\n"
|
||||
" list [--remote] 查看技能列表\n"
|
||||
" search <关键词> 搜索技能\n"
|
||||
" install <名称> 安装技能\n"
|
||||
" uninstall <名称> 卸载技能\n"
|
||||
" info <名称> 查看技能详情\n"
|
||||
" enable <名称> 启用技能\n"
|
||||
" disable <名称> 禁用技能"
|
||||
)
|
||||
|
||||
def _skill_list_local(self) -> str:
|
||||
from cli.utils import load_skills_config, get_skills_dir, get_builtin_skills_dir
|
||||
config = load_skills_config()
|
||||
|
||||
if not config:
|
||||
skills_dir = get_skills_dir()
|
||||
builtin_dir = get_builtin_skills_dir()
|
||||
entries = []
|
||||
for d, source in [(builtin_dir, "builtin"), (skills_dir, "custom")]:
|
||||
if not os.path.isdir(d):
|
||||
continue
|
||||
for name in sorted(os.listdir(d)):
|
||||
skill_path = os.path.join(d, name)
|
||||
if os.path.isdir(skill_path) and not name.startswith("."):
|
||||
if os.path.exists(os.path.join(skill_path, "SKILL.md")):
|
||||
entries.append({"name": name, "source": source, "enabled": True})
|
||||
if not entries:
|
||||
return "暂无已安装的技能\n\n💡 /skill list --remote 浏览技能广场"
|
||||
config = {e["name"]: e for e in entries}
|
||||
|
||||
sorted_entries = sorted(config.values(), key=lambda e: e.get("name", ""))
|
||||
enabled_count = sum(1 for e in sorted_entries if e.get("enabled", True))
|
||||
|
||||
lines = [f"📦 已安装的技能 ({enabled_count}/{len(sorted_entries)})", ""]
|
||||
for entry in sorted_entries:
|
||||
name = entry.get("name", "")
|
||||
enabled = entry.get("enabled", True)
|
||||
source = entry.get("source", "")
|
||||
icon = "✅" if enabled else "⏸️"
|
||||
desc = entry.get("description", "")
|
||||
if len(desc) > 50:
|
||||
desc = desc[:47] + "…"
|
||||
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 查看可用命令"
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user