// 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 ? `
]*>([\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 `
${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 ``;
}).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))}` +
`` +
``;
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 ``;
});
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 ``;
}).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();
});
})();