@@ -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}")