// GenericAgent 桌面版 —— bridge 适配 + 业务 UI(HTTP 命令 / WS 状态 / i18n)。 // 文案全部走 i18n:静态用 data-i18n / data-i18n-ph / data-i18n-title, // 动态用 t(key)。dev 标注层与发给 agent 的预设 prompt 不进 UI 字典。 'use strict'; /* ═══════════════ 端口/URL 常量 ═══════════════ bridge / conductor 的端口和 origin 都集中在这里。要换端口、要切同源、 要让 bridge 代理 conductor —— 改这一块即可,下面所有 URL 引用全都跟着走。 *_ORIGIN 不带尾巴 path,调用方自己拼 "/sessions" "/ws" 等。 */ const BRIDGE_PORT = 14168; const CONDUCTOR_PORT = 8900; const BRIDGE_ORIGIN = `${location.protocol}//${location.hostname}:${BRIDGE_PORT}`; const BRIDGE_WS_ORIGIN = `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.hostname}:${BRIDGE_PORT}`; const CONDUCTOR_ORIGIN = `${location.protocol}//${location.hostname}:${CONDUCTOR_PORT}`; const CONDUCTOR_WS_ORIGIN = `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.hostname}:${CONDUCTOR_PORT}`; /* ═══════════════ 进程状态 store ═══════════════ */ const _serviceById = {}; const _serviceListeners = new Set(); function _serviceList() { return Object.values(_serviceById).sort((a, b) => String(a.id).localeCompare(String(b.id))); } function _serviceNotify() { const items = _serviceList(); for (const cb of _serviceListeners) { try { cb(items, _serviceById); } catch (e) { console.error('[service-store]', e); } } } const gaServiceStore = { applySnapshot(services) { for (const k of Object.keys(_serviceById)) delete _serviceById[k]; for (const s of services || []) { if (s && s.id) _serviceById[s.id] = s; } _serviceNotify(); }, applyChanged(service) { if (service && service.id) _serviceById[service.id] = service; _serviceNotify(); }, onServices(cb) { _serviceListeners.add(cb); cb(_serviceList(), _serviceById); return () => _serviceListeners.delete(cb); }, list: _serviceList, get: (id) => _serviceById[id], }; let bridgeUiOffline = false; /* ═══════════════ Bridge 适配(HTTP 命令 + WS 状态) ═══════════════ */ (function initGaBridge() { const listeners = new Map(); let ws = null; let cachedBridgeReady = null; let wsRetries = 0; let wsRetryTimer = null; const bridgeBase = BRIDGE_ORIGIN; const wsUrl = `${BRIDGE_WS_ORIGIN}/ws`; function on(channel, cb) { if (typeof cb !== 'function') return () => {}; if (!listeners.has(channel)) listeners.set(channel, new Set()); listeners.get(channel).add(cb); if (channel === 'bridge-ready' && cachedBridgeReady) { try { cb(cachedBridgeReady); } catch (err) { console.error('[ga bridge] replay bridge-ready', err); } } return () => listeners.get(channel)?.delete(cb); } function emit(channel, payload) { if (channel === 'bridge-ready') cachedBridgeReady = payload; const set = listeners.get(channel); if (!set) return; for (const cb of Array.from(set)) { try { cb(payload); } catch (err) { console.error('[ga bridge]', channel, err); } } } function handleServiceWs(msg) { if (msg.type === 'services.snapshot') gaServiceStore.applySnapshot(msg.services); else if (msg.type === 'service.changed') gaServiceStore.applyChanged(msg.service); emit('service-state', msg); } const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); const tauriInvoke = (name, args = {}) => { const invoke = window.__TAURI__?.core?.invoke; if (!invoke) throw new Error('Tauri IPC is not available'); return invoke(name, args); }; async function http(path, options = {}) { const headers = Object.assign({}, options.headers || {}); const init = Object.assign({}, options, { headers }); if (init.body && typeof init.body !== 'string') { headers['Content-Type'] = headers['Content-Type'] || 'application/json'; init.body = JSON.stringify(init.body); } const res = await fetch(`${bridgeBase}${path}`, init); const text = await res.text(); let data = null; try { data = text ? JSON.parse(text) : {}; } catch (_) { data = { raw: text }; } if (!res.ok) { const err = new Error((data && (data.error || data.message)) || `${res.status} ${res.statusText}`); err.status = res.status; err.data = data; throw err; } return data; } async function waitBridgeStatus(timeoutMs = 20000) { const deadline = Date.now() + timeoutMs; let lastErr = null; while (Date.now() < deadline) { try { const status = await http('/status'); wsRetries = 0; connectWs(); return status; } catch (err) { lastErr = err; await sleep(350); } } throw lastErr || new Error('Bridge did not become ready'); } function connectWs() { if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return; if (wsRetryTimer) { clearTimeout(wsRetryTimer); wsRetryTimer = null; } try { ws = new WebSocket(wsUrl); ws.addEventListener('open', () => { wsRetries = 0; emit('bridge-log', 'WS connected'); }); ws.addEventListener('message', (ev) => { let msg; try { msg = JSON.parse(ev.data); } catch (_) { return; } if (msg.type === 'bridge-ready') emit('bridge-ready', msg); else if (msg.type === 'services.snapshot' || msg.type === 'service.changed') handleServiceWs(msg); else if (msg.type === 'session-state') emit('bridge-notification', msg); else if (msg.type === 'bridge-log') emit('bridge-log', msg.payload || msg); else if (msg.type === 'bridge-error') emit('bridge-error', msg.payload || msg); }); ws.addEventListener('close', () => { emit('bridge-closed', { reason: 'ws-closed' }); scheduleWsReconnect(); }); ws.addEventListener('error', () => emit('bridge-error', { type: 'ws-error', message: 'WebSocket error' })); } catch (err) { emit('bridge-error', { type: 'ws-error', message: err.message || String(err) }); scheduleWsReconnect(); } } /* WS 自动重连(指数退避,封顶 30s)。手机浏览器后台会被 OS 掐 WS, 不重连的话回到前台还是死连接。`visibilitychange` 那一段是回前台立刻重连。 */ function scheduleWsReconnect() { if (wsRetryTimer) clearTimeout(wsRetryTimer); if (typeof document !== 'undefined' && document.hidden) return; // 后台等回前台再连 const delay = Math.min(30000, 1000 * Math.pow(2, wsRetries)); wsRetries++; wsRetryTimer = setTimeout(() => { wsRetryTimer = null; connectWs(); }, delay); } if (typeof document !== 'undefined') { document.addEventListener('visibilitychange', () => { if (!document.hidden && (!ws || ws.readyState >= WebSocket.CLOSING)) { wsRetries = 0; connectWs(); } }); } async function rpc(method, params = {}) { switch (method) { case 'app/status': return http('/status'); case 'app/config/get': return http('/config'); case 'app/config/save': return http('/config', { method: 'POST', body: params || {} }); case 'get/model-profiles': return http('/model-profiles'); case 'session/new': return http('/session/new', { method: 'POST', body: params || {} }); case 'session/prompt': { const sid = params.sessionId || params.id || params.bridgeSessionId; if (!sid) throw new Error('session/prompt missing sessionId'); return http(`/session/${encodeURIComponent(sid)}/prompt`, { method: 'POST', body: params || {} }); } case 'session/poll': { const sid = params.sessionId || params.id || params.bridgeSessionId; if (!sid) throw new Error('session/poll missing sessionId'); const after = params.afterId ?? params.after ?? 0; const limit = params.limit ?? 200; return http(`/session/${encodeURIComponent(sid)}/messages?after=${encodeURIComponent(after)}&limit=${encodeURIComponent(limit)}`); } case 'session/cancel': { const sid = params.sessionId || params.id || params.bridgeSessionId; if (!sid) throw new Error('session/cancel missing sessionId'); return http(`/session/${encodeURIComponent(sid)}/cancel`, { method: 'POST', body: params || {} }); } case 'app/path/open': return http('/path/open', { method: 'POST', body: params || {} }); case 'services/start': { const id = params.id; if (!id) throw new Error('services/start missing id'); return http('/services/start', { method: 'POST', body: { id } }); } case 'services/stop': { const id = params.id; if (!id) throw new Error('services/stop missing id'); return http('/services/stop', { method: 'POST', body: { id } }); } case 'services/logs': { const id = params.id; if (!id) throw new Error('services/logs missing id'); const tail = params.tail ?? 200; return http(`/services/logs?id=${encodeURIComponent(id)}&tail=${encodeURIComponent(tail)}`); } case 'services/panel': return http('/services/panel'); case 'services/bridge/exit': return http('/services/bridge/exit', { method: 'POST' }); case 'services/mykey/get': return http('/services/mykey'); case 'services/mykey/save': return http('/services/mykey', { method: 'POST', body: params || {} }); case 'app/path/selectGaRoot': return http('/config'); case 'list_continuable_sessions': return { sessions: [] }; case 'restore_session': throw new Error('restore_session is not implemented in web2 bridge'); default: throw new Error(`Unknown RPC method: ${method}`); } } async function startService(id) { try { const res = await rpc('services/start', { id }); if (res.service) gaServiceStore.applyChanged(res.service); return res; } catch (e) { if (e.data && e.data.service) gaServiceStore.applyChanged(e.data.service); throw e; } } async function stopService(id) { const res = await rpc('services/stop', { id }); if (res.service) gaServiceStore.applyChanged(res.service); return res; } async function spawnBridge() { connectWs(); try { const status = await http('/status'); bridgeUiOffline = false; return status; } catch (_) { await tauriInvoke('start_bridge'); const status = await waitBridgeStatus(); bridgeUiOffline = false; return status; } } async function exitBridge() { const res = await rpc('services/bridge/exit'); cachedBridgeReady = null; if (ws) { try { ws.close(); } catch (_) {} } return res; } window.ga = { platform: navigator.platform.toLowerCase().includes('mac') ? 'darwin' : 'win32', startBridge: async () => { connectWs(); return http('/status'); }, spawnBridge, stopBridge: async () => ({ ok: true }), exitBridge, checkStatus: () => rpc('app/status', {}), getConfig: () => rpc('app/config/get', {}), saveConfig: (cfg) => rpc('app/config/save', cfg || {}), getModelProfiles: () => rpc('get/model-profiles', {}), selectGaRoot: () => rpc('app/path/selectGaRoot', {}), openMykeyTemplate: () => rpc('app/path/open', { kind: 'mykeyTemplate' }), openMykey: () => rpc('app/path/open', { kind: 'mykey' }), startService, stopService, getServiceLogs: (id, tail = 200) => rpc('services/logs', { id, tail }), getServicePanel: () => rpc('services/panel', {}), getMykeyContent: () => rpc('services/mykey/get', {}), saveMykeyContent: (content) => rpc('services/mykey/save', { content }), tauriInvoke, setBridgeUiOffline: (offline) => { bridgeUiOffline = !!offline; }, pollSession: (sessionId, afterId = 0) => rpc('session/poll', { sessionId, afterId }), rpc, onBridgeMessage: (cb) => on('bridge-message', cb), onBridgeNotification: (cb) => on('bridge-notification', cb), onBridgeError: (cb) => on('bridge-error', cb), onBridgeClosed: (cb) => on('bridge-closed', cb), onBridgeReady: (cb) => on('bridge-ready', cb), onBridgeLog: (cb) => on('bridge-log', cb), onServiceState: (cb) => on('service-state', cb), onOpenSearch: (cb) => on('open-search', cb), }; connectWs(); http('/status').then(status => emit('bridge-ready', status)) .catch(err => emit('bridge-error', { type: 'http-error', message: err.message || String(err) })); })(); /* ═══════════════ i18n ═══════════════ */ const I18N = { zh: { 'app.title': 'GenericAgent 桌面版', 'brand.sub': '桌面终端', 'nav.chat': '聊天', 'nav.services': '后台服务', 'nav.channels': '消息通道', 'nav.status': '状态面板', 'nav.collab': '指挥家', 'nav.token': '用量', 'foot.settings': '配置', 'foot.ver': 'GenericAgent · 桌面版', 'chat.startTitle': '开始对话', 'chat.startSub': '直接输入,或点预设功能一键启动', 'preset.butler.t': '指挥家', 'preset.butler.d': '复杂任务自动拆解,只需查看进度和简报', 'preset.plan.t': 'Plan 模式', 'preset.plan.d': '加载 Plan SOP,按探索→规划→执行→验证流程', 'preset.goal.t': 'Goal 模式', 'preset.goal.d': '设定目标,自主完成', 'preset.autonomous.t': '自主行动', 'preset.autonomous.d': '按 SOP 规划/执行任务,产出报告(reflect/autonomous.py 同源)', 'preset.hive.t': 'Hive 协作', 'preset.hive.d': '多 worker 协同攻坚', 'preset.review.t': '深度复核', 'preset.review.d': '挑刺式质量把关', 'preset.findwork.t': '找点事做', 'preset.findwork.d': '分析当前情况,推荐一批让你感兴趣的 TODO', 'preset.mine.t': '我的·周报', 'preset.mine.d': '自定义:抓本周提交并写周报', 'preset.add.t': '自定义', 'preset.add.d': '任意一句话存为功能', 'composer.placeholder': 'GA 能帮你做些什么?', 'search.placeholder': '搜索会话…', 'conv.new': '新对话', 'ctx.pin': '置顶', 'ctx.unpin': '取消置顶', 'ctx.rename': '重命名', 'ctx.del': '删除', 'common.close': '关闭', 'common.more': '更多', 'common.optional': '选填', 'common.save': '保存', 'modal.preset': '预设功能', 'modal.addModel': '添加模型', 'modal.editModel': '编辑模型', 'modal.settings': '配置', 'modal.customPreset': '自定义预设', 'modal.editCustomPreset': '编辑任务', 'customPreset.titlePh': '标题,例如「写周报」', 'customPreset.promptPh': 'Prompt 内容,发送时会作为消息提交', 'customPreset.empty': '标题和 Prompt 不能为空', 'customPreset.removeTitle': '删除', 'customPreset.editTitle': '编辑', 'builtinPreset.restoreBtn': '恢复默认预设', 'set.appearance': '外观', 'set.plainUi': '素色', 'set.fontSize': '聊天字号', 'set.lang': '语言', 'set.model': '模型', 'set.addModel': '添加模型', 'set.features': '功能', 'set.importMykey': '导入已有模型配置(mykey.py)', 'set.exportMykey': '导出当前模型配置', 'set.serviceManager': '后台服务管理', 'shortcut.askConfirm': '是否在桌面创建 GenericAgent 快捷方式?', 'appearance.light': '浅色', 'appearance.dark': '深色', 'set.noModels': '暂无模型,点击下方添加', 'lang.zh': '简体中文', 'lang.en': 'English', 'model.name': '备注', 'model.namePh': '会显示在模型列表', 'model.apikey': 'API Key', 'model.apikeyPh': 'sk-...', 'model.apikeyKeep': '留空则保持原 Key 不变', 'model.apibase': 'API 地址', 'model.apibasePh': 'https://.../v1/messages', 'model.protocol': '协议', 'model.protocolPick': '请选择…', 'model.protocolOai': 'OpenAI 兼容 (chat/completions)', 'model.protocolClaude': 'Anthropic (Claude /v1/messages)', 'model.stream': '响应方式', 'model.streamOn': '流式', 'model.streamOff': '非流式', 'model.model': '模型', 'model.modelPh': 'model 参数名', 'model.modelHint': '须与中转站/官方文档中的 model 字段完全一致', 'model.retries': '重试 (次)', 'model.connTimeout': '连接超时 (s)', 'model.readTimeout': '读取超时 (s)', 'model.save': '保存', 'common.cancel': '取消', 'common.confirm': '确认', 'common.edit': '编辑', 'common.delete': '删除', 'pq.title': '快速接入官方模型', 'pq.sub': '填好 API Key 即可使用', 'pq.toggle': '展开 / 收起', 'pq.deepseekDesc': '官方 API · OpenAI 兼容', 'pq.qwenDesc': '通义千问 · 阿里云百炼', 'guide.step1': '点击下方链接,登录后创建并复制 API Key', 'guide.step2': '把 Key 粘贴到下方「API Key」输入框', 'guide.step3': '点击保存,即可在模型列表中选用', 'guide.prefillTip': '已为你预填 API 地址、协议与模型,可按需修改', 'guide.getKey': '获取 {name} 的 API Key', 'guide.copy': '复制链接', 'guide.copied': '链接已复制', 'err.modelSave': '保存失败', 'err.modelRequired': '请填写模型、API Key 和 API 地址', 'err.modelDelete': '删除失败', 'err.modelDeleteLast': '至少保留一个模型', 'confirm.modelDelete': '确定删除该模型配置?', 'model.aggregation': '渠道组(自动故障转移)', 'model.aggregationShort': '渠道组', 'model.aggregationDesc': '按顺序尝试,失败自动切换到下一个', 'model.emptyMixin': '尚未加入模型', 'model.addToMixin': '加入渠道组', 'model.inMixin': '已在渠道组', 'model.removeFromMixin': '移出渠道组', 'model.alreadyInMixin': '已在渠道组中', 'model.dragReorder': '拖拽调整顺序', 'err.mixinFailed': '操作失败', 'page.services.title': '后台服务', 'page.services.sub': 'IM 消息通道与后台进程,集中查看、启停与日志', 'page.channels.title': '消息通道', 'page.channels.sub': '后台 IM 进程:列表、启停与日志(同 hub.pyw)', 'page.status.title': '状态面板', 'page.status.sub': 'hub.pyw 管理的后台进程/服务,集中查看与启停', 'page.collab.title': '指挥家', 'page.collab.sub': '交代目标,自动拆活与跟进', 'collab.progressTitle': '分工进度', 'collab.progressEmpty': '还没有任务在执行。告诉指挥家你的目标后,这里会显示拆分后的处理进度。', 'collab.placeholder': '请对指挥家描述你想完成的目标', 'collab.guideTitle': '把要完成的事告诉指挥家', 'collab.guideWhen': '适合需要多步处理、要花一些时间才能完成的目标。日常聊天和快问快答,请用左侧「聊天」。', 'collab.guideStep1t': '描述目标', 'collab.guideStep1d': '在聊天框里写下你想做的事,发给指挥家', 'collab.guideStep2t': '自动拆解', 'collab.guideStep2d': '指挥家自动拆解、分配任务,实时监督和调度', 'collab.guideStep3t': '交付摘要', 'collab.guideStep3d': '指挥家根据执行状态,呈上任务简报', 'collab.guideStep4t': '随时调整', 'collab.guideStep4d': '随时补充要求或细节,指挥家都会处理', 'collab.chipProgress': '现在进展如何?', 'collab.chipPause': '先暂停当前任务', 'collab.chipSummary': '总结一下目前的结果', 'collab.showProgressTitle': '查看分工进度', 'collab.statRunning': '进行中', 'collab.statDone': '已完成', 'collab.plusMenu': '更多操作', 'collab.switchMode': '切换模式', 'collab.typing': '指挥家正在处理', 'collab.offline': '无法连接指挥家服务,请确认后端已启动。', 'collab.retry': '重试', 'collab.reconnect': '连接断开,正在重连… 已保留上次任务进度。', 'collab.reconnectIn': '{n} 秒后重试', 'collab.stRunning': '执行中', 'collab.stReported': '已回报', 'collab.stPaused': '已暂停', 'collab.stFailed': '遇到问题', 'collab.stTerminated': '已终止', 'collab.summaryRunning': '正在处理中…', 'collab.summaryWait': '等待回报', 'collab.taskFallback': '任务 {n}', 'collab.timeJust': '刚刚', 'collab.timeSec': '{n} 秒前', 'collab.timeMin': '{n} 分钟前', 'collab.timeHr': '{n} 小时前', 'collab.timeDay': '{n} 天前', 'page.token.title': '用量', 'page.token.sub': '每会话与累计用量及缓存率', 'status.connecting': '正在连接…', 'status.ready': '服务在线', 'status.running': '处理中', 'status.disconnected': '服务离线', 'status.stopped': '已停止', 'status.idle': '待命', 'conv.emptyList': '暂无会话,点「+ 新对话」开始', 'conv.defaultTitle': '新对话', 'err.bridge': '服务未响应', 'err.newSession': '新建会话失败', 'err.poll': '轮询失败', 'err.stop': '停止失败', 'err.interruptTimeout': '等待上一轮停止超时,请稍后再试', 'sys.interruptPrev.hint': '已停止上一轮,正在处理新消息', 'chat.interrupting': '正在停止上一轮…', 'chat.sessionLoading': '正在加载会话…', 'sys.stopRequested': '已请求停止', 'slash.help': '可用命令:\n/new 新会话 /clear 清屏 /stop 停止 /settings 设置', 'slash.unknown': '未知命令', 'upload.hint': '上传文件:选择 / 拖拽 / 粘贴', 'upload.button': '上传文件', 'upload.tooLarge': '文件过大或数量超限', 'upload.empty': '跳过空文件', 'upload.failed': '上传失败', 'err.charLimit': '已达字数上限({n}),发送时将自动截断', 'err.charLimitReached': '已达字数上限({n})', 'err.numMax': '不能超过 {n}', 'file.openFailed': '无法打开文件', 'file.kindGeneric': '文件', 'file.kindDoc': '文档', 'file.kindSheet': '表格', 'file.kindSlide': '幻灯片', 'file.kindCode': '代码', 'file.kindArchive': '压缩包', 'file.kindAudio': '音频', 'file.kindVideo': '视频', 'upload.removeTitle': '移除', 'upload.dropHint': '松开以上传文件', 'lightbox.closeTitle': '关闭', 'fold.thinking': '思考', 'fold.tool': '工具调用', 'fold.toolResult': '工具结果', 'fold.llm': 'LLM Running', 'fold.turn': '第 {n} 轮', 'plan.header': '计划 ({done}/{total})', 'plan.complete': '✓ 计划完成 ({n}/{n})', 'plan.running': '计划执行中', 'plan.completeTitle': '计划完成', 'plan.placeholder': '计划模式已激活', 'plan.waiting': '等待写入 {path} …', 'plan.overflow': '还有 {n} 项', 'plan.current': '当前', 'plan.collapse': '收起', 'plan.expand': '展开', 'plan.details': '详情', 'plan.capsuleRunning': '运行中', 'plan.capsuleComplete': '已完成', 'timing.elapsed': '已运行 {t}', 'model.auto': '自动选择', 'model.menuLabel': '选择模型', 'chip.plan': 'Plan', 'chip.auto': 'Auto', 'ch.wechat': '微信', 'ch.wecom': '企业微信', 'ch.lark': '飞书', 'ch.dingtalk': '钉钉', 'ch.qq': 'QQ', 'ch.telegram': 'Telegram', 'ch.discord': 'Discord', 'ch.loading': '加载中…', 'ch.empty': '未发现 IM 进程脚本', 'ch.logEmpty': '暂无日志', 'err.channelLoad': '加载失败', 'err.channelStart': '启动失败', 'err.channelStop': '停止失败', 'err.mykeyImport': '导入模型配置失败', 'err.mykeyExport': '导出模型配置失败', 'err.channelNotConfigured': '请先在 mykey.py 中配置该平台', 'sys.channelStarted': '已启动', 'sys.channelStopped': '已停止', 'modal.channelLogs': '进程日志', 'modal.mykeyConfig': 'mykey.py 配置', 'sys.configSaved': '配置已保存', 'sys.mykeyImported': '模型配置已导入', 'sys.mykeyExported': '模型配置已导出', 'st.starting': '启动中…', 'st.stopping': '停止中…', 'st.online': '在线', 'st.offline': '离线', 'st.error': '错误', 'st.running': '运行', 'st.abnormal': '异常', 'act.configure': '配置', 'act.logs': '日志', 'act.restart': '重启', 'act.stop': '停止', 'act.start': '启动', 'act.exit': '退出', 'act.copy': '复制', 'act.copied': '已复制', 'act.copyTex': 'TeX', 'act.send': '发送', 'proc.imbotWechat': 'imbot · 微信', 'proc.imbotDing': 'imbot · 钉钉', 'proc.scheduler': '定时任务调度', 'proc.conductor': '指挥家', 'cm.scheduling': '调度中', 'cm.running': '执行中', 'cm.idleSt': '空闲', 'cm.master': '已派 3 子任务', 'cm.w1': '子任务:抓取数据', 'cm.w2': '子任务:复核结果', 'cm.sub': '等待派单', 'tok.total': '累计', 'tok.cost': '缓存率', 'tok.today': '今日', 'tok.tabAll': '聊天', 'tok.tabConductor': '指挥家', 'tok.condTotal': '指挥家累计', 'tok.condCurrent': '指挥家本次', 'tok.condTip': '指挥家消耗不计入聊天累计', 'tok.condOffline': '指挥家服务离线', 'tok.disclaimer': '不同 API 网站的计费价格可能会有差异,请以实际网站为准。', 'tok.colSession': '会话', 'tok.colIn': '输入', 'tok.colOut': '输出', 'tok.colCacheW': '缓存写入', 'tok.colCache': '缓存读取', 'tok.colCost': '成本', 'tok.from': '从', 'tok.to': '到', 'tok.reset': '重置', 'tok.noData': '暂无记录', 'tok.deleted': '此会话已删除', 'tok.pricingUnknown': '⚠ 此模型计费规则尚未明确,按默认估算', 'tok.priceInput': '输入: $', 'tok.priceOutput': '输出: $', 'tok.priceCacheW': '缓存写入: $', 'tok.priceCacheR': '缓存读取: $', 'presetPrompt.goal': '进入 Goal 模式:读 L3 goal mode SOP,自主达成我接下来描述的目标。', 'presetPrompt.plan': '进入 Plan 模式:先读 memory/plan_sop.md,按其中「探索→规划→执行→验证」流程,等我接下来描述要做的任务。', 'presetPrompt.autonomous': '🤖 进入自主行动模式:阅读 memory/autonomous_operation_sop.md,按 SOP 选取或规划任务,独立执行并产出报告。', 'presetPrompt.hive': '启动 Goal Hive 模式:按 hive SOP 拉起多个 worker 协同完成我接下来的目标。', 'presetPrompt.review': '进入监察者模式:对刚才的产出严格挑刺、逐项复核并报告问题。', 'presetPrompt.findwork': '按照自主行动的规划部分,充分分析我的情况,给我生成一批 TODO,务必让我感兴趣。', 'presetPrompt.mine': '抓取本周的 git 提交并写一份周报。', 'ask.banner': 'GA 等你回答', 'ask.replyHint': '在下方输入框回复', 'ask.placeholderOpen': '在此输入你的回答… (Enter 发送)', }, en: { 'app.title': 'GenericAgent Desktop', 'brand.sub': 'Desktop terminal', 'nav.chat': 'Chat', 'nav.services': 'Services', 'nav.channels': 'Channels', 'nav.status': 'Status', 'nav.collab': 'Conductor', 'nav.token': 'Usage', 'foot.settings': 'Settings', 'foot.ver': 'GenericAgent · Desktop', 'chat.startTitle': 'Start a conversation', 'chat.startSub': 'Type a message, or pick a preset', 'preset.butler.t': 'Conductor', 'preset.butler.d': 'Auto-decompose complex tasks; just check progress and briefings', 'preset.plan.t': 'Plan mode', 'preset.plan.d': 'Load Plan SOP — explore→plan→execute→verify', 'preset.goal.t': 'Goal mode', 'preset.goal.d': 'Set a goal, run autonomously', 'preset.autonomous.t': 'Autonomous mode', 'preset.autonomous.d': 'Plan/execute tasks per SOP and produce reports (same as reflect/autonomous.py)', 'preset.hive.t': 'Hive', 'preset.hive.d': 'Multi-worker collaboration', 'preset.review.t': 'Deep review', 'preset.review.d': 'Strict quality check', 'preset.findwork.t': 'Find me work', 'preset.findwork.d': 'Analyze my context and suggest a batch of interesting TODOs', 'preset.mine.t': 'My · Weekly', 'preset.mine.d': 'Custom: weekly report from commits', 'preset.add.t': 'Custom', 'preset.add.d': 'Save any prompt as a function', 'composer.placeholder': 'What can GA do for you?', 'search.placeholder': 'Search chats…', 'conv.new': 'New chat', 'ctx.pin': 'Pin', 'ctx.unpin': 'Unpin', 'ctx.rename': 'Rename', 'ctx.del': 'Delete', 'common.close': 'Close', 'common.more': 'More', 'common.optional': 'Optional', 'common.save': 'Save', 'modal.preset': 'Presets', 'modal.addModel': 'Add model', 'modal.editModel': 'Edit model', 'modal.settings': 'Settings', 'modal.customPreset': 'Custom preset', 'modal.editCustomPreset': 'Edit task', 'customPreset.titlePh': 'Title, e.g. "Weekly report"', 'customPreset.promptPh': 'Prompt body — sent as the message when clicked', 'customPreset.empty': 'Title and Prompt cannot be empty', 'customPreset.removeTitle': 'Delete', 'customPreset.editTitle': 'Edit', 'builtinPreset.restoreBtn': 'Restore defaults', 'set.appearance': 'Appearance', 'set.plainUi': 'Plain', 'set.fontSize': 'Chat font size', 'set.lang': 'Language', 'set.model': 'Model', 'set.addModel': 'Add model', 'set.features': 'Features', 'set.importMykey': 'Import model config (mykey.py)', 'set.exportMykey': 'Export current model config', 'set.serviceManager': 'Service manager', 'shortcut.askConfirm': 'Create a desktop shortcut for GenericAgent?', 'appearance.light': 'Light', 'appearance.dark': 'Dark', 'set.noModels': 'No models yet — add one below', 'lang.zh': '简体中文', 'lang.en': 'English', 'model.name': 'Note', 'model.namePh': 'Shown in the model list', 'model.apikey': 'API Key', 'model.apikeyPh': 'sk-...', 'model.apikeyKeep': 'Leave blank to keep the current key', 'model.apibase': 'API base URL', 'model.apibasePh': 'https://.../v1/messages', 'model.protocol': 'Protocol', 'model.protocolPick': 'Select…', 'model.protocolOai': 'OpenAI-compatible (chat/completions)', 'model.protocolClaude': 'Anthropic (Claude /v1/messages)', 'model.stream': 'Response', 'model.streamOn': 'Stream', 'model.streamOff': 'Non-stream', 'model.model': 'Model', 'model.modelPh': 'model parameter name', 'model.modelHint': 'Must match the model field in your provider docs exactly', 'model.retries': 'Retries (×)', 'model.connTimeout': 'Connect (s)', 'model.readTimeout': 'Read (s)', 'model.save': 'Save', 'common.cancel': 'Cancel', 'common.confirm': 'Confirm', 'common.edit': 'Edit', 'common.delete': 'Delete', 'pq.title': 'Quick connect a model', 'pq.sub': 'Add your API key to get started', 'pq.toggle': 'Expand / collapse', 'pq.deepseekDesc': 'Official API · OpenAI-compatible', 'pq.qwenDesc': 'Tongyi Qwen · Aliyun Bailian', 'guide.step1': 'Open the link, sign in, then create & copy your API key', 'guide.step2': 'Paste the key into the “API Key” field below', 'guide.step3': 'Click Save — then pick it from the model list', 'guide.prefillTip': 'API base, protocol and model are pre-filled — edit if needed', 'guide.getKey': 'Get your {name} API key', 'guide.copy': 'Copy link', 'guide.copied': 'Link copied', 'err.modelSave': 'Save failed', 'err.modelRequired': 'Model, API Key and base URL are required', 'err.modelDelete': 'Delete failed', 'err.modelDeleteLast': 'At least one model is required', 'confirm.modelDelete': 'Delete this model profile?', 'model.aggregation': 'Channel group (auto failover)', 'model.aggregationShort': 'Channel group', 'model.aggregationDesc': 'Tries in order, switches to the next on failure', 'model.emptyMixin': 'No models added yet', 'model.addToMixin': 'Add to channel', 'model.inMixin': 'In channel', 'model.removeFromMixin': 'Remove from channel', 'model.alreadyInMixin': 'Already in the channel', 'model.dragReorder': 'Drag to reorder', 'err.mixinFailed': 'Operation failed', 'page.services.title': 'Services', 'page.services.sub': 'IM channels and background processes — view, start/stop, logs', 'page.channels.title': 'Channels', 'page.channels.sub': 'Background IM processes: list, start/stop, logs (hub.pyw style)', 'page.status.title': 'Status', 'page.status.sub': 'Background processes/services managed by hub.pyw', 'page.collab.title': 'Conductor', 'page.collab.sub': 'Describe a goal — split, delegate, and follow up', 'collab.progressTitle': 'Progress', 'collab.progressEmpty': 'No tasks running yet. After you describe a goal to Conductor, split tasks will appear here.', 'collab.placeholder': 'Describe the goal you want to accomplish', 'collab.guideTitle': 'Tell Conductor what you want done', 'collab.guideWhen': 'Best for multi-step goals that take a while. For everyday chat and quick questions, use Chat in the sidebar.', 'collab.guideStep1t': 'Describe your goal', 'collab.guideStep1d': 'Write what you want done in the chat box and send it to Conductor', 'collab.guideStep2t': 'Auto breakdown', 'collab.guideStep2d': 'Conductor breaks down, assigns, monitors, and coordinates', 'collab.guideStep3t': 'Summary', 'collab.guideStep3d': 'Conductor delivers a briefing based on execution status', 'collab.guideStep4t': 'Adjust anytime', 'collab.guideStep4d': 'Add requirements or details anytime — Conductor handles them', 'collab.chipProgress': 'How is it going?', 'collab.chipPause': 'Pause current tasks', 'collab.chipSummary': 'Summarize progress so far', 'collab.showProgressTitle': 'View task progress', 'collab.statRunning': 'Running', 'collab.statDone': 'Done', 'collab.plusMenu': 'More actions', 'collab.switchMode': 'Switch mode', 'collab.typing': 'Conductor is working', 'collab.offline': 'Cannot reach the service. Make sure the backend is running.', 'collab.retry': 'Retry', 'collab.reconnect': 'Disconnected — reconnecting… Your last progress is kept.', 'collab.reconnectIn': 'Retry in {n}s', 'collab.stRunning': 'Running', 'collab.stReported': 'Reported', 'collab.stPaused': 'Paused', 'collab.stFailed': 'Issue', 'collab.stTerminated': 'Ended', 'collab.summaryRunning': 'Working…', 'collab.summaryWait': 'Awaiting report', 'collab.taskFallback': 'Task {n}', 'collab.timeJust': 'just now', 'collab.timeSec': '{n}s ago', 'collab.timeMin': '{n}m ago', 'collab.timeHr': '{n}h ago', 'collab.timeDay': '{n}d ago', 'page.token.title': 'Usage', 'page.token.sub': 'Per-session and total usage & cache rate', 'status.connecting': 'Connecting…', 'status.ready': 'Service online', 'status.running': 'Working…', 'status.disconnected': 'Service offline', 'status.stopped': 'Stopped', 'status.idle': 'Standby', 'conv.emptyList': 'No chats yet — click “+ New chat”', 'conv.defaultTitle': 'New chat', 'err.bridge': 'Service not responding', 'err.newSession': 'Failed to create session', 'err.poll': 'Polling failed', 'err.stop': 'Stop failed', 'err.interruptTimeout': 'Timed out waiting for the previous reply to stop — try again', 'sys.interruptPrev.hint': 'Previous reply stopped — processing new message', 'chat.interrupting': 'Stopping previous reply…', 'chat.sessionLoading': 'Loading conversation…', 'sys.stopRequested': 'Stop requested', 'slash.help': 'Commands:\n/new new chat /clear clear /stop stop /settings settings', 'slash.unknown': 'Unknown command', 'upload.hint': 'Upload file: pick / drag / paste', 'upload.button': 'Upload file', 'upload.tooLarge': 'File too large or limit reached', 'upload.empty': 'Skipped empty file', 'upload.failed': 'Upload failed', 'err.charLimit': 'Character limit reached ({n}), text will be truncated on send', 'err.charLimitReached': 'Character limit reached ({n})', 'err.numMax': 'Cannot exceed {n}', 'file.openFailed': 'Cannot open file', 'file.kindGeneric': 'File', 'file.kindDoc': 'Document', 'file.kindSheet': 'Spreadsheet', 'file.kindSlide': 'Slides', 'file.kindCode': 'Code', 'file.kindArchive': 'Archive', 'file.kindAudio': 'Audio', 'file.kindVideo': 'Video', 'upload.removeTitle': 'Remove', 'upload.dropHint': 'Drop to upload files', 'lightbox.closeTitle': 'Close', 'fold.thinking': 'Thinking', 'fold.tool': 'Tool call', 'fold.toolResult': 'Tool result', 'fold.llm': 'LLM Running', 'fold.turn': 'Turn {n}', 'plan.header': 'Plan ({done}/{total})', 'plan.complete': '✓ Plan complete ({n}/{n})', 'plan.running': 'Running plan', 'plan.completeTitle': 'Plan complete', 'plan.placeholder': 'Plan mode activated', 'plan.waiting': 'waiting for {path} …', 'plan.overflow': '+{n} more', 'plan.current': 'Now', 'plan.collapse': 'Collapse', 'plan.expand': 'Expand', 'plan.details': 'Details', 'plan.capsuleRunning': 'Running', 'plan.capsuleComplete': 'Done', 'timing.elapsed': 'Elapsed {t}', 'model.auto': 'Auto', 'model.menuLabel': 'Select model', 'chip.plan': 'Plan', 'chip.auto': 'Auto', 'ch.wechat': 'WeChat', 'ch.wecom': 'WeCom', 'ch.lark': 'Lark', 'ch.dingtalk': 'DingTalk', 'ch.qq': 'QQ', 'ch.telegram': 'Telegram', 'ch.discord': 'Discord', 'ch.loading': 'Loading…', 'ch.empty': 'No IM process scripts found', 'ch.logEmpty': 'No log output yet', 'err.channelLoad': 'Failed to load', 'err.channelStart': 'Start failed', 'err.channelStop': 'Stop failed', 'err.mykeyImport': 'Failed to import model config', 'err.mykeyExport': 'Failed to export model config', 'err.channelNotConfigured': 'Configure this platform in mykey.py first', 'sys.channelStarted': 'Started', 'sys.channelStopped': 'Stopped', 'modal.channelLogs': 'Process logs', 'modal.mykeyConfig': 'mykey.py', 'sys.configSaved': 'Configuration saved', 'sys.mykeyImported': 'Model config imported', 'sys.mykeyExported': 'Model config exported', 'st.starting': 'Starting…', 'st.stopping': 'Stopping…', 'st.online': 'Online', 'st.offline': 'Offline', 'st.error': 'Error', 'st.running': 'Running', 'st.abnormal': 'Error', 'act.configure': 'Configure', 'act.logs': 'Logs', 'act.restart': 'Restart', 'act.stop': 'Stop', 'act.start': 'Start', 'act.exit': 'Exit', 'act.copy': 'Copy', 'act.copied': 'Copied', 'act.copyTex': 'TeX', 'act.send': 'Send', 'proc.imbotWechat': 'imbot · WeChat', 'proc.imbotDing': 'imbot · DingTalk', 'proc.scheduler': 'Scheduler', 'proc.conductor': 'Conductor', 'cm.scheduling': 'Scheduling', 'cm.running': 'Running', 'cm.idleSt': 'Idle', 'cm.master': 'Dispatched 3 subtasks', 'cm.w1': 'Subtask: fetch data', 'cm.w2': 'Subtask: review results', 'cm.sub': 'Waiting for tasks', 'tok.total': 'Total', 'tok.cost': 'Cache rate', 'tok.today': 'Today', 'tok.tabAll': 'Chat', 'tok.tabConductor': 'Conductor', 'tok.condTotal': 'Conductor Total', 'tok.condCurrent': 'Conductor Current', 'tok.condTip': 'Conductor usage is not included in chat totals', 'tok.condOffline': 'Service offline', 'tok.disclaimer': 'Pricing may vary by API provider. Please refer to the actual website.', 'tok.colSession': 'Session', 'tok.colIn': 'Input', 'tok.colOut': 'Output', 'tok.colCacheW': 'Cache write', 'tok.colCache': 'Cache read', 'tok.colCost': 'Cost', 'tok.from': 'From', 'tok.to': 'To', 'tok.reset': 'Reset', 'tok.noData': 'No records', 'tok.deleted': 'Session deleted', 'tok.pricingUnknown': '⚠ Pricing not confirmed, using defaults', 'tok.priceInput': 'Input: $', 'tok.priceOutput': 'Output: $', 'tok.priceCacheW': 'Cache write: $', 'tok.priceCacheR': 'Cache read: $', 'presetPrompt.goal': 'Enter Goal mode: read the L3 goal-mode SOP and autonomously achieve the goal I describe next.', 'presetPrompt.plan': 'Enter Plan mode: first read memory/plan_sop.md, follow its explore→plan→execute→verify flow, and wait for the task I describe next.', 'presetPrompt.autonomous': '🤖 Enter autonomous mode: read memory/autonomous_operation_sop.md, follow the SOP to pick or plan a task, execute independently, and produce a report.', 'presetPrompt.hive': 'Start Goal Hive mode: per the hive SOP, spawn multiple workers to collaboratively achieve the goal I describe next.', 'presetPrompt.review': 'Enter reviewer mode: strictly scrutinize the previous output, review item by item and report issues.', 'presetPrompt.findwork': 'Following the autonomous planning section, analyze my situation thoroughly and generate a batch of TODOs that genuinely interest me.', 'presetPrompt.mine': 'Collect this week\'s git commits and write a weekly report.', 'ask.banner': 'GA is waiting for your answer', 'ask.replyHint': 'Reply in the input below', 'ask.placeholderOpen': 'Type your answer here… (Enter to send)', }, }; const LANGS = ['zh', 'en']; const STORE = { lang: 'ga_lang', theme: 'ga_theme', appearance: 'ga_appearance', plain: 'ga_plain', fontSize: 'ga_font_size', llmNo: 'ga_llm_no' }; const APPEARANCE_IDS = ['light', 'dark']; const CHAT_FONT_MIN = 10; const CHAT_FONT_MAX = 20; const CHAT_FONT_DEFAULT = 14; const CHAT_FONT_LEGACY = { sm: 12, md: 14, lg: 16 }; const HLJS_THEME_BASE = 'https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/'; function normalizeChatFontSize(value) { if (typeof value === 'string' && CHAT_FONT_LEGACY[value]) return CHAT_FONT_LEGACY[value]; const n = parseInt(value, 10); if (Number.isFinite(n)) return Math.min(CHAT_FONT_MAX, Math.max(CHAT_FONT_MIN, n)); return CHAT_FONT_DEFAULT; } function bootUiFromDom() { const root = document.documentElement; const out = { lang: 'zh', theme: '1', appearance: 'light', plainUi: false, chatFontSize: CHAT_FONT_DEFAULT }; if (root.lang === 'en') out.lang = 'en'; if (root.dataset.theme) out.theme = root.dataset.theme; if (APPEARANCE_IDS.includes(root.dataset.appearance)) out.appearance = root.dataset.appearance; if (out.appearance === 'light' && root.dataset.plain === '1') out.plainUi = true; if (root.dataset.chatFont) out.chatFontSize = normalizeChatFontSize(root.dataset.chatFont); return out; } let { lang, theme, appearance, plainUi, chatFontSize } = bootUiFromDom(); function syncHljsTheme() { const link = document.getElementById('hljs-theme'); if (link) link.href = HLJS_THEME_BASE + (appearance === 'dark' ? 'github-dark.min.css' : 'github.min.css'); document.querySelectorAll('.bubble.md pre code').forEach(block => { if (typeof hljs !== 'undefined') hljs.highlightElement(block); }); } /** 服务端 ui 落盘后的本地镜像,仅供 index.html 内联脚本首帧防闪;不是真相源。 */ function syncBootCache() { localStorage.setItem(STORE.lang, lang); localStorage.setItem(STORE.theme, theme); localStorage.setItem(STORE.appearance, appearance); localStorage.setItem(STORE.fontSize, String(chatFontSize)); if (plainUi) localStorage.setItem(STORE.plain, '1'); else localStorage.removeItem(STORE.plain); localStorage.setItem(STORE.llmNo, String(state.llmNo)); } async function persistUiPrefs() { try { await window.ga.saveConfig({ config: { lang, theme, appearance, plain: plainUi, llmNo: state.llmNo, fontSize: chatFontSize }, }); syncBootCache(); } catch (_) {} } const bridgeHost = () => BRIDGE_ORIGIN; async function bridgeFetch(path, opts = {}) { const headers = { ...(opts.headers || {}) }; const init = { ...opts, headers }; if (init.body && typeof init.body !== 'string') { headers['Content-Type'] = 'application/json'; init.body = JSON.stringify(init.body); } const res = await fetch(`${bridgeHost()}${path}`, init); let data = {}; try { data = await res.json(); } catch (_) {} if (!res.ok) throw new Error(data.error || data.message || res.statusText); return data; } function t(key) { return (I18N[lang] && I18N[lang][key]) || (I18N.zh[key]) || key; } window.gaT = t; document.addEventListener('collab:running-count', e => { const b = document.getElementById('collab-badge'); if (!b) return; const n = e.detail?.count || 0; b.hidden = !n; b.textContent = n ? (n > 9 ? '9+' : String(n)) : ''; }); function optionalPh(key) { const sep = (lang === 'en') ? ', ' : ','; return `${t('common.optional')}${sep}${t(key)}`; } function applyI18n() { document.documentElement.lang = (lang === 'en') ? 'en' : 'zh-CN'; document.title = t('app.title'); document.querySelectorAll('[data-i18n]').forEach(el => { el.textContent = t(el.dataset.i18n); }); document.querySelectorAll('[data-i18n-ph]').forEach(el => { const phKey = el.dataset.i18nPh; const val = el.hasAttribute('data-optional-ph') ? optionalPh(phKey) : t(phKey); if (el.isContentEditable) el.setAttribute('data-ph', val); // contenteditable 用 :empty::before 显示 else el.setAttribute('placeholder', val); }); document.querySelectorAll('[data-i18n-title]').forEach(el => { el.setAttribute('title', t(el.dataset.i18nTitle)); }); renderLangList(); // 语言切换后重算激活模型 chip 文案;若当前会话已有渠道组运行态模型,保留运行态而非退回首选项 const _ap = (state.modelProfiles || []).find(p => (p.id ?? 0) === state.llmNo); if (_ap) state.modelName = modelDisplayName(_ap); if (_ap?.kind === 'mixin' && state.liveModel?.sessionId === state.activeId) applyLiveModel(state.liveModel); else if (typeof updateModelChip === 'function') updateModelChip(); window.gaRefreshModelGuide?.(); window.collabRetranslate?.(); syncAskUserUi(); } // 语言对应国旗 SVG(en 用美国旗,按要求) const FLAGS = { zh: '', en: '', }; function renderLangList() { const box = document.getElementById('lang-list'); if (!box) return; box.innerHTML = ''; LANGS.forEach(code => { const row = document.createElement('label'); row.className = 'model-row' + (lang === code ? ' sel' : ''); row.innerHTML = `${FLAGS[code] || ''}${escapeHtml(t('lang.' + code))}`; row.addEventListener('click', (e) => { e.preventDefault(); selectLang(code); }); box.appendChild(row); }); } function selectLang(code) { if (!LANGS.includes(code) || lang === code) return; lang = code; applyI18n(); renderSessionList(); refreshStatusLabel(); updateModelChip(); renderSettingsModels(); if (typeof renderAllPresets === 'function') renderAllPresets(); if (isServicesPageActive()) refreshServicesPanel(); void persistUiPrefs(); } function syncChatFontSegments(value) { document.querySelectorAll('.chat-font-seg').forEach(el => { const v = parseInt(el.dataset.value, 10); el.classList.toggle('on', v <= value); el.classList.toggle('cur', v === value); }); const stepper = document.getElementById('chat-font-stepper'); if (stepper) { stepper.setAttribute('aria-valuenow', String(value)); stepper.setAttribute('aria-valuetext', `${value}px`); } } function chatFontFromPointer(clientX) { const segs = document.getElementById('chat-font-segments'); if (!segs) return chatFontSize; const rect = segs.getBoundingClientRect(); const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width)); return CHAT_FONT_MIN + Math.round(ratio * (CHAT_FONT_MAX - CHAT_FONT_MIN)); } function initChatFontStepper() { const segs = document.getElementById('chat-font-segments'); if (!segs || segs.childElementCount) return; for (let i = CHAT_FONT_MIN; i <= CHAT_FONT_MAX; i++) { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'chat-font-seg'; btn.dataset.value = String(i); btn.tabIndex = -1; btn.addEventListener('click', (e) => { e.stopPropagation(); applyChatFontSize(i); }); segs.appendChild(btn); } const stepper = document.getElementById('chat-font-stepper'); if (!stepper || stepper.dataset.bound) return; stepper.dataset.bound = '1'; let dragging = false; const pick = (clientX, persist) => applyChatFontSize(chatFontFromPointer(clientX), { persist }); stepper.addEventListener('pointerdown', (e) => { if (e.button !== 0) return; dragging = true; stepper.setPointerCapture(e.pointerId); pick(e.clientX, false); }); stepper.addEventListener('pointermove', (e) => { if (!dragging) return; pick(e.clientX, false); }); const endDrag = (e, persist) => { if (!dragging) return; dragging = false; try { stepper.releasePointerCapture(e.pointerId); } catch (_) {} pick(e.clientX, persist); }; stepper.addEventListener('pointerup', (e) => endDrag(e, true)); stepper.addEventListener('pointercancel', (e) => endDrag(e, false)); stepper.addEventListener('keydown', (e) => { if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { e.preventDefault(); applyChatFontSize(chatFontSize - 1); } else if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { e.preventDefault(); applyChatFontSize(chatFontSize + 1); } else if (e.key === 'Home') { e.preventDefault(); applyChatFontSize(CHAT_FONT_MIN); } else if (e.key === 'End') { e.preventDefault(); applyChatFontSize(CHAT_FONT_MAX); } }); } function applyChatFontSize(size, { persist } = { persist: true }) { chatFontSize = normalizeChatFontSize(size); document.documentElement.dataset.chatFont = String(chatFontSize); document.documentElement.style.setProperty('--chat-font', `${chatFontSize}px`); const label = document.getElementById('chat-font-value'); if (label) label.textContent = `${chatFontSize}px`; syncChatFontSegments(chatFontSize); if (persist) void persistUiPrefs(); } function applyTheme(id, { persist } = { persist: true }) { // 主题选色已下线,只保留灰色亮色主题(--accent 在 styles.css 里硬编码)。 // 函数保留可调用,只把 dataset.theme 固定到 '1' 兼容旧 localStorage。 theme = '1'; document.documentElement.dataset.theme = '1'; if (persist) void persistUiPrefs(); } function syncPlainSwitch() { const row = document.getElementById('plain-ui-row'); const sw = document.getElementById('plain-ui-switch'); if (!row || !sw) return; const show = appearance === 'light'; row.hidden = !show; sw.setAttribute('aria-checked', plainUi ? 'true' : 'false'); } function applyAppearance(nextApp, nextPlain, { persist } = { persist: true }) { appearance = APPEARANCE_IDS.includes(nextApp) ? nextApp : 'light'; if (appearance === 'light') plainUi = !!nextPlain; else plainUi = false; document.documentElement.dataset.appearance = appearance; if (plainUi) document.documentElement.dataset.plain = '1'; else delete document.documentElement.dataset.plain; document.querySelectorAll('#appearance-seg .appear-card').forEach(el => { const on = el.dataset.appearance === appearance; el.classList.toggle('sel', on); el.setAttribute('aria-checked', on ? 'true' : 'false'); }); syncPlainSwitch(); syncHljsTheme(); if (persist) void persistUiPrefs(); } /* ═══════════════ 侧边栏导航 ═══════════════ */ const nav = document.getElementById('nav'); const pages = document.querySelectorAll('#pages .page'); let currentPage = 'chat'; function gaGoPage(key) { const item = nav?.querySelector(`.nav-item[data-page="${key}"]`); if (!item) return; currentPage = key; nav.querySelectorAll('.nav-item').forEach(n => n.classList.toggle('active', n === item)); pages.forEach(p => p.classList.toggle('active', p.dataset.page === key)); renderSessionList(); window.gaSetActiveFileComposer?.(key === 'collab' ? 'collab' : 'chat'); if (key === 'collab') window.collabInit?.(); } window.gaGoPage = gaGoPage; nav.addEventListener('click', (e) => { const item = e.target.closest('.nav-item'); if (!item) return; gaGoPage(item.dataset.page); }); /* ═══════════════ 弹窗开关 ═══════════════ */ const openModal = (id) => { const m = document.getElementById(id); if (m) m.hidden = false; }; window.gaOpenModal = openModal; const closeModals = () => document.querySelectorAll('.modal').forEach(m => { m.hidden = true; m.querySelectorAll('.field-limit-hint').forEach(h => h.style.display = 'none'); }); const bindClick = (id, fn) => { const el = document.getElementById(id); if (el) el.addEventListener('click', fn); }; function openServiceManagerFromSettings() { closeModals(); gaGoPage('services'); setSvcTab('status'); void loadStatusPanel(); } bindClick('add-model-btn', (e) => { e.stopPropagation(); openAddModelForm(); }); bindClick('settings-btn', (e) => { e.stopPropagation(); openSettings(); }); bindClick('settings-services-btn', (e) => { e.stopPropagation(); openServiceManagerFromSettings(); }); const importMykeyInput = document.getElementById('import-mykey-input'); async function importMykeyFromFile(file) { if (!file) return; const text = await file.text(); if (!text.trim()) throw new Error(t('err.mykeyImport')); await window.ga.saveMykeyContent(text); await loadModelProfiles(); } bindClick('import-mykey-btn', (e) => { e.stopPropagation(); if (importMykeyInput) importMykeyInput.click(); }); if (importMykeyInput) { importMykeyInput.addEventListener('change', async () => { const file = importMykeyInput.files && importMykeyInput.files[0]; importMykeyInput.value = ''; if (!file) return; try { await importMykeyFromFile(file); showChanToast(t('sys.mykeyImported'), '', 'ok'); } catch (err) { showChanToast(t('err.mykeyImport'), err.message || String(err), 'err'); } }); } async function exportMykeyToDir() { const res = await window.ga.getMykeyContent(); const content = (res && res.content) ? String(res.content) : ''; if (!content.trim()) throw new Error(t('err.mykeyExport')); // WebView2:独立缓存 + 无目录选择/下载;走 Tauri 原生另存为 if (window.__TAURI__?.core?.invoke) { const path = await window.ga.tauriInvoke('export_mykey', { content }); if (!path) return; showChanToast(t('sys.mykeyExported'), path, 'ok'); return; } if (typeof window.showDirectoryPicker === 'function') { const dir = await window.showDirectoryPicker(); const handle = await dir.getFileHandle('mykey.py', { create: true }); const writable = await handle.createWritable(); await writable.write(content); await writable.close(); showChanToast(t('sys.mykeyExported'), '', 'ok'); return; } const blob = new Blob([content], { type: 'text/plain;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'mykey.py'; a.rel = 'noopener'; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); showChanToast(t('sys.mykeyExported'), '', 'ok'); } bindClick('export-mykey-btn', async (e) => { e.stopPropagation(); try { await exportMykeyToDir(); } catch (err) { if (err && (err.name === 'AbortError' || err.code === 20)) return; showChanToast(t('err.mykeyExport'), err.message || String(err), 'err'); } }); // 侧边栏「快速接入」:点击官方模型按钮 → 打开预填好的添加模型表单 const pqEl = document.getElementById('provider-quickstart'); if (pqEl) pqEl.addEventListener('click', (e) => { const btn = e.target.closest('.pq-btn[data-provider]'); if (!btn) return; e.preventDefault(); e.stopPropagation(); openAddModelFormForProvider(btn.dataset.provider); }); // 「快速接入」卡片折叠/展开(向下箭头),状态记忆到 localStorage const pqToggle = document.getElementById('pq-toggle'); if (pqEl && pqToggle) { const applyPq = (collapsed) => { pqEl.classList.toggle('collapsed', collapsed); pqToggle.setAttribute('aria-expanded', collapsed ? 'false' : 'true'); }; let pqCollapsed = false; try { pqCollapsed = localStorage.getItem('ga_pq_collapsed') === '1'; } catch (_) {} applyPq(pqCollapsed); const togglePq = (e) => { if (e) e.stopPropagation(); pqCollapsed = !pqEl.classList.contains('collapsed'); applyPq(pqCollapsed); try { localStorage.setItem('ga_pq_collapsed', pqCollapsed ? '1' : '0'); } catch (_) {} }; pqToggle.addEventListener('click', togglePq); pqToggle.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); togglePq(); } }); } // 接入指引:复制获取 API Key 的链接 bindClick('model-guide-copy', (e) => { e.preventDefault(); e.stopPropagation(); const link = document.getElementById('model-guide-link'); const url = link ? link.href : ''; if (!url || !navigator.clipboard) return; navigator.clipboard.writeText(url).then(() => showChanToast(t('guide.copied'), '', 'ok')).catch(() => {}); }); bindClick('preset-btn', (e) => { e.stopPropagation(); openModal('preset-modal'); }); document.querySelectorAll('.modal').forEach(m => m.addEventListener('click', (e) => { if (e.target.closest('[data-close]')) { m.hidden = true; m.querySelectorAll('.field-limit-hint').forEach(h => h.style.display = 'none'); } })); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeModals(); }); function showConfirmDialog({ title, message, okText, okKind = 'primary', cancelText } = {}) { const modal = document.getElementById('confirm-modal'); if (!modal) return Promise.resolve(false); const titleEl = document.getElementById('confirm-title'); const msgEl = document.getElementById('confirm-message'); const okBtn = document.getElementById('confirm-ok'); const cancelBtn = document.getElementById('confirm-cancel'); if (titleEl) titleEl.textContent = title || t('common.confirm'); if (msgEl) msgEl.textContent = message || ''; if (cancelBtn) cancelBtn.textContent = cancelText || t('common.cancel'); if (okBtn) { okBtn.textContent = okText || t('common.confirm'); okBtn.classList.toggle('danger', okKind === 'danger'); okBtn.classList.toggle('primary', okKind !== 'danger'); } modal.hidden = false; return new Promise(resolve => { let done = false; const finish = (yes) => { if (done) return; done = true; modal.hidden = true; cleanup(); resolve(yes); }; const onOk = (e) => { e.preventDefault(); e.stopPropagation(); finish(true); }; const onCancel = (e) => { e.preventDefault(); e.stopPropagation(); finish(false); }; const onClose = (e) => { if (e.target.closest('[data-close]')) finish(false); }; const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); finish(false); } }; const cleanup = () => { okBtn?.removeEventListener('click', onOk); cancelBtn?.removeEventListener('click', onCancel); modal.removeEventListener('click', onClose, true); document.removeEventListener('keydown', onKey, true); }; okBtn?.addEventListener('click', onOk); cancelBtn?.addEventListener('click', onCancel); modal.addEventListener('click', onClose, true); document.addEventListener('keydown', onKey, true); okBtn?.focus(); }); } /* ═══════════════ Markdown ═══════════════ */ if (typeof marked !== 'undefined') { marked.setOptions({ gfm: true, breaks: true, mangle: false, headerIds: false }); } const ALLOWED_URI_RE = /^(https?:|mailto:|tel:|#|\/)/i; function escapeHtml(s) { const d = document.createElement('div'); d.textContent = String(s == null ? '' : s); return d.innerHTML; } /** GA list_llms 形如 SessionClass/备注;桌面 UI 只展示 / 后一段 */ function profileLabel(name) { const s = String(name || ''); const i = s.indexOf('/'); return (i >= 0 ? s.slice(i + 1) : s).trim(); } function normalizeProfiles(list) { return (list || []).map(p => ({ ...p, name: profileLabel(p.name) || p.name })); } function sanitizeMarkdown(html) { const tpl = document.createElement('template'); tpl.innerHTML = String(html); const blocked = new Set(['SCRIPT','STYLE','IFRAME','OBJECT','EMBED','LINK','META','BASE','FORM','INPUT','BUTTON']); const walker = document.createTreeWalker(tpl.content, NodeFilter.SHOW_ELEMENT); const rmv = []; while (walker.nextNode()) { const el = walker.currentNode; if (blocked.has(el.tagName)) { rmv.push(el); continue; } for (const attr of Array.from(el.attributes)) { const n = attr.name.toLowerCase(), v = attr.value.trim(); if (n.startsWith('on') || n === 'srcdoc') { el.removeAttribute(attr.name); continue; } if ((n === 'href' || n === 'src' || n === 'xlink:href') && v && !ALLOWED_URI_RE.test(v)) el.removeAttribute(attr.name); } if (el.tagName === 'A') { el.setAttribute('rel','noopener noreferrer'); el.setAttribute('target','_blank'); } } rmv.forEach(el => el.remove()); return tpl.innerHTML; } /* ═══════════════ LaTeX 保护 (PR移植) ═══════════════ */ const _latexSlots = []; function protectLatex(text) { _latexSlots.length = 0; // 先保护代码围栏和行内代码,避免其中的 $ \( \[ 被误匹配 const _codeSlots = []; // 代码围栏 ```...``` text = text.replace(/```[\s\S]*?```/g, (m) => { const id = _codeSlots.length; _codeSlots.push(m); return `\x00CODE:${id}\x00`; }); // 行内代码 `...` text = text.replace(/`[^`\n]+`/g, (m) => { const id = _codeSlots.length; _codeSlots.push(m); return `\x00CODE:${id}\x00`; }); // 块级 \[...\] text = text.replace(/\\\[([\s\S]+?)\\\]/g, (_, expr) => { const id = _latexSlots.length; _latexSlots.push({ expr: expr.trim(), display: true }); return ``; }); // 块级 $$...$$ text = text.replace(/\$\$([\s\S]+?)\$\$/g, (_, expr) => { const id = _latexSlots.length; _latexSlots.push({ expr: expr.trim(), display: true }); return ``; }); // 行内 \(...\) text = text.replace(/\\\(([\s\S]+?)\\\)/g, (_, expr) => { const id = _latexSlots.length; _latexSlots.push({ expr: expr.trim(), display: false }); return ``; }); // 行内 $...$(不贪婪,排除 $$ 和转义) text = text.replace(/(? { const id = _latexSlots.length; _latexSlots.push({ expr: expr.trim(), display: false }); return ``; }); // 恢复代码占位符 text = text.replace(/\x00CODE:(\d+)\x00/g, (_, i) => _codeSlots[Number(i)]); return text; } function restoreLatex(html) { if (!_latexSlots.length) return html; return html.replace(//g, (_, i) => { const slot = _latexSlots[Number(i)]; if (!slot) return ''; if (typeof katex === 'undefined') { return slot.display ? `
${escapeHtml(slot.expr)}
` : `${escapeHtml(slot.expr)}`; } try { const rendered = katex.renderToString(slot.expr, { displayMode: slot.display, throwOnError: false }); return slot.display ? `
${rendered}
` : `${rendered}`; } catch (_) { return escapeHtml(slot.expr); } }); } function renderMarkdown(text) { if (typeof marked === 'undefined') return escapeHtml(text).replace(/\n/g, '
'); try { const protected_ = protectLatex(String(text || '')); let html = sanitizeMarkdown(marked.parse(protected_)); html = restoreLatex(html); // TUI 风格代码块:包装 pre>code 为 .code-block 容器 + 语言头 html = html.replace(/
]*>([\s\S]*?)<\/code><\/pre>/g,
      (_, lang, body) => {
        const label = lang || 'code';
        return `
${escapeHtml(label)}
${body}
`; }); return html; } catch (_) { return escapeHtml(text); } } /** * Agent 流协议(与 agent_loop.py / continue_cmd 一致)按行解析: * - 工具调用:🛠️ 行 + 开围栏行 `` `{n}text `` + 正文 + 闭围栏行(仅 `{n},取区间内最后一行) * - 工具结果:开围栏行 `` `{n} ``(n≥5)+ 正文 + 同长度闭围栏行 */ function parseAgentFenceLine(line) { const m = /^[ \t]*(`{3,})([^\n`]*)[ \t]*$/.exec(line ?? ''); if (!m) return null; return { ticks: m[1].length, tag: m[2] }; } function isAgentStructureBoundaryLine(line, opts) { if (/^🛠️ Tool:/.test(line)) return true; // 工具「结果」区内:5 反引号是开/闭围栏,不能当边界(否则闭围栏会被当成下一结构 → 拆出多个空「工具结果」) if (!opts || !opts.forToolResult) { const f = parseAgentFenceLine(line); if (f && f.ticks >= 5 && f.tag === '') return true; } if (/^\*\*LLM Running \(Turn \d+\)/.test(line)) return true; if (/^/i.test(line)) return true; return false; } function indexOfNextAgentStructureLine(lines, from, opts) { for (let i = from; i < lines.length; i++) { if (isAgentStructureBoundaryLine(lines[i], opts)) return i; } return lines.length; } function lastFenceCloseLineIndex(lines, from, toExclusive, tickCount) { let last = -1; for (let i = from; i < toExclusive; i++) { const f = parseAgentFenceLine(lines[i]); if (f && f.ticks === tickCount && f.tag === '') last = i; } return last; } function parseToolCallBlock(lines, i) { const m = /^🛠️ Tool: `([^`]+)`/.exec(lines[i] || ''); if (!m) return null; const open = parseAgentFenceLine(lines[i + 1]); if (!open || open.tag !== 'text') return null; const bodyStart = i + 2; const zoneEnd = indexOfNextAgentStructureLine(lines, bodyStart); const closeIdx = lastFenceCloseLineIndex(lines, bodyStart, zoneEnd, open.ticks); if (closeIdx < 0) return null; return { name: m[1], body: lines.slice(bodyStart, closeIdx).join('\n'), nextLine: closeIdx + 1, }; } function parseToolResultBlock(lines, i) { const open = parseAgentFenceLine(lines[i]); if (!open || open.ticks < 5 || open.tag !== '') return null; const bodyStart = i + 1; const zoneEnd = indexOfNextToolResultZoneEnd(lines, bodyStart); const closeIdx = lastFenceCloseLineIndex(lines, bodyStart, zoneEnd, open.ticks); if (closeIdx < 0) return null; return { body: lines.slice(bodyStart, closeIdx).join('\n'), nextLine: closeIdx + 1, }; } /** 工具结果区 zone:不把 5 反引号围栏行当边界(见 isAgentStructureBoundaryLine) */ function indexOfNextToolResultZoneEnd(lines, from) { return indexOfNextAgentStructureLine(lines, from, { forToolResult: true }); } /** 流式未闭合工具调用(对齐 TUI _safe_pos:末尾 in-flight 🛠️ 块) */ function parseInFlightToolCall(lines, i) { if (parseToolCallBlock(lines, i)) return null; const m = /^🛠️ Tool: `([^`]+)`/.exec(lines[i] || ''); if (!m) return null; const open = parseAgentFenceLine(lines[i + 1]); let bodyStart; let zoneEnd; if (open && open.tag === 'text') { bodyStart = i + 2; zoneEnd = indexOfNextAgentStructureLine(lines, bodyStart); if (lastFenceCloseLineIndex(lines, bodyStart, zoneEnd, open.ticks) >= 0) return null; } else { bodyStart = i + 1; zoneEnd = lines.length; for (let j = i + 1; j < lines.length; j++) { if (isAgentStructureBoundaryLine(lines[j])) { zoneEnd = j; break; } } } return { name: m[1], body: lines.slice(bodyStart, zoneEnd).join('\n'), nextLine: zoneEnd, inFlight: true, }; } /** 流式未闭合工具结果(5 反引号围栏未到) */ function parseInFlightToolResult(lines, i) { if (parseToolResultBlock(lines, i)) return null; const open = parseAgentFenceLine(lines[i]); if (!open || open.ticks < 5 || open.tag !== '') return null; const bodyStart = i + 1; const zoneEnd = indexOfNextToolResultZoneEnd(lines, bodyStart); if (lastFenceCloseLineIndex(lines, bodyStart, zoneEnd, open.ticks) >= 0) return null; return { body: lines.slice(bodyStart, zoneEnd).join('\n'), nextLine: zoneEnd, inFlight: true, }; } /** 将 agent 协议块替换为占位符,其余行原样保留给 Markdown */ function foldAgentProtocolBlocks(body, { onTool, onResult }) { const lines = String(body || '').split('\n'); const out = []; let proseFrom = 0; let i = 0; const flushProse = (until) => { if (until <= proseFrom) return; out.push(lines.slice(proseFrom, until).join('\n')); proseFrom = until; }; while (i < lines.length) { const tool = parseToolCallBlock(lines, i); if (tool) { flushProse(i); out.push(onTool(tool.name, tool.body)); i = tool.nextLine; proseFrom = i; continue; } const result = parseToolResultBlock(lines, i); if (result) { flushProse(i); out.push(onResult(result.body)); i = result.nextLine; proseFrom = i; continue; } const liveTool = parseInFlightToolCall(lines, i); if (liveTool) { flushProse(i); out.push(onTool(liveTool.name, liveTool.body, { inFlight: true })); i = liveTool.nextLine; proseFrom = i; continue; } const liveResult = parseInFlightToolResult(lines, i); if (liveResult) { flushProse(i); out.push(onResult(liveResult.body, { inFlight: true })); i = liveResult.nextLine; proseFrom = i; continue; } i++; } flushProse(lines.length); return out.join(''); } function extractAskUserToolJson(content) { const lines = String(content || '').split('\n'); for (let i = 0; i < lines.length; i++) { const block = parseToolCallBlock(lines, i); if (block && block.name === 'ask_user') return block.body; } return null; } // ============================================================================ // [turn_segs 数据结构层] —— 单轮渲染的纯函数,供 append-only draft 与静态消息复用 // 设计:每个函数自包含(内部自建 folds/asks 占位栈,渲染完即还原),轮间零共享状态。 // renderTurnBody(body) : 单轮原文 → 该轮内层HTML(块级折叠thinking/tool/result) // renderTurnFold(body, turnIndex) : 单轮原文 → 旧轮的
折叠壳(内部下标0起,标题显示1起+summary副标题) // 结构化 turn_segs 渲染使用的纯函数:折叠工具块、ask_user 与轮摘要。 // ============================================================================ // 去除 GenericAgent 轮次分隔标记;turn_segs 已结构化,不应展示原始 marker function stripTurnMarker(body) { return String(body || '') .replace(/^\s*\**LLM Running \(Turn \d+\) \.\.\.\**\s*/i, ''); } function renderTurnBody(body) { // 自包含:每次调用独立的占位栈,渲染完立即还原,无跨调用共享状态 const folds = []; const asks = []; const stash = (label, b, cls, opts) => { folds.push({ label, body: b, cls: cls || '', open: !!(opts && opts.open) }); return `\n\n§§FOLD:${folds.length - 1}§§\n\n`; }; const stashAsk = (data) => { asks.push(data); return `\n\n§§ASK:${asks.length - 1}§§\n\n`; }; let s = stripTurnMarker(body); s = s.replace(/[\s\S]*?<\/thinking>/gi, m => stash(t('fold.thinking'), m.replace(/<\/?thinking>/gi, ''), 'fold-thinking')); s = foldAgentProtocolBlocks(s, { onTool(name, json, meta) { if (name === 'ask_user' && !meta?.inFlight) { const data = parseAskUserJson(json); if (data && normalizeAskUserData(data)) return stashAsk(data); } const live = !!meta?.inFlight; return stash(`${t('fold.tool')}: ${name}${live ? ' …' : ''}`, json, live ? 'fold-tool fold-tool-live' : 'fold-tool', { open: live }); }, onResult(b, meta) { const live = !!meta?.inFlight; return stash(`${t('fold.toolResult')}${live ? ' …' : ''}`, b, live ? 'fold-result fold-tool-live' : 'fold-result', { open: live }); }, }); s = s.replace(/[\s\S]*?<\/function_calls>/gi, m => stash(t('fold.tool'), m, 'fold-tool')); s = s.replace(/[\s\S]*?<\/function_results>/gi, m => stash(t('fold.toolResult'), m, 'fold-result')); s = s.replace(/([\s\S]*?)<\/summary>/gi, (_, inner) => `
${inner}
`); let html = renderMarkdown(s); // 还原占位符 html = html .replace(/§§ASK:(\d+)§§/g, (_, i) => { const data = asks[Number(i)]; return data ? renderAskUserNotice(data) : ''; }) .replace(/§§FOLD:(\d+)§§/g, (_, i) => { const f = folds[Number(i)]; if (!f) return ''; const openAttr = f.open ? ' open' : ''; return `
${escapeHtml(f.label)}
${escapeHtml(f.body)}
`; }); return html; } // 抽出该轮首个 文本作为折叠头副标题;无则回退提取工具名列表 function extractTurnSummaryPure(raw) { const m = /([\s\S]*?)<\/summary>/i.exec(raw || ''); if (m) return m[1].replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim(); const tools = []; const toolRe = /🛠️\s*Tool:\s*`([^`]+)`/g; let tm; while ((tm = toolRe.exec(raw || '')) !== null) { if (!tools.includes(tm[1])) tools.push(tm[1]); } return tools.length ? tools.join(', ') : ''; } // 旧轮折叠壳:内部 turnIndex 从 0 起;UI 标题显示为 1 起。调用方一律传内部下标。 function turnDisplayNo(turnIndex) { return Math.max(0, Number(turnIndex) || 0) + 1; } function renderTurnFold(body, turnIndex) { const raw = stripTurnMarker(body); const sum = extractTurnSummaryPure(raw); const bodyForRender = sum ? raw.replace(/[\s\S]*?<\/summary>\s*/i, '') : raw; const inner = renderTurnBody(bodyForRender); const turnLabel = t('fold.turn').replace('{n}', turnDisplayNo(turnIndex)); const head = sum ? `${escapeHtml(turnLabel)}:${escapeHtml(sum)}` : escapeHtml(turnLabel); return `
${head}${inner}
`; } function lastInflightBlockBody(body) { const lines = String(body || '').split('\n'); let last = null; for (let i = 0; i < lines.length; i++) { const liveTool = parseInFlightToolCall(lines, i); if (liveTool?.inFlight) { last = { body: liveTool.body }; i = liveTool.nextLine - 1; continue; } const closedTool = parseToolCallBlock(lines, i); if (closedTool) { last = null; i = closedTool.nextLine - 1; continue; } const liveRes = parseInFlightToolResult(lines, i); if (liveRes?.inFlight) { last = { body: liveRes.body }; i = liveRes.nextLine - 1; continue; } const closedRes = parseToolResultBlock(lines, i); if (closedRes) { last = null; i = closedRes.nextLine - 1; continue; } } return last; } function tryPatchInflightToolDom(curEl, body, prevBody) { if (!prevBody || body.length < prevBody.length || !body.startsWith(prevBody)) return false; if (!curEl.querySelector('details.fold-tool-live')) return false; const prevBlock = lastInflightBlockBody(prevBody); const curBlock = lastInflightBlockBody(body); if (!curBlock || !prevBlock || !curBlock.body.startsWith(prevBlock.body)) return false; const liveFolds = curEl.querySelectorAll('details.fold-tool-live'); const pre = liveFolds[liveFolds.length - 1]?.querySelector('.fold-pre'); if (!pre) return false; pre.textContent = curBlock.body; return true; } function parseAskUserJson(raw) { if (raw == null) return null; const txt = String(raw).trim(); if (!txt) return null; try { return JSON.parse(txt); } catch (_) {} try { let out = ''; let inStr = false; let esc = false; for (let i = 0; i < txt.length; i++) { const c = txt[i]; if (esc) { out += c; esc = false; continue; } if (c === '\\') { out += c; esc = true; continue; } if (c === '"') { inStr = !inStr; out += c; continue; } if (inStr) { if (c === '\n') out += '\\n'; else if (c === '\r') out += '\\r'; else if (c === '\t') out += '\\t'; else if (c.charCodeAt(0) < 0x20) out += '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'); else out += c; } else out += c; } return JSON.parse(out); } catch (_) {} return null; } function normalizeAskUserData(data) { const raw = data || {}; const question = String(raw.question || '').trim(); if (!question) return null; const cs = raw.candidates || []; const candidates = Array.isArray(cs) ? cs.map(x => String(x == null ? '' : x)).filter(x => x.trim()) : []; return { question, candidates }; } /** 格式化 ask_user 题干:编号与正文同行;无空行时在 2./3. 前分段 */ function formatAskUserQuestion(text) { let s = String(text || '').trim(); if (!s) return s; // 「1.\n正文」→「1. 正文」 s = s.replace(/^(\d+[.、::)])\s*\n+\s*/gm, '$1 '); s = s.replace(/(\n)(\d+[.、::)])\s*\n+\s*/g, '$1$2 '); s = s.replace(/(\n|^)(问题\s*\d+\s*[::.、)]?)\s*\n+\s*/gi, '$1$2 '); // 题与题之间:尚无空行时,仅在 2./3. 前插入空行(不动 1. 与题干) if (!/\n\s*\n/.test(s)) { s = s.replace(/(\S)\s+(?=问题\s*[2-9]\d*\s*[::.、)]?\s*)/gi, '$1\n\n'); s = s.replace(/(\S)\s+(?=[2-9]\d*[.、::)]\s+\S)/g, '$1\n\n'); } return boldAskQuestionLines(s); } function boldAskQuestionLines(text) { return String(text || '').split('\n').map(line => { const t = line.trim(); if (!t || /^\*\*.+\*\*$/.test(t)) return line; if (/^\d+[.、::)]\s+\S/.test(t)) return '**' + t + '**'; if (/^问题\s*\d+/i.test(t)) return '**' + t + '**'; if (/[??]\s*$/.test(t) && !/^[A-Da-d][.)]\s/.test(t)) return '**' + t + '**'; return line; }).join('\n'); } function markAskOptionHtml(html) { let out = String(html || ''); out = out.replace(/

([^<]*[A-Da-d][.)]\s[^<]*)<\/p>/gi, '

$1

'); out = out.replace(/()\s*([A-Da-d][.)]\s[^<]+)/gi, '$2'); return out; } /** 预览模式:true = 始终显示 candidates;false = 题干已含选项/多题时不重复渲染底部列表 */ const ASK_USER_ALWAYS_SHOW_CANDIDATES = false; /** 题干已含选项/多题,或 candidates 无法与题干对应时,不再重复渲染底部列表 */ function shouldShowAskCandidates(item) { if (!item || !item.candidates.length) return false; if (ASK_USER_ALWAYS_SHOW_CANDIDATES) return true; const q = item.question; if (/两个问题|多个问题|两道|两题/.test(q)) return false; if ((q.match(/问题\s*\d/gi) || []).length >= 2) return false; if ((q.match(/^[ \t]*\d+[.、::)]\s+/gm) || []).length >= 2) return false; if ((q.match(/^[ \t]*[A-Da-d][.)]\s/mg) || []).length >= 2) return false; const comboN = item.candidates.filter(c => /\d+[A-Da-d]\s*\+\s*\d+[A-Da-d]/i.test(c)).length; if (comboN >= Math.max(1, Math.ceil(item.candidates.length * 0.5))) return false; // 题干里有多道问句,却把全部选项平铺在 candidates → 无法区分归属,不展示 const qMarks = (q.match(/[??]/g) || []).length; if (qMarks >= 2 && item.candidates.length > 4) return false; return true; } function renderAskUserNotice(data) { const item = normalizeAskUserData(data); if (!item) return ''; // 单题与多题统一处理:多题的选项本就内联在题干里;单题的选项放在 candidates 里, // 这里把它折叠进题干,按同样的 A./B./C. 内联方式渲染,不再单独画一个编号列表。 const question = foldAskCandidates(item); const qHtml = markAskOptionHtml(renderMarkdown(formatAskUserQuestion(question))); return `
${escapeHtml(t('ask.banner'))} ${escapeHtml(t('ask.replyHint'))}
${qHtml ? `
${qHtml}
` : ''}
`; } /** 单题的 candidates 折叠进题干(统一成 A./B./C. 内联选项);多题或无法对应时原样返回题干 */ function foldAskCandidates(item) { if (!shouldShowAskCandidates(item)) return item.question; const opts = item.candidates.map((c, j) => { const label = String(c).replace(/^\s*(?:[A-Za-z]|\d{1,2})\s*[.)、::]\s*/, '').trim(); return `${String.fromCharCode(65 + j)}. ${label}`; }).join('\n'); // 用单换行(而非空行)拼进题干,让题干+选项渲染成同一个

,每个选项都跟在
后面 — // 与多题内联选项走完全一致的 .ask-option-line 缩进,避免首项 A 贴左边、B/C/D 缩进的错位。 return item.question.replace(/\s+$/, '') + '\n' + opts; } function askUserPlaceholder(item) { // 单题与多题统一:都用自由作答提示,不再针对单题单独显示「输入 1/2/3 选择」 return t('ask.placeholderOpen'); } function assistantStructuredText(msg) { if (!msg || msg.role !== 'assistant') return ''; if (Array.isArray(msg.turn_segs) && msg.turn_segs.length) return msg.turn_segs.join('\n'); return typeof msg.content === 'string' ? msg.content : ''; } function getPendingAskUser(sess) { if (!sess || rt(sess).busy) return null; const msgs = sess.messages || []; let lastAskIdx = -1; let askData = null; for (let i = msgs.length - 1; i >= 0; i--) { if (msgs[i].role !== 'assistant') continue; const json = extractAskUserToolJson(assistantStructuredText(msgs[i])); if (json != null) { lastAskIdx = i; askData = normalizeAskUserData(parseAskUserJson(json)); break; } } if (!askData) return null; const replied = msgs.slice(lastAskIdx + 1).some(m => m.role === 'user'); return replied ? null : askData; } function syncAskUserUi() { const sess = activeSess(); const pending = sess ? getPendingAskUser(sess) : null; const notices = [...document.querySelectorAll('.ask-user-notice')]; notices.forEach((el, i) => { const isLast = i === notices.length - 1; el.classList.toggle('is-active', !!pending && isLast); el.classList.toggle('is-answered', !pending || !isLast); }); if (inputEl) inputEl.setAttribute('data-ph', pending ? askUserPlaceholder(pending) : t('composer.placeholder')); // contenteditable 用 data-ph(无 placeholder 属性) if (composerEl) composerEl.classList.toggle('is-awaiting-answer', !!pending); } /* ═══════════════ 渲染后增强 (PR移植) ═══════════════ */ /* ───────────── 统一复制 SVG Icon ───────────── */ // Phosphor 图标助手:把 window.gaIcon(name) 包一层,给动态渲染的 UI 用,与静态 [data-ga-icon] 保持一致 const GA_ICON = (name, className = '') => (typeof window.gaIcon === 'function' ? window.gaIcon(name, className) : ''); const SVG_COPY_ICON = GA_ICON('copy'); const SVG_CHECK_ICON = GA_ICON('check'); function postRenderEnhance(containerEl) { if (!containerEl) return; // 代码高亮 + 复制按钮(.code-block 容器已自带头部复制按钮,跳过) containerEl.querySelectorAll('pre code').forEach(block => { if (typeof hljs !== 'undefined') hljs.highlightElement(block); if (block.closest('.code-block')) return; // TUI 风格容器已有复制按钮 if (!block.parentElement.querySelector('.code-copy-btn')) { const btn = document.createElement('button'); btn.className = 'code-copy-btn'; btn.innerHTML = SVG_COPY_ICON; btn.title = t('act.copy'); btn.onclick = () => { navigator.clipboard.writeText(block.textContent).then(() => { btn.innerHTML = SVG_CHECK_ICON; setTimeout(() => btn.innerHTML = SVG_COPY_ICON, 1500); }); }; block.parentElement.style.position = 'relative'; block.parentElement.appendChild(btn); } }); // TUI 代码块头部复制按钮绑定 containerEl.querySelectorAll('.code-block-copy').forEach(btn => { if (btn.dataset.bound) return; btn.dataset.bound = '1'; btn.onclick = () => { const code = btn.closest('.code-block').querySelector('code'); if (!code) return; navigator.clipboard.writeText(code.textContent.trim()).then(() => { btn.textContent = '\u2713'; setTimeout(() => { btn.textContent = '\u29C9'; }, 1500); }); }; }); // KaTeX 复制按钮 containerEl.querySelectorAll('.katex-block').forEach(el => { if (el.querySelector('.latex-copy-btn')) return; const src = el.querySelector('annotation[encoding="application/x-tex"]'); if (!src) return; const btn = document.createElement('button'); btn.className = 'latex-copy-btn'; btn.textContent = '\u29C9'; btn.title = t('act.copyTex'); btn.onclick = () => { navigator.clipboard.writeText(src.textContent).then(() => { btn.textContent = '\u2713'; setTimeout(() => btn.textContent = '\u29C9', 1500); }); }; el.style.position = 'relative'; el.appendChild(btn); }); syncAskUserUi(); } /* ═══════════════ 状态 ═══════════════ */ const state = { sessions: new Map(), activeId: null, bridgeReady: false, llmNo: 0, modelProfiles: [], modelName: null, runtime: new Map(), pendingFiles: [], fileSeq: 0, }; function rt(sess) { let r = state.runtime.get(sess.id); if (!r) { r = { polling:false, busy:false, lastId:0, seen:new Set(), draftEl:null, draftSegs:null, draftTurn:0, taskStartedAt:null, taskEndedAt:null, taskTimerId:null, planCompleteAt:null, planLostAt:null, planHoldItems:[], planLastPayload:null, planLastComplete:false, planHideTimer:null, planDismissedComplete:false, planCollapsed:false, planShowAll:false }; state.runtime.set(sess.id, r); } return r; } const activeSess = () => state.sessions.get(state.activeId) || null; const isActive = (sess) => sess && sess.id === state.activeId; function saveSessions() {} function patchSession(sess, fields) { if (!sess.bridgeSessionId) return; fetch(`${BRIDGE_ORIGIN}/session/${encodeURIComponent(sess.bridgeSessionId)}`, { method: 'PATCH', headers: {'Content-Type':'application/json'}, body: JSON.stringify(fields) }).catch(() => {}); } async function loadSessions() { try { const res = await fetch(`${BRIDGE_ORIGIN}/sessions`); const data = await res.json(); if (!data.sessions) return; for (const s of data.sessions) { state.sessions.set(s.id, { id: s.id, bridgeSessionId: s.id, title: s.title, messages: [], untitled: s.untitled ?? true, pinned: s.pinned ?? false, lastActiveTs: s.updatedAt || s.createdAt }); } // 刷新后固定恢复「上次正在看的会话」(前端持久化的 ga_active),而不是 bridge 的 // activeSessionId(=最近更新的会话,会随后台会话变动而跳来跳去)。没有有效的已存 // 会话则置空 → 显示「新会话」空态,由用户自己点选。 const savedActive = localStorage.getItem('ga_active'); state.activeId = (savedActive && state.sessions.has(savedActive)) ? savedActive : null; } catch (_) {} } /* ═══════════════ DOM refs ═══════════════ */ const chatPage = document.querySelector('.page[data-page="chat"]'); const msgArea = chatPage.querySelector('.msg-area'); const chatStart = msgArea.querySelector('.chat-start'); const inputEl = document.getElementById('chat-input'); const sendBtn = document.getElementById('send-btn'); const planBarEl = document.getElementById('plan-bar'); const composerEl = document.getElementById('chat-composer'); const msgLoading = document.getElementById('msg-loading'); const sessionLoadingEl = document.getElementById('session-loading'); const MIN_MSG_LOADING_MS = 450; const HYDRATE_LOADING_TIMEOUT_MS = 10000; const POLL_MSG_LIMIT = 200; const PLAN_LOST_GRACE_MS = 1500; // tuiapp_v2._PLAN_LOST_GRACE_SEC const PLAN_COMPLETE_GRACE_MS = 3000; // tuiapp_v2._PLAN_GRACE_SEC function isPlanPresetPrompt(text) { const p = String(text || '').toLowerCase(); return p.includes('plan_sop') || p.includes('plan 模式') || p.includes('plan mode'); } let _submitInFlight = false; const runToggle = document.getElementById('run-toggle'); const chatStatus = pageStatusBar(runToggle); const runLabel = runToggle?.querySelector('.rs-label'); const convListEl = document.querySelector('.conv-list'); const newConvBtn = document.querySelector('.new-conv'); const searchInput = document.querySelector('.search input'); const rpResize = document.getElementById('rp-resize'); const rpPanel = document.getElementById('rightpanel'); const bodyEl = document.querySelector('.body'); /* 每个页面的 page-top 各自挂一对 hamburger / 会话 按钮(.pt-sb-toggle / .pt-rp-toggle), 全部绑同一个 toggle,效果跟以前的单一 sb-toggle/rp-toggle 一样,只是入口变成顶栏。 */ document.querySelectorAll('.pt-sb-toggle').forEach(b => b.addEventListener('click', () => bodyEl.classList.toggle('sb-collapsed'))); document.querySelectorAll('.pt-rp-toggle').forEach(b => b.addEventListener('click', () => bodyEl.classList.toggle('rp-collapsed'))); const sbResize = document.getElementById('sb-resize'); const sbPanel = document.querySelector('.sidebar'); // 通用拖拽:dir=+1 拖动 →clientX 增大就增宽(左侧栏);dir=-1 反之(右侧) function bindResize(handle, panel, dir, min, max) { if (!handle || !panel) return; let dragging = false, startX = 0, startW = 0; handle.addEventListener('mousedown', (e) => { dragging = true; startX = e.clientX; startW = panel.offsetWidth; handle.classList.add('dragging'); panel.style.transition = 'none'; // 拖拽期间禁用 transition,避免宽度动画延迟 document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; e.preventDefault(); }); document.addEventListener('mousemove', (e) => { if (!dragging) return; const w = Math.min(max, Math.max(min, startW + dir * (e.clientX - startX))); panel.style.width = w + 'px'; panel.style.flex = '0 0 ' + w + 'px'; }); document.addEventListener('mouseup', () => { if (!dragging) return; dragging = false; handle.classList.remove('dragging'); panel.style.transition = ''; // 恢复 CSS transition(按钮折叠动画仍生效) document.body.style.cursor = ''; document.body.style.userSelect = ''; }); } bindResize(rpResize, rpPanel, -1, 160, 400); // 右栏:cursor 左移 → 增宽 bindResize(sbResize, sbPanel, +1, 180, 360); // 左栏:cursor 右移 → 增宽 const modelChip = document.getElementById('model-chip'); const modelNameEl= modelChip ? modelChip.querySelector('.model-name') : null; // conductor 页面也有一个独立的模型 chip,共用一份模型数据 const collabModelChip = document.getElementById('cdb-model-chip'); const collabModelNameEl = collabModelChip ? collabModelChip.querySelector('.model-name') : null; let msgsEl = null; function ensureMsgs() { if (!msgsEl) { msgsEl = document.createElement('div'); msgsEl.className = 'msgs'; msgArea.insertBefore(msgsEl, msgLoading || null); } return msgsEl; } function refreshEmptyState(sess) { const has = sess && sess.messages.length > 0; msgArea.classList.toggle('has-msgs', !!has); if (chatStart) chatStart.style.display = has ? 'none' : ''; if (msgsEl) msgsEl.style.display = has ? '' : 'none'; } function planTpl(tpl, v) { return String(tpl || '').replace(/\{(\w+)\}/g, (_, k) => (v[k] != null ? String(v[k]) : `{${k}}`)); } let planPollTimer; function syncPlanPollTimer() { const on = !!(activeSess()?.bridgeSessionId && state.bridgeReady); if (on && !planPollTimer) { planPollTimer = setInterval(() => { const s = activeSess(); if (!s || !isActive(s)) return; planFetch(s); planTick(s); }, 1000); } else if (!on && planPollTimer) { clearInterval(planPollTimer); planPollTimer = null; } } function clearPlanGrace(r) { r.planCompleteAt = r.planLostAt = null; r.planHoldItems = []; r.planLastPayload = null; r.planLastComplete = false; r.planDismissedComplete = false; if (r.planHideTimer) { clearTimeout(r.planHideTimer); r.planHideTimer = null; } } function schedulePlanCompleteDismiss(sess) { const r = rt(sess); if (r.planHideTimer) clearTimeout(r.planHideTimer); r.planHideTimer = setTimeout(() => { r.planHideTimer = null; r.planDismissedComplete = true; if (isActive(sess)) refreshPlanBar(null); }, PLAN_COMPLETE_GRACE_MS); } /** tuiapp_v2._refresh_planbar:用 runtime 里缓存的 items / placeholder 重绘 */ function refreshPlanBarFromRuntime(sess) { const r = rt(sess); const lp = r.planLastPayload; let items = r.planHoldItems || []; if (r.planLostAt != null && Date.now() - r.planLostAt >= PLAN_LOST_GRACE_MS) { items = []; r.planHoldItems = []; r.planLostAt = null; } if (r.planDismissedComplete) { refreshPlanBar(null); return; } if (r.planCompleteAt != null && Date.now() - r.planCompleteAt >= PLAN_COMPLETE_GRACE_MS) { r.planDismissedComplete = true; refreshPlanBar(null); return; } if (!items.length) { if (lp?.active && lp.placeholder) { refreshPlanBar(lp); return; } const held = r.planHoldItems || []; if (lp?.complete && (lp.items?.length || held.length)) { refreshPlanBar({ active: true, placeholder: false, items: lp.items?.length ? lp.items : held, done: lp.done ?? held.filter(it => it.status === 'done').length, total: lp.total ?? (lp.items?.length || held.length), complete: true, step: lp.step || '', }); return; } refreshPlanBar(null); return; } refreshPlanBar({ active: true, placeholder: false, items, done: lp?.done ?? items.filter(it => it.status === 'done').length, total: lp?.total ?? items.length, complete: !!(lp?.complete || (items.length && items.every(it => it.status === 'done'))), step: lp?.step || '', }); } /** 每秒 tick grace(对齐 TUI _poll_plan_files → _refresh_planbar) */ function planTick(sess) { if (!sess || !isActive(sess)) return; refreshPlanBarFromRuntime(sess); } function applyPlanPayload(sess, raw) { if (!sess) return; const r = rt(sess); const now = Date.now(); if (raw?.active) { if (raw.placeholder && !r.planLastPayload?.active) { r.planCollapsed = false; r.planShowAll = false; } r.planLastPayload = raw; const items = raw.items || []; if (items.length) { r.planLostAt = null; r.planHoldItems = items; } else if (!raw.placeholder && !raw.complete && r.planHoldItems.length) { if (!r.planLostAt) r.planLostAt = now; } const nowComplete = !!raw.complete && (items.length > 0 || r.planHoldItems.length > 0); const wasComplete = r.planLastComplete; if (nowComplete && !wasComplete) { r.planCompleteAt = now; schedulePlanCompleteDismiss(sess); } else if (!nowComplete) { r.planCompleteAt = null; r.planDismissedComplete = false; if (r.planHideTimer) { clearTimeout(r.planHideTimer); r.planHideTimer = null; } } r.planLastComplete = nowComplete; } else if (r.planHoldItems.length && !r.planDismissedComplete) { if (!r.planLostAt) r.planLostAt = now; } else if (!r.planDismissedComplete) { clearPlanGrace(r); } if (!isActive(sess)) return; if (r.planDismissedComplete) { refreshPlanBar(null); return; } if (raw?.active && raw.placeholder) { refreshPlanBar(raw); return; } if (raw?.active && raw.complete && (raw.items?.length || r.planHoldItems.length)) { refreshPlanBar({ ...raw, items: raw.items?.length ? raw.items : r.planHoldItems, }); return; } refreshPlanBarFromRuntime(sess); } function planItemUi(status, isCurrent) { const st = String(status || 'open').toLowerCase(); if (st === 'done') return { cls: 'plan-item--done', mark: '✓' }; if (st === 'error' || st === 'failed') return { cls: 'plan-item--error', mark: '✕' }; if (st === 'warn' || st === 'warning') return { cls: 'plan-item--warn', mark: '!' }; if (isCurrent) return { cls: 'plan-item--current', mark: '●' }; return { cls: 'plan-item--pending', mark: '○' }; } function pickPlanWindow(items, stepText) { const list = Array.isArray(items) ? items : []; if (!list.length) return { shown: [], curInShown: -1, overflow: 0 }; let cur = list.findIndex(it => it.status !== 'done'); if (cur < 0) cur = list.length - 1; const step = String(stepText || '').trim(); if (step) { const hit = list.findIndex(it => String(it.content || '').includes(step.slice(0, 24))); if (hit >= 0) cur = hit; } let start, end; if (cur <= 1) { start = cur; end = Math.min(list.length, cur + 3); } else { start = cur - 1; end = Math.min(list.length, cur + 2); } const shown = list.slice(start, end); return { shown, curInShown: cur - start, overflow: Math.max(0, list.length - shown.length) }; } function planCapsuleLabel(plan) { const step = plan.step ? String(plan.step).slice(0, 80) : ''; if (plan.complete) return { tag: t('plan.capsuleComplete'), step: step || planTpl(t('plan.complete'), { n: plan.total }) }; if (plan.placeholder) return { tag: t('plan.placeholder'), step: '' }; return { tag: t('plan.capsuleRunning'), step: step || planTpl(t('plan.header'), { done: plan.done, total: plan.total }) }; } function bindPlanCardUiOnce() { if (!planBarEl || planBarEl._planUiBound) return; planBarEl._planUiBound = true; planBarEl.addEventListener('click', (e) => { const sess = activeSess(); if (!sess) return; const r = rt(sess); const payload = r.planLastPayload; if (!payload?.active) return; if (e.target.closest('[data-plan-expand]')) { r.planCollapsed = false; refreshPlanBar(payload); } else if (e.target.closest('[data-plan-collapse]')) { r.planCollapsed = true; refreshPlanBar(payload); } else if (e.target.closest('[data-plan-details]')) { r.planShowAll = !r.planShowAll; refreshPlanBar(payload); } }); } function refreshPlanBar(plan) { if (!planBarEl) return; bindPlanCardUiOnce(); if (!plan?.active) { planBarEl.hidden = true; planBarEl.replaceChildren(); planBarEl.className = 'plan-card'; return; } const sess = activeSess(); const r = sess ? rt(sess) : { planCollapsed: false, planShowAll: false }; const collapsed = !!r.planCollapsed; const stepText = plan.step ? String(plan.step).slice(0, 120) : ''; const done = plan.done ?? (plan.items || []).filter(it => it.status === 'done').length; const total = plan.total ?? (plan.items || []).length; const mod = [ 'plan-card', collapsed ? 'plan-card--collapsed' : 'plan-card--expanded', plan.complete ? 'plan-card--complete' : '', plan.placeholder ? 'plan-card--placeholder' : '', ].filter(Boolean).join(' '); planBarEl.hidden = false; planBarEl.className = mod; if (collapsed) { const cap = planCapsuleLabel(plan); planBarEl.innerHTML = ''; const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'plan-capsule'; btn.dataset.planExpand = '1'; const dot = document.createElement('span'); dot.className = 'plan-status-dot'; const txt = document.createElement('span'); txt.className = 'plan-capsule-text'; if (cap.step) txt.innerHTML = `${escapeHtml(cap.tag)} · ${escapeHtml(cap.step)}`; else txt.textContent = cap.tag; btn.append(dot, txt); planBarEl.append(btn); return; } const frag = document.createDocumentFragment(); const head = document.createElement('div'); head.className = 'plan-card-head'; const dot = document.createElement('span'); dot.className = 'plan-status-dot'; const title = document.createElement('span'); title.className = 'plan-title'; title.textContent = plan.placeholder ? t('plan.placeholder') : plan.complete ? t('plan.completeTitle') : t('plan.running'); head.append(dot, title); if (!plan.placeholder && total > 0) { const prog = document.createElement('span'); prog.className = 'plan-progress'; prog.textContent = `${done}/${total}`; head.append(prog); } const actions = document.createElement('div'); actions.className = 'plan-head-actions'; const collapseBtn = document.createElement('button'); collapseBtn.type = 'button'; collapseBtn.className = 'plan-btn'; collapseBtn.dataset.planCollapse = '1'; collapseBtn.textContent = t('plan.collapse'); actions.append(collapseBtn); head.append(actions); frag.append(head); if (stepText) { const cur = document.createElement('div'); cur.className = 'plan-current'; const lab = document.createElement('span'); lab.className = 'plan-current-label'; lab.textContent = `${t('plan.current')}:`; const body = document.createElement('span'); body.className = 'plan-current-text'; body.textContent = stepText; cur.append(lab, body); frag.append(cur); } if (plan.placeholder) { const wait = document.createElement('div'); wait.className = 'plan-wait'; wait.textContent = planTpl(t('plan.waiting'), { path: plan.pathHint || 'plan.md' }); frag.append(wait); } else { const list = plan.items || []; const { shown, curInShown, overflow } = r.planShowAll ? { shown: list, curInShown: list.findIndex(it => it.status !== 'done'), overflow: 0 } : pickPlanWindow(list, stepText); if (shown.length) { const ul = document.createElement('ul'); ul.className = 'plan-items'; shown.forEach((it, i) => { const ui = planItemUi(it.status, i === curInShown); const li = document.createElement('li'); li.className = 'plan-item ' + ui.cls; const mark = document.createElement('span'); mark.className = 'plan-item-mark'; mark.textContent = ui.mark; const txt = document.createElement('span'); txt.className = 'plan-item-text'; txt.textContent = it.content || ''; li.append(mark, txt); ul.append(li); }); frag.append(ul); } const foot = document.createElement('div'); foot.className = 'plan-foot'; const moreN = r.planShowAll ? 0 : (overflow || Math.max(0, list.length - shown.length)); if (moreN > 0) { const hint = document.createElement('span'); hint.className = 'plan-more-hint'; hint.textContent = planTpl(t('plan.overflow'), { n: moreN }); foot.append(hint); } if (list.length > 3) { const det = document.createElement('button'); det.type = 'button'; det.className = 'plan-btn'; det.dataset.planDetails = '1'; det.textContent = r.planShowAll ? t('plan.collapse') : t('plan.details'); foot.append(det); } if (foot.childNodes.length) frag.append(foot); } planBarEl.replaceChildren(frag); } async function planFetch(sess) { if (!sess?.bridgeSessionId || !state.bridgeReady || !isActive(sess)) return; try { const res = await fetch(`${BRIDGE_ORIGIN}/session/${encodeURIComponent(sess.bridgeSessionId)}/plan`); if (!res.ok) throw new Error(`plan ${res.status}`); const data = await res.json(); applyPlanPayload(sess, data.plan ?? data.result?.plan); } catch (_) { /* 对齐 TUI:读盘/网络失败不立刻清空条 */ } } async function planPoll(sess) { await planFetch(sess); planTick(sess); } /* ═══════════════ 消息渲染 ═══════════════ */ function stripAttachPlaceholders(text) { return String(text || '').replace(/\[(Image|File)\s+#\d+\]\s*/g, '').trim(); } // 把消息文本里的 [Image #N]/[File #N] 占位符“按原位置”渲染成内联 chip(显示文件名),其余文本转义+换行, // 这样消息里能看到附件在文本中的位置(卡片/缩略图照常另外渲染)。lookup(kind,n) 取该附件文件名。 // 同时兜底去掉内联的本地上传路径(历史/conductor 回显)。 function renderMsgTextWithChips(text, lookup) { const s = String(text || '').replace(/[^\s]*desktop_uploads[^\s]*\s*/g, ''); const esc = t2 => escapeHtml(t2).replace(/\n/g, '
'); const re = /\[(Image|File)\s+#(\d+)\]/g; let out = '', last = 0, m; while ((m = re.exec(s))) { out += esc(s.slice(last, m.index)); const name = (lookup && lookup(m[1], Number(m[2]))) || (m[1] === 'Image' ? 'image' : 'file'); out += `${escapeHtml(name)}`; last = re.lastIndex; } out += esc(s.slice(last)); return out.trim(); } function fileSubLabel(name) { const m = String(name || '').match(/\.([^.]+)$/); if (!m) return t('file.kindGeneric'); const ext = m[1].toLowerCase(); const docExts = ['pdf', 'doc', 'docx', 'rtf', 'odt', 'pages', 'tex']; const sheetExts = ['xls', 'xlsx', 'csv', 'tsv', 'numbers', 'ods']; const slideExts = ['ppt', 'pptx', 'key', 'odp']; const codeExts = ['py', 'js', 'ts', 'tsx', 'jsx', 'java', 'c', 'cpp', 'h', 'hpp', 'rs', 'go', 'rb', 'php', 'sh', 'html', 'css', 'json', 'yaml', 'yml', 'xml', 'sql', 'md']; const archiveExts = ['zip', 'tar', 'gz', 'rar', '7z', 'bz2']; const audioExts = ['mp3', 'wav', 'flac', 'aac', 'ogg', 'm4a']; const videoExts = ['mp4', 'mov', 'avi', 'mkv', 'webm', 'wmv']; if (docExts.includes(ext)) return t('file.kindDoc'); if (sheetExts.includes(ext)) return t('file.kindSheet'); if (slideExts.includes(ext)) return t('file.kindSlide'); if (codeExts.includes(ext)) return t('file.kindCode') + ' · ' + ext.toUpperCase(); if (archiveExts.includes(ext)) return t('file.kindArchive'); if (audioExts.includes(ext)) return t('file.kindAudio'); if (videoExts.includes(ext)) return t('file.kindVideo'); return ext.toUpperCase(); } /** 无 turn_segs 时按 LLM Running 标记切轮(与 stapp.fold_turns 同源) */ function splitContentTurnSegs(content) { const src = String(content || ''); const parts = src.split(/\**LLM Running \(Turn \d+\) \.\.\.\**/); const segs = []; for (let i = 1; i < parts.length; i++) { const body = parts[i] || ''; if (body.length || segs.length) segs.push(body); } if (segs.length > 1) return segs; return src.length ? [src] : []; } function assistantTurnSegs(msg) { if (Array.isArray(msg?.turn_segs) && msg.turn_segs.length) return msg.turn_segs; if (typeof msg?.content === 'string' && msg.content.length) return splitContentTurnSegs(msg.content); return []; } /** turn_segs 未就绪时回退 content(双轨兼容,不改 ljq 渲染主路径) */ function draftSegsFromPartial(raw, m) { const segs = Array.isArray(m.turn_segs) ? m.turn_segs : []; if (segs.length && segs.some(s => (s || '').length > 0)) return segs; const content = typeof raw.content === 'string' ? raw.content : (m.content || ''); return content ? [content] : segs; } function firstNonEmptyTurnIndex(segs) { const arr = Array.isArray(segs) ? segs : []; for (let i = 0; i < arr.length; i++) { if ((arr[i] || '').length > 0) return i; } return 0; } // 后端 curr_turn 是内部 0-based 下标。展示时优先当前 turn;若该 turn 为空,回退到最后一个非空 turn。 function resolveVisibleTurnIndex(segs, preferredTurn) { const arr = Array.isArray(segs) ? segs : []; const first = firstNonEmptyTurnIndex(arr); const preferred = Number.isFinite(Number(preferredTurn)) ? Number(preferredTurn) : -1; if (preferred >= 0 && (arr[preferred] || '').length > 0) return preferred; for (let i = arr.length - 1; i >= 0; i--) { if ((arr[i] || '').length > 0) return i; } return Math.max(first, preferred >= 0 ? preferred : (arr.length ? arr.length - 1 : first)); } // 静态完整渲染:visibleTurn 之前的 seg 固化折叠;visibleTurn 作为当前正文展示。 function renderAssistantTurnsHtml(segs, currTurn, withCursor = false) { const arr = Array.isArray(segs) ? segs : []; if (!arr.length) return ''; const first = firstNonEmptyTurnIndex(arr); const curr = resolveVisibleTurnIndex(arr, currTurn); let html = ''; for (let i = first; i < curr; i++) { if ((arr[i] || '').length > 0) html += `

${renderTurnFold(arr[i] || '', i)}
`; } html += `
${renderTurnBody(arr[curr] || '')}${withCursor ? '' : ''}
`; return html; } function assistantCopyText(msg) { const segs = assistantTurnSegs(msg); if (segs.length) { // 复制保持旧行为:只复制当前/最后可见 turn,不复制已折叠历史 turn。 const turn = resolveVisibleTurnIndex(segs, msg?.curr_turn); return stripTurnMarker(segs[turn] || '').replace(/[\s\S]*?<\/summary>\s*/i, '').trim(); } return ''; } function msgNode(msg) { const el = document.createElement('div'); el.className = 'msg ' + (msg.role || 'system'); if (msg.role === 'user') { const shown = (typeof msg.display === 'string' && msg.display.length) ? msg.display : msg.content; const imgsHtml = (msg.images && msg.images.length) ? `
${msg.images.map(im => ``).join('')}
` : ''; const filesHtml = (msg.files && msg.files.length) ? `
${msg.files.map(f => { const name = f.name || 'file'; const sub = fileSubLabel(name); return `
${GA_ICON('fileText')}${escapeHtml(name)}${escapeHtml(sub)}
`; }).join('')}
` : ''; const chipText = renderMsgTextWithChips(shown, (kind, n) => { const hit = (kind === 'Image' ? (msg.images || []) : (msg.files || [])).find(x => x.id === 'f-' + n); return hit && (hit.name || ''); }); const textHtml = chipText ? `
${chipText}
` : ''; el.innerHTML = `
${filesHtml}${imgsHtml}${textHtml}
`; } else if (msg.role === 'assistant') { const segs = assistantTurnSegs(msg); let html = renderAssistantTurnsHtml(segs, msg.curr_turn, false); if (msg.stopped) html += `

[${escapeHtml(t('status.stopped'))}]

`; el.innerHTML = `
${html}
`; postRenderEnhance(el.querySelector('.bubble')); } else if (msg.role === 'error') el.innerHTML = `
${escapeHtml(msg.content)}
`; else el.innerHTML = `
${escapeHtml(msg.content)}
`; if (msg.role === 'user' || msg.role === 'assistant') { const copyBtn = document.createElement('button'); copyBtn.className = 'bubble-copy-btn'; copyBtn.title = t('act.copy'); copyBtn.innerHTML = SVG_COPY_ICON; copyBtn.addEventListener('click', (e) => { e.stopPropagation(); const text = (msg.role === 'user') ? stripAttachPlaceholders((typeof msg.display === 'string' && msg.display.length) ? msg.display : (msg.content || '')) : assistantCopyText(msg); navigator.clipboard.writeText(text).then(() => { copyBtn.innerHTML = SVG_CHECK_ICON; setTimeout(() => { copyBtn.innerHTML = SVG_COPY_ICON; }, 1500); }); }); el.appendChild(copyBtn); } return el; } function collabItemToMsg(item) { const attach = arr => (arr || []).map(x => { const sid = x.sid != null ? x.sid : (String(x.id || '').startsWith('f-') ? String(x.id).slice(2) : x.id); return { id: 'f-' + sid, name: x.name, path: x.path, dataUrl: x.dataUrl }; }); if (item.role === 'user') { return { role: 'user', content: item.msg, display: item.msg, images: attach(item.images), files: attach(item.files) }; } if (item.role === 'conductor') return { role: 'assistant', turn_segs: [item.msg || ''], curr_turn: 0 }; if (item.role === 'error') return { role: 'error', content: item.msg || '' }; return { role: 'system', content: item.msg || '' }; } function renderAllMessages(sess) { const box = ensureMsgs(); box.innerHTML = ''; for (const m of sess.messages) box.appendChild(msgNode(m)); syncAskUserUi(); // badge 恢复在 pollSession finally 中执行(此时 messages 已通过异步加载填充) refreshEmptyState(sess); scrollBottom(true); } // 遍历消息对,用 ts 差值恢复 badge;对运行中任务恢复 taskStartedAt function restoreElapsedBadges(sess, box) { const msgs = sess.messages; if (!msgs || !msgs.length) return; const nodes = box.querySelectorAll('.msg'); let lastUserTs = null; for (let i = 0; i < msgs.length; i++) { if (msgs[i].role === 'user') { lastUserTs = msgs[i].ts ? msgs[i].ts * 1000 : null; // 无 ts 则重置 } else if (msgs[i].role === 'assistant') { if (lastUserTs && msgs[i].ts) { const elapsed = msgs[i].ts * 1000 - lastUserTs; if (elapsed > 0 && nodes[i]) { ensureTaskElapsedBadge(nodes[i], lastUserTs, msgs[i].ts * 1000); } } lastUserTs = null; } } // 运行中任务:最后一条是 user 且 session busy,恢复实时计时 if (lastUserTs && rt(sess).busy) { const r = rt(sess); r.taskStartedAt = lastUserTs; r.taskEndedAt = null; } } function appendMessage(sess, msg) { if (!isActive(sess)) return; const el = msgNode(msg); ensureMsgs().appendChild(el); if (msg.role === 'assistant') { const r = rt(sess); if (r.taskStartedAt) { ensureTaskElapsedBadge(el, r.taskStartedAt, r.taskEndedAt || Date.now()); r.taskStartedAt = null; r.taskEndedAt = null; } } refreshEmptyState(sess); scrollBottom(true); if (msg.role === 'assistant' || msg.role === 'user') syncAskUserUi(); } function isNearBottom(threshold = 80) { return msgArea.scrollHeight - msgArea.scrollTop - msgArea.clientHeight < threshold; } function scrollBottom(force) { if (force || isNearBottom()) { requestAnimationFrame(() => { msgArea.scrollTop = msgArea.scrollHeight; }); } } /* ═══════════════ 打字机效果 (PR移植) ═══════════════ */ const TW_SPEED = 10; // 逐字步长;paintDraft 只重绘当前轮 const TW_INTERVAL = 35; // ms const TW_CATCHUP_THRESHOLD = 480; // 积压过大时加速追平 const TW_CATCHUP_MULTIPLIER = 8; const TW_RECOVER_MIN = 1200; // 保留常量;刷新恢复改由 snapDraftRecover 一律对齐 const DRAFT_INTERACT_MS = 520; // 用户滚代码块/点折叠时暂缓 DOM 重写 function isDraftInteractFrozen(r) { return Date.now() < (r.draftFreezeUntil || 0); } function armDraftInteractFreeze(r, ms = DRAFT_INTERACT_MS) { r.draftFreezeUntil = Math.max(r.draftFreezeUntil || 0, Date.now() + ms); } function snapshotDraftScroll(root) { if (!root) return []; return [...root.querySelectorAll('.bubble .code-block pre, .bubble .fold-pre')].map(n => n.scrollTop); } function restoreDraftScroll(root, tops) { if (!root || !tops.length) return; const nodes = root.querySelectorAll('.bubble .code-block pre, .bubble .fold-pre'); tops.forEach((top, i) => { if (nodes[i] && top > 0) nodes[i].scrollTop = top; }); } function bindDraftInteractGuard(el, r) { if (!el || el.dataset.gaDraftGuard) return; el.dataset.gaDraftGuard = '1'; const arm = () => { if (!r._suppressToggleFreeze) armDraftInteractFreeze(r); }; el.addEventListener('mousedown', (e) => { if (e.target.closest('details summary, .code-block pre, .fold-pre')) armDraftInteractFreeze(r); }, true); el.addEventListener('wheel', (e) => { if (e.target.closest('.code-block pre, .fold-pre')) armDraftInteractFreeze(r); }, { capture: true, passive: true }); el.addEventListener('toggle', (e) => { if (e.target.matches('details')) arm(); }, true); } function resetTypewriterState(r) { if (r.twState?.timer) clearInterval(r.twState.timer); r.twState = null; r.draftRecoverPending = false; r.draftStreamBaseline = 0; r._draftPaintBody = ''; } /** 刷新/hydrate/重连:已有 partial 一次性对齐全文并单帧绘制,不重播打字机 */ function snapDraftRecover(r) { if (!r.draftRecoverPending || !r.draftEl) return false; if (!r.twState) r.twState = { turn: 0, shown: 0, timer: null }; const tw = r.twState; if (tw.timer) { clearInterval(tw.timer); tw.timer = null; } const segs = Array.isArray(r.draftSegs) ? r.draftSegs : []; const turn = Math.max(0, Number(r.draftTurn) || 0); tw.turn = turn; tw.shown = (segs[turn] || '').length; r.draftRecoverPending = false; r.draftStreamBaseline = tw.shown; r._draftPaintBody = ''; ensureDraftFrozenThrough(r, turn); paintDraft(r, turn, segs[turn] || ''); return true; } function renderDraft(sess) { const r = rt(sess); if (!isActive(sess)) return; const box = ensureMsgs(); if (!r.draftEl || r.draftEl.parentNode !== box) { r.draftEl = document.createElement('div'); r.draftEl.className = 'msg assistant'; box.appendChild(r.draftEl); bindDraftInteractGuard(r.draftEl, r); if (r.taskStartedAt) ensureTaskElapsedBadge(r.draftEl, r.taskStartedAt, null); } if (!r.twState) r.twState = { turn: 0, shown: 0, timer: null }; const tw = r.twState; const backendTurn = Math.max(0, Number(r.draftTurn) || 0); if (tw.turn == null || tw.turn < 0) tw.turn = 0; if (tw.turn > backendTurn) tw.turn = backendTurn; // 流式渲染模型:segs 是源数据;tw.turn 是正在打字的 turn;tw.turn 之前的 DOM 一旦 frozen 就不再动。 ensureDraftFrozenThrough(r, tw.turn); const tick = () => { const segs = Array.isArray(r.draftSegs) ? r.draftSegs : []; const backendTurnNow = Math.max(0, Number(r.draftTurn) || 0); if (tw.turn == null || tw.turn < 0) tw.turn = 0; if (tw.turn > backendTurnNow) tw.turn = backendTurnNow; r.streamTurn = tw.turn; const cur = segs[tw.turn] || ''; // 当前 turn 打完,并且后端已进入后续 turn:当前 DOM 固化,然后打字机推进到下一 turn。 if (tw.shown >= cur.length && backendTurnNow > tw.turn) { paintDraft(r, tw.turn, cur); freezeCurrentTurnDom(r, tw.turn); tw.turn += 1; tw.shown = 0; r.streamTurn = tw.turn; return; } if (tw.shown >= cur.length) return; // 中途不停止 timer,等新 partial/done。 if (isDraftInteractFrozen(r)) return; const t0 = performance.now(); const backlog = cur.length - tw.shown; let step = backlog > TW_CATCHUP_THRESHOLD ? TW_SPEED * TW_CATCHUP_MULTIPLIER : TW_SPEED; const last = tw.lastElapsed || 0; if (last > 60) step = Math.max(step, Math.ceil(backlog / 3)); else step = Math.min(step, 120); tw.shown = Math.min(tw.shown + step, cur.length); paintDraft(r, tw.turn, cur.slice(0, tw.shown)); tw.lastElapsed = performance.now() - t0; }; if (snapDraftRecover(r)) { if (!tw.timer) tw.timer = setInterval(tick, TW_INTERVAL); refreshEmptyState(sess); return; } if (!tw.timer) tw.timer = setInterval(tick, TW_INTERVAL); if (!isDraftInteractFrozen(r)) tick(); refreshEmptyState(sess); } function ensureDraftFrozenThrough(r, currTurn) { if (!r.draftEl) return; const segs = Array.isArray(r.draftSegs) ? r.draftSegs : []; const upto = Math.max(0, Number(currTurn) || 0); let bubble = r.draftEl.querySelector(':scope > .bubble.md'); if (!bubble) { bubble = document.createElement('div'); bubble.className = 'bubble md'; r.draftEl.appendChild(bubble); } const cur = bubble.querySelector(':scope > .turn-cur'); for (let turn = 0; turn < upto; turn++) { if (bubble.querySelector(`:scope > .turn-frozen[data-turn="${turn}"]`)) continue; const frozen = document.createElement('div'); frozen.className = 'turn-frozen'; frozen.dataset.turn = turn; frozen.innerHTML = renderTurnFold(segs[turn] || '', turn); if (cur) bubble.insertBefore(frozen, cur); else bubble.appendChild(frozen); postRenderEnhance(frozen); } } function freezeCurrentTurnDom(r, turn) { if (!r.draftEl) return; const bubble = r.draftEl.querySelector(':scope > .bubble.md'); if (!bubble) return; const cur = bubble.querySelector(':scope > .turn-cur'); if (!cur) return; cur.className = 'turn-frozen'; cur.dataset.turn = turn; cur.innerHTML = renderTurnFold((r.draftSegs || [])[turn] || '', turn); postRenderEnhance(cur); } function paintDraft(r, turn, visibleCurrBody) { if (!r.draftEl || isDraftInteractFrozen(r)) return; const wasNear = isNearBottom(); let bubble = r.draftEl.querySelector(':scope > .bubble.md'); if (!bubble) { bubble = document.createElement('div'); bubble.className = 'bubble md'; r.draftEl.appendChild(bubble); } let cur = bubble.querySelector(':scope > .turn-cur'); if (!cur) { cur = document.createElement('div'); cur.className = 'turn-cur'; bubble.appendChild(cur); } cur.dataset.turn = turn ?? 0; const body = visibleCurrBody || ''; const prevBody = r._draftPaintBody || ''; if (!tryPatchInflightToolDom(cur, body, prevBody)) { cur.innerHTML = renderTurnBody(body) + ''; postRenderEnhance(cur); } else if (!cur.querySelector('.cursor')) { cur.insertAdjacentHTML('beforeend', ''); } r._draftPaintBody = body; if (wasNear) { const inCodeScroll = document.activeElement?.closest?.('.code-block pre, .fold-pre') && r.draftEl.contains(document.activeElement); if (!inCodeScroll) scrollBottom(); } } function flushTypewriter(sess) { resetTypewriterState(rt(sess)); } /* ═══════════════ 运行状态 ═══════════════ */ function pageStatusBar(btnEl) { const label = btnEl?.querySelector('.rs-label'); return { /** state: 'ready' | 'busy' | 'offline' | 'connecting';兼容旧调用 set(text, true) */ set(text, state = 'ready') { if (!btnEl) return; const mode = state === true ? 'busy' : (state === false ? 'ready' : state); btnEl.classList.remove('busy', 'offline', 'connecting'); if (mode === 'busy') btnEl.classList.add('busy'); else if (mode === 'offline') btnEl.classList.add('offline'); else if (mode === 'connecting') btnEl.classList.add('connecting'); if (label) label.textContent = text ?? ''; }, setBusy(text) { this.set(text, 'busy'); }, setReady() { this.set(t('status.ready'), 'ready'); }, setDisconnected() { this.set(t('status.disconnected'), 'offline'); }, setConnecting() { this.set(t('status.connecting'), 'connecting'); }, }; } function refreshStatusLabel() { const s = activeSess(); if (s && rt(s).busy) { chatStatus.setBusy(formatTaskElapsed(Date.now() - (rt(s).taskStartedAt || Date.now()))); } else if (state.bridgeReady) { chatStatus.setReady(); } else { chatStatus.setDisconnected(); } } /* ═══════════════ 消息计时 ═══════════════ */ function formatTaskElapsed(ms) { const v = Number(ms); if (!Number.isFinite(v) || v < 0) return ''; const sec = Math.round(v / 1000); if (sec < 60) return t('timing.elapsed').replace('{t}', `${Math.max(1, sec)}s`); const min = Math.floor(sec / 60), s = sec % 60; if (min < 60) return t('timing.elapsed').replace('{t}', `${min}m ${s}s`); const hr = Math.floor(min / 60), m = min % 60; return t('timing.elapsed').replace('{t}', `${hr}h ${m}m`); } function ensureTaskElapsedBadge(wrap, startedAt, endedAt) { if (!wrap || !startedAt) return null; let badge = wrap.querySelector(':scope > .task-elapsed'); if (!badge) { badge = document.createElement('div'); badge.className = 'task-elapsed'; wrap.prepend(badge); } const elapsed = (endedAt || Date.now()) - startedAt; badge.textContent = formatTaskElapsed(elapsed); badge.dataset.live = endedAt ? '' : '1'; return badge; } function startTaskTimer(sess) { const r = rt(sess); if (r.taskStartedAt) return; // 已在计时,不重置 // 优先从消息时间戳恢复(刷新后持久化) const msgs = sess.messages; let restored = 0; if (msgs && msgs.length) { for (let i = msgs.length - 1; i >= 0; i--) { if (msgs[i].role === 'user' && msgs[i].ts) { restored = msgs[i].ts * 1000; break; } } } r.taskStartedAt = restored || Date.now(); r.taskEndedAt = null; if (r.taskTimerId) clearInterval(r.taskTimerId); r.taskTimerId = setInterval(() => { if (!r.taskStartedAt) return; const el = r.draftEl || document.querySelector('.msg-list .msg.assistant:last-child'); if (el) ensureTaskElapsedBadge(el, r.taskStartedAt, null); // 更新左上角状态栏显示实时耗时 if (isActive(sess)) { chatStatus.setBusy(formatTaskElapsed(Date.now() - r.taskStartedAt)); } }, 1000); } function stopTaskTimer(sess) { const r = rt(sess); if (r.taskTimerId) { clearInterval(r.taskTimerId); r.taskTimerId = null; } if (!r.taskStartedAt) return; r.taskEndedAt = Date.now(); } function setBusy(sess, busy) { const r = rt(sess); if (r.busy && !busy) resetTypewriterState(r); r.busy = busy; if (busy) startTaskTimer(sess); else stopTaskTimer(sess); if (!isActive(sess)) return; if (busy) { chatStatus.setBusy(formatTaskElapsed(Date.now() - (r.taskStartedAt || Date.now()))); } else if (state.bridgeReady) { chatStatus.setReady(); } else { chatStatus.setDisconnected(); } if (sendBtn) { sendBtn.classList.toggle('is-stop', busy); sendBtn.setAttribute('aria-label', busy ? t('act.stop') : t('act.send')); sendBtn.title = busy ? t('act.stop') : ''; } } // run-toggle 现为纯状态展示组件:运行中转红,不再响应点击(停止改由发送键的录制键承担) /* ═══════════════ 会话 ═══════════════ */ function isUntitled(x) { return !x || /^(new chat|新对话|新会话)$/i.test(String(x).trim()); } function sortedSessions() { // display order: pinned first, then most-recently-active. [0] is the topmost. return [...state.sessions.values()].sort((a, b) => { if (a.pinned && !b.pinned) return -1; if (!a.pinned && b.pinned) return 1; return (b.lastActiveTs || 0) - (a.lastActiveTs || 0); }); } function renderSessionList() { convListEl.innerHTML = ''; const query = (searchInput ? searchInput.value : '').trim().toLowerCase(); const all = sortedSessions(); const filtered = query ? all.filter(s => { const title = displayTitle(s).toLowerCase(); const hasMsg = s.messages && s.messages.some(m => (m.text || '').toLowerCase().includes(query)); return title.includes(query) || hasMsg; }) : all; if (filtered.length === 0) { const e = document.createElement('div'); e.className = 'conv-empty'; e.textContent = t('conv.emptyList'); convListEl.appendChild(e); return; } for (const sess of filtered) { const r = state.runtime.get(sess.id); const busy = !!(r && r.busy); const item = document.createElement('div'); item.className = 'conv-item' + (currentPage === 'chat' && sess.id === state.activeId ? ' active' : '') + (busy ? '' : ' idle'); item.dataset.id = sess.id; const pinSvg = sess.pinned ? GA_ICON('pushPinSimple', 'ci-pin') : ''; item.innerHTML = `
` + `
${pinSvg}${escapeHtml(displayTitle(sess))}
` + `
${busy ? t('status.running') : t('status.idle')}
` + ``; convListEl.appendChild(item); } } if (searchInput) searchInput.addEventListener('input', () => renderSessionList()); async function ensureBridgeSession(sess) { if (sess.bridgeSessionId) return sess.bridgeSessionId; const res = await window.ga.rpc('session/new', { cwd: '', mcp_servers: [] }); if (res?.error) throw new Error(res.error.message || res.error); sess.bridgeSessionId = res.sessionId || res.result?.sessionId; return sess.bridgeSessionId; } // 仿 TUI(continue_cmd.py 的 _preview_text 思路):sess.title 只在用户手动 rename 时被填, // 平时为空;sidebar 显示名实时从消息派生 —— 优先取最后一段 assistant 输出里的 ..., // 其次用首条用户消息纯文本,都没有时回退到 t('conv.defaultTitle')。 function isAutoTitle(x) { const s = String(x || '').trim(); if (!s) return true; if (/^(new chat|新对话|新会话)$/i.test(s)) return true; if (/^agent-\d+$/i.test(s)) return true; // 兼容上一轮误存的 agent-N return false; } function displayTitle(sess) { if (sess && sess.title && !isAutoTitle(sess.title)) return sess.title; const msgs = (sess && sess.messages) || []; // 1) 优先:最后一段 assistant 文本里的 ... for (let i = msgs.length - 1; i >= 0; i--) { const m = msgs[i]; if (!m || m.role !== 'assistant') continue; const txt = assistantStructuredText(m); const sm = /([\s\S]*?)<\/summary>/i.exec(txt); if (sm && sm[1].trim()) { const line = sm[1].trim().split('\n')[0].trim(); if (line) return line.length > 60 ? line.slice(0, 60) + '…' : line; } } // 2) 兜底:首条用户消息纯文本(去附件占位符) for (const m of msgs) { if (!m || m.role !== 'user') continue; const raw = typeof m.content === 'string' ? m.content : (m.display || ''); const clean = stripAttachPlaceholders(raw).trim(); if (clean) return clean.length > 40 ? clean.slice(0, 40) + '…' : clean; } return t('conv.defaultTitle'); } async function newSession() { const localId = 'local-' + Date.now() + '-' + Math.random().toString(16).slice(2); const sess = { id: localId, bridgeSessionId: null, title: '', messages: [], untitled: true, lastActiveTs: Date.now() }; state.sessions.set(localId, sess); try { await ensureBridgeSession(sess); state.sessions.delete(localId); sess.id = sess.bridgeSessionId; state.sessions.set(sess.id, sess); } catch (e) { showError(t('err.newSession') + ': ' + (e.message || e)); } setActiveSession(sess.id); saveSessions(); renderSessionList(); } function sessionNeedsHydrate(sess) { return !!(sess?.bridgeSessionId && state.bridgeReady && !sess.messages.length); } function runSessionHydrate(sess) { setSessionLoading(true); const tid = setTimeout(() => { if (isActive(sess)) setSessionLoading(false); }, HYDRATE_LOADING_TIMEOUT_MS); return hydrateSession(sess).finally(() => { clearTimeout(tid); if (isActive(sess)) setSessionLoading(false); }); } function setActiveSession(id) { setSessionLoading(false); state.activeId = id; if (id) localStorage.setItem('ga_active', id); // 持久化当前会话,刷新后固定恢复它 const sess = state.sessions.get(id); if (!sess) return; if (msgsEl) msgsEl.innerHTML = ''; const r = rt(sess); r.draftEl = null; resetTypewriterState(r); renderAllMessages(sess); setBusy(sess, rt(sess).busy); renderSessionList(); refreshPlanBar(null); syncPlanPollTimer(); if (!sess.bridgeSessionId || !state.bridgeReady) return; if (sessionNeedsHydrate(sess)) { runSessionHydrate(sess); } else { restoreElapsedBadges(sess, ensureMsgs()); planPoll(sess); } } async function closeSession(id) { const sess = state.sessions.get(id); if (sess && sess.bridgeSessionId) { try { await window.ga.rpc('session/cancel', { sessionId: sess.bridgeSessionId }); } catch (_) {} fetch(`${BRIDGE_ORIGIN}/session/${sess.bridgeSessionId}`, { method: 'DELETE' }).catch(() => {}); } state.sessions.delete(id); state.runtime.delete(id); if (state.activeId === id) { const next = (sortedSessions()[0] || {}).id || null; // 切到列表最靠上的会话 if (next) setActiveSession(next); else { state.activeId = null; localStorage.removeItem('ga_active'); if (msgsEl) msgsEl.innerHTML = ''; refreshEmptyState(null); refreshStatusLabel(); } } saveSessions(); renderSessionList(); } const convMenu = document.getElementById('conv-menu'); let menuTargetId = null; convListEl.addEventListener('click', (e) => { const more = e.target.closest('.ci-more'); if (more) { e.stopPropagation(); menuTargetId = more.closest('.conv-item').dataset.id; // 根据当前会话置顶状态切菜单文案:置顶 / 取消置顶 const tgt = state.sessions.get(menuTargetId); const pinSpan = convMenu.querySelector('[data-act="pin"] [data-i18n]'); if (pinSpan) { const k = tgt && tgt.pinned ? 'ctx.unpin' : 'ctx.pin'; pinSpan.setAttribute('data-i18n', k); pinSpan.textContent = t(k); } convMenu.hidden = false; const rect = more.getBoundingClientRect(); convMenu.style.top = (rect.bottom + 4) + 'px'; convMenu.style.left = (rect.right - convMenu.offsetWidth) + 'px'; return; } const it = e.target.closest('.conv-item'); if (it && it.dataset.id) { setActiveSession(it.dataset.id); const chatNav = nav.querySelector('.nav-item[data-page="chat"]'); if (chatNav && !chatNav.classList.contains('active')) chatNav.click(); } }); convMenu.addEventListener('click', (e) => { e.stopPropagation(); const act = e.target.closest('.ctx-item')?.dataset.act; const sess = menuTargetId && state.sessions.get(menuTargetId); if (sess && act === 'pin') { if (sess.pinned) { sess.pinned = false; // 取消置顶 + 放到 pinned 之后、其它 unpinned 之前(unpinned 区域顶部) const others = [...state.sessions.values()].filter(s => s.id !== sess.id); const m = new Map(); for (const s of others) if (s.pinned) m.set(s.id, s); // 先所有仍 pinned 的 m.set(sess.id, sess); // 再本会话(刚 unpinned) for (const s of others) if (!s.pinned) m.set(s.id, s); // 再其它 unpinned state.sessions = m; } else { sess.pinned = true; // 置顶 + 移到列表顶 const m = new Map(); m.set(sess.id, sess); for (const [k, v] of state.sessions) if (k !== sess.id) m.set(k, v); state.sessions = m; } saveSessions(); patchSession(sess, { pinned: sess.pinned }); renderSessionList(); } else if (sess && act === 'rename') { convMenu.hidden = true; const item = convListEl.querySelector(`.conv-item[data-id="${sess.id}"]`); if (!item) return; const titleEl = item.querySelector('.ci-title'); if (!titleEl) return; const oldTitle = sess.title || ''; const inp = document.createElement('input'); inp.className = 'ci-rename-input'; inp.maxLength = 50; inp.value = oldTitle; titleEl.replaceWith(inp); bindToastLimit(inp); inp.focus(); inp.select(); const finish = (save) => { if (inp._done) return; inp._done = true; const val = inp.value.trim(); if (save && val && val !== oldTitle) { sess.title = val; sess.untitled = false; saveSessions(); patchSession(sess, { title: val, untitled: false }); const history = tokLoadHistory(); const sid = sess.bridgeSessionId || sess.id; let changed = false; history.forEach(h => { if (h.sessionId === sid) { h.title = val; changed = true; } }); if (changed) tokSaveHistory(history); } renderSessionList(); }; inp.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); finish(true); } else if (e.key === 'Escape') { e.preventDefault(); finish(false); } }); inp.addEventListener('blur', () => finish(true)); return; } else if (sess && act === 'del') { closeSession(sess.id); } convMenu.hidden = true; }); document.addEventListener('click', () => { convMenu.hidden = true; }); newConvBtn.addEventListener('click', (e) => { e.preventDefault(); newSession(); }); /* ═══════════════ 轮询 + 流式 ═══════════════ */ function normalize(m) { const o = { id: Number(m.id || 0), role: m.role || 'system' }; if (m.role !== 'assistant') o.content = m.content || ''; if (typeof m.display === 'string' && m.display.length) o.display = m.display; if (m.stopped) o.stopped = true; if (m.images) o.images = m.images; if (m.files) o.files = m.files; if (m.ts) o.ts = m.ts; // [turn_segs双轨] 透传结构化轮数组(若后端提供);落库消息与 partial 都可能带 if (Array.isArray(m.turn_segs)) o.turn_segs = m.turn_segs; if (typeof m.curr_turn === 'number') o.curr_turn = m.curr_turn; if (m.role === 'assistant' && !o.turn_segs?.length && typeof m.content === 'string') o.content = m.content; return o; } function upsert(sess, raw, partial) { const m = normalize(raw); const r = rt(sess); if (partial && m.role === 'assistant') { if (!r.draftEl) resetTypewriterState(r); const prevLen = (Array.isArray(r.draftSegs) ? (r.draftSegs[r.draftTurn] || '') : '').length; r.draftSegs = draftSegsFromPartial(raw, m); r.draftTurn = (typeof m.curr_turn === 'number') ? m.curr_turn : Math.max(0, r.draftSegs.length - 1); const curLen = (r.draftSegs[r.draftTurn] || '').length; const wasEmpty = prevLen === 0; // 新 draft(含刷新/停止后再开):对齐已有 partial,避免从 0 重播打字机 if ((!r.draftEl || wasEmpty) && curLen > 0) { r.draftRecoverPending = true; } const tw = r.twState; if (tw && curLen > (r.draftStreamBaseline || 0)) { const baseline = r.draftStreamBaseline || 0; if (tw.shown < baseline) tw.shown = baseline; } r.draftStreamBaseline = curLen; if (isActive(sess)) renderDraft(sess); return; } if (!m.id || r.seen.has(m.id)) return; r.seen.add(m.id); r.lastId = Math.max(r.lastId, m.id); if (m.role === 'assistant' && r.draftEl) { // done 收尾:用 final m 原地重画 bubble(保留 ljq 的"复用 draftEl 不闪烁"优化), // 同时彻底丢掉 partial 累积的 DOM——避免 frp 高延迟下漏掉的最后一拍丢字。 // assistantTurnSegs(m) 双轨处理 turn_segs/content,跟 msgNode refresh 路径同源。 flushTypewriter(sess); const segs = assistantTurnSegs(m); const curr = resolveVisibleTurnIndex(segs, m.curr_turn); r.draftSegs = segs; r.draftTurn = curr; r.streamTurn = curr; if (!r.twState) r.twState = { shown: 0, timer: null }; if (r.twState.timer) { clearInterval(r.twState.timer); r.twState.timer = null; } r.twState.turn = curr; r.twState.shown = (segs[curr] || '').length; r.draftRecoverPending = false; if (isActive(sess)) { let bubble = r.draftEl.querySelector(':scope > .bubble.md'); if (!bubble) { bubble = document.createElement('div'); bubble.className = 'bubble md'; r.draftEl.appendChild(bubble); } bubble.innerHTML = renderAssistantTurnsHtml(segs, curr, false); postRenderEnhance(bubble); } const cursor = r.draftEl.querySelector('.cursor'); if (cursor) cursor.remove(); if (r.taskStartedAt) { ensureTaskElapsedBadge(r.draftEl, r.taskStartedAt, r.taskEndedAt || Date.now()); r.taskStartedAt = null; r.taskEndedAt = null; } if (!r.draftEl.querySelector('.bubble-copy-btn')) { const copyBtn = document.createElement('button'); copyBtn.className = 'bubble-copy-btn'; copyBtn.title = t('act.copy'); copyBtn.innerHTML = SVG_COPY_ICON; copyBtn.addEventListener('click', (e) => { e.stopPropagation(); const text = assistantCopyText(m); navigator.clipboard.writeText(text).then(() => { copyBtn.innerHTML = SVG_CHECK_ICON; setTimeout(() => { copyBtn.innerHTML = SVG_COPY_ICON; }, 1500); }); }); r.draftEl.appendChild(copyBtn); } r.draftEl = null; r.draftSegs = null; r.draftTurn = 0; r.streamTurn = 0; sess.messages.push(m); refreshEmptyState(sess); if (m.role === 'assistant' || m.role === 'user') syncAskUserUi(); saveSessions(); return; } sess.messages.push(m); appendMessage(sess, m); saveSessions(); } async function fetchSessionPoll(sess, opts = {}) { const r = rt(sess); const sid = sess.bridgeSessionId || sess.id; const afterId = opts.after ?? r.lastId ?? 0; const limit = opts.limit ?? POLL_MSG_LIMIT; const res = await window.ga.rpc('session/poll', { sessionId: sid, afterId, limit }); if (res?.error) throw new Error(res.error.message || res.error); return res.result || res; } function applyPollResult(sess, result) { if (result.partial) upsert(sess, result.partial, true); for (const msg of (result.messages || [])) upsert(sess, msg, false); const busy = result.status === 'running' || !!result.partial; setBusy(sess, busy); if (isActive(sess)) { applyPlanPayload(sess, result.plan); applyLiveModel(result.model, sess); } return busy; } /** 渠道组随故障转移变化时,用运行态当前子模型刷新 chip(非渠道组/无 agent 时不动,保持静态显示) */ function applyLiveModel(live, sess = activeSess()) { const selected = (state.modelProfiles || []).find(p => (p.id ?? 0) === state.llmNo); if (!selected || selected.kind !== 'mixin' || !live || !live.isMixin || !live.current) return; state.liveModel = { ...live, sessionId: sess?.id || state.activeId }; const label = `${t('model.aggregationShort')}${lang === 'en' ? ' (' : '('}${profileLabel(live.current) || live.current}${lang === 'en' ? ')' : ')'}`; if (state.modelName !== label) { state.modelName = label; updateModelChip(); } } /** hydrate 批量灌历史,避免逐条 appendMessage 触发全量重绘 */ function hydrateHistoryMessages(sess, messages) { const r = rt(sess); for (const raw of (messages || [])) { const m = normalize(raw); if (!m.id || r.seen.has(m.id)) continue; r.seen.add(m.id); r.lastId = Math.max(r.lastId, m.id); sess.messages.push(m); } if (isActive(sess)) renderAllMessages(sess); } /** 拉历史:limit=0 一次拿全量(bridge 不截断);不等 idle,running 续交给 pollSession */ async function hydrateSession(sess) { try { const result = await fetchSessionPoll(sess, { after: 0, limit: 0 }); hydrateHistoryMessages(sess, result.messages); if (result.partial) upsert(sess, result.partial, true); const busy = result.status === 'running' || !!result.partial; setBusy(sess, busy); if (isActive(sess)) applyPlanPayload(sess, result.plan); if (busy && !rt(sess).polling) pollSession(sess); } catch (e) { showError(t('err.poll') + ': ' + (e.message || e)); setBusy(sess, false); } finally { if (isActive(sess)) { restoreElapsedBadges(sess, ensureMsgs()); syncAskUserUi(); } tokPollBridge(); } } async function pollSession(sess) { const r = rt(sess); if (r.polling) { r.pollAgain = true; return; } r.polling = true; r.pollAgain = false; /* 手机切后台再回前台时,第一拍 fetch 经常用着死连接秒挂(Failed to fetch), 但只要给链路 1-2 秒重建,后续就稳。原版一炸就 showError,体验糟。 改成:同一次 polling 循环里连续失败 ≥ MAX_ERRORS 次才放弃, 单次失败做指数退避(1s / 2s / 4s / 8s),够 ride through 一次后台恢复抖动。 */ const MAX_ERRORS = 5; let consecutiveErrors = 0; try { do { try { const result = await fetchSessionPoll(sess); consecutiveErrors = 0; const busy = applyPollResult(sess, result); if (busy) await new Promise(z => setTimeout(z, 500)); else { if (r.draftEl) { r.draftEl.remove(); r.draftEl = null; r.draftSegs = null; r.draftTurn = 0; } resetTypewriterState(r); break; } } catch (innerErr) { consecutiveErrors++; if (consecutiveErrors >= MAX_ERRORS) throw innerErr; const backoff = Math.min(8000, 1000 * Math.pow(2, consecutiveErrors - 1)); await new Promise(z => setTimeout(z, backoff)); } } while (true); } catch (e) { showError(t('err.poll') + ': ' + (e.message || e)); setBusy(sess, false); } finally { r.polling = false; renderSessionList(); // 历史消息已全部加载,恢复已完成任务的耗时 badge if (isActive(sess)) { restoreElapsedBadges(sess, ensureMsgs()); syncAskUserUi(); } tokPollBridge(); if (r.pollAgain) { r.pollAgain = false; pollSession(sess); } } } function removeUsedPendingFiles(usedFiles) { if (!usedFiles.length) return; const usedSids = new Set(usedFiles.map(f => f.sid)); const touched = new Set(usedFiles.map(f => fileCtx(f))); state.pendingFiles = state.pendingFiles.filter(f => !usedSids.has(f.sid)); touched.forEach(ctx => renderThumbStrip(ctx)); } function clearDraft(sess) { const r = rt(sess); resetTypewriterState(r); if (r.draftEl) { r.draftEl.remove(); r.draftEl = null; r.draftSegs = null; r.draftTurn = 0; } } async function waitSessionIdle(sess, maxMs = 4000) { const start = Date.now(); while (rt(sess).busy && Date.now() - start < maxMs) { await new Promise(z => setTimeout(z, 100)); } return !rt(sess).busy; } function setSessionLoading(on) { if (!msgArea || !sessionLoadingEl) return; if (on && msgArea.classList.contains('is-loading')) return; msgArea.classList.toggle('is-session-loading', !!on); sessionLoadingEl.hidden = !on; if (on && sessionLoadingEl.querySelector('[data-i18n]')) { sessionLoadingEl.querySelector('[data-i18n]').textContent = t('chat.sessionLoading'); } } function setMsgLoading(on) { if (msgArea) msgArea.classList.toggle('is-loading', !!on); if (msgLoading) { msgLoading.hidden = !on; if (on) { setSessionLoading(false); scrollBottom(); } } } function setComposerLocked(on) { if (composerEl) composerEl.classList.toggle('is-locked', !!on); if (inputEl) inputEl.contentEditable = on ? 'false' : 'true'; // contenteditable 无 readOnly,改切 contentEditable if (sendBtn) { sendBtn.disabled = !!on; sendBtn.classList.toggle('is-busy', !!on); sendBtn.setAttribute('aria-busy', on ? 'true' : 'false'); } } /** stapp.py 同款:运行中再发 → cancel 当前轮次,等 idle 后再提交新 prompt */ async function interruptBeforeSend(sess) { if (!rt(sess).busy) return true; const t0 = Date.now(); setMsgLoading(true); try { clearDraft(sess); try { const res = await window.ga.rpc('session/cancel', { sessionId: sess.bridgeSessionId || sess.id }); if (res?.error) throw new Error(res.error.message || res.error); } catch (e) { showChanToast(t('err.stop') + ': ' + (e.message || e), '', 'err'); return false; } showChanToast(t('sys.interruptPrev.hint'), '', 'info'); const idle = await waitSessionIdle(sess); clearDraft(sess); if (!idle) { showChanToast(t('err.interruptTimeout'), '', 'err'); return false; } return true; } finally { const wait = Math.max(0, MIN_MSG_LOADING_MS - (Date.now() - t0)); if (wait) await new Promise(r => setTimeout(r, wait)); setMsgLoading(false); } } /* ═══════════════ 发送 / 取消 ═══════════════ */ async function sendPrompt(text) { text = String(text || '').trim(); if (!text) return false; if (!state.bridgeReady) { showError(t('err.bridge')); return false; } if (!state.activeId) { await newSession(); if (!state.activeId) return false; } const sess = activeSess(); const r = rt(sess); if (r.busy) { const interrupted = await interruptBeforeSend(sess); if (!interrupted) return false; } // PLAN/AUTO 现在是预设功能(preset 卡片)一次性发送,不再是常驻 prefix const composedPrompt = expandFilePlaceholders(text).trim(); const usedFiles = collectUsedFiles(text); const userMsg = { role: 'user', content: text, ts: Date.now() / 1000 }; const previewImgs = usedFiles.filter(f => f.isImage).map(f => ({ id: 'f-' + f.sid, name: f.name, path: f.path, dataUrl: f.dataUrl || '' })); if (previewImgs.length) userMsg.images = previewImgs; const previewFiles = usedFiles.filter(f => !f.isImage).map(f => ({ id: 'f-' + f.sid, name: f.name, path: f.path })); if (previewFiles.length) userMsg.files = previewFiles; sess.messages.push(userMsg); appendMessage(sess, userMsg); if (isPlanPresetPrompt(text)) { const pr = rt(sess); pr.planCollapsed = false; pr.planShowAll = false; const sidHint = (sess.bridgeSessionId || sess.id || 'sess').replace(/\//g, '_'); applyPlanPayload(sess, { active: true, placeholder: true, done: 0, total: 0, complete: false, step: '', pathHint: `plan_${sidHint}/plan.md`, items: [], }); } sess.lastActiveTs = Date.now(); // 仿 TUI:不再从首条消息自动改名 —— 标题在 newSession 时已设为 agent-N, // 之后只接受用户手动 rename。 saveSessions(); setBusy(sess, true); try { let sid = await ensureBridgeSession(sess); try { await bridgeFetch(`/session/${encodeURIComponent(sid)}/restore`, { method: 'POST', body: {} }); } catch (restoreErr) { if (/not found/i.test(restoreErr.message || '')) { sess.bridgeSessionId = null; sid = await ensureBridgeSession(sess); state.sessions.delete(sess.id); sess.id = sess.bridgeSessionId; state.sessions.set(sess.id, sess); state.activeId = sess.id; localStorage.setItem('ga_active', sess.id); // 会话 id 因 bridge 重建而变更,同步持久化 } } const res = await window.ga.rpc('session/prompt', { sessionId: sid, prompt: composedPrompt, display: text, llmNo: state.llmNo, files: previewFiles, imageMetas: previewImgs.map(im => ({ name: im.name, path: im.path })) }); if (res?.error) throw new Error(res.error.message || res.error); removeUsedPendingFiles(usedFiles); const uid = Number(res.userMessageId || res.result?.userMessageId || 0); if (uid) { r.seen.add(uid); r.lastId = Math.max(r.lastId, uid); } planPoll(sess); pollSession(sess); return true; } catch (e) { const em = { role: 'error', content: e.message || String(e) }; sess.messages.push(em); appendMessage(sess, em); setBusy(sess, false); return false; } } async function cancelPrompt() { const sess = activeSess(); if (!sess || !rt(sess).busy) return false; try { const res = await window.ga.rpc('session/cancel', { sessionId: sess.bridgeSessionId || sess.id }); if (res?.error) throw new Error(res.error.message || res.error); clearDraft(sess); rt(sess).pollAgain = true; return true; } catch (e) { showError(t('err.stop') + ': ' + (e.message || e)); return false; } } /* ═══════════════ 输入区 / slash / 预设 ═══════════════ */ async function submitInput() { if (_submitInFlight) return; let text = composerText('chat'); if (!text.trim()) return; if (text.trim().startsWith('/')) { inputEl.innerHTML = ''; handleSlash(text.trim()); return; } if (text.length > 20000) { text = text.slice(0, 20000); showToast(t('err.charLimit').replace('{n}', 20000), 'warn'); } _submitInFlight = true; setComposerLocked(true); try { const sent = await sendPrompt(text); if (sent) { inputEl.innerHTML = ''; } } finally { _submitInFlight = false; setComposerLocked(false); syncAskUserUi(); } } sendBtn.addEventListener('click', (e) => { e.preventDefault(); const sess = activeSess(); if (sess && rt(sess).busy) { cancelPrompt(); return; } // 运行中:发送键是录制键 → 纯停止 submitInput(); }); inputEl.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && e.keyCode !== 229) { e.preventDefault(); submitInput(); } }); // 输入框的 input/paste 监听统一在 bindComposerUpload(ctx) 里绑(chat + collab 通用) function showSystem(text) { const sess = activeSess(); if (!sess) return; const m = { role: 'system', content: text }; sess.messages.push(m); appendMessage(sess, m); } function showError(text) { const sess = activeSess(); if (sess) { const m = { role: 'error', content: text }; sess.messages.push(m); appendMessage(sess, m); } else console.error(text); } let _toastTimer = null; function showToast(text) { let el = document.getElementById('ga-toast'); if (!el) { el = document.createElement('div'); el.id = 'ga-toast'; el.className = 'ga-toast'; document.body.appendChild(el); } el.textContent = text; el.classList.add('show'); clearTimeout(_toastTimer); _toastTimer = setTimeout(() => el.classList.remove('show'), 1800); } async function handleSlash(cmd) { const name = cmd.slice(1).split(/\s+/)[0]; switch (name) { case 'help': showSystem(t('slash.help')); break; case 'new': await newSession(); break; case 'clear': { const s = activeSess(); if (s) { s.messages = []; renderAllMessages(s); } break; } case 'stop': if (await cancelPrompt()) showSystem(t('sys.stopRequested')); break; case 'settings': openSettings(); break; default: showSystem(t('slash.unknown') + ': /' + name); } } // 预设卡:按 data-preset 解耦(与翻译后的标题无关) document.querySelectorAll('.feature-grid').forEach(grid => { grid.addEventListener('click', (e) => { const editBtn = e.target.closest('.fc-edit'); if (editBtn) { e.stopPropagation(); const cp = state.customPresets.find(p => p.id === editBtn.dataset.editId); if (cp) openCustomPresetEditor(cp); return; } const xBtn = e.target.closest('.fc-x'); if (xBtn) { e.stopPropagation(); const kind = xBtn.dataset.removeKind; const id = xBtn.dataset.removeId; if (kind === 'builtin') hideBuiltinPreset(id); else if (kind === 'custom') removeCustomPreset(id); return; } const card = e.target.closest('.fcard'); if (!card || !grid.contains(card)) return; const key = card.dataset.preset; if (key === 'add') { closeModals(); openModal('custom-preset-modal'); resetCustomPresetForm(); return; } if (card.classList.contains('fcard-custom')) { const id = card.dataset.id; const cp = state.customPresets.find(p => p.id === id); if (cp) { closeModals(); sendPrompt(cp.prompt); } return; } if (!key) { inputEl.focus(); closeModals(); return; } const bp = BUILTIN_PRESETS.find(p => p.key === key); if (bp?.navigate) { closeModals(); gaGoPage(bp.navigate); window.collabFocus?.(); return; } const prompt = I18N[lang]['presetPrompt.' + key] || I18N.zh['presetPrompt.' + key]; closeModals(); if (prompt) sendPrompt(prompt); }); }); /* ═══════════════ 模型 / 设置 ═══════════════ */ function updateModelChip() { const name = state.modelName || ''; if (modelNameEl) modelNameEl.textContent = name; if (collabModelNameEl) collabModelNameEl.textContent = name; } function modelDisplayName(p, fallbackName) { if (p && p.kind === 'mixin') { // 静态回退显示「渠道组(首选模型名)」;运行后由 applyLiveModel 切到真实当前子模型。 const primary = (p.members || [])[0]; if (!primary) return t('model.aggregation'); const open = lang === 'en' ? ' (' : '(', close = lang === 'en' ? ')' : ')'; return `${t('model.aggregationShort')}${open}${profileLabel(primary) || primary}${close}`; } return profileLabel(fallbackName ?? (p && p.name)) || (fallbackName ?? (p && p.name)) || null; } async function selectModel(id, name) { state.llmNo = id; state.liveModel = null; const p = (state.modelProfiles || []).find(x => (x.id ?? 0) === id); state.modelName = modelDisplayName(p, name); updateModelChip(); renderSettingsModels(); await persistUiPrefs(); } async function addToMixin(id) { try { const res = await bridgeFetch(`/model-profiles/${id}/mixin`, { method: 'POST', body: {} }); if (res?.ok === false || res?.error) throw new Error(res.error || t('err.mixinFailed')); state.modelProfiles = normalizeProfiles(res.profiles || []); renderSettingsModels(); } catch (ex) { showChanToast(t('err.mixinFailed'), ex.message || '', 'err'); } } async function removeFromMixin(id) { try { const res = await bridgeFetch(`/model-profiles/${id}/mixin`, { method: 'DELETE', body: {} }); if (res?.ok === false || res?.error) throw new Error(res.error || t('err.mixinFailed')); state.modelProfiles = normalizeProfiles(res.profiles || []); renderSettingsModels(); } catch (ex) { showChanToast(t('err.mixinFailed'), ex.message || '', 'err'); } } async function reorderMixin(members) { try { const res = await bridgeFetch('/model-profiles/mixin/order', { method: 'PUT', body: { members } }); if (res?.ok === false || res?.error) throw new Error(res.error || t('err.mixinFailed')); state.modelProfiles = normalizeProfiles(res.profiles || []); renderSettingsModels(); const active = state.modelProfiles.find(p => (p.id ?? 0) === state.llmNo); if (active) { state.modelName = modelDisplayName(active); updateModelChip(); } } catch (ex) { showChanToast(t('err.mixinFailed'), ex.message || '', 'err'); renderSettingsModels(); } } function flipReorder(container, mutate) { const rows = [...container.querySelectorAll('.model-member:not(.dragging)')]; const first = new Map(rows.map(el => [el, el.getBoundingClientRect()])); mutate(); rows.forEach(el => { const a = first.get(el), b = el.getBoundingClientRect(); if (!a) return; const dx = a.left - b.left, dy = a.top - b.top; if (!dx && !dy) return; el.style.transition = 'none'; el.style.transform = `translate(${dx}px, ${dy}px)`; requestAnimationFrame(() => { el.style.transition = 'transform .14s cubic-bezier(.2,.8,.2,1)'; el.style.transform = ''; }); }); } function bindMixinDrag(body, members) { let drag = null; const clear = () => { body.querySelectorAll('.model-member').forEach(x => x.classList.remove('dragging', 'drag-over')); document.body.classList.remove('mixin-dragging'); }; body.addEventListener('pointerdown', (e) => { const handle = e.target.closest('.model-member-drag'); if (!handle || e.button !== 0) return; const row = handle.closest('.model-member'); if (!row) return; e.preventDefault(); handle.setPointerCapture?.(e.pointerId); drag = { handle, row, name: row.dataset.member, order: [...members], original: [...members], pointerId: e.pointerId, over: null }; row.classList.add('dragging'); document.body.classList.add('mixin-dragging'); }); body.addEventListener('pointermove', (e) => { if (!drag) return; const over = document.elementFromPoint(e.clientX, e.clientY)?.closest('.model-member'); if (!over || !body.contains(over) || over === drag.row) return; const rect = over.getBoundingClientRect(); const after = e.clientY > rect.top + rect.height / 2; const overKey = `${over.dataset.member}:${after ? 'after' : 'before'}`; if (overKey === drag.over) return; drag.over = overKey; const from = drag.order.indexOf(drag.name), overIdx = drag.order.indexOf(over.dataset.member); if (from < 0 || overIdx < 0) return; const [moved] = drag.order.splice(from, 1); let insertAt = drag.order.indexOf(over.dataset.member) + (after ? 1 : 0); drag.order.splice(insertAt, 0, moved); flipReorder(body, () => { if (after) body.insertBefore(drag.row, over.nextSibling); else body.insertBefore(drag.row, over); }); }); const finish = (e) => { if (!drag) return; drag.handle.releasePointerCapture?.(drag.pointerId); const changed = JSON.stringify(drag.order) !== JSON.stringify(drag.original); const next = drag.order; drag = null; clear(); if (changed) reorderMixin(next); }; body.addEventListener('pointerup', finish); body.addEventListener('pointercancel', finish); } const MODEL_ACT_EDIT = GA_ICON('pencilSimple'); const MODEL_ACT_DEL = GA_ICON('trash'); let editingModelId = null; function setModelApikeyMode(isAdd) { const apikey = document.getElementById('model-apikey-input'); const apikeyReq = document.querySelector('#model-apikey-label .field-req'); if (!apikey) return; apikey.required = isAdd; apikey.dataset.i18nPh = isAdd ? 'model.apikeyPh' : 'model.apikeyKeep'; if (isAdd) apikey.removeAttribute('data-optional-ph'); else { apikey.value = ''; apikey.setAttribute('data-optional-ph', ''); } if (apikeyReq) apikeyReq.hidden = !isAdd; } /* ═══════════════ 官方模型快速接入(DeepSeek / 通义千问)═══════════════ */ // 预填 API 地址 / 协议 / 模型,用户只需粘贴 API Key。apibase 末尾的 /v1 会被 // 后端自动补成 /v1/chat/completions(见 mykey_template.py 的拼接规则)。 const PROVIDER_PRESETS = { deepseek: { label: 'DeepSeek', descKey: 'pq.deepseekDesc', protocol: 'oai', apibase: 'https://api.deepseek.com/v1', model: 'deepseek-v4-pro', name: 'DeepSeek', keyUrl: 'https://platform.deepseek.com/api_keys', color: '#4D6BFE', tint: 'rgba(77,107,254,.12)', logo: '', }, qwen: { label: '通义千问', descKey: 'pq.qwenDesc', protocol: 'oai', apibase: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen3.6-max-preview', name: '通义千问', keyUrl: 'https://bailian.console.aliyun.com/?apiKey=1', color: '#615CED', tint: 'rgba(97,92,237,.12)', logo: '', }, }; window.gaProviderPresets = PROVIDER_PRESETS; // 在「添加模型」弹窗顶部显示/隐藏接入指引横幅。key 为 null 时隐藏。 function setModelGuide(key) { const box = document.getElementById('model-guide'); if (!box) return; const p = key && PROVIDER_PRESETS[key]; if (!p) { box.hidden = true; box.dataset.provider = ''; return; } box.hidden = false; box.dataset.provider = key; const logo = document.getElementById('model-guide-logo'); if (logo) { logo.innerHTML = p.logo || ''; logo.style.background = p.tint || ''; } const nameEl = document.getElementById('model-guide-name'); if (nameEl) nameEl.textContent = p.label; const link = document.getElementById('model-guide-link'); if (link) { link.href = p.keyUrl; link.textContent = t('guide.getKey').replace('{name}', p.label); } } window.gaRefreshModelGuide = () => { const box = document.getElementById('model-guide'); if (box && !box.hidden && box.dataset.provider) setModelGuide(box.dataset.provider); }; function openAddModelFormForProvider(key) { const p = PROVIDER_PRESETS[key]; if (!p) return openAddModelForm(); editingModelId = null; const form = document.getElementById('add-model-form'); const title = document.getElementById('model-form-title'); const errEl = document.getElementById('add-model-err'); if (title) title.dataset.i18n = 'modal.addModel'; if (form) { form.reset(); form.model.value = p.model || ''; form.apibase.value = p.apibase || ''; form.name.value = p.name || ''; const pr = form.querySelector(`input[name="protocol"][value="${p.protocol}"]`); if (pr) pr.checked = true; } setModelApikeyMode(true); if (errEl) { errEl.hidden = true; errEl.textContent = ''; } setModelGuide(key); openModal('add-model-modal'); applyI18n(); const apikey = document.getElementById('model-apikey-input'); if (apikey) setTimeout(() => apikey.focus(), 60); } function openAddModelForm() { editingModelId = null; const form = document.getElementById('add-model-form'); const title = document.getElementById('model-form-title'); const errEl = document.getElementById('add-model-err'); if (title) title.dataset.i18n = 'modal.addModel'; if (form) form.reset(); setModelApikeyMode(true); setModelGuide(null); if (errEl) { errEl.hidden = true; errEl.textContent = ''; } openModal('add-model-modal'); applyI18n(); } async function openEditModelForm(id) { editingModelId = id; setModelGuide(null); const errEl = document.getElementById('add-model-err'); if (errEl) { errEl.hidden = true; errEl.textContent = ''; } try { const res = await bridgeFetch(`/model-profiles/${id}`); const p = res.profile; if (!p) throw new Error(t('err.modelSave')); const form = document.getElementById('add-model-form'); const title = document.getElementById('model-form-title'); if (title) title.dataset.i18n = 'modal.editModel'; if (form) { form.model.value = p.model || ''; form.apibase.value = p.apibase || ''; form.name.value = p.name || ''; form.max_retries.value = p.max_retries ?? 5; form.connect_timeout.value = p.connect_timeout ?? 15; form.read_timeout.value = p.read_timeout ?? 300; // 编辑模式:按 varName 回填协议分段控件 const pv = /claude/i.test(p.varName || '') ? 'claude' : 'oai'; const pr = form.querySelector(`input[name="protocol"][value="${pv}"]`); if (pr) pr.checked = true; // 回填流式开关(默认流式) const sv = (p.stream === false) ? 'false' : 'true'; const sr = form.querySelector(`input[name="stream"][value="${sv}"]`); if (sr) sr.checked = true; } setModelApikeyMode(false); openModal('add-model-modal'); applyI18n(); } catch (ex) { showChanToast(t('err.modelSave'), ex.message || '', 'err'); } } async function deleteModel(id, name) { const label = profileLabel(name) || name || ('#' + id); if (!(await showConfirmDialog({ title: t('common.delete'), message: `${t('confirm.modelDelete')}\n${label}`, okText: t('common.delete'), okKind: 'danger' }))) return; try { const res = await bridgeFetch(`/model-profiles/${id}`, { method: 'DELETE', body: {} }); if (res?.ok === false || res?.error) throw new Error(res.error || t('err.modelDelete')); const wasActive = state.llmNo === id; const oldNo = state.llmNo; state.modelProfiles = normalizeProfiles(res.profiles || []); if (wasActive) { const p = state.modelProfiles[0]; if (p) await selectModel(p.id ?? 0, p.name); else { state.llmNo = 0; state.modelName = null; updateModelChip(); } } else if (oldNo > id) { const p = state.modelProfiles[oldNo - 1]; if (p) await selectModel(p.id ?? (oldNo - 1), p.name); } renderSettingsModels(); } catch (ex) { const msg = ex.message || ''; showChanToast(msg.includes('last profile') ? t('err.modelDeleteLast') : t('err.modelDelete'), msg.includes('last profile') ? '' : msg, 'err'); } } function renderSettingsModels() { const box = document.getElementById('model-list'); if (!box) return; box.innerHTML = ''; const list = state.modelProfiles || []; const mixin = list.find(p => p.kind === 'mixin'); const natives = list.filter(p => p.kind !== 'mixin'); const byName = new Map(natives.map(p => [p.name, p])); // ── 渠道组(自动故障转移):可展开组;本身也可被选为激活模型 ── if (mixin) { const gid = mixin.id ?? 0; const members = mixin.members || []; const expanded = state.mixinExpanded !== false; // 默认展开 const group = document.createElement('div'); group.className = 'model-group'; const head = document.createElement('label'); head.className = 'model-row model-row--mixin' + (state.llmNo === gid ? ' sel' : ''); head.innerHTML = `${GA_ICON(expanded ? 'caretDown' : 'caretRight')}${escapeHtml(t('model.aggregation'))}`; head.querySelector('[data-act="toggle"]').addEventListener('click', (e) => { e.stopPropagation(); e.preventDefault(); state.mixinExpanded = !expanded; renderSettingsModels(); }); head.addEventListener('click', (e) => { if (e.target.closest('[data-act="toggle"]')) return; e.preventDefault(); selectModel(gid, mixin.name); }); group.appendChild(head); if (expanded) { const body = document.createElement('div'); body.className = 'model-mixin-body'; if (!members.length) { const em = document.createElement('div'); em.className = 'model-mixin-empty'; em.textContent = t('model.emptyMixin'); body.appendChild(em); } else { members.forEach((mName, i) => { const mp = byName.get(mName); const row = document.createElement('div'); row.className = 'model-member'; row.dataset.member = mName; row.innerHTML = `${escapeHtml(profileLabel(mName) || mName)}`; row.querySelector('[data-act="unmix"]').addEventListener('click', (e) => { e.stopPropagation(); e.preventDefault(); if (mp) removeFromMixin(mp.id ?? 0); }); body.appendChild(row); }); bindMixinDrag(body, members); } group.appendChild(body); } box.appendChild(group); } // ── 独立模型(聚合渠道组已自带分隔,这里不再单列标题)── if (!natives.length) { const empty = document.createElement('div'); empty.className = 'set-empty'; empty.textContent = t('set.noModels'); box.appendChild(empty); } else { for (const p of natives) { const id = p.id ?? 0; const label = profileLabel(p.name) || p.name || ('#' + id); const row = document.createElement('label'); row.className = 'model-row' + (state.llmNo === id ? ' sel' : ''); // 独立列表按钮统一为「加入渠道组」(➕);移除只在渠道组展开区做。 // 已在渠道组的,按钮仍是「加入」,但点击只提示「已在渠道组中」,并用 is-in 给个淡淡的视觉区分。 const mixToggle = !mixin ? '' : ``; row.innerHTML = `${escapeHtml(label)}${mixToggle}`; row.querySelector('[data-act="edit"]').addEventListener('click', (e) => { e.stopPropagation(); e.preventDefault(); openEditModelForm(id); }); row.querySelector('[data-act="delete"]').addEventListener('click', (e) => { e.stopPropagation(); e.preventDefault(); deleteModel(id, p.name); }); const addBtn = row.querySelector('[data-act="addmix"]'); if (addBtn) addBtn.addEventListener('click', (e) => { e.stopPropagation(); e.preventDefault(); if (p.inMixin) showToast(t('model.alreadyInMixin')); else addToMixin(id); }); row.addEventListener('click', (e) => { if (e.target.closest('.model-row-actions')) return; e.preventDefault(); selectModel(id, p.name); }); box.appendChild(row); } } applyI18n(); } function openSettings() { openModal('settings-modal'); renderSettingsModels(); renderLangList(); applyTheme(theme, { persist: false }); applyAppearance(appearance, plainUi, { persist: false }); applyChatFontSize(chatFontSize, { persist: false }); } async function loadModelProfiles() { try { const res = await window.ga.getModelProfiles(); const list = res?.profiles || res?.result?.profiles || []; state.modelProfiles = normalizeProfiles(list); const active = state.modelProfiles.find(p => p.active) || state.modelProfiles[0]; if (active) { state.llmNo = active.id ?? 0; state.modelName = modelDisplayName(active); } updateModelChip(); renderSettingsModels(); } catch (_) {} } /* ═══════════════ 模型菜单(chat + conductor 共用一份逻辑,各自一个 DOM) ═══════════════ */ const modelMenu = document.getElementById('model-menu'); const collabModelMenu = document.getElementById('cdb-model-menu'); function renderModelMenu(menuEl) { if (!menuEl) return; const list = state.modelProfiles || []; const rows = list.map((p, i) => { const no = (p.id ?? i); const isActive = (state.llmNo === no) ? ' active' : ''; const label = (isActive && p.kind === 'mixin' && state.modelName) ? state.modelName : modelDisplayName(p); return `
${escapeHtml(label || '')}
`; }); menuEl.innerHTML = rows.join(''); applyI18n(); } function openModelMenu(chipEl, menuEl) { if (!chipEl || !menuEl) return; if (typeof convMenu !== 'undefined' && convMenu) convMenu.hidden = true; window.collabComposer?.closeMenu?.(); closeAllModelMenus(); renderModelMenu(menuEl); menuEl.hidden = false; chipEl.classList.add('open'); const chipRect = chipEl.getBoundingClientRect(); const composer = chipEl.closest('.composer'); if (composer) { const composerRect = composer.getBoundingClientRect(); menuEl.style.left = (chipRect.left - composerRect.left) + 'px'; menuEl.style.bottom = (composerRect.bottom - chipRect.top + 4) + 'px'; } } function closeAllModelMenus() { if (modelMenu) modelMenu.hidden = true; if (collabModelMenu) collabModelMenu.hidden = true; if (modelChip) modelChip.classList.remove('open'); if (collabModelChip) collabModelChip.classList.remove('open'); } function bindModelMenuItemClick(menuEl) { if (!menuEl) return; menuEl.addEventListener('click', (e) => { e.stopPropagation(); const item = e.target.closest('.ga-menu-item'); if (!item) return; const no = parseInt(item.dataset.llmno, 10); if (Number.isNaN(no)) return; const p = (state.modelProfiles || []).find(x => (x.id ?? 0) === no); selectModel(no, (p && p.name) || ''); closeAllModelMenus(); }); } bindModelMenuItemClick(modelMenu); bindModelMenuItemClick(collabModelMenu); if (modelChip) modelChip.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); if (modelMenu && !modelMenu.hidden) { closeAllModelMenus(); return; } openModelMenu(modelChip, modelMenu); }); if (collabModelChip) collabModelChip.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); if (collabModelMenu && !collabModelMenu.hidden) { closeAllModelMenus(); return; } openModelMenu(collabModelChip, collabModelMenu); }); document.addEventListener('click', (e) => { if (e.target.closest('#model-menu') || e.target.closest('#model-chip') || e.target.closest('#cdb-model-menu') || e.target.closest('#cdb-model-chip') || e.target.closest('#chat-menu') || e.target.closest('#chat-plus-btn') || e.target.closest('#cdb-menu') || e.target.closest('#cdb-plus-btn')) return; closeAllModelMenus(); window.chatComposer?.closeMenu?.(); window.collabComposer?.closeMenu?.(); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { closeAllModelMenus(); window.chatComposer?.closeMenu?.(); window.collabComposer?.closeMenu?.(); } }); // 主题色板已删除,点击事件不再注册 const appearanceSeg = document.getElementById('appearance-seg'); if (appearanceSeg) appearanceSeg.addEventListener('click', (e) => { const btn = e.target.closest('.appear-card[data-appearance]'); if (!btn) return; const isLight = btn.dataset.appearance === 'light'; applyAppearance(btn.dataset.appearance, isLight && plainUi); }); const plainUiSwitch = document.getElementById('plain-ui-switch'); if (plainUiSwitch) plainUiSwitch.addEventListener('click', () => { if (appearance === 'light') applyAppearance('light', !plainUi); }); async function loadBridgeConfig() { try { const res = await window.ga.getConfig(); const cfg = res?.config || {}; if (LANGS.includes(cfg.lang)) { lang = cfg.lang; applyI18n(); } if (cfg.theme != null) applyTheme(cfg.theme, { persist: false }); if (cfg.appearance) applyAppearance(cfg.appearance, !!cfg.plain, { persist: false }); if (cfg.fontSize != null) applyChatFontSize(cfg.fontSize, { persist: false }); if (cfg.llmNo != null && state.modelProfiles.length) { const p = state.modelProfiles.find(x => (x.id ?? 0) === cfg.llmNo); if (p) { state.llmNo = cfg.llmNo; state.modelName = modelDisplayName(p); updateModelChip(); renderSettingsModels(); } } syncBootCache(); } catch (_) {} } const addModelForm = document.getElementById('add-model-form'); if (addModelForm) addModelForm.addEventListener('submit', async (e) => { e.preventDefault(); const errEl = document.getElementById('add-model-err'); const fd = new FormData(addModelForm); const payload = Object.fromEntries(fd.entries()); const isEdit = editingModelId != null; if (!payload.apibase?.trim() || !payload.model?.trim()) { if (errEl) { errEl.textContent = t('err.modelRequired'); errEl.hidden = false; } return; } if (!isEdit && !payload.apikey?.trim()) { if (errEl) { errEl.textContent = t('err.modelRequired'); errEl.hidden = false; } return; } try { const res = isEdit ? await bridgeFetch(`/model-profiles/${editingModelId}`, { method: 'PUT', body: payload }) : await bridgeFetch('/model-profiles', { method: 'POST', body: payload }); if (res?.ok === false || res?.error) throw new Error(res.error || t('err.modelSave')); state.modelProfiles = normalizeProfiles(res.profiles || []); const pid = isEdit ? editingModelId : (res.profileId ?? state.modelProfiles.at(-1)?.id ?? 0); const p = state.modelProfiles.find(x => (x.id ?? 0) === pid) || state.modelProfiles.at(-1); if (p) await selectModel(p.id ?? pid, p.name); document.getElementById('add-model-modal').hidden = true; addModelForm.reset(); editingModelId = null; if (errEl) errEl.hidden = true; } catch (ex) { if (errEl) { errEl.textContent = (ex.message || t('err.modelSave')); errEl.hidden = false; } } }); /* ═══════════════ 文件上传(图片+任意文件,tuiapp_v2 模式) ═══════════════ */ const MAX_UPLOAD_FILES = 10; const MAX_UPLOAD_BYTES = 500 * 1024 * 1024; // 500 MB const IMG_EXT_RE = /\.(png|jpe?g|gif|webp|bmp|svg)$/i; const thumbStrip = document.getElementById('thumb-strip'); const chatPanel = document.querySelector('main.main'); let activeFileComposer = 'chat'; function fileCtx(f) { return f.ctx || 'chat'; } function filesForCtx(ctx) { return state.pendingFiles.filter(f => fileCtx(f) === ctx); } function composerPageEl(ctx) { const page = ctx === 'collab' ? 'collab' : 'chat'; return document.querySelector(`.page--chat-ui[data-page="${page}"]`); } function composerRootEl(ctx) { return composerPageEl(ctx)?.querySelector('.composer'); } function composerCfg(ctx = activeFileComposer) { const root = composerRootEl(ctx); const page = composerPageEl(ctx); return { input: root?.querySelector('.composer-inset .input') || null, strip: root?.querySelector('.thumb-strip') || null, uploadBtn: null, imgInput: root?.querySelector('input[type="file"]') || null, dropZone: ctx === 'collab' ? page : chatPanel, }; } function renderThumbStrip(ctx = activeFileComposer) { const cfg = composerCfg(ctx); if (!cfg.strip) return; const files = filesForCtx(ctx); if (files.length === 0) { cfg.strip.innerHTML = ''; cfg.strip.hidden = true; return; } cfg.strip.innerHTML = files.map(f => { if (f.isImage && f.dataUrl) { return `
`; } const name = f.name || 'file'; const label = name.replace(/[<>&]/g, c => ({ '<': '<', '>': '>', '&': '&' }[c])); const sub = fileSubLabel(name).replace(/[<>&]/g, c => ({ '<': '<', '>': '>', '&': '&' }[c])); const path = (f.path || '').replace(/[<>&"]/g, c => ({ '<': '<', '>': '>', '&': '&', '"': '"' }[c])); const dataName = name.replace(/[<>&"]/g, c => ({ '<': '<', '>': '>', '&': '&', '"': '"' }[c])); return `
${GA_ICON('fileText')}${label}${sub}
`; }).join(''); cfg.strip.hidden = false; applyI18n(); } // 在 ctx 对应输入框(contenteditable)的光标处插入原子 chip(删不进中间,像 @人) function insertPlaceholderInComposer(file, ctx = activeFileComposer) { const input = composerCfg(ctx).input; if (!input) return; const chip = document.createElement('span'); chip.className = 'ph-chip'; chip.setAttribute('contenteditable', 'false'); chip.dataset.sid = String(file.sid); chip.dataset.kind = file.isImage ? 'image' : 'file'; chip.textContent = file.name || 'file'; input.focus(); const sel = window.getSelection(); let range; if (sel && sel.rangeCount && input.contains(sel.getRangeAt(0).commonAncestorContainer)) { range = sel.getRangeAt(0); } else { range = document.createRange(); range.selectNodeContents(input); range.collapse(false); } range.deleteContents(); range.insertNode(chip); const sp = document.createTextNode(' '); // chip 后补一个 nbsp,便于继续打字/定位光标 chip.after(sp); range.setStartAfter(sp); range.collapse(true); if (sel) { sel.removeAllRanges(); sel.addRange(range); } input.dispatchEvent(new Event('input', { bubbles: true })); } // 按 sid 从该文件所属 ctx 的输入框移除 chip(连同紧邻空格) function removePlaceholderFromComposer(file) { const input = composerCfg(fileCtx(file)).input; if (!input) return; const chip = input.querySelector(`.ph-chip[data-sid="${file.sid}"]`); if (!chip) return; const next = chip.nextSibling; if (next && next.nodeType === 3) next.nodeValue = next.nodeValue.replace(/^[\s ]/, ''); chip.remove(); input.dispatchEvent(new Event('input', { bubbles: true })); } // 读取 contenteditable 输入框为纯文本:chip → [Image #N]/[File #N],
/
→ 换行 function readComposerTextFrom(input) { if (!input) return ''; const ser = (node, first) => { if (node.nodeType === 3) return node.nodeValue; if (node.nodeType !== 1) return ''; if (node.classList && node.classList.contains('ph-chip')) { const kind = node.dataset.kind === 'image' ? 'Image' : 'File'; return `[${kind} #${node.dataset.sid}]`; } if (node.tagName === 'BR') return '\n'; let inner = ''; node.childNodes.forEach(c => { inner += ser(c, false); }); return (first ? '' : '\n') + inner; }; let out = ''; input.childNodes.forEach((n, i) => { out += ser(n, i === 0); }); return out.replace(/ /g, ' '); } function composerText(ctx = activeFileComposer) { return readComposerTextFrom(composerCfg(ctx).input); } function isImageFile(f) { return (f && (f.type || '').startsWith('image/')) || IMG_EXT_RE.test(f?.name || ''); } function placeholderFor(file) { return file.isImage ? `[Image #${file.sid}]` : `[File #${file.sid}]`; } function expandFilePlaceholders(text) { return text.replace(/\[(Image|File) #(\d+)\]/g, (m, kind, n) => { const f = state.pendingFiles.find(x => x.sid === Number(n)); return (f && f.path) ? f.path : ''; // #3 悬空占位符(无对应文件)→ 删掉,不把垃圾发给 agent }); } function collectUsedFiles(text) { const used = []; text.replace(/\[(Image|File) #(\d+)\]/g, (m, kind, n) => { const f = state.pendingFiles.find(x => x.sid === Number(n)); if (f) used.push(f); return m; }); return used; } // ── 附件占位符健壮性 ───────────────────────────────────────────── // 统一移除一个待发附件:出列 + (可选)抹占位符 + 重绘 + 删 bridge 上的文件 function removePendingFile(sid, { stripPlaceholder = false } = {}) { const idx = state.pendingFiles.findIndex(f => f.sid === sid); if (idx < 0) return; const removed = state.pendingFiles.splice(idx, 1)[0]; if (stripPlaceholder) removePlaceholderFromComposer(removed); renderThumbStrip(fileCtx(removed)); if (removed.path) { fetch(`${BRIDGE_ORIGIN}/upload`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: removed.path }), }).catch(() => {}); } } // #1 对账:DOM 里 chip 没了(被原子删除/退格整块删)→ 同步移除附件 + 删磁盘文件 function reconcilePendingFiles(ctx = activeFileComposer) { const input = composerCfg(ctx).input; if (!input) return; const present = new Set([...input.querySelectorAll('.ph-chip[data-sid]')].map(c => Number(c.dataset.sid))); for (const f of filesForCtx(ctx).filter(x => !present.has(x.sid))) { removePendingFile(f.sid, { stripPlaceholder: false }); } } async function uploadOne(name, dataUrl, sid) { const res = await fetch(`${BRIDGE_ORIGIN}/upload`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, dataUrl, sid: sid || '' }), }); const j = await res.json(); if (!j.ok) throw new Error(j.error || 'upload failed'); return j.path; } async function addFiles(fileList) { const files = Array.from(fileList || []); if (files.length === 0) return; let skipped = false; let emptyHit = false; const accepted = []; for (const f of files) { if (!f || f.size === 0) { emptyHit = true; continue; } if (f.size > MAX_UPLOAD_BYTES) { skipped = true; continue; } if (state.pendingFiles.length + accepted.length >= MAX_UPLOAD_FILES) { skipped = true; break; } accepted.push(f); } if (emptyHit) showChanToast(t('upload.empty'), '', 'err'); if (accepted.length === 0) { if (skipped) showChanToast(t('upload.tooLarge'), '', 'err'); return; } const ctx = activeFileComposer; let uploadSid = ''; if (ctx === 'collab') { uploadSid = 'collab'; } else { let upSess = activeSess(); if (!upSess) { await newSession(); upSess = activeSess(); } if (upSess && !upSess.bridgeSessionId) { try { await ensureBridgeSession(upSess); } catch (_) {} } uploadSid = (upSess && upSess.bridgeSessionId) || ''; } for (const f of accepted) { try { const dataUrl = await new Promise((resolve, reject) => { const r = new FileReader(); r.onload = () => resolve(String(r.result || '')); r.onerror = () => reject(r.error); r.readAsDataURL(f); }); const path = await uploadOne(f.name || 'file', dataUrl, uploadSid); state.fileSeq += 1; const sid = state.fileSeq; const isImage = isImageFile(f); const entry = { sid, name: f.name || 'file', isImage, path, dataUrl: isImage ? dataUrl : '', ctx, }; state.pendingFiles.push(entry); insertPlaceholderInComposer(entry, ctx); renderThumbStrip(ctx); } catch (e) { showChanToast(t('upload.failed'), e.message || String(e), 'err'); } } if (skipped) showChanToast(t('upload.tooLarge'), '', 'err'); } function handleThumbStripClick(e, ctx) { const x = e.target.closest('.x'); if (x) { const sid = Number(x.dataset.sid); const idx = state.pendingFiles.findIndex(f => f.sid === sid && fileCtx(f) === ctx); if (idx >= 0) { const removed = state.pendingFiles[idx]; state.pendingFiles.splice(idx, 1); removePlaceholderFromComposer(removed); renderThumbStrip(ctx); if (removed.path) { fetch(`${BRIDGE_ORIGIN}/upload`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: removed.path }), }).catch(() => {}); } } return; } const fileChip = e.target.closest('.file-chip.pending'); if (fileChip) { const path = fileChip.getAttribute('data-path'); const name = fileChip.getAttribute('data-name'); if (path) openUploadFile(path, name); return; } const img = e.target.closest('img'); if (img && img.src) openLightbox(img.src); } function bindComposerUpload(ctx) { const cfg = composerCfg(ctx); if (!cfg.input || cfg.input.dataset.gaUploadBound) return; cfg.input.dataset.gaUploadBound = ctx; if (cfg.uploadBtn && !cfg.uploadBtn.dataset.bound) { cfg.uploadBtn.dataset.bound = '1'; cfg.uploadBtn.addEventListener('click', (e) => { e.preventDefault(); activeFileComposer = ctx; cfg.imgInput?.click(); }); } if (cfg.imgInput && !cfg.imgInput.dataset.bound) { cfg.imgInput.dataset.bound = '1'; cfg.imgInput.addEventListener('change', () => { activeFileComposer = ctx; addFiles(cfg.imgInput.files); cfg.imgInput.value = ''; }); } cfg.strip?.addEventListener('click', (e) => handleThumbStripClick(e, ctx)); cfg.input.addEventListener('paste', (e) => { activeFileComposer = ctx; const cd = e.clipboardData || window.clipboardData; const items = cd && cd.items; const files = []; if (items) for (const it of items) { if (it.kind === 'file') { const f = it.getAsFile(); if (f) files.push(f); } } if (files.length) { e.preventDefault(); addFiles(files); return; } // 富文本粘贴 → 强制纯文本(contenteditable 默认会粘 HTML,会污染输入框) e.preventDefault(); const text = cd ? cd.getData('text/plain').replace(/\r\n/g, '\n') : ''; if (!text) return; // Hard-limit: only insert what fits within maxLen const maxLen = 20000; const curLen = cfg.input.textContent.length; const sel = window.getSelection(); const selLen = (sel.rangeCount && cfg.input.contains(sel.anchorNode)) ? sel.toString().length : 0; const remaining = maxLen - curLen + selLen; if (remaining <= 0) { showChanToast(t('err.charLimit').replace('{n}', maxLen), '', 'err'); return; } const insert = text.slice(0, remaining); document.execCommand('insertText', false, insert); if (text.length > remaining) showChanToast(t('err.charLimit').replace('{n}', maxLen), '', 'err'); }); cfg.input.addEventListener('input', () => { activeFileComposer = ctx; // 内容清空后浏览器可能残留
,抹掉以便 :empty 占位提示生效 if (!cfg.input.textContent.trim() && !cfg.input.querySelector('.ph-chip')) cfg.input.innerHTML = ''; reconcilePendingFiles(ctx); // chip 被删 → 同步清理附件 + 删磁盘文件 }); const zone = cfg.dropZone; const dropKey = `dropBound_${ctx}`; if (!zone || zone.dataset[dropKey]) return; zone.dataset[dropKey] = '1'; let dragDepth = 0; const hasFiles = (e) => { const types = e.dataTransfer && e.dataTransfer.types; if (!types) return false; for (let i = 0; i < types.length; i += 1) { if (types[i] === 'Files') return true; } return false; }; zone.addEventListener('dragenter', (e) => { if (!hasFiles(e)) return; e.preventDefault(); activeFileComposer = ctx; dragDepth += 1; zone.classList.add('dragover'); zone.dataset.dropHint = t('upload.dropHint'); }); zone.addEventListener('dragover', (e) => { if (!hasFiles(e)) return; e.preventDefault(); activeFileComposer = ctx; e.dataTransfer.dropEffect = 'copy'; }); zone.addEventListener('dragleave', (e) => { if (!hasFiles(e)) return; dragDepth = Math.max(0, dragDepth - 1); if (dragDepth === 0) zone.classList.remove('dragover'); }); zone.addEventListener('drop', (e) => { if (!hasFiles(e)) return; e.preventDefault(); dragDepth = 0; zone.classList.remove('dragover'); activeFileComposer = ctx; addFiles(e.dataTransfer.files); }); } bindComposerUpload('chat'); bindComposerUpload('collab'); Object.assign(window, { gaSetActiveFileComposer: ctx => { activeFileComposer = ctx === 'collab' ? 'collab' : 'chat'; }, gaPageStatusBar: pageStatusBar, gaExpandFilePlaceholders: expandFilePlaceholders, gaRenderMsgChips: renderMsgTextWithChips, gaCollectUsedFiles: collectUsedFiles, gaComposerText: composerText, gaClearUsedPendingFiles: text => removeUsedPendingFiles(collectUsedFiles(text)), gaFileSubLabel: fileSubLabel, gaMsgNode: msgNode, gaCollabItemToMsg: collabItemToMsg, gaPostRenderEnhance: postRenderEnhance, gaEscapeHtml: escapeHtml, }); if (chatPanel) { const blockFileDrop = e => { const types = e.dataTransfer?.types; if (!types) return; for (let i = 0; i < types.length; i += 1) if (types[i] === 'Files') { e.preventDefault(); return; } }; window.addEventListener('dragover', blockFileDrop); window.addEventListener('drop', blockFileDrop); } /* ═══════════════ bridge 事件 ═══════════════ */ window.ga.onBridgeReady(async () => { state.bridgeReady = true; syncPlanPollTimer(); refreshStatusLabel(); if (!state.activeId) { refreshEmptyState(null); } await loadModelProfiles(); await loadBridgeConfig(); if (isServicesPageActive()) renderChannelList(gaServiceStore.list()); const sess = activeSess(); if (sess && sessionNeedsHydrate(sess)) { await runSessionHydrate(sess); } else if (sess) planPoll(sess); delete document.documentElement.dataset.bootHasSessions; if (sess) refreshEmptyState(sess); }); setTimeout(() => { delete document.documentElement.dataset.bootHasSessions; }, 3000); window.ga.onBridgeNotification((msg) => { if (msg && msg.type === 'session-state') { for (const sess of state.sessions.values()) { if (sess.bridgeSessionId === msg.sessionId) { if (msg.status === 'running' || msg.state === 'running') pollSession(sess); if (msg.state === 'idle' || msg.status === 'idle') tokPollBridge(); renderSessionList(); break; } } } }); window.ga.onBridgeError((err) => { console.warn('[bridge error]', err); }); window.ga.onBridgeClosed(() => { state.bridgeReady = false; syncPlanPollTimer(); const s = activeSess(); if (s) applyPlanPayload(s, null); chatStatus.setDisconnected(); }); /* ═══════════════ Token 用量页 ═══════════════ */ const tokTbody = document.getElementById('tok-tbody'); const tokTable = document.getElementById('tok-table'); const tokPager = document.getElementById('tok-pager'); const tokSince = document.getElementById('tok-since'); const tokUntil = document.getElementById('tok-until'); const tokTotalN = document.getElementById('tok-total-n'); const tokTodayN = document.getElementById('tok-today-n'); const tokCostN = document.getElementById('tok-cost-n'); const TOK_PER_PAGE = 15; let _tokPage = 0; let _tokHistory = []; let _tokLastSnap = {}; // Model price table: $/M tokens [input, output] const MODEL_PRICES = { 'gpt-5.4':[2.50,15],'gpt-5':[1.25,10],'gpt-5-mini':[0.25,2],'gpt-4o':[2.50,10],'gpt-4o-mini':[0.15,0.60], 'gpt-4.1':[2,8],'gpt-4.1-mini':[0.40,1.60],'gpt-4.1-nano':[0.10,0.40],'o4-mini':[0.55,2.20], 'claude-opus-4-8':[5,25],'claude-opus-4-7':[5,25],'claude-opus-4-6':[5,25],'claude-sonnet-4-6':[3,15],'claude-sonnet-4-5':[3,15],'claude-haiku-4-5':[1,5], 'deepseek-v4':[0.14,0.28],'deepseek-v4-pro':[0.435,0.87],'deepseek-chat':[0.14,0.28],'deepseek-reasoner':[0.55,2.19], 'glm-5.1':[0.50,0.50],'minimax-m2.7':[0.50,0.50],'kimi-for-coding':[0.50,2], }; const CNY_RATE = 7.2; function estCost(inp, out, model, cacheRead, cacheCreate) { let p = [3,15]; if (model) { const m = model.toLowerCase().replace(/\[.*\]/,''); p = MODEL_PRICES[m] || Object.entries(MODEL_PRICES).find(([k])=>m.includes(k))?.[1] || p; } const isClaudeOrDS = model && /claude|deepseek/i.test(model); const cacheReadRate = isClaudeOrDS ? 0.1 : 0.5; const cacheWriteRate = isClaudeOrDS ? 1.25 : 1.0; const cost = (inp*p[0] + out*p[1] + (cacheRead||0)*p[0]*cacheReadRate + (cacheCreate||0)*p[0]*cacheWriteRate) / 1e6 * CNY_RATE; return cost.toFixed(2); } function fmtTok(n) { return n>=1e6?(n/1e6).toFixed(2)+'M':n>=1e3?(n/1e3).toFixed(1)+'k':String(n); } function fmtTime(ts) { return new Date(ts*1000).toLocaleString(undefined,{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}); } function modelPriceTip(model) { if (!model) return ''; const m = model.toLowerCase().replace(/\[.*\]/,''); const entry = MODEL_PRICES[m] || Object.entries(MODEL_PRICES).find(([k])=>m.includes(k))?.[1]; const known = !!entry; const p = entry || [3,15]; const isClaudeOrDS = /claude|deepseek/i.test(m); const cacheReadRate = isClaudeOrDS ? 0.1 : 0.5; const cacheWriteRate = isClaudeOrDS ? 1.25 : 1.0; const lines = []; if (!known) lines.push(t('tok.pricingUnknown')); lines.push(t('tok.priceInput') + p[0] + ' /M'); lines.push(t('tok.priceOutput') + p[1] + ' /M'); lines.push(t('tok.priceCacheW') + (p[0] * cacheWriteRate).toFixed(2) + ' /M'); lines.push(t('tok.priceCacheR') + (p[0] * cacheReadRate).toFixed(2) + ' /M'); return lines.join('\n'); } function tokLoadHistory() { return _tokHistory; } function tokSaveHistory(h) { _tokHistory = h; fetch(`${BRIDGE_ORIGIN}/token-history`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({history:h, snap:_tokLastSnap, conductorHist:_condHist, conductorLast:_condLast}) }).catch(()=>{}); } let _tokPolling = false; async function tokPollBridge() { if (_tokPolling) return; _tokPolling = true; try { if (!_tokHistory.length) { const stored = await bridgeFetch('/token-history'); if (stored.history?.length) _tokHistory = stored.history; if (stored.snap) _tokLastSnap = stored.snap; if (stored.conductorHist) _condHist = stored.conductorHist; if (stored.conductorLast) _condLast = stored.conductorLast; } const data = await bridgeFetch('/token-stats'); const history = tokLoadHistory(); for (const r of (data.records||[])) { const key = r.thread; const sid = key.replace('GA-',''); const sess = [...state.sessions.values()].find(s=>s.bridgeSessionId===sid); if (sess && rt(sess).busy) continue; const prev = _tokLastSnap[key] || {input:0,output:0,cacheCreate:0,cacheRead:0}; let di = r.input-prev.input, do_ = r.output-prev.output, dc = r.cacheCreate-prev.cacheCreate, dr = r.cacheRead-prev.cacheRead; if (di<0||do_<0||dc<0||dr<0) { di = r.input; do_ = r.output; dc = r.cacheCreate; dr = r.cacheRead; } if (di>0||do_>0||dc>0||dr>0) { const title = sess ? displayTitle(sess) : sid; history.push({sessionId:sid, title:title, input:di, output:do_, cacheCreate:dc, cacheRead:dr, model:r.model||'', ts:Date.now()/1000}); if(sess?.title) history.forEach(h=>{if(h.sessionId===sid&&(!h.title||h.title===sid))h.title=sess.title;}); } _tokLastSnap[key] = {input:r.input, output:r.output, cacheCreate:r.cacheCreate, cacheRead:r.cacheRead}; } tokSaveHistory(history); } catch(_) {} _tokPolling = false; } function tokGetFiltered() { let records = tokLoadHistory(); const parseD = v => v ? new Date(v.replace(/\s+/,'T')).getTime()/1000 : 0; const since = parseD(tokSince?.value); const until = parseD(tokUntil?.value); if (since) records = records.filter(r=>r.ts>=since); if (until) records = records.filter(r=>r.ts<=until); return records; } function tokRenderStats(filtered, all) { let total=0, totalInput=0, totalCacheRead=0, totalCacheCreate=0; filtered.forEach(r=>{total+=(r.input||0)+(r.output||0)+(r.cacheRead||0)+(r.cacheCreate||0); totalInput+=(r.input||0); totalCacheRead+=(r.cacheRead||0); totalCacheCreate+=(r.cacheCreate||0);}); if(tokTotalN) tokTotalN.textContent=fmtTok(total); const cacheBase = totalInput + totalCacheRead + totalCacheCreate; if(tokCostN) tokCostN.textContent= cacheBase > 0 ? (totalCacheRead / cacheBase * 100).toFixed(1) + '%' : '0%'; const todayStart=new Date(); todayStart.setHours(0,0,0,0); const todayTs=todayStart.getTime()/1000; let todayT=0; all.filter(r=>r.ts>=todayTs).forEach(r=>{todayT+=(r.input||0)+(r.output||0)+(r.cacheRead||0)+(r.cacheCreate||0);}); if(tokTodayN) tokTodayN.textContent=fmtTok(todayT); } function tokRenderTable(records) { if(!tokTbody) return; const bySession=new Map(); for(const r of records){ const k=r.sessionId||'?'; const ss= r._conductor ? null : [...state.sessions.values()].find(s=>s.bridgeSessionId===k); let title = ss ? displayTitle(ss) : (r.title||k); const deleted = r._conductor ? !!r._killed : !ss; if(!bySession.has(k)) bySession.set(k,{title:title,deleted:deleted,input:0,output:0,cacheCreate:0,cacheRead:0,lastTs:0,prompts:[]}); const s=bySession.get(k); s.input+=r.input||0; s.output+=r.output||0; s.cacheCreate+=r.cacheCreate||0; s.cacheRead+=r.cacheRead||0; if(r.ts>s.lastTs){s.lastTs=r.ts; s.title=title;} s.prompts.push(r); } tokTbody.innerHTML=''; if(bySession.size===0){tokTbody.innerHTML=`${t('tok.noData')}`;if(tokPager)tokPager.innerHTML='';return;} const sorted=[...bySession.values()].sort((a,b)=>b.lastTs-a.lastTs); const totalPages=Math.ceil(sorted.length/TOK_PER_PAGE); if(_tokPage>=totalPages)_tokPage=totalPages-1; const pageItems=sorted.slice(_tokPage*TOK_PER_PAGE,(_tokPage+1)*TOK_PER_PAGE); for(const s of pageItems){ const sCacheBase = s.input + s.cacheRead + s.cacheCreate; const sCacheRate = sCacheBase > 0 ? (s.cacheRead / sCacheBase * 100).toFixed(1) + '%' : '0%'; const tr=document.createElement('tr'); tr.className='tok-row-session'; tr.innerHTML=`${escapeHtml(s.title)}${s.deleted?''+t('tok.deleted')+'':''}${fmtTok(s.input)}${fmtTok(s.output)}${fmtTok(s.cacheCreate)}${fmtTok(s.cacheRead)}${sCacheRate}`; tokTbody.appendChild(tr); const details=[]; s.prompts.sort((a,b)=>b.ts-a.ts); for(const p of s.prompts){ const dr=document.createElement('tr'); dr.className='tok-detail'; dr.hidden=true; const modelHtml = p.model ? ` · ${escapeHtml(p.model)}` : ''; const pCacheBase = (p.input||0) + (p.cacheRead||0) + (p.cacheCreate||0); const pCacheRate = pCacheBase > 0 ? ((p.cacheRead||0) / pCacheBase * 100).toFixed(1) + '%' : '0%'; dr.innerHTML=`${fmtTime(p.ts)}${modelHtml}${fmtTok(p.input||0)}${fmtTok(p.output||0)}${fmtTok(p.cacheCreate||0)}${fmtTok(p.cacheRead||0)}${pCacheRate}`; tokTbody.appendChild(dr); details.push(dr); } tr.addEventListener('click',()=>{const o=tr.classList.toggle('open');details.forEach(d=>d.hidden=!o);}); } if(tokPager){renderTokPager(tokPager, totalPages, _tokPage, p => { _tokPage = p; tokRenderTable(records); });} } /* 分页按钮:首页/末页 + 当前页前后各 2 + 省略号,最多渲染 ~9 个 DOM, 1000 页和 10 页都长一样,不会一行排开拖死浏览器。 */ function renderTokPager(host, totalPages, currentPage, onJump) { host.innerHTML = ''; if (totalPages <= 1) return; const makeBtn = (label, page, opts = {}) => { const b = document.createElement('button'); if (opts.svg) b.innerHTML = label; else b.textContent = label; if (opts.active) b.classList.add('active'); if (opts.arrow) b.classList.add('tok-pager-arrow'); if (opts.disabled) b.disabled = true; if (!opts.disabled) b.addEventListener('click', () => onJump(page)); return b; }; const makeEllipsis = () => { const s = document.createElement('span'); s.className = 'tok-pager-gap'; s.textContent = '…'; return s; }; // 收集页码:1 / 当前-2..当前+2 / 末页,去重并填省略号 const pages = new Set([0, totalPages - 1]); for (let i = Math.max(0, currentPage - 2); i <= Math.min(totalPages - 1, currentPage + 2); i++) pages.add(i); const sorted = [...pages].sort((a, b) => a - b); // 首尾箭头用 phosphor 图标(跟侧栏 .chev 同款),不再用 Unicode 字符 host.appendChild(makeBtn(GA_ICON('caretLeft'), currentPage - 1, { svg: true, arrow: true, disabled: currentPage === 0 })); let prev = -1; for (const p of sorted) { if (prev >= 0 && p - prev > 1) host.appendChild(makeEllipsis()); host.appendChild(makeBtn(String(p + 1), p, { active: p === currentPage })); prev = p; } host.appendChild(makeBtn(GA_ICON('caretRight'), currentPage + 1, { svg: true, arrow: true, disabled: currentPage === totalPages - 1 })); } async function loadTokenPage(){await tokPollBridge();const f=tokGetFiltered();const all=tokLoadHistory();tokRenderStats(f,all);tokRenderTable(f);} const _COND_HIST_KEY = 'conductor_token_hist'; const _COND_LAST_KEY = 'conductor_token_last'; const _condZero = {input:0,output:0,cacheCreate:0,cacheRead:0,cost:0}; let _condHist = null, _condLast = null; function _condLoadHist() { return _condHist || {..._condZero}; } function _condLoadLast() { return _condLast; } function _condSave(hist, last) { _condHist = hist; _condLast = last; fetch(`${BRIDGE_ORIGIN}/token-history`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({history:_tokHistory, snap:_tokLastSnap, conductorHist:hist, conductorLast:last}) }).catch(()=>{}); } /* ─── Token tab switching ─── */ let _tokTab = 'chat'; const tokTabs = document.getElementById('tok-tabs'); const tokFilter = document.querySelector('.tok-filter'); const tokStatRow = document.querySelector('.page[data-page="token"] .stat-row'); if (tokTabs) tokTabs.addEventListener('click', e => { const btn = e.target.closest('.tok-tab'); if (!btn || btn.classList.contains('active')) return; tokTabs.querySelectorAll('.tok-tab').forEach(b => b.classList.remove('active')); btn.classList.add('active'); _tokTab = btn.dataset.tab; _tokPage = 0; if (_tokTab === 'conductor') { if (tokFilter) tokFilter.style.display = 'none'; if (tokStatRow) tokStatRow.style.display = 'none'; if (tokTable) tokTable.classList.add('tok-table--conductor'); loadConductorTokens(); } else { if (tokFilter) tokFilter.style.display = ''; if (tokStatRow) tokStatRow.style.display = ''; if (tokTable) tokTable.classList.remove('tok-table--conductor'); loadTokenPage(); } }); async function loadConductorTokens() { let curIn = 0, curOut = 0, curCc = 0, curCr = 0, curCost = 0; let fetchOk = false; try { const data = await (await fetch(`${CONDUCTOR_ORIGIN}/token-stats`)).json(); const recs = (data.records || []).filter(r => r.thread === 'conductor-agent' || r.thread.startsWith('subagent-')); for (const r of recs) { curIn += r.input || 0; curOut += r.output || 0; curCc += r.cacheCreate || 0; curCr += r.cacheRead || 0; curCost += parseFloat(estCost(r.input || 0, r.output || 0, r.model || '', r.cacheRead || 0, r.cacheCreate || 0)); } fetchOk = true; } catch (_) { if (tokTbody) tokTbody.innerHTML = `${t('tok.condOffline')}`; return; } const hist = _condLoadHist(); const last = _condLoadLast(); if (fetchOk && last && (curIn < last.input || curOut < last.output)) { hist.input += last.input; hist.output += last.output; hist.cacheCreate += last.cacheCreate; hist.cacheRead += last.cacheRead; hist.cost += last.cost; } if (fetchOk) _condSave(hist, {input:curIn, output:curOut, cacheCreate:curCc, cacheRead:curCr, cost:curCost}); const hIn = hist.input + curIn, hOut = hist.output + curOut, hCc = hist.cacheCreate + curCc, hCr = hist.cacheRead + curCr, hCost = hist.cost + curCost; if (!tokTbody) return; const tip = t('tok.condTip'); const _ci = GA_ICON('gitFork', 'tok-cond-ico'); const hCacheBase = hIn + hCr + hCc; const hCacheRate = hCacheBase > 0 ? (hCr / hCacheBase * 100).toFixed(1) + '%' : '0%'; const curCacheBase = curIn + curCr + curCc; const curCacheRate = curCacheBase > 0 ? (curCr / curCacheBase * 100).toFixed(1) + '%' : '0%'; tokTbody.innerHTML = `${_ci}${t('tok.condTotal')}${fmtTok(hIn)}${fmtTok(hOut)}${fmtTok(hCc)}${fmtTok(hCr)}${hCacheRate}${_ci}${t('tok.condCurrent')}${fmtTok(curIn)}${fmtTok(curOut)}${fmtTok(curCc)}${fmtTok(curCr)}${curCacheRate}`; const pager = document.getElementById('tok-pager'); if (pager) pager.innerHTML = ''; } /* Flatpickr 初始化 */ const _fpOpts = { enableTime:true, time_24hr:true, dateFormat:'Y-m-d H:i', locale:window.flatpickr?.l10ns?.[document.documentElement.lang==='en'?'default':'zh']||'default', allowInput:false, onChange(){ _tokPage=0; loadTokenPage(); } }; const fpSince = tokSince ? flatpickr(tokSince, _fpOpts) : null; const fpUntil = tokUntil ? flatpickr(tokUntil, _fpOpts) : null; const tokResetBtn=document.getElementById('tok-reset'); if(tokResetBtn)tokResetBtn.addEventListener('click',()=>{if(fpSince)fpSince.clear();if(fpUntil)fpUntil.clear();_tokPage=0;loadTokenPage();}); /* ─── Token trend chart ─── */ nav.addEventListener('click',(e)=>{const item=e.target.closest('.nav-item');if(item&&item.dataset.page==='token'){if(_tokTab==='conductor')loadConductorTokens();else loadTokenPage();}if(item&&item.dataset.page==='services')refreshServicesPanel();}); /* ═══════════════ 自定义预设 ═══════════════ */ const CP_KEY = 'ga_custom_presets'; const HB_KEY = 'ga_hidden_builtins'; const BUILTIN_PRESETS = [ { key: 'butler', titleKey: 'preset.butler.t', descKey: 'preset.butler.d', navigate: 'collab', get iconSvg() { return GA_ICON('gitFork', 'fc-ic'); } }, { key: 'plan', titleKey: 'preset.plan.t', descKey: 'preset.plan.d', promptKey: 'presetPrompt.plan', get iconSvg() { return GA_ICON('listChecks', 'fc-ic'); } }, { key: 'goal', titleKey: 'preset.goal.t', descKey: 'preset.goal.d', promptKey: 'presetPrompt.goal', get iconSvg() { return GA_ICON('crosshair', 'fc-ic'); } }, { key: 'autonomous', titleKey: 'preset.autonomous.t', descKey: 'preset.autonomous.d', promptKey: 'presetPrompt.autonomous', get iconSvg() { return GA_ICON('gridFour', 'fc-ic'); } }, { key: 'hive', titleKey: 'preset.hive.t', descKey: 'preset.hive.d', promptKey: 'presetPrompt.hive', get iconSvg() { return GA_ICON('hexagon', 'fc-ic'); } }, { key: 'review', titleKey: 'preset.review.t', descKey: 'preset.review.d', promptKey: 'presetPrompt.review', get iconSvg() { return GA_ICON('magnifyingGlass', 'fc-ic'); } }, { key: 'findwork', titleKey: 'preset.findwork.t', descKey: 'preset.findwork.d', promptKey: 'presetPrompt.findwork', get iconSvg() { return GA_ICON('robot', 'fc-ic'); } }, { key: 'mine', titleKey: 'preset.mine.t', descKey: 'preset.mine.d', promptKey: 'presetPrompt.mine', get iconSvg() { return GA_ICON('star', 'fc-ic'); } }, ]; const ADD_ICON_SVG = GA_ICON('plus', 'fc-ic'); // 自定义保存后生成的卡片图标(用户图标,表示"用户自定义的任务")—— 与"添加"卡的 + 区分 const CUSTOM_ICON_SVG = ''; state.customPresets = []; state.hiddenBuiltins = new Set(); function loadCustomPresets() { try { const raw = localStorage.getItem(CP_KEY); const arr = raw ? JSON.parse(raw) : []; state.customPresets = Array.isArray(arr) ? arr.filter(p => p && p.id && p.title && p.prompt) : []; } catch { state.customPresets = []; } } function saveCustomPresets() { localStorage.setItem(CP_KEY, JSON.stringify(state.customPresets)); } function loadHiddenBuiltins() { try { const raw = localStorage.getItem(HB_KEY); const arr = raw ? JSON.parse(raw) : []; state.hiddenBuiltins = new Set(Array.isArray(arr) ? arr.filter(k => typeof k === 'string') : []); } catch { state.hiddenBuiltins = new Set(); } } function saveHiddenBuiltins() { localStorage.setItem(HB_KEY, JSON.stringify([...state.hiddenBuiltins])); } const EDIT_PENCIL_SVG = ''; function makeCardEl({ kind, dataAttrs, iconSvg, titleText, descText, removable, editable }) { const card = document.createElement('div'); card.className = 'fcard ' + kind; for (const [k, v] of Object.entries(dataAttrs || {})) card.dataset[k] = v; card.innerHTML = iconSvg; if (editable) { const ed = document.createElement('button'); ed.className = 'fc-edit'; ed.type = 'button'; ed.dataset.editId = dataAttrs?.id || ''; ed.dataset.i18nTitle = 'customPreset.editTitle'; ed.title = t('customPreset.editTitle'); ed.innerHTML = EDIT_PENCIL_SVG; card.appendChild(ed); } if (removable) { const x = document.createElement('button'); x.className = 'fc-x'; x.type = 'button'; x.dataset.removeKind = kind === 'fcard-builtin' ? 'builtin' : 'custom'; x.dataset.removeId = dataAttrs?.id || dataAttrs?.preset || ''; x.dataset.i18nTitle = 'customPreset.removeTitle'; x.title = t('customPreset.removeTitle'); x.textContent = '×'; card.appendChild(x); } const titleEl = document.createElement('div'); titleEl.className = 'fc-t'; titleEl.textContent = titleText; titleEl.title = titleText; // 截断后悬停看完整标题 card.appendChild(titleEl); const descEl = document.createElement('div'); descEl.className = 'fc-d'; descEl.textContent = descText; descEl.title = descText; // 截断后悬停看完整描述 card.appendChild(descEl); return card; } function renderAllPresets() { document.querySelectorAll('.feature-grid').forEach(grid => { grid.innerHTML = ''; for (const bp of BUILTIN_PRESETS) { if (state.hiddenBuiltins.has(bp.key)) continue; grid.appendChild(makeCardEl({ kind: 'fcard-builtin', dataAttrs: { preset: bp.key }, iconSvg: bp.iconSvg, titleText: t(bp.titleKey), descText: t(bp.descKey), removable: true, })); } for (const cp of state.customPresets) { grid.appendChild(makeCardEl({ kind: 'fcard-custom', dataAttrs: { id: cp.id }, iconSvg: CUSTOM_ICON_SVG, titleText: cp.title, descText: cp.prompt, removable: true, editable: true, })); } const addCard = makeCardEl({ kind: 'add', dataAttrs: { preset: 'add' }, iconSvg: ADD_ICON_SVG, titleText: t('preset.add.t'), descText: t('preset.add.d'), removable: false, }); grid.appendChild(addCard); }); updateRestoreBtnVisibility(); } function addCustomPreset(title, prompt) { const id = 'cp-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 6); state.customPresets.push({ id, title, prompt }); saveCustomPresets(); renderAllPresets(); } function updateCustomPreset(id, title, prompt) { const cp = state.customPresets.find(p => p.id === id); if (!cp) return; cp.title = title; cp.prompt = prompt; saveCustomPresets(); renderAllPresets(); } function removeCustomPreset(id) { const idx = state.customPresets.findIndex(p => p.id === id); if (idx < 0) return; state.customPresets.splice(idx, 1); saveCustomPresets(); renderAllPresets(); } function hideBuiltinPreset(key) { if (!BUILTIN_PRESETS.some(bp => bp.key === key)) return; state.hiddenBuiltins.add(key); saveHiddenBuiltins(); renderAllPresets(); } function restoreBuiltinPresets() { state.hiddenBuiltins.clear(); saveHiddenBuiltins(); renderAllPresets(); } function updateRestoreBtnVisibility() { const btn = document.getElementById('preset-restore-btn'); if (!btn) return; btn.hidden = state.hiddenBuiltins.size === 0; } const cpModal = document.getElementById('custom-preset-modal'); const cpTitleInput = document.getElementById('cp-title'); const cpPromptInput = document.getElementById('cp-prompt'); const cpSaveBtn = document.getElementById('cp-save'); const cpError = document.getElementById('cp-error'); const cpModalTitle = cpModal?.querySelector('.modal-title'); let cpEditId = null; // null=新建模式; 否则为正在编辑的自定义预设 id function clearCpFieldHints() { cpModal?.querySelectorAll('.field-limit-hint').forEach(h => { h.style.display = 'none'; }); } function resetCustomPresetForm() { cpEditId = null; if (cpModalTitle) cpModalTitle.textContent = t('modal.customPreset'); if (cpTitleInput) cpTitleInput.value = ''; if (cpPromptInput) cpPromptInput.value = ''; if (cpError) { cpError.hidden = true; cpError.textContent = ''; } clearCpFieldHints(); setTimeout(() => { if (cpTitleInput) cpTitleInput.focus(); }, 0); } function openCustomPresetEditor(cp) { closeModals(); openModal('custom-preset-modal'); cpEditId = cp.id; if (cpModalTitle) cpModalTitle.textContent = t('modal.editCustomPreset'); if (cpTitleInput) cpTitleInput.value = cp.title; if (cpPromptInput) cpPromptInput.value = cp.prompt; if (cpError) { cpError.hidden = true; cpError.textContent = ''; } clearCpFieldHints(); setTimeout(() => { if (cpTitleInput) cpTitleInput.focus(); }, 0); } if (cpSaveBtn) cpSaveBtn.addEventListener('click', () => { const title = (cpTitleInput?.value || '').trim(); const prompt = (cpPromptInput?.value || '').trim(); if (!title || !prompt) { if (cpError) { cpError.textContent = t('customPreset.empty'); cpError.hidden = false; } return; } if (cpEditId) updateCustomPreset(cpEditId, title, prompt); else addCustomPreset(title, prompt); cpEditId = null; if (cpModal) cpModal.hidden = true; }); const restoreBtn = document.getElementById('preset-restore-btn'); if (restoreBtn) restoreBtn.addEventListener('click', () => { restoreBuiltinPresets(); }); /* ═══════════════ 图片预览 lightbox ═══════════════ */ const lightbox = document.getElementById('lightbox'); const lightboxImg = document.getElementById('lightbox-img'); function openLightbox(src) { if (!lightbox || !lightboxImg || !src) return; lightboxImg.src = src; lightbox.hidden = false; } function closeLightbox() { if (!lightbox || !lightboxImg) return; lightbox.hidden = true; lightboxImg.src = ''; } if (lightbox) { lightbox.addEventListener('click', (e) => { if (e.target.closest('[data-close]')) closeLightbox(); }); } document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && lightbox && !lightbox.hidden) closeLightbox(); }); if (msgArea) { msgArea.addEventListener('click', (e) => { const img = e.target.closest('.user-imgs img'); if (img && img.src) { openLightbox(img.src); return; } const fileChip = e.target.closest('.user-files .file-chip'); if (fileChip) { const path = fileChip.getAttribute('data-path'); const name = fileChip.getAttribute('data-name'); if (path) openUploadFile(path, name); } }); } function uploadRawUrl(path, download) { return `${BRIDGE_ORIGIN}/upload/raw?path=${encodeURIComponent(path || '')}${download ? '&download=1' : ''}`; } function bridgeIsLocal() { return location.hostname === '127.0.0.1' || location.hostname === 'localhost'; } async function openUploadFile(path, name) { // 远程访问:浏览器无法调起 bridge 那台/本机的系统程序,降级为下载到本机 if (!bridgeIsLocal()) { const a = document.createElement('a'); a.href = uploadRawUrl(path, true); a.download = name || ''; document.body.appendChild(a); a.click(); a.remove(); return; } // 本地:bridge 与你同机,调系统默认程序打开 / 在文件夹显示 const mode = isPreviewableByName(name || path) ? 'open' : 'reveal'; try { const res = await fetch(`${BRIDGE_ORIGIN}/path/open`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kind: 'upload', path, mode }), }); const j = await res.json(); if (!j.ok) throw new Error(j.error || 'open failed'); } catch (e) { showChanToast(t('file.openFailed'), e.message || String(e), 'err'); } } const PREVIEWABLE_EXTS = new Set([ 'pdf', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg', 'heic', 'tiff', 'txt', 'md', 'log', 'json', 'yaml', 'yml', 'xml', 'csv', 'tsv', 'ini', 'toml', 'env', 'rtf', 'py', 'js', 'ts', 'tsx', 'jsx', 'java', 'c', 'cpp', 'h', 'hpp', 'rs', 'go', 'rb', 'php', 'sh', 'bash', 'zsh', 'fish', 'lua', 'pl', 'r', 'scala', 'kt', 'swift', 'html', 'htm', 'css', 'scss', 'sass', 'less', 'vue', 'svelte', 'sql', 'doc', 'docx', 'pages', 'odt', 'xls', 'xlsx', 'numbers', 'ods', 'ppt', 'pptx', 'key', 'odp', 'mp3', 'wav', 'flac', 'aac', 'ogg', 'm4a', 'mp4', 'mov', 'avi', 'mkv', 'webm', 'wmv', ]); function isPreviewableByName(name) { const m = String(name || '').match(/\.([^./\\]+)$/); if (!m) return false; return PREVIEWABLE_EXTS.has(m[1].toLowerCase()); } /* ═══════════════ 后台服务页 Tab(消息通道 / 状态面板) ═══════════════ */ let _svcTab = 'channels'; const svcTabsEl = document.getElementById('svc-tabs'); function isServicesPageActive() { return !!document.querySelector('.page[data-page="services"].active'); } function isSvcTab(tab) { return isServicesPageActive() && _svcTab === tab; } function setSvcTab(tab) { if (!tab || tab === _svcTab) return; _svcTab = tab; svcTabsEl?.querySelectorAll('.svc-tab').forEach((b) => b.classList.toggle('active', b.dataset.tab === tab)); document.querySelectorAll('[data-svc-panel]').forEach((p) => p.classList.toggle('active', p.dataset.svcPanel === tab)); if (tab === 'channels') renderChannelList(gaServiceStore.list()); else loadStatusPanel(); } function refreshServicesPanel() { if (!isServicesPageActive()) return; if (_svcTab === 'channels') renderChannelList(gaServiceStore.list()); else loadStatusPanel(); } if (svcTabsEl) { svcTabsEl.addEventListener('click', (e) => { const btn = e.target.closest('.svc-tab'); if (!btn) return; setSvcTab(btn.dataset.tab); }); } /* ═══════════════ 消息通道(复用 gaServiceStore + WS 同步) ═══════════════ */ const CHAN_ICON = GA_ICON('chatTeardropText', 'lr-ic'); const CHAN_FILE_LABELS = { 'qqapp.py': 'ch.qq', 'wechatapp.py': 'ch.wechat', 'wecomapp.py': 'ch.wecom', 'dingtalkapp.py': 'ch.dingtalk', 'tgapp.py': 'ch.telegram', 'dcapp.py': 'ch.discord', 'fsapp.py': 'ch.lark', }; const chanListEl = document.getElementById('chan-list'); const chanEmptyEl = document.getElementById('chan-empty'); const chanLogModal = document.getElementById('chan-log-modal'); const chanLogPre = document.getElementById('chan-log-pre'); const chanLogTitle = document.getElementById('chan-log-title'); const chanConfigModal = document.getElementById('chan-config-modal'); const chanConfigTitle = document.getElementById('chan-config-title'); const chanConfigEditor = document.getElementById('chan-config-editor'); const chanConfigSave = document.getElementById('chan-config-save'); let _chanLogId = null; let _chanBusy = false; let _chanToastTimer = null; function getToastRoot() { let root = document.getElementById('toast-root'); if (!root) { root = document.createElement('div'); root.id = 'toast-root'; root.className = 'toast-root'; root.setAttribute('aria-live', 'polite'); document.body.appendChild(root); } return root; } function showChanToast(title, detail, kind) { if (!title) return; const root = getToastRoot(); if (_chanToastTimer) clearTimeout(_chanToastTimer); root.innerHTML = ''; const el = document.createElement('div'); el.className = `toast toast-${kind === 'err' ? 'err' : kind === 'info' ? 'info' : 'ok'}`; const tEl = document.createElement('span'); tEl.className = 'toast-title'; tEl.textContent = title; el.appendChild(tEl); if (detail) { const dEl = document.createElement('span'); dEl.className = 'toast-detail'; dEl.textContent = detail; el.appendChild(dEl); } root.appendChild(el); const show = () => el.classList.add('show'); requestAnimationFrame(show); setTimeout(show, 16); _chanToastTimer = setTimeout(() => { el.classList.remove('show'); setTimeout(() => el.remove(), 300); }, 3000); } /* ── Input length validation ── */ (function initInputLimits() { let _toastTimer = null; // msgKey 默认用会截断的文案;硬上限输入框传 'err.charLimitReached'(只提醒不提截断) function limitToast(maxLen, msgKey = 'err.charLimit') { clearTimeout(_toastTimer); _toastTimer = setTimeout(() => { const msg = t(msgKey).replace('{n}', maxLen); showChanToast(msg, '', 'err'); }, 300); } // Toast-based: for elements with maxLength attribute (input/textarea) —— 硬上限,不会截断 function bindToastLimit(el) { if (!el || !el.maxLength || el.maxLength < 0) return; el.addEventListener('input', () => { if (el.value.length >= el.maxLength) limitToast(el.maxLength, 'err.charLimitReached'); }); } // Toast-based: for contenteditable elements (no native maxLength) // Note: we only warn on input, not hard-truncate, because truncating innerHTML // would destroy embedded chips, cursor position, and break IME composition. // Actual truncation happens at send time in submitInput(). function bindContentEditableLimit(el, maxLen) { if (!el) return; let composing = false; el.addEventListener('compositionstart', () => { composing = true; }); el.addEventListener('compositionend', () => { composing = false; trimExcess(); }); // Layer 1: block non-IME input when at capacity el.addEventListener('beforeinput', (e) => { if (composing) return; // let IME through, trim after compositionend if (e.inputType === 'historyUndo' || e.inputType === 'historyRedo') return; if (e.inputType && e.inputType.startsWith('delete')) return; if (el.textContent.length >= maxLen) { e.preventDefault(); limitToast(maxLen); } }); // Layer 2: after IME commits, trim excess from last text node function trimExcess() { const cur = el.textContent.length; if (cur <= maxLen) return; const excess = cur - maxLen; // Walk text nodes in reverse to trim from the end (skip chip internals) const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); const textNodes = []; while (walker.nextNode()) textNodes.push(walker.currentNode); if (!textNodes.length) return; let toRemove = excess; for (let i = textNodes.length - 1; i >= 0 && toRemove > 0; i--) { const node = textNodes[i]; if (node.nodeValue.length <= toRemove) { toRemove -= node.nodeValue.length; node.nodeValue = ''; } else { node.nodeValue = node.nodeValue.slice(0, node.nodeValue.length - toRemove); toRemove = 0; } } limitToast(maxLen); } } // Per-field inline hint: creates a small red span right after the input. // 用 JS 管控长度(去掉原生 maxlength),以便"超限尝试"时可靠告警: // 超出上限的字符先被键入,再由本逻辑截回上限并告警——每次超限都触发 input, // 兼容打字/粘贴/中文 IME。到达上限本身合法、不告警。告警出现后自动消失,不常驻。 function bindFieldInlineLimit(el) { if (!el || !el.maxLength || el.maxLength < 0) return; const max = el.maxLength; el.removeAttribute('maxlength'); // 转为 JS 管控 const hint = document.createElement('span'); hint.className = 'field-limit-hint'; hint.style.cssText = 'color:var(--err,#dc2626);font-size:.75rem;display:none;margin-top:2px'; el.insertAdjacentElement('afterend', hint); let hideTimer = null; function warn() { hint.textContent = t('err.charLimitReached').replace('{n}', max); hint.style.display = 'block'; clearTimeout(hideTimer); hideTimer = setTimeout(() => { hint.style.display = 'none'; }, 2500); } function enforce() { if (el.value.length <= max) return; const atEnd = el.selectionStart >= el.value.length; el.value = el.value.slice(0, max); if (atEnd) el.setSelectionRange(max, max); warn(); } let composing = false; el.addEventListener('compositionstart', () => { composing = true; }); el.addEventListener('compositionend', () => { composing = false; enforce(); }); el.addEventListener('input', () => { if (!composing) enforce(); }); } window.bindFieldInlineLimit = bindFieldInlineLimit; window.bindToastLimit = bindToastLimit; // Number field: clamp to max on blur, no red text; block non-integer chars function bindNumberClamp(el) { if (!el || !el.max) return; const max = Number(el.max); if (!max) return; // Block all non-digit keys (allow navigation/editing keys) el.addEventListener('keydown', (e) => { if (e.ctrlKey || e.metaKey || e.altKey) return; // allow shortcuts if (['Backspace','Delete','Tab','ArrowLeft','ArrowRight','Home','End'].includes(e.key)) return; if (e.key.length === 1 && !/[0-9]/.test(e.key)) e.preventDefault(); }); el.addEventListener('input', () => { // Strip non-digit chars (IME can bypass keydown) const cleaned = el.value.replace(/[^0-9]/g, ''); if (cleaned !== el.value) el.value = cleaned; const v = Number(el.value); if (el.value !== '' && v > max) el.value = max; }); } // Wait for DOM ready function setup() { // Contenteditable inputs (chat + collab) const chatInput = document.querySelector('.input[contenteditable][data-i18n-ph="composer.placeholder"]'); const collabInput = document.getElementById('cdb-input'); bindContentEditableLimit(chatInput, 20000); bindContentEditableLimit(collabInput, 20000); // Toast targets (standard inputs/textareas with maxLength) const searchInput = document.querySelector('.search input'); const mykeyEditor = document.getElementById('chan-config-editor'); [searchInput, mykeyEditor].forEach(bindToastLimit); // Model form: per-field inline hints const form = document.getElementById('add-model-form'); if (form) { ['model', 'apikey', 'apibase', 'name'].forEach(name => { bindFieldInlineLimit(form.querySelector(`[name="${name}"]`)); }); ['max_retries', 'connect_timeout', 'read_timeout'].forEach(name => { bindNumberClamp(form.querySelector(`[name="${name}"]`)); }); } // Preset form: 每字段独立内联提示(标题/Prompt 各自独立,互不串扰) bindFieldInlineLimit(document.getElementById('cp-title')); bindFieldInlineLimit(document.getElementById('cp-prompt')); // First-run desktop-shortcut prompt (Windows portable bundle only). Driven from the web UI // so the dialog always renders on top — a native dialog from the Rust startup thread had no // parent window and got buried behind the main window on first launch. maybeAskDesktopShortcut(); } async function maybeAskDesktopShortcut() { try { const should = await window.ga.tauriInvoke('shortcut_should_ask'); if (!should) return; const create = await showConfirmDialog({ title: t('common.confirm'), message: t('shortcut.askConfirm'), okText: t('common.confirm') }); await window.ga.tauriInvoke('shortcut_decide', { create }); } catch (_) { /* not in tauri / not a bundle — ignore */ } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', setup); } else { setup(); } })(); function channelDisplayName(ch) { const file = (ch.name || ch.id || '').split('/').pop(); const key = CHAN_FILE_LABELS[file]; return key ? t(key) : (ch.name || ch.id || ''); } function channelStatusClass(status) { if (status === 'running') return 'on'; if (status === 'error') return 'err'; return 'off'; } function channelStatusLabel(status) { const map = { running: 'st.running', offline: 'st.offline', error: 'st.error', starting: 'st.starting', stopping: 'st.stopping', }; return t(map[status] || 'st.offline'); } function channelErrorMessage(code) { const map = { not_configured: 'err.channelNotConfigured' }; return t(map[code] || code || 'err.channelStart'); } function channelToastDetail(e) { const svc = e.data && e.data.service; if (svc && svc.lastError) return svc.lastError; const code = e.data && e.data.error; return channelErrorMessage(code || e.message); } function renderChannelList(channels) { if (!chanListEl) return; const rows = (channels || []).filter((ch) => (ch.id || '').startsWith('frontends/')); chanListEl.innerHTML = ''; if (chanEmptyEl) chanEmptyEl.hidden = rows.length > 0; for (const ch of rows) { const row = document.createElement('div'); row.className = 'list-row'; row.dataset.channelId = ch.id; const stClass = channelStatusClass(ch.status || 'offline'); const running = !!ch.running; row.innerHTML = ` ${CHAN_ICON}
`; row.querySelector('.chan-name').textContent = channelDisplayName(ch); row.querySelector('.chan-path').textContent = ch.name || ch.id; row.querySelector('.chan-status').textContent = channelStatusLabel(ch.status || 'offline'); row.querySelector('[data-act="configure"]').textContent = t('act.configure'); row.querySelector('[data-act="logs"]').textContent = t('act.logs'); chanListEl.appendChild(row); } } async function toggleChannel(id, running, toggleEl) { if (_chanBusy) return; _chanBusy = true; if (toggleEl) toggleEl.disabled = true; const label = channelDisplayName(gaServiceStore.get(id) || { id }); try { if (running) { await window.ga.stopService(id); showChanToast(t('sys.channelStopped') + ' · ' + label, '', 'ok'); } else { const res = await window.ga.startService(id); if (res && res.service && res.service.status === 'error') { throw Object.assign(new Error(res.service.lastError || 'start_failed'), { data: res }); } showChanToast(t('sys.channelStarted') + ' · ' + label, '', 'ok'); } } catch (e) { showChanToast( (running ? t('err.channelStop') : t('err.channelStart')) + ' · ' + label, channelToastDetail(e), 'err' ); } finally { _chanBusy = false; if (toggleEl) toggleEl.disabled = false; } } async function openChannelLogs(id) { if (!chanLogModal || !chanLogPre) return; _chanLogId = id; const ch = gaServiceStore.get(id) || { id }; const titleName = id === '__bridge__' ? (ch.name || 'bridge') : statusDisplayName(ch); if (chanLogTitle) chanLogTitle.textContent = t('modal.channelLogs') + ' · ' + titleName; chanLogPre.textContent = t('ch.loading'); openModal('chan-log-modal'); try { const res = await window.ga.getServiceLogs(id, 200); const lines = res.lines || []; chanLogPre.textContent = lines.length ? lines.join('\n') : t('ch.logEmpty'); } catch (e) { chanLogPre.textContent = t('err.channelLoad') + ': ' + (e.message || e); } } async function openChannelMykey(channelId) { if (!chanConfigModal || !chanConfigEditor) return; const ch = gaServiceStore.get(channelId) || { id: channelId }; if (chanConfigTitle) { chanConfigTitle.textContent = t('modal.mykeyConfig') + (channelId ? ' · ' + channelDisplayName(ch) : ''); } chanConfigEditor.value = t('ch.loading'); chanConfigEditor.disabled = true; if (chanConfigSave) chanConfigSave.disabled = true; openModal('chan-config-modal'); try { const res = await window.ga.getMykeyContent(); chanConfigEditor.value = res.content || ''; } catch (e) { chanConfigEditor.value = t('err.channelLoad') + ': ' + (e.message || e); } finally { chanConfigEditor.disabled = false; if (chanConfigSave) chanConfigSave.disabled = false; chanConfigEditor.focus(); } } async function saveChannelMykey() { if (!chanConfigEditor || !chanConfigSave) return; chanConfigSave.disabled = true; try { await window.ga.saveMykeyContent(chanConfigEditor.value); showChanToast(t('sys.configSaved'), '', 'ok'); chanConfigModal.hidden = true; } catch (e) { showChanToast(t('err.channelLoad'), e.message || String(e), 'err'); } finally { chanConfigSave.disabled = false; } } if (chanConfigSave) { chanConfigSave.addEventListener('click', saveChannelMykey); } /* ═══════════════ 状态面板(复用 ServiceManager + 启停/日志) ═══════════════ */ const statusListEl = document.getElementById('status-list'); const BRIDGE_SERVICE_ID = '__bridge__'; const EXTRA_SERVICE_IDS = new Set(['frontends/conductor.py', 'reflect/scheduler.py']); function bridgeOfflinePanelServices() { return [ { id: BRIDGE_SERVICE_ID, name: `bridge (:${BRIDGE_PORT})`, status: 'offline', running: false, pid: null, memMb: null, cpuPct: null, managed: false, }, { id: 'frontends/conductor.py', name: 'frontends/conductor.py', status: 'offline', running: false, pid: null, memMb: null, cpuPct: null, managed: false, bridgeOffline: true, }, { id: 'reflect/scheduler.py', name: 'reflect/scheduler.py', status: 'offline', running: false, pid: null, memMb: null, cpuPct: null, managed: false, bridgeOffline: true, }, ]; } function statusDisplayName(s) { if (!s) return ''; if (s.id === BRIDGE_SERVICE_ID) return s.name || 'bridge'; if (s.id === 'reflect/scheduler.py') return t('proc.scheduler'); if (s.id === 'frontends/conductor.py') return t('proc.conductor'); return channelDisplayName(s); } function fmtPid(pid) { return pid ? `PID ${pid}` : '—'; } function fmtRes(s) { const cpu = s.cpuPct != null ? `${s.cpuPct}%` : '—'; const mem = s.memMb != null ? `${s.memMb}MB` : '—'; return `${cpu} / ${mem}`; } function renderStatusPanel(services) { if (!statusListEl) return; statusListEl.innerHTML = ''; for (const s of services || []) { const row = document.createElement('div'); row.className = 'list-row'; row.dataset.serviceId = s.id; const stClass = channelStatusClass(s.status || 'offline'); const running = !!s.running; const managed = s.managed !== false; const isBridge = s.id === BRIDGE_SERVICE_ID; const isExtra = EXTRA_SERVICE_IDS.has(s.id); let acts = ''; if (isBridge) { if (running) { acts += ``; acts += ``; } else { acts += ``; } } else if (!s.bridgeOffline) { acts += ``; if (managed) { if (running) acts += ``; if (isExtra) { acts += ``; } else { acts += ``; } } } row.innerHTML = ` ${acts}`; row.querySelector('.st-name').textContent = statusDisplayName(s); row.querySelector('.st-status').textContent = channelStatusLabel(s.status || 'offline'); row.querySelector('.st-pid').textContent = fmtPid(s.pid); row.querySelector('.st-res').textContent = fmtRes(s); const logBtn = row.querySelector('[data-act="logs"]'); if (logBtn) logBtn.textContent = t('act.logs'); const rstBtn = row.querySelector('[data-act="restart"]'); if (rstBtn) rstBtn.textContent = t('act.restart'); const startBridgeBtn = row.querySelector('[data-act="bridge-start"]'); if (startBridgeBtn) startBridgeBtn.textContent = t('act.start'); const exitBridgeBtn = row.querySelector('[data-act="bridge-exit"]'); if (exitBridgeBtn) exitBridgeBtn.textContent = t('act.exit'); const textToggleBtn = row.querySelector('.link-btn[data-act="toggle"]'); if (textToggleBtn) textToggleBtn.textContent = running ? t('act.exit') : t('act.start'); statusListEl.appendChild(row); } } async function loadStatusPanel() { if (!statusListEl) return; if (bridgeUiOffline) { renderStatusPanel(bridgeOfflinePanelServices()); return; } try { const res = await window.ga.getServicePanel(); renderStatusPanel(res.services || []); } catch (_) { window.ga.setBridgeUiOffline(true); gaServiceStore.applySnapshot(bridgeOfflinePanelServices()); renderStatusPanel(bridgeOfflinePanelServices()); } } async function restartService(id) { const label = statusDisplayName(gaServiceStore.get(id) || { id }); await window.ga.stopService(id); const res = await window.ga.startService(id); if (res && res.service && res.service.status === 'error') { throw Object.assign(new Error(res.service.lastError || 'start_failed'), { data: res }); } showChanToast(t('act.restart') + ' · ' + label, '', 'ok'); } if (statusListEl) { statusListEl.addEventListener('click', async (e) => { const row = e.target.closest('.list-row'); if (!row) return; const id = row.dataset.serviceId; const actEl = e.target.closest('[data-act]'); if (!actEl || !id) return; const act = actEl.dataset.act; if (act === 'logs') { openChannelLogs(id); return; } if (act === 'bridge-start') { if (_chanBusy) return; _chanBusy = true; actEl.disabled = true; try { await window.ga.spawnBridge(); await loadStatusPanel(); showChanToast(t('sys.channelStarted') + ' · bridge', '', 'ok'); } catch (err) { showChanToast(t('err.channelStart') + ' · bridge', err.message || String(err), 'err'); } finally { _chanBusy = false; actEl.disabled = false; } return; } if (act === 'bridge-exit') { if (_chanBusy) return; _chanBusy = true; actEl.disabled = true; try { window.ga.setBridgeUiOffline(true); gaServiceStore.applySnapshot(bridgeOfflinePanelServices()); renderStatusPanel(bridgeOfflinePanelServices()); await window.ga.exitBridge(); showChanToast(t('sys.channelStopped') + ' · bridge', '', 'ok'); } catch (err) { showChanToast(t('err.channelStop') + ' · bridge', err.message || String(err), 'err'); } finally { _chanBusy = false; actEl.disabled = false; } return; } if (act === 'restart') { if (_chanBusy) return; _chanBusy = true; try { await restartService(id); await loadStatusPanel(); } catch (err) { showChanToast(t('act.restart') + ' · ' + statusDisplayName({ id }), err.message || String(err), 'err'); } finally { _chanBusy = false; } return; } if (act === 'toggle') { if (actEl.disabled || _chanBusy) return; const running = actEl.classList.contains('on'); await toggleChannel(id, running, actEl); if (isSvcTab('status')) loadStatusPanel(); } }); } gaServiceStore.onServices((list) => { if (isSvcTab('channels')) renderChannelList(list); if (isSvcTab('status')) { if (bridgeUiOffline) renderStatusPanel(bridgeOfflinePanelServices()); else loadStatusPanel(); } }); if (chanListEl) { chanListEl.addEventListener('click', async (e) => { const row = e.target.closest('.list-row'); if (!row) return; const id = row.dataset.channelId; const actEl = e.target.closest('[data-act]'); if (!actEl || !id) return; const act = actEl.dataset.act; if (act === 'logs') { openChannelLogs(id); return; } if (act === 'configure') { openChannelMykey(id); return; } if (act === 'toggle') { if (actEl.disabled || _chanBusy) return; const running = actEl.classList.contains('on'); await toggleChannel(id, running, actEl); } }); } /* ═══════════════ 启动 ═══════════════ */ (async () => { await loadSessions(); applyAppearance(appearance, plainUi, { persist: false }); applyTheme(theme, { persist: false }); initChatFontStepper(); applyChatFontSize(chatFontSize, { persist: false }); syncHljsTheme(); applyI18n(); updateModelChip(); renderSessionList(); loadCustomPresets(); loadHiddenBuiltins(); renderAllPresets(); if (state.activeId) setActiveSession(state.activeId); else refreshEmptyState(null); // bridge-ready 可能在上面的 await 期间就已到达(WS 一连上 bridge 即推送), // 此时 state.bridgeReady 已为 true,直接按真实状态渲染,避免把「就绪」覆盖回「连接中」。 if (state.bridgeReady) refreshStatusLabel(); else chatStatus.setConnecting(); window.ga.startBridge && window.ga.startBridge(); })(); /* 聊天 / Conductor 共用 composer 绑定(结构:.composer > .composer-slot > .composer-inset) */ function bindComposerInRoot(root, opts) { if (!root || root.dataset.composerBound) return null; root.dataset.composerBound = '1'; const ctx = opts.ctx || root.dataset.composerCtx || 'chat'; const input = root.querySelector('.composer-inset .input'); const fileInput = root.querySelector('input[type="file"]'); const plusBtn = root.querySelector('.composer-plus'); const menu = root.querySelector('.composer-menu'); const sendBtn = root.querySelector('.send'); function closeMenu() { if (!menu) return; menu.hidden = true; plusBtn?.setAttribute('aria-expanded', 'false'); } function openMenu() { if (!menu || !plusBtn) return; closeAllModelMenus?.(); if (ctx === 'chat') window.collabComposer?.closeMenu?.(); else window.chatComposer?.closeMenu?.(); menu.hidden = false; plusBtn.setAttribute('aria-expanded', 'true'); } function toggleMenu() { if (!menu) return; if (menu.hidden) openMenu(); else closeMenu(); } function doSend() { opts.onSend?.(); } plusBtn?.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); toggleMenu(); }); menu?.addEventListener('click', (e) => { const item = e.target.closest('[data-composer-action]'); if (!item) return; e.stopPropagation(); closeMenu(); const act = item.dataset.composerAction; if (act === 'upload') { window.gaSetActiveFileComposer?.(ctx); fileInput?.click(); return; } if (act === 'preset') openModal('preset-modal'); }); input?.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && e.keyCode !== 229) { e.preventDefault(); doSend(); } }); sendBtn?.addEventListener('click', (e) => { e.preventDefault(); doSend(); }); opts.afterBind?.(root, { input, closeMenu, doSend }); return { ctx, input, closeMenu, focus: () => input?.focus() }; } (function () { 'use strict'; const root = document.getElementById('chat-composer'); const bound = bindComposerInRoot(root, { ctx: 'chat', onSend() { const sess = activeSess(); if (sess && rt(sess).busy) { cancelPrompt(); return; } submitInput(); }, }); if (bound) window.chatComposer = { closeMenu: bound.closeMenu, focus: bound.focus }; })(); (function () { 'use strict'; const root = document.getElementById('cdb-composer'); if (!root) return; let onSend = null; const input = root.querySelector('.composer-inset .input'); const sendBtn = root.querySelector('.send'); function text() { return window.gaComposerText?.('collab') ?? ''; } function clearIfMatch(raw) { if (input && text().trim() === String(raw || '').trim()) input.innerHTML = ''; } function setEnabled(on) { if (input) input.contentEditable = on ? 'true' : 'false'; if (sendBtn) sendBtn.disabled = !on; } const bound = bindComposerInRoot(root, { ctx: 'collab', onSend() { if (onSend) onSend(text()); }, afterBind() { document.querySelectorAll('#collab-quick [data-prompt-key]').forEach((btn) => { btn.addEventListener('click', () => { if (!onSend) return; const key = btn.dataset.promptKey; onSend((window.gaT && window.gaT(key)) || key); }); }); }, }); if (!bound) return; function init(handler) { onSend = handler; } window.collabComposer = { init, text, clearIfMatch, setEnabled, focus: bound.focus, closeMenu: bound.closeMenu, }; })(); /* Conductor 页 — 直连 Conductor WS,不走 bridge session */ (function () { 'use strict'; const wsUrl = () => `${CONDUCTOR_WS_ORIGIN}/ws`; const FAIL_MAX = 5, RECON_BASE = 1200, RECON_MAX = 30000; const $ = id => document.getElementById(id); const t = k => (window.gaT && window.gaT(k)) || k; const esc = s => (window.gaEscapeHtml ? window.gaEscapeHtml(s) : String(s ?? '').replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]))); const stripAttach = text => String(text || '') .replace(/\[(Image|File)\s+#\d+\]\s*/g, '') .replace(/[^\s]*desktop_uploads[^\s]*\s*/g, '') // 兜底:去掉内联的本地上传路径,避免历史/回显消息把全路径甩出来 .trim(); const GA_STATUS_BREATHE_SM = ''; function collabStatusMark(status) { switch (status) { case 'running': return GA_STATUS_BREATHE_SM; case 'reported': return ''; case 'paused': return ''; case 'failed': return ''; case 'terminated': return ''; default: return ''; } } const ST_KEYS = { running: 'collab.stRunning', reported: 'collab.stReported', paused: 'collab.stPaused', failed: 'collab.stFailed', terminated: 'collab.stTerminated' }; const S = { everConnected: false, reconnecting: false, serviceAvailable: false, messages: [], workers: [], runningCount: 0, conductorTyping: false, failCount: 0, historyReady: false, reconnectAt: 0, progressOpen: false, }; let ws, connectTimer, reconnectTick, titleSeq = 0, wsGen = 0, localSeq = 0; const titleSeen = new Map(); let prevRail = { running: 0, done: 0, issue: 0, count: 0, sig: '' }; const prevUpdated = new Map(); const collabStatus = window.gaPageStatusBar?.($('collab-run-toggle')); let draftEl = null; const scrollMsgs = () => { const root = $('collab-msgs'); const sc = root?.querySelector('.collab-scroll'); if (sc) sc.scrollTop = sc.scrollHeight; }; const showDraft = () => S.conductorTyping && S.serviceAvailable && S.historyReady && S.messages.length > 0; function workerSig(list) { return (list || []).map(w => `${w.id}:${w.updatedAt}:${w.status}`).join('|'); } function pulseEl(el) { if (!el) return; el.classList.remove('pulse'); void el.offsetWidth; el.classList.add('pulse'); } function syncProgressDrawer() { const page = document.querySelector('.page[data-page="collab"]'); if (page) page.classList.toggle('collab-prog-open', S.progressOpen); } function syncRail(opts = {}) { const rail = $('collab-rail'); const hasChat = S.historyReady && S.messages.length > 0; if (rail) rail.hidden = !hasChat; const running = S.workers.filter(w => w.status === 'running').length; const done = S.workers.filter(w => w.status === 'reported').length; const issue = S.workers.filter(w => w.status === 'failed').length; const runBadge = $('collab-rail-run'); const doneBadge = $('collab-rail-done'); const issueBadge = $('collab-rail-issue'); const runN = $('collab-rail-run-n'); const doneN = $('collab-rail-done-n'); const issueN = $('collab-rail-issue-n'); if (runBadge) runBadge.hidden = running <= 0; if (doneBadge) doneBadge.hidden = done <= 0; if (issueBadge) issueBadge.hidden = issue <= 0; if (runN) runN.textContent = String(running); if (doneN) doneN.textContent = String(done); if (issueN) issueN.textContent = String(issue); const sig = workerSig(S.workers); if (opts.pulse) { if (running > prevRail.running || S.workers.length > prevRail.count) pulseEl(runBadge); if (done > prevRail.done) pulseEl(doneBadge); if (issue > prevRail.issue) pulseEl(issueBadge); } else if (sig !== prevRail.sig) { if (running !== prevRail.running) pulseEl(runBadge); if (done !== prevRail.done) pulseEl(doneBadge); if (issue !== prevRail.issue) pulseEl(issueBadge); } prevRail = { running, done, issue, count: S.workers.length, sig }; syncProgressDrawer(); } function toggleProgress(open) { S.progressOpen = typeof open === 'boolean' ? open : !S.progressOpen; syncRail(); } function clearDraft() { if (draftEl) { draftEl.remove(); draftEl = null; } } function syncDraft() { const list = $('collab-msg-list'); if (!list || list.hidden || !showDraft()) return clearDraft(); if (!draftEl) draftEl = document.createElement('div'); draftEl.className = 'msg system collab-msg-enter'; draftEl.setAttribute('aria-label', t('collab.typing')); draftEl.innerHTML = '
'; list.appendChild(draftEl); requestAnimationFrame(scrollMsgs); } function relTime(ts) { if (!ts) return ''; const ms = typeof ts === 'number' ? (ts > 1e12 ? ts : ts * 1000) : Date.parse(ts); if (!ms || Number.isNaN(ms)) return ''; const sec = Math.max(0, Math.floor((Date.now() - ms) / 1000)); if (sec < 10) return t('collab.timeJust'); if (sec < 60) return t('collab.timeSec').replace('{n}', sec); const min = Math.floor(sec / 60); if (min < 60) return t('collab.timeMin').replace('{n}', min); const hr = Math.floor(min / 60); return hr < 24 ? t('collab.timeHr').replace('{n}', hr) : t('collab.timeDay').replace('{n}', Math.floor(hr / 24)); } function mapStatus(status, reply) { const r = (reply || '').trim(); if (status === 'running') return 'running'; if (status === 'failed') return 'failed'; if (status === 'aborted') return 'terminated'; if (status === 'stopped') return r ? 'reported' : 'paused'; return 'paused'; } function normalizeWorker(raw) { if (!titleSeen.has(raw.id)) titleSeen.set(raw.id, ++titleSeq); const ui = mapStatus(raw.status, raw.reply); let title = String(raw.prompt ?? '').replace(/^[\s请帮我麻烦]+/u, '').trim(); if (!title) title = t('collab.taskFallback').replace('{n}', titleSeen.get(raw.id)); else { title = (title.split(/[\n。!?.!?]/)[0] || '').trim(); if (title.length > 18) title = title.slice(0, 18) + '…'; } const reply = String(raw.reply || '').replace(/\s+/g, ' ').trim(); let summary = reply ? (reply.length > 80 ? reply.slice(0, 80) + '…' : reply) : t(ui === 'running' ? 'collab.summaryRunning' : 'collab.summaryWait'); return { id: raw.id, title, status: ui, summary, fullReply: raw.reply || '', updatedAt: raw.updated_at }; } function syncCollabStatus() { if (!collabStatus) return; if (S.conductorTyping && S.serviceAvailable) collabStatus.setBusy(t('status.running')); else if (S.serviceAvailable) collabStatus.setReady(); else if (S.reconnecting || (!S.everConnected && S.failCount < FAIL_MAX)) collabStatus.setConnecting(); else collabStatus.set(t('collab.offlineShort'), 'offline'); // 顶栏直接显示"无法连接 Conductor(8900)"取代"未连接" } function setConnUi() { const retry = $('collab-retry'); const avail = S.serviceAvailable; const trying = !avail && !S.everConnected && S.failCount < FAIL_MAX; // 只有"真离线且不在自动重连"才显示刷新图标(右边的手动重试入口) if (retry) retry.hidden = avail || S.reconnecting || trying; window.collabComposer?.setEnabled?.(avail); syncCollabStatus(); syncDraft(); syncRail(); } let cardMenu = null; function hideCardMenu() { if (cardMenu) { cardMenu.remove(); cardMenu = null; } } function showCardMenu(x, y, sid) { hideCardMenu(); cardMenu = document.createElement('div'); cardMenu.className = 'ctx-menu'; cardMenu.style.left = x + 'px'; cardMenu.style.top = y + 'px'; cardMenu.innerHTML = `
${GA_ICON('trash')}${esc(t('ctx.del'))}
`; cardMenu.querySelector('.ctx-item').onclick = (e) => { e.stopPropagation(); fetch(`${CONDUCTOR_ORIGIN}/subagent/${sid}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'kill' }) }); hideCardMenu(); }; document.body.appendChild(cardMenu); setTimeout(() => document.addEventListener('mousedown', (e) => { if (!cardMenu?.contains(e.target)) hideCardMenu(); }, { once: true }), 0); } let drawerEl = null; function closeWorkerDrawer() { if (drawerEl) { drawerEl.remove(); drawerEl = null; } } function openWorkerDrawer(w) { closeWorkerDrawer(); drawerEl = document.createElement('div'); drawerEl.className = 'collab-drawer-wrap'; drawerEl.innerHTML = `
`; const bubble = drawerEl.querySelector('.collab-drawer-body .bubble'); if (bubble) { bubble.innerHTML = renderTurnBody(w.fullReply || t('collab.summaryWait')); postRenderEnhance(bubble); } drawerEl.querySelector('.collab-drawer-backdrop').onclick = closeWorkerDrawer; drawerEl.querySelector('.collab-drawer-close').onclick = closeWorkerDrawer; document.body.appendChild(drawerEl); } function renderWorkers() { const box = $('collab-workers'), empty = $('collab-progress-empty'); if (!box) return; if (empty) empty.hidden = S.workers.length > 0; box.innerHTML = S.workers.map(w => `
${collabStatusMark(w.status)}${esc(t(ST_KEYS[w.status] || 'collab.stPaused'))}${w.updatedAt ? `${esc(relTime(w.updatedAt))}` : ''}
${esc(w.title)}
${esc(w.summary)}
`).join(''); box.querySelectorAll('.collab-card').forEach(el => { const w = S.workers.find(x => x.id === el.dataset.sid); if (w) { const prev = prevUpdated.get(w.id); if (prev != null && prev !== w.updatedAt) pulseEl(el); prevUpdated.set(w.id, w.updatedAt); } el.addEventListener('contextmenu', e => { e.preventDefault(); showCardMenu(e.clientX, e.clientY, el.dataset.sid); }); el.addEventListener('click', () => { if (w) openWorkerDrawer(w); }); }); const running = S.workers.filter(w => w.status === 'running').length; const done = S.workers.filter(w => w.status === 'reported').length; S.runningCount = running; document.dispatchEvent(new CustomEvent('collab:running-count', { detail: { count: running } })); const stats = $('collab-progress-stats'); if (stats) { const has = running > 0 || done > 0; stats.hidden = !has; if (has) { stats.innerHTML = [ running > 0 ? `${GA_STATUS_BREATHE_SM}${running} ${esc(t('collab.statRunning'))}` : '', done > 0 ? `${done} ${esc(t('collab.statDone'))}` : '', ].filter(Boolean).join(''); } } syncRail({ pulse: true }); } function syncMessages() { const area = $('collab-msgs'), welcome = $('collab-welcome'), list = $('collab-msg-list'); if (!area || !list) return; if (!S.historyReady) { area.classList.remove('has-msgs'); if (welcome) welcome.hidden = true; list.hidden = true; syncRail(); return; } const has = S.messages.length > 0; area.classList.toggle('has-msgs', has); if (welcome) welcome.hidden = has; list.hidden = !has; list.replaceChildren(); const toMsg = window.gaCollabItemToMsg; const render = window.gaMsgNode; if (toMsg && render) { for (const item of S.messages) { const el = render(toMsg(item)); el.classList.add('collab-msg-enter'); list.appendChild(el); } } syncDraft(); scrollMsgs(); syncRail(); } function pushMsg(item) { if (item.id && S.messages.some(m => m.id === item.id)) return; if (item.role === 'user') { const plain = stripAttach(item.msg); const expand = window.gaExpandFilePlaceholders; for (let i = S.messages.length - 1; i >= 0; i--) { const m = S.messages[i]; // 服务端回显的 msg 是 expand(本地文本)(附件被展开成了本地路径),和本地乐观消息其实是同一条。 // 命中后保留本地那条的干净显示(占位符 msg + 结构化 files/images 卡片),只补服务端 id/ts, // 丢弃带路径的回显文本 —— 既去重、又不丢卡片、不外露本地路径,与 chat 显示一致。 if (m._local && m.role === 'user' && (stripAttach(m.msg) === plain || m.msg === item.msg || (expand && expand(m.msg) === item.msg))) { m.id = item.id || m.id; if (item.ts != null) m.ts = item.ts; if (item.read != null) m.read = item.read; m._local = false; syncMessages(); setConnUi(); return; } } } S.messages.push(item); if (item.role === 'conductor') S.conductorTyping = false; syncMessages(); setConnUi(); } function setWorkers(rawList) { S.workers = (rawList || []).map(normalizeWorker); renderWorkers(); } function onWsData(data, gen) { if (gen !== wsGen) return; if (data.type === 'hello') { S.historyReady = true; S.messages = (data.chat || []).map(raw => ({ id: raw.id, role: raw.role || 'system', msg: raw.msg || '', ts: raw.ts, read: raw.read, files: raw.files || [], images: raw.images || [] })); S.conductorTyping = !!data.running; setWorkers(data.subagents || []); syncMessages(); setConnUi(); } else if (data.type === 'subagents') setWorkers(data.items || []); else if (data.type === 'chat') pushMsg({ id: data.item.id, role: data.item.role || 'system', msg: data.item.msg || '', ts: data.item.ts, read: data.item.read, files: data.item.files || [], images: data.item.images || [] }); } function resetWs() { wsGen++; if (!ws) return; const old = ws; ws = null; old.onopen = old.onclose = old.onerror = old.onmessage = null; try { old.close(); } catch {} } function scheduleReconnect() { clearTimeout(connectTimer); clearInterval(reconnectTick); if (!S.everConnected && S.failCount >= FAIL_MAX) { S.reconnecting = false; return setConnUi(); } const delay = Math.min(RECON_MAX, RECON_BASE * Math.pow(2, Math.max(0, S.failCount - 1))); S.reconnectAt = Date.now() + delay; S.reconnecting = S.everConnected; setConnUi(); reconnectTick = setInterval(() => { if (!S.reconnecting) clearInterval(reconnectTick); else setConnUi(); }, 500); connectTimer = setTimeout(connect, delay); } function connect() { if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return; clearTimeout(connectTimer); clearInterval(reconnectTick); const gen = ++wsGen; setConnUi(); let sock; try { sock = new WebSocket(wsUrl()); } catch (e) { if (gen !== wsGen) return; S.failCount++; return scheduleReconnect(); } ws = sock; sock.onopen = () => { if (gen !== wsGen) return; S.everConnected = true; S.serviceAvailable = true; S.reconnecting = false; S.failCount = 0; setConnUi(); }; sock.onclose = (ev) => { if (gen !== wsGen) return; S.serviceAvailable = false; if (S.everConnected) S.reconnecting = true; else S.failCount++; setConnUi(); scheduleReconnect(); }; sock.onerror = () => {}; sock.onmessage = ev => { if (gen !== wsGen) return; try { onWsData(JSON.parse(ev.data), gen); } catch {} }; } function sendText(rawText) { const text = (rawText || '').trim(); if (!text || !ws || ws.readyState !== WebSocket.OPEN) return false; window.gaSetActiveFileComposer?.('collab'); const expand = window.gaExpandFilePlaceholders || (s => s); const collect = window.gaCollectUsedFiles || (() => []); const clearUsed = window.gaClearUsedPendingFiles || (() => {}); const used = collect(text); const images = [], files = []; for (const f of used) (f.isImage ? images : files).push(f.isImage ? { path: f.path, dataUrl: f.dataUrl, name: f.name, sid: f.sid } : { path: f.path, name: f.name, sid: f.sid }); S.messages.push({ id: `_local_${++localSeq}`, _local: true, role: 'user', msg: text, ts: Date.now() / 1000, images, files }); S.conductorTyping = true; syncMessages(); ws.send(JSON.stringify({ msg: expand(text), files, images })); clearUsed(text); window.collabComposer?.clearIfMatch?.(text); setConnUi(); return true; } $('collab-retry')?.addEventListener('click', () => { S.failCount = 0; S.reconnecting = false; resetWs(); connect(); }); $('collab-rail-toggle')?.addEventListener('click', () => toggleProgress()); $('collab-prog-close')?.addEventListener('click', () => toggleProgress(false)); window.collabComposer?.init?.(sendText); window.collabInit = () => { window.gaSetActiveFileComposer?.('collab'); syncMessages(); setConnUi(); renderWorkers(); connect(); }; window.collabFocus = () => window.collabComposer?.focus?.(); window.collabRetranslate = () => { renderWorkers(); syncMessages(); setConnUi(); }; })(); /* ═══════════════ Composer layout: inline / stacked ═══════════════ */ (function initComposerLayout() { const BREAKPOINT = 480; const SINGLE_LINE = 36; document.querySelectorAll('.composer-inset').forEach(inset => { const input = inset.querySelector('.input'); if (!input) return; function update() { const wide = inset.offsetWidth >= BREAKPOINT; const single = input.scrollHeight <= SINGLE_LINE; inset.classList.toggle('is-inline', wide && single); } new ResizeObserver(update).observe(inset); input.addEventListener('input', update); update(); }); })();