mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-21 14:17:11 +08:00
feat(wecom_bot): add Wecom Bot QR code scan auth
This commit is contained in:
@@ -102,13 +102,17 @@ class SkillManager:
|
|||||||
else:
|
else:
|
||||||
enabled = entry.metadata.default_enabled if entry.metadata else True
|
enabled = entry.metadata.default_enabled if entry.metadata else True
|
||||||
|
|
||||||
merged[name] = {
|
entry_dict = {
|
||||||
"name": name,
|
"name": name,
|
||||||
"description": skill.description,
|
"description": skill.description,
|
||||||
"source": prev.get("source") or skill.source,
|
"source": prev.get("source") or skill.source,
|
||||||
"enabled": enabled,
|
"enabled": enabled,
|
||||||
"category": category,
|
"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.skills_config = merged
|
||||||
self._save_skills_config()
|
self._save_skills_config()
|
||||||
|
|||||||
@@ -244,10 +244,13 @@ class BrowserService:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
if self._alive and self._thread and self._thread.is_alive():
|
if self._alive and self._thread and self._thread.is_alive():
|
||||||
return
|
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
|
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._ready = threading.Event()
|
||||||
self._thread = threading.Thread(target=self._run_loop, daemon=True, name="BrowserThread")
|
self._thread = threading.Thread(target=self._run_loop, daemon=True, name="BrowserThread")
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ const I18N = {
|
|||||||
weixin_scan_scanned: '已扫码,请在手机上确认', weixin_scan_expired: '二维码已过期,正在刷新...',
|
weixin_scan_scanned: '已扫码,请在手机上确认', weixin_scan_expired: '二维码已过期,正在刷新...',
|
||||||
weixin_scan_success: '登录成功,正在启动通道...', weixin_scan_fail: '获取二维码失败',
|
weixin_scan_success: '登录成功,正在启动通道...', weixin_scan_fail: '获取二维码失败',
|
||||||
weixin_qr_tip: '二维码约2分钟后过期',
|
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_title: '定时任务', tasks_desc: '查看和管理定时任务',
|
||||||
tasks_coming: '即将推出', tasks_coming_desc: '定时任务管理功能即将在此提供',
|
tasks_coming: '即将推出', tasks_coming_desc: '定时任务管理功能即将在此提供',
|
||||||
logs_title: '日志', logs_desc: '实时日志输出 (run.log)',
|
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_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_scan_success: 'Login successful, starting channel...', weixin_scan_fail: 'Failed to load QR code',
|
||||||
weixin_qr_tip: 'QR code expires in ~2 minutes',
|
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_title: 'Scheduled Tasks', tasks_desc: 'View and manage scheduled tasks',
|
||||||
tasks_coming: 'Coming Soon', tasks_coming_desc: 'Scheduled task management will be available here',
|
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)',
|
logs_title: 'Logs', logs_desc: 'Real-time log output (run.log)',
|
||||||
@@ -1684,7 +1692,7 @@ function renderSkillCard(card, sk) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center gap-2 mb-1">
|
<div class="flex items-center gap-2 mb-1">
|
||||||
<span class="font-medium text-sm text-slate-700 dark:text-slate-200 truncate flex-1">${escapeHtml(sk.name)}</span>
|
<span class="font-medium text-sm text-slate-700 dark:text-slate-200 truncate flex-1">${escapeHtml(sk.display_name || sk.name)}</span>
|
||||||
<button
|
<button
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked="${enabled}"
|
aria-checked="${enabled}"
|
||||||
@@ -1879,19 +1887,23 @@ function renderActiveChannels() {
|
|||||||
const hasFields = (ch.fields || []).length > 0;
|
const hasFields = (ch.fields || []).length > 0;
|
||||||
|
|
||||||
const weixinWaiting = ch.name === 'weixin' && ch.login_status && ch.login_status !== 'logged_in';
|
const weixinWaiting = ch.name === 'weixin' && ch.login_status && ch.login_status !== 'logged_in';
|
||||||
|
const wecomNeedsCreds = ch.name === 'wecom_bot' && !_wecomBotHasCreds(ch);
|
||||||
let statusDot, statusText;
|
let statusDot, statusText;
|
||||||
if (weixinWaiting) {
|
if (weixinWaiting) {
|
||||||
statusDot = 'bg-amber-400 animate-pulse';
|
statusDot = 'bg-amber-400 animate-pulse';
|
||||||
statusText = ch.login_status === 'scanned'
|
statusText = ch.login_status === 'scanned'
|
||||||
? `<span class="text-xs text-primary-500">${t('weixin_scan_scanned')}</span>`
|
? `<span class="text-xs text-primary-500">${t('weixin_scan_scanned')}</span>`
|
||||||
: `<span class="text-xs text-amber-500">${t('weixin_scan_waiting')}</span>`;
|
: `<span class="text-xs text-amber-500">${t('weixin_scan_waiting')}</span>`;
|
||||||
|
} else if (wecomNeedsCreds) {
|
||||||
|
statusDot = 'bg-amber-400 animate-pulse';
|
||||||
|
statusText = `<span class="text-xs text-amber-500">${t('channels_connecting')}</span>`;
|
||||||
} else {
|
} else {
|
||||||
statusDot = 'bg-primary-400';
|
statusDot = 'bg-primary-400';
|
||||||
statusText = `<span class="text-xs text-primary-500">${t('channels_connected')}</span>`;
|
statusText = `<span class="text-xs text-primary-500">${t('channels_connected')}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="flex items-center gap-4${hasFields || weixinWaiting ? ' mb-5' : ''}">
|
<div class="flex items-center gap-4${hasFields || weixinWaiting || wecomNeedsCreds ? ' mb-5' : ''}">
|
||||||
<div class="w-10 h-10 rounded-xl bg-${ch.color}-50 dark:bg-${ch.color}-900/20 flex items-center justify-center flex-shrink-0">
|
<div class="w-10 h-10 rounded-xl bg-${ch.color}-50 dark:bg-${ch.color}-900/20 flex items-center justify-center flex-shrink-0">
|
||||||
<i class="fas ${ch.icon} text-${ch.color}-500 text-base"></i>
|
<i class="fas ${ch.icon} text-${ch.color}-500 text-base"></i>
|
||||||
</div>
|
</div>
|
||||||
@@ -1918,6 +1930,15 @@ function renderActiveChannels() {
|
|||||||
${t('weixin_scan_title')}
|
${t('weixin_scan_title')}
|
||||||
</button>
|
</button>
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
|
${wecomNeedsCreds ? `<div id="wecom-active-auth" class="flex flex-col items-center py-2">
|
||||||
|
<p class="text-sm text-slate-500 dark:text-slate-400 mb-3">${t('wecom_scan_desc')}</p>
|
||||||
|
<button onclick="startWecomBotAuthInCard()"
|
||||||
|
class="px-5 py-2 rounded-lg bg-emerald-500 hover:bg-emerald-600 text-white text-sm font-medium
|
||||||
|
cursor-pointer transition-colors duration-150">
|
||||||
|
<i class="fas fa-qrcode mr-2"></i>${t('wecom_scan_btn')}
|
||||||
|
</button>
|
||||||
|
<div id="wecom-card-scan-status" class="mt-3"></div>
|
||||||
|
</div>` : ''}
|
||||||
${hasFields ? `<div class="space-y-4">
|
${hasFields ? `<div class="space-y-4">
|
||||||
${fieldsHtml}
|
${fieldsHtml}
|
||||||
<div class="flex items-center justify-end gap-3 pt-1">
|
<div class="flex items-center justify-end gap-3 pt-1">
|
||||||
@@ -2154,6 +2175,13 @@ function onAddChannelSelect(chName) {
|
|||||||
return;
|
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);
|
const ch = channelsData.find(c => c.name === chName);
|
||||||
if (!ch) return;
|
if (!ch) return;
|
||||||
|
|
||||||
@@ -2375,6 +2403,191 @@ function connectWeixinAfterQr() {
|
|||||||
.catch(() => {});
|
.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 `
|
||||||
|
<div id="wecom-bot-panel" data-default-mode="${defaultMode}">
|
||||||
|
<div class="flex items-center justify-center gap-1 mb-5 bg-slate-100 dark:bg-white/5 rounded-lg p-1">
|
||||||
|
<button id="wecom-tab-scan" onclick="switchWecomBotMode('scan')"
|
||||||
|
class="flex-1 px-3 py-1.5 rounded-md text-xs font-medium transition-colors
|
||||||
|
bg-white dark:bg-slate-700 text-slate-800 dark:text-slate-100 shadow-sm">
|
||||||
|
${scanLabel}
|
||||||
|
</button>
|
||||||
|
<button id="wecom-tab-manual" onclick="switchWecomBotMode('manual')"
|
||||||
|
class="flex-1 px-3 py-1.5 rounded-md text-xs font-medium transition-colors
|
||||||
|
text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200">
|
||||||
|
${manualLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="wecom-mode-content"></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<div class="flex flex-col items-center py-4">
|
||||||
|
<p class="text-sm text-slate-600 dark:text-slate-300 mb-2">${t('wecom_scan_desc')}</p>
|
||||||
|
<button onclick="startWecomBotAuth()"
|
||||||
|
class="mt-3 px-6 py-2.5 rounded-lg bg-emerald-500 hover:bg-emerald-600 text-white text-sm font-medium
|
||||||
|
cursor-pointer transition-colors duration-150">
|
||||||
|
<i class="fas fa-qrcode mr-2"></i>${t('wecom_scan_btn')}
|
||||||
|
</button>
|
||||||
|
<div id="wecom-scan-status" class="mt-3"></div>
|
||||||
|
</div>`;
|
||||||
|
} 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 = `<div class="space-y-4">${buildChannelFieldsHtml('wecom_bot', ch ? ch.fields || [] : [])}</div>`;
|
||||||
|
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 = `
|
||||||
|
<div class="flex flex-col items-center py-2">
|
||||||
|
<div class="w-10 h-10 rounded-full bg-emerald-50 dark:bg-emerald-900/30 flex items-center justify-center mb-2">
|
||||||
|
<i class="fas fa-check text-emerald-500 text-lg"></i>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm font-medium text-emerald-600 dark:text-emerald-400">${t('wecom_scan_success')}</p>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
connectWecomBotAfterAuth(bot.botid, bot.secret);
|
||||||
|
},
|
||||||
|
onError: function(err) {
|
||||||
|
if (statusEl) {
|
||||||
|
statusEl.innerHTML = `<p class="text-sm text-red-500">${t('wecom_scan_fail')}: ${err.message || err.code || ''}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}).catch(err => {
|
||||||
|
if (statusEl) {
|
||||||
|
statusEl.innerHTML = `<p class="text-sm text-red-500">SDK load failed: ${err.message}</p>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<div class="flex flex-col items-center py-2">
|
||||||
|
<div class="w-10 h-10 rounded-full bg-emerald-50 dark:bg-emerald-900/30 flex items-center justify-center mb-2">
|
||||||
|
<i class="fas fa-check text-emerald-500 text-lg"></i>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm font-medium text-emerald-600 dark:text-emerald-400">${t('wecom_scan_success')}</p>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
connectWecomBotAfterAuth(bot.botid, bot.secret);
|
||||||
|
},
|
||||||
|
onError: function(err) {
|
||||||
|
if (statusEl) {
|
||||||
|
statusEl.innerHTML = `<p class="text-sm text-red-500">${t('wecom_scan_fail')}: ${err.message || err.code || ''}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}).catch(err => {
|
||||||
|
if (statusEl) {
|
||||||
|
statusEl.innerHTML = `<p class="text-sm text-red-500">SDK load failed: ${err.message}</p>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
// Scheduler View
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
|
|||||||
@@ -330,28 +330,42 @@ class WecomBotChannel(ChatChannel):
|
|||||||
|
|
||||||
All intermediate segments (thinking before tool calls) and the final answer
|
All intermediate segments (thinking before tool calls) and the final answer
|
||||||
are accumulated into a single stream message, separated by '---'.
|
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]
|
stream_id = uuid.uuid4().hex[:16]
|
||||||
self._stream_states[req_id] = {
|
self._stream_states[req_id] = {
|
||||||
"stream_id": stream_id,
|
"stream_id": stream_id,
|
||||||
"committed": "", # finalized content from previous segments
|
"committed": "",
|
||||||
"current": "", # current segment being streamed
|
"current": "",
|
||||||
|
"last_push_time": 0,
|
||||||
|
"last_push_len": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _push_stream(state: dict):
|
def _push_stream(state: dict, force: bool = False):
|
||||||
"""Push current stream content to wecom."""
|
"""Push current stream content to wecom (throttled unless forced)."""
|
||||||
self._ws_send({
|
now = time.time()
|
||||||
"cmd": "aibot_respond_msg",
|
if not force and now - state["last_push_time"] < 0.1:
|
||||||
"headers": {"req_id": req_id},
|
return
|
||||||
"body": {
|
content = state["committed"] + state["current"]
|
||||||
"msgtype": "stream",
|
if len(content) == state["last_push_len"]:
|
||||||
"stream": {
|
return
|
||||||
"id": state["stream_id"],
|
state["last_push_time"] = now
|
||||||
"finish": False,
|
state["last_push_len"] = len(content)
|
||||||
"content": state["committed"] + state["current"],
|
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):
|
def on_event(event: dict):
|
||||||
event_type = event.get("type")
|
event_type = event.get("type")
|
||||||
@@ -378,6 +392,7 @@ class WecomBotChannel(ChatChannel):
|
|||||||
else:
|
else:
|
||||||
state["committed"] += state["current"]
|
state["committed"] += state["current"]
|
||||||
state["current"] = ""
|
state["current"] = ""
|
||||||
|
_push_stream(state, force=True)
|
||||||
|
|
||||||
return on_event
|
return on_event
|
||||||
|
|
||||||
@@ -452,11 +467,16 @@ class WecomBotChannel(ChatChannel):
|
|||||||
if req_id:
|
if req_id:
|
||||||
state = self._stream_states.pop(req_id, None)
|
state = self._stream_states.pop(req_id, None)
|
||||||
if state:
|
if state:
|
||||||
final_content = state["committed"] or content
|
final_content = state["committed"] if state["committed"] else content
|
||||||
stream_id = state["stream_id"]
|
stream_id = state["stream_id"]
|
||||||
else:
|
else:
|
||||||
final_content = content
|
final_content = content
|
||||||
stream_id = uuid.uuid4().hex[:16]
|
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({
|
self._ws_send({
|
||||||
"cmd": "aibot_respond_msg",
|
"cmd": "aibot_respond_msg",
|
||||||
"headers": {"req_id": req_id},
|
"headers": {"req_id": req_id},
|
||||||
|
|||||||
@@ -324,7 +324,7 @@ def _install_local(path: str, result: InstallResult):
|
|||||||
_batch_install_skills(discovered, path, skills_dir, "local", result)
|
_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.
|
"""Register a newly installed skill into skills_config.json.
|
||||||
|
|
||||||
source values: builtin, cow, github, clawhub, linkai, local, url
|
source values: builtin, cow, github, clawhub, linkai, local, url
|
||||||
@@ -341,18 +341,28 @@ def _register_installed_skill(name: str, source: str = "cowhub"):
|
|||||||
config = {}
|
config = {}
|
||||||
|
|
||||||
if name in 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
|
return
|
||||||
|
|
||||||
skill_dir = os.path.join(skills_dir, name)
|
skill_dir = os.path.join(skills_dir, name)
|
||||||
description = _read_skill_description(skill_dir) or ""
|
description = _read_skill_description(skill_dir) or ""
|
||||||
|
|
||||||
config[name] = {
|
entry = {
|
||||||
"name": name,
|
"name": name,
|
||||||
"description": description,
|
"description": description,
|
||||||
"source": source,
|
"source": source,
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"category": "skill",
|
"category": "skill",
|
||||||
}
|
}
|
||||||
|
if display_name:
|
||||||
|
entry["display_name"] = display_name
|
||||||
|
config[name] = entry
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(config_path, "w", encoding="utf-8") as f:
|
with open(config_path, "w", encoding="utf-8") as f:
|
||||||
@@ -657,7 +667,15 @@ def _list_local():
|
|||||||
|
|
||||||
def _print_skill_table(entries):
|
def _print_skill_table(entries):
|
||||||
"""Print skills as a formatted table."""
|
"""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
|
name_w = max(name_w, 4) + 2
|
||||||
desc_w = 40
|
desc_w = 40
|
||||||
|
|
||||||
@@ -666,8 +684,7 @@ def _print_skill_table(entries):
|
|||||||
click.echo(f" {header}")
|
click.echo(f" {header}")
|
||||||
click.echo(f" {'─' * (name_w + 10 + 10 + desc_w)}")
|
click.echo(f" {'─' * (name_w + 10 + 10 + desc_w)}")
|
||||||
|
|
||||||
for e in entries:
|
for e, label in zip(entries, labels):
|
||||||
name = e.get("name", "")
|
|
||||||
enabled = e.get("enabled", True)
|
enabled = e.get("enabled", True)
|
||||||
source = e.get("source", "")
|
source = e.get("source", "")
|
||||||
desc = e.get("description", "") or ""
|
desc = e.get("description", "") or ""
|
||||||
@@ -675,7 +692,7 @@ def _print_skill_table(entries):
|
|||||||
desc = desc[:desc_w - 3] + "..."
|
desc = desc[:desc_w - 3] + "..."
|
||||||
|
|
||||||
status_icon = click.style("✓ on ", fg="green") if enabled else click.style("✗ off", fg="red")
|
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()
|
click.echo()
|
||||||
|
|
||||||
@@ -937,10 +954,12 @@ def _install_hub(name, result: InstallResult, provider=None):
|
|||||||
raise SkillInstallError(f"Failed to connect to Skill Hub: {e}")
|
raise SkillInstallError(f"Failed to connect to Skill Hub: {e}")
|
||||||
|
|
||||||
content_type = resp.headers.get("Content-Type", "")
|
content_type = resp.headers.get("Content-Type", "")
|
||||||
|
hub_display_name = ""
|
||||||
|
|
||||||
if "application/json" in content_type:
|
if "application/json" in content_type:
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
source_type = data.get("source_type")
|
source_type = data.get("source_type")
|
||||||
|
hub_display_name = data.get("display_name", "")
|
||||||
|
|
||||||
if source_type == "github":
|
if source_type == "github":
|
||||||
source_url = data.get("source_url", "")
|
source_url = data.get("source_url", "")
|
||||||
@@ -956,6 +975,8 @@ def _install_hub(name, result: InstallResult, provider=None):
|
|||||||
else:
|
else:
|
||||||
_check_github_spec(source_url)
|
_check_github_spec(source_url)
|
||||||
_install_github(source_url, result, skill_name=name, timeout=gh_timeout)
|
_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
|
return
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
gh_err = e
|
gh_err = e
|
||||||
@@ -987,9 +1008,11 @@ def _install_hub(name, result: InstallResult, provider=None):
|
|||||||
installed_before = len(result.installed)
|
installed_before = len(result.installed)
|
||||||
_install_zip_bytes(mirror_resp.content, name, skills_dir, result=result, source_label="cowhub")
|
_install_zip_bytes(mirror_resp.content, name, skills_dir, result=result, source_label="cowhub")
|
||||||
if len(result.installed) == installed_before:
|
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.installed.append(name)
|
||||||
result.messages.append(f"Installed '{name}' from mirror.")
|
result.messages.append(f"Installed '{name}' from mirror.")
|
||||||
|
elif hub_display_name:
|
||||||
|
_register_installed_skill(name, display_name=hub_display_name)
|
||||||
return
|
return
|
||||||
|
|
||||||
if source_type == "registry":
|
if source_type == "registry":
|
||||||
@@ -1022,9 +1045,11 @@ def _install_hub(name, result: InstallResult, provider=None):
|
|||||||
installed_before = len(result.installed)
|
installed_before = len(result.installed)
|
||||||
_install_zip_bytes(dl_resp.content, name, skills_dir, result=result, source_label=src_provider)
|
_install_zip_bytes(dl_resp.content, name, skills_dir, result=result, source_label=src_provider)
|
||||||
if len(result.installed) == installed_before:
|
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.installed.append(name)
|
||||||
result.messages.append(f"Installed '{name}' from {src_provider}.")
|
result.messages.append(f"Installed '{name}' from {src_provider}.")
|
||||||
|
elif hub_display_name:
|
||||||
|
_register_installed_skill(name, display_name=hub_display_name)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Fallback: download mirror from Skill Hub
|
# Fallback: download mirror from Skill Hub
|
||||||
@@ -1050,9 +1075,11 @@ def _install_hub(name, result: InstallResult, provider=None):
|
|||||||
installed_before = len(result.installed)
|
installed_before = len(result.installed)
|
||||||
_install_zip_bytes(mirror_resp.content, name, skills_dir, result=result, source_label="cowhub")
|
_install_zip_bytes(mirror_resp.content, name, skills_dir, result=result, source_label="cowhub")
|
||||||
if len(result.installed) == installed_before:
|
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.installed.append(name)
|
||||||
result.messages.append(f"Installed '{name}' from mirror.")
|
result.messages.append(f"Installed '{name}' from mirror.")
|
||||||
|
elif hub_display_name:
|
||||||
|
_register_installed_skill(name, display_name=hub_display_name)
|
||||||
else:
|
else:
|
||||||
raise SkillInstallError("Unsupported registry provider.")
|
raise SkillInstallError("Unsupported registry provider.")
|
||||||
return
|
return
|
||||||
@@ -1066,6 +1093,8 @@ def _install_hub(name, result: InstallResult, provider=None):
|
|||||||
else:
|
else:
|
||||||
_check_github_spec(source_url)
|
_check_github_spec(source_url)
|
||||||
_install_github(source_url, result, skill_name=name)
|
_install_github(source_url, result, skill_name=name)
|
||||||
|
if hub_display_name:
|
||||||
|
_register_installed_skill(name, display_name=hub_display_name)
|
||||||
return
|
return
|
||||||
|
|
||||||
elif "application/zip" in content_type:
|
elif "application/zip" in content_type:
|
||||||
@@ -1407,7 +1436,10 @@ def info(name):
|
|||||||
enabled = entry.get("enabled", True)
|
enabled = entry.get("enabled", True)
|
||||||
status_str = click.style("✓ enabled", fg="green") if enabled else click.style("✗ disabled", fg="red")
|
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}")
|
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" Source: {source}")
|
||||||
click.echo(f" Status: {status_str}")
|
click.echo(f" Status: {status_str}")
|
||||||
click.echo(f" Path: {skill_dir}")
|
click.echo(f" Path: {skill_dir}")
|
||||||
|
|||||||
@@ -544,10 +544,13 @@ class CowCliPlugin(Plugin):
|
|||||||
enabled = entry.get("enabled", True)
|
enabled = entry.get("enabled", True)
|
||||||
source = entry.get("source", "")
|
source = entry.get("source", "")
|
||||||
icon = "✅" if enabled else "⏸️"
|
icon = "✅" if enabled else "⏸️"
|
||||||
|
display = entry.get("display_name", "") or name
|
||||||
desc = entry.get("description", "")
|
desc = entry.get("description", "")
|
||||||
if len(desc) > 50:
|
if len(desc) > 50:
|
||||||
desc = desc[:47] + "…"
|
desc = desc[:47] + "…"
|
||||||
line = f"{icon} {name}"
|
line = f"{icon} {display}"
|
||||||
|
if display != name:
|
||||||
|
line += f" ({name})"
|
||||||
if desc:
|
if desc:
|
||||||
line += f"\n {desc}"
|
line += f"\n {desc}"
|
||||||
if source:
|
if source:
|
||||||
@@ -685,13 +688,16 @@ class CowCliPlugin(Plugin):
|
|||||||
def _format_install_result(result) -> str:
|
def _format_install_result(result) -> str:
|
||||||
"""Format InstallResult into a chat-friendly message."""
|
"""Format InstallResult into a chat-friendly message."""
|
||||||
from cli.commands.skill import _read_skill_description
|
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()
|
skills_dir = get_skills_dir()
|
||||||
|
config = load_skills_config()
|
||||||
|
|
||||||
lines = []
|
lines = []
|
||||||
for skill_name in result.installed:
|
for skill_name in result.installed:
|
||||||
desc = _read_skill_description(os.path.join(skills_dir, skill_name))
|
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:
|
if desc:
|
||||||
lines.append(f" {desc}")
|
lines.append(f" {desc}")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user