From 66b71c50e989e45b277e439b1a4a36155161b7ad Mon Sep 17 00:00:00 2001 From: zhayujie Date: Tue, 31 Mar 2026 21:27:50 +0800 Subject: [PATCH] feat(wecom_bot): add Wecom Bot QR code scan auth --- agent/skills/manager.py | 6 +- agent/tools/browser/browser_service.py | 9 +- channel/web/static/js/console.js | 217 ++++++++++++++++++++++++- channel/wecom_bot/wecom_bot_channel.py | 52 ++++-- cli/commands/skill.py | 50 +++++- plugins/cow_cli/cow_cli.py | 12 +- 6 files changed, 312 insertions(+), 34 deletions(-) diff --git a/agent/skills/manager.py b/agent/skills/manager.py index 6e1c4259..c7daf7ad 100644 --- a/agent/skills/manager.py +++ b/agent/skills/manager.py @@ -102,13 +102,17 @@ class SkillManager: else: enabled = entry.metadata.default_enabled if entry.metadata else True - merged[name] = { + entry_dict = { "name": name, "description": skill.description, "source": prev.get("source") or skill.source, "enabled": enabled, "category": category, } + display_name = prev.get("display_name") + if display_name: + entry_dict["display_name"] = display_name + merged[name] = entry_dict self.skills_config = merged self._save_skills_config() diff --git a/agent/tools/browser/browser_service.py b/agent/tools/browser/browser_service.py index cc1b94e9..424efc49 100644 --- a/agent/tools/browser/browser_service.py +++ b/agent/tools/browser/browser_service.py @@ -244,10 +244,13 @@ class BrowserService: with self._lock: if self._alive and self._thread and self._thread.is_alive(): return + # Wait for old thread to fully exit before creating a new one + old = self._thread + if old and old.is_alive(): + old.join(timeout=5) + # Fresh queue to avoid stale sentinels from a previous close() + self._task_queue = queue.Queue() self._alive = True - # Reuse existing queue so tasks queued during restart are not lost - if self._task_queue is None: - self._task_queue = queue.Queue() self._ready = threading.Event() self._thread = threading.Thread(target=self._run_loop, daemon=True, name="BrowserThread") self._thread.start() diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js index ae87c094..dc0625b2 100644 --- a/channel/web/static/js/console.js +++ b/channel/web/static/js/console.js @@ -56,6 +56,10 @@ const I18N = { weixin_scan_scanned: '已扫码,请在手机上确认', weixin_scan_expired: '二维码已过期,正在刷新...', weixin_scan_success: '登录成功,正在启动通道...', weixin_scan_fail: '获取二维码失败', weixin_qr_tip: '二维码约2分钟后过期', + wecom_scan_btn: '扫码创建企微机器人', wecom_scan_desc: '使用企业微信扫码,一键创建智能机器人', + wecom_scan_success: '创建成功,正在启动通道...', + wecom_scan_fail: '创建失败', + wecom_mode_scan: '扫码接入', wecom_mode_manual: '手动填写', tasks_title: '定时任务', tasks_desc: '查看和管理定时任务', tasks_coming: '即将推出', tasks_coming_desc: '定时任务管理功能即将在此提供', logs_title: '日志', logs_desc: '实时日志输出 (run.log)', @@ -107,6 +111,10 @@ const I18N = { weixin_scan_scanned: 'Scanned, please confirm on your phone', weixin_scan_expired: 'QR code expired, refreshing...', weixin_scan_success: 'Login successful, starting channel...', weixin_scan_fail: 'Failed to load QR code', weixin_qr_tip: 'QR code expires in ~2 minutes', + wecom_scan_btn: 'Scan to Create WeCom Bot', wecom_scan_desc: 'Scan with WeCom to create a bot instantly', + wecom_scan_success: 'Bot created, starting channel...', + wecom_scan_fail: 'Bot creation failed', + wecom_mode_scan: 'Scan QR', wecom_mode_manual: 'Manual', tasks_title: 'Scheduled Tasks', tasks_desc: 'View and manage scheduled tasks', tasks_coming: 'Coming Soon', tasks_coming_desc: 'Scheduled task management will be available here', logs_title: 'Logs', logs_desc: 'Real-time log output (run.log)', @@ -1684,7 +1692,7 @@ function renderSkillCard(card, sk) {
- ${escapeHtml(sk.name)} + ${escapeHtml(sk.display_name || sk.name)}
` : ''} + ${wecomNeedsCreds ? `
+

${t('wecom_scan_desc')}

+ +
+
` : ''} ${hasFields ? `
${fieldsHtml}
@@ -2154,6 +2175,13 @@ function onAddChannelSelect(chName) { return; } + if (chName === 'wecom_bot') { + actions.classList.add('hidden'); + const ch = channelsData.find(c => c.name === chName); + fieldsContainer.innerHTML = buildWecomBotPanel(ch); + return; + } + const ch = channelsData.find(c => c.name === chName); if (!ch) return; @@ -2375,6 +2403,191 @@ function connectWeixinAfterQr() { .catch(() => {}); } +// ===================================================================== +// WeCom Bot QR Auth +// ===================================================================== +const WECOM_BOT_SDK_URL = 'https://wwcdn.weixin.qq.com/node/wework/js/wecom-aibot-sdk@0.1.0.min.js'; +const WECOM_BOT_SOURCE = 'cowagent'; +let _wecomSdkLoaded = false; + +function ensureWecomSdkLoaded() { + return new Promise((resolve, reject) => { + if (_wecomSdkLoaded && window.WecomAIBotSDK) { resolve(); return; } + if (document.querySelector(`script[src="${WECOM_BOT_SDK_URL}"]`)) { + _wecomSdkLoaded = true; resolve(); return; + } + const s = document.createElement('script'); + s.src = WECOM_BOT_SDK_URL; + s.onload = () => { _wecomSdkLoaded = true; resolve(); }; + s.onerror = () => reject(new Error('Failed to load WecomAIBotSDK')); + document.head.appendChild(s); + }); +} + +function _wecomBotHasCreds(ch) { + if (!ch || !ch.fields) return false; + const idField = ch.fields.find(f => f.key === 'wecom_bot_id'); + const secretField = ch.fields.find(f => f.key === 'wecom_bot_secret'); + return !!(idField && idField.value && secretField && secretField.value); +} + +function buildWecomBotPanel(ch) { + const scanLabel = t('wecom_mode_scan'); + const manualLabel = t('wecom_mode_manual'); + const hasCreds = _wecomBotHasCreds(ch); + const defaultMode = hasCreds ? 'manual' : 'scan'; + return ` +
+
+ + +
+
+
`; +} + +function switchWecomBotMode(mode) { + const scanTab = document.getElementById('wecom-tab-scan'); + const manualTab = document.getElementById('wecom-tab-manual'); + const content = document.getElementById('wecom-mode-content'); + const actions = document.getElementById('add-channel-actions'); + if (!scanTab || !manualTab || !content) return; + + const activeClasses = 'bg-white dark:bg-slate-700 text-slate-800 dark:text-slate-100 shadow-sm'; + const inactiveClasses = 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200'; + + if (mode === 'scan') { + scanTab.className = scanTab.className.replace(/text-slate-500[^\s]*/g, '').replace(/hover:\S+/g, ''); + scanTab.className = `flex-1 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${activeClasses}`; + manualTab.className = `flex-1 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${inactiveClasses}`; + actions.classList.add('hidden'); + content.innerHTML = ` +
+

${t('wecom_scan_desc')}

+ +
+
`; + } else { + manualTab.className = `flex-1 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${activeClasses}`; + scanTab.className = `flex-1 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${inactiveClasses}`; + const ch = channelsData.find(c => c.name === 'wecom_bot'); + content.innerHTML = `
${buildChannelFieldsHtml('wecom_bot', ch ? ch.fields || [] : [])}
`; + bindSecretFieldEvents(content); + actions.classList.remove('hidden'); + } +} + +function startWecomBotAuth() { + const statusEl = document.getElementById('wecom-scan-status'); + ensureWecomSdkLoaded().then(() => { + WecomAIBotSDK.openBotInfoAuthWindow({ + source: WECOM_BOT_SOURCE, + onCreated: function(bot) { + if (statusEl) { + statusEl.innerHTML = ` +
+
+ +
+

${t('wecom_scan_success')}

+
`; + } + connectWecomBotAfterAuth(bot.botid, bot.secret); + }, + onError: function(err) { + if (statusEl) { + statusEl.innerHTML = `

${t('wecom_scan_fail')}: ${err.message || err.code || ''}

`; + } + } + }); + }).catch(err => { + if (statusEl) { + statusEl.innerHTML = `

SDK load failed: ${err.message}

`; + } + }); +} + +function connectWecomBotAfterAuth(botId, secret) { + fetch('/api/channels', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'connect', + channel: 'wecom_bot', + config: { wecom_bot_id: botId, wecom_bot_secret: secret } + }) + }) + .then(r => r.json()) + .then(data => { + if (data.status === 'success') { + const ch = channelsData.find(c => c.name === 'wecom_bot'); + if (ch) { + ch.active = true; + (ch.fields || []).forEach(f => { + if (f.key === 'wecom_bot_id') f.value = botId; + if (f.key === 'wecom_bot_secret') f.value = ChannelsHandler_maskSecret(secret); + }); + } + setTimeout(() => renderActiveChannels(), 1500); + } + }) + .catch(() => {}); +} + +function startWecomBotAuthInCard() { + const statusEl = document.getElementById('wecom-card-scan-status'); + ensureWecomSdkLoaded().then(() => { + WecomAIBotSDK.openBotInfoAuthWindow({ + source: WECOM_BOT_SOURCE, + onCreated: function(bot) { + if (statusEl) { + statusEl.innerHTML = ` +
+
+ +
+

${t('wecom_scan_success')}

+
`; + } + connectWecomBotAfterAuth(bot.botid, bot.secret); + }, + onError: function(err) { + if (statusEl) { + statusEl.innerHTML = `

${t('wecom_scan_fail')}: ${err.message || err.code || ''}

`; + } + } + }); + }).catch(err => { + if (statusEl) { + statusEl.innerHTML = `

SDK load failed: ${err.message}

`; + } + }); +} + +// Initialize wecom bot panel with correct default mode when inserted into DOM +document.addEventListener('DOMContentLoaded', function() { + const observer = new MutationObserver(function() { + const panel = document.getElementById('wecom-bot-panel'); + if (panel && !panel.dataset.initialized) { + panel.dataset.initialized = '1'; + switchWecomBotMode(panel.dataset.defaultMode || 'scan'); + } + }); + observer.observe(document.body, { childList: true, subtree: true }); +}); + // ===================================================================== // Scheduler View // ===================================================================== diff --git a/channel/wecom_bot/wecom_bot_channel.py b/channel/wecom_bot/wecom_bot_channel.py index 895119dc..a69e37ea 100644 --- a/channel/wecom_bot/wecom_bot_channel.py +++ b/channel/wecom_bot/wecom_bot_channel.py @@ -330,28 +330,42 @@ class WecomBotChannel(ChatChannel): All intermediate segments (thinking before tool calls) and the final answer are accumulated into a single stream message, separated by '---'. + Throttles push to at most once per 100ms to avoid WebSocket congestion. """ stream_id = uuid.uuid4().hex[:16] self._stream_states[req_id] = { "stream_id": stream_id, - "committed": "", # finalized content from previous segments - "current": "", # current segment being streamed + "committed": "", + "current": "", + "last_push_time": 0, + "last_push_len": 0, } - def _push_stream(state: dict): - """Push current stream content to wecom.""" - self._ws_send({ - "cmd": "aibot_respond_msg", - "headers": {"req_id": req_id}, - "body": { - "msgtype": "stream", - "stream": { - "id": state["stream_id"], - "finish": False, - "content": state["committed"] + state["current"], + def _push_stream(state: dict, force: bool = False): + """Push current stream content to wecom (throttled unless forced).""" + now = time.time() + if not force and now - state["last_push_time"] < 0.1: + return + content = state["committed"] + state["current"] + if len(content) == state["last_push_len"]: + return + state["last_push_time"] = now + state["last_push_len"] = len(content) + try: + self._ws_send({ + "cmd": "aibot_respond_msg", + "headers": {"req_id": req_id}, + "body": { + "msgtype": "stream", + "stream": { + "id": state["stream_id"], + "finish": False, + "content": content, + }, }, - }, - }) + }) + except Exception as e: + logger.warning(f"[WecomBot] Stream push failed: {e}") def on_event(event: dict): event_type = event.get("type") @@ -378,6 +392,7 @@ class WecomBotChannel(ChatChannel): else: state["committed"] += state["current"] state["current"] = "" + _push_stream(state, force=True) return on_event @@ -452,11 +467,16 @@ class WecomBotChannel(ChatChannel): if req_id: state = self._stream_states.pop(req_id, None) if state: - final_content = state["committed"] or content + final_content = state["committed"] if state["committed"] else content stream_id = state["stream_id"] else: final_content = content stream_id = uuid.uuid4().hex[:16] + + # Brief pause so the server finishes processing the last intermediate chunk + # before receiving the finish packet + time.sleep(0.15) + self._ws_send({ "cmd": "aibot_respond_msg", "headers": {"req_id": req_id}, diff --git a/cli/commands/skill.py b/cli/commands/skill.py index 73813b8a..1586bba8 100644 --- a/cli/commands/skill.py +++ b/cli/commands/skill.py @@ -324,7 +324,7 @@ def _install_local(path: str, result: InstallResult): _batch_install_skills(discovered, path, skills_dir, "local", result) -def _register_installed_skill(name: str, source: str = "cowhub"): +def _register_installed_skill(name: str, source: str = "cowhub", display_name: str = ""): """Register a newly installed skill into skills_config.json. source values: builtin, cow, github, clawhub, linkai, local, url @@ -341,18 +341,28 @@ def _register_installed_skill(name: str, source: str = "cowhub"): config = {} if name in config: + if display_name and not config[name].get("display_name"): + config[name]["display_name"] = display_name + try: + with open(config_path, "w", encoding="utf-8") as f: + json.dump(config, f, indent=4, ensure_ascii=False) + except Exception: + pass return skill_dir = os.path.join(skills_dir, name) description = _read_skill_description(skill_dir) or "" - config[name] = { + entry = { "name": name, "description": description, "source": source, "enabled": True, "category": "skill", } + if display_name: + entry["display_name"] = display_name + config[name] = entry try: with open(config_path, "w", encoding="utf-8") as f: @@ -657,7 +667,15 @@ def _list_local(): def _print_skill_table(entries): """Print skills as a formatted table.""" - name_w = max(len(e.get("name", "")) for e in entries) + def _display_label(e): + display = e.get("display_name", "") + name = e.get("name", "") + if display and display != name: + return f"{display} ({name})" + return name + + labels = [_display_label(e) for e in entries] + name_w = max((len(l) for l in labels), default=4) name_w = max(name_w, 4) + 2 desc_w = 40 @@ -666,8 +684,7 @@ def _print_skill_table(entries): click.echo(f" {header}") click.echo(f" {'─' * (name_w + 10 + 10 + desc_w)}") - for e in entries: - name = e.get("name", "") + for e, label in zip(entries, labels): enabled = e.get("enabled", True) source = e.get("source", "") desc = e.get("description", "") or "" @@ -675,7 +692,7 @@ def _print_skill_table(entries): desc = desc[:desc_w - 3] + "..." status_icon = click.style("✓ on ", fg="green") if enabled else click.style("✗ off", fg="red") - click.echo(f" {name:<{name_w}} {status_icon} {source:<10} {desc}") + click.echo(f" {label:<{name_w}} {status_icon} {source:<10} {desc}") click.echo() @@ -937,10 +954,12 @@ def _install_hub(name, result: InstallResult, provider=None): raise SkillInstallError(f"Failed to connect to Skill Hub: {e}") content_type = resp.headers.get("Content-Type", "") + hub_display_name = "" if "application/json" in content_type: data = resp.json() source_type = data.get("source_type") + hub_display_name = data.get("display_name", "") if source_type == "github": source_url = data.get("source_url", "") @@ -956,6 +975,8 @@ def _install_hub(name, result: InstallResult, provider=None): else: _check_github_spec(source_url) _install_github(source_url, result, skill_name=name, timeout=gh_timeout) + if hub_display_name: + _register_installed_skill(name, display_name=hub_display_name) return except Exception as e: gh_err = e @@ -987,9 +1008,11 @@ def _install_hub(name, result: InstallResult, provider=None): installed_before = len(result.installed) _install_zip_bytes(mirror_resp.content, name, skills_dir, result=result, source_label="cowhub") if len(result.installed) == installed_before: - _register_installed_skill(name, source="cowhub") + _register_installed_skill(name, source="cowhub", display_name=hub_display_name) result.installed.append(name) result.messages.append(f"Installed '{name}' from mirror.") + elif hub_display_name: + _register_installed_skill(name, display_name=hub_display_name) return if source_type == "registry": @@ -1022,9 +1045,11 @@ def _install_hub(name, result: InstallResult, provider=None): installed_before = len(result.installed) _install_zip_bytes(dl_resp.content, name, skills_dir, result=result, source_label=src_provider) if len(result.installed) == installed_before: - _register_installed_skill(name, source=src_provider) + _register_installed_skill(name, source=src_provider, display_name=hub_display_name) result.installed.append(name) result.messages.append(f"Installed '{name}' from {src_provider}.") + elif hub_display_name: + _register_installed_skill(name, display_name=hub_display_name) return # Fallback: download mirror from Skill Hub @@ -1050,9 +1075,11 @@ def _install_hub(name, result: InstallResult, provider=None): installed_before = len(result.installed) _install_zip_bytes(mirror_resp.content, name, skills_dir, result=result, source_label="cowhub") if len(result.installed) == installed_before: - _register_installed_skill(name, source="cowhub") + _register_installed_skill(name, source="cowhub", display_name=hub_display_name) result.installed.append(name) result.messages.append(f"Installed '{name}' from mirror.") + elif hub_display_name: + _register_installed_skill(name, display_name=hub_display_name) else: raise SkillInstallError("Unsupported registry provider.") return @@ -1066,6 +1093,8 @@ def _install_hub(name, result: InstallResult, provider=None): else: _check_github_spec(source_url) _install_github(source_url, result, skill_name=name) + if hub_display_name: + _register_installed_skill(name, display_name=hub_display_name) return elif "application/zip" in content_type: @@ -1407,7 +1436,10 @@ def info(name): enabled = entry.get("enabled", True) status_str = click.style("✓ enabled", fg="green") if enabled else click.style("✗ disabled", fg="red") + display_name = entry.get("display_name", "") click.echo(f"\n Skill: {name}") + if display_name and display_name != name: + click.echo(f" Display: {display_name}") click.echo(f" Source: {source}") click.echo(f" Status: {status_str}") click.echo(f" Path: {skill_dir}") diff --git a/plugins/cow_cli/cow_cli.py b/plugins/cow_cli/cow_cli.py index bc114780..5c79f438 100644 --- a/plugins/cow_cli/cow_cli.py +++ b/plugins/cow_cli/cow_cli.py @@ -544,10 +544,13 @@ class CowCliPlugin(Plugin): enabled = entry.get("enabled", True) source = entry.get("source", "") icon = "✅" if enabled else "⏸️" + display = entry.get("display_name", "") or name desc = entry.get("description", "") if len(desc) > 50: desc = desc[:47] + "…" - line = f"{icon} {name}" + line = f"{icon} {display}" + if display != name: + line += f" ({name})" if desc: line += f"\n {desc}" if source: @@ -685,13 +688,16 @@ class CowCliPlugin(Plugin): def _format_install_result(result) -> str: """Format InstallResult into a chat-friendly message.""" from cli.commands.skill import _read_skill_description - from cli.utils import get_skills_dir + from cli.utils import get_skills_dir, load_skills_config skills_dir = get_skills_dir() + config = load_skills_config() lines = [] for skill_name in result.installed: desc = _read_skill_description(os.path.join(skills_dir, skill_name)) - lines.append(f"✅ 技能安装成功:{skill_name}") + display = config.get(skill_name, {}).get("display_name", "") or skill_name + label = f"{display} ({skill_name})" if display != skill_name else skill_name + lines.append(f"✅ 技能安装成功:{label}") if desc: lines.append(f" {desc}")