Files
2026-07-13 13:04:19 +08:00

6204 lines
268 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// GenericAgent 桌面版 —— bridge 适配 + 业务 UIHTTP 命令 / 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: '<svg class="flag" viewBox="0 0 30 20" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><rect width="30" height="20" fill="#ee1c25"/><polygon points="6,3.5 6.9,6.2 9.7,6.2 7.4,7.9 8.3,10.6 6,8.9 3.7,10.6 4.6,7.9 2.3,6.2 5.1,6.2" fill="#ffde00"/><circle cx="11.5" cy="2.6" r=".9" fill="#ffde00"/><circle cx="13.2" cy="4.3" r=".9" fill="#ffde00"/><circle cx="13.2" cy="6.7" r=".9" fill="#ffde00"/><circle cx="11.5" cy="8.4" r=".9" fill="#ffde00"/></svg>',
en: '<svg class="flag" viewBox="0 0 38 20" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><rect width="38" height="20" fill="#ffffff"/><g fill="#b22234"><rect width="38" height="1.54"/><rect y="3.08" width="38" height="1.54"/><rect y="6.16" width="38" height="1.54"/><rect y="9.24" width="38" height="1.54"/><rect y="12.32" width="38" height="1.54"/><rect y="15.4" width="38" height="1.54"/><rect y="18.48" width="38" height="1.54"/></g><rect width="15.2" height="10.78" fill="#3c3b6e"/></svg>',
};
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 = `<input type="radio" name="lang-pick"${lang === code ? ' checked' : ''}>${FLAGS[code] || ''}<span>${escapeHtml(t('lang.' + code))}</span>`;
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 `<!--LATEX:${id}-->`;
});
// 块级 $$...$$
text = text.replace(/\$\$([\s\S]+?)\$\$/g, (_, expr) => {
const id = _latexSlots.length;
_latexSlots.push({ expr: expr.trim(), display: true });
return `<!--LATEX:${id}-->`;
});
// 行内 \(...\)
text = text.replace(/\\\(([\s\S]+?)\\\)/g, (_, expr) => {
const id = _latexSlots.length;
_latexSlots.push({ expr: expr.trim(), display: false });
return `<!--LATEX:${id}-->`;
});
// 行内 $...$(不贪婪,排除 $$ 和转义)
text = text.replace(/(?<!\\)\$([^\n$]+?)\$/g, (_, expr) => {
const id = _latexSlots.length;
_latexSlots.push({ expr: expr.trim(), display: false });
return `<!--LATEX:${id}-->`;
});
// 恢复代码占位符
text = text.replace(/\x00CODE:(\d+)\x00/g, (_, i) => _codeSlots[Number(i)]);
return text;
}
function restoreLatex(html) {
if (!_latexSlots.length) return html;
return html.replace(/<!--LATEX:(\d+)-->/g, (_, i) => {
const slot = _latexSlots[Number(i)];
if (!slot) return '';
if (typeof katex === 'undefined') {
return slot.display ? `<div class="katex-block">${escapeHtml(slot.expr)}</div>`
: `<span class="katex-inline">${escapeHtml(slot.expr)}</span>`;
}
try {
const rendered = katex.renderToString(slot.expr, { displayMode: slot.display, throwOnError: false });
return slot.display ? `<div class="katex-block">${rendered}</div>`
: `<span class="katex-inline">${rendered}</span>`;
} catch (_) { return escapeHtml(slot.expr); }
});
}
function renderMarkdown(text) {
if (typeof marked === 'undefined') return escapeHtml(text).replace(/\n/g, '<br>');
try {
const protected_ = protectLatex(String(text || ''));
let html = sanitizeMarkdown(marked.parse(protected_));
html = restoreLatex(html);
// TUI 风格代码块:包装 pre>code 为 .code-block 容器 + 语言头
html = html.replace(/<pre><code\b(?:\s+class="language-([^"]*)")?[^>]*>([\s\S]*?)<\/code><\/pre>/g,
(_, lang, body) => {
const label = lang || 'code';
return `<div class="code-block"><div class="code-block-head"><span class="code-block-lang">${escapeHtml(label)}</span><button class="code-block-copy" aria-label="Copy code">\u29C9</button></div><pre><code class="language-${escapeHtml(label)}">${body}</code></pre></div>`;
});
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 (/^<thinking>/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) : 单轮原文 → 旧轮的<details>折叠壳(内部下标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(/<thinking>[\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(/<function_calls>[\s\S]*?<\/function_calls>/gi, m => stash(t('fold.tool'), m, 'fold-tool'));
s = s.replace(/<function_results>[\s\S]*?<\/function_results>/gi, m => stash(t('fold.toolResult'), m, 'fold-result'));
s = s.replace(/<summary>([\s\S]*?)<\/summary>/gi, (_, inner) => `<div class="turn-summary">${inner}</div>`);
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 `<details class="fold ${f.cls}"${openAttr}><summary>${escapeHtml(f.label)}</summary><pre class="fold-pre">${escapeHtml(f.body)}</pre></details>`;
});
return html;
}
// 抽出该轮首个 <summary> 文本作为折叠头副标题;无则回退提取工具名列表
function extractTurnSummaryPure(raw) {
const m = /<summary>([\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(/<summary>[\s\S]*?<\/summary>\s*/i, '') : raw;
const inner = renderTurnBody(bodyForRender);
const turnLabel = t('fold.turn').replace('{n}', turnDisplayNo(turnIndex));
const head = sum
? `${escapeHtml(turnLabel)}<span class="turn-head-sum">${escapeHtml(sum)}</span>`
: escapeHtml(turnLabel);
return `<details class="fold fold-turn"><summary>${head}</summary>${inner}</details>`;
}
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(/<p>([^<]*[A-Da-d][.)]\s[^<]*)<\/p>/gi, '<p class="ask-option-line">$1</p>');
out = out.replace(/(<br\s*\/?>)\s*([A-Da-d][.)]\s[^<]+)/gi, '<span class="ask-option-line">$2</span>');
return out;
}
/** 预览模式:true = 始终显示 candidatesfalse = 题干已含选项/多题时不重复渲染底部列表 */
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 `<div class="ask-user-notice" data-ask-user="1">
<div class="ask-user-banner">
<span class="ask-user-banner-text">${escapeHtml(t('ask.banner'))}</span>
<span class="ask-user-banner-sep" aria-hidden="true">·</span>
<span class="ask-user-banner-hint">${escapeHtml(t('ask.replyHint'))}</span>
</div>
${qHtml ? `<div class="ask-user-body md">${qHtml}</div>` : ''}
</div>`;
}
/** 单题的 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');
// 用单换行(而非空行)拼进题干,让题干+选项渲染成同一个 <p>,每个选项都跟在 <br> 后面 —
// 与多题内联选项走完全一致的 .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)} · <em>${escapeHtml(cap.step)}</em>`;
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, '<br>');
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 += `<span class="ph-chip" contenteditable="false">${escapeHtml(name)}</span>`;
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 += `<div class="turn-frozen" data-turn="${i}">${renderTurnFold(arr[i] || '', i)}</div>`;
}
html += `<div class="turn-cur" data-turn="${curr}">${renderTurnBody(arr[curr] || '')}${withCursor ? '<span class="cursor"></span>' : ''}</div>`;
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(/<summary>[\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)
? `<div class="user-imgs">${msg.images.map(im => `<img src="${im.dataUrl || uploadRawUrl(im.path)}" data-path="${escapeHtml(im.path || '')}" alt="">`).join('')}</div>`
: '';
const filesHtml = (msg.files && msg.files.length)
? `<div class="user-files">${msg.files.map(f => {
const name = f.name || 'file';
const sub = fileSubLabel(name);
return `<div class="file-chip" data-path="${escapeHtml(f.path || '')}" data-name="${escapeHtml(name)}"><span class="fc-icon">${GA_ICON('fileText')}</span><span class="fc-meta"><span class="fc-name">${escapeHtml(name)}</span><span class="fc-sub">${escapeHtml(sub)}</span></span></div>`;
}).join('')}</div>`
: '';
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 ? `<div class="bubble">${chipText}</div>` : '';
el.innerHTML = `<div class="user-stack">${filesHtml}${imgsHtml}${textHtml}</div>`;
}
else if (msg.role === 'assistant') {
const segs = assistantTurnSegs(msg);
let html = renderAssistantTurnsHtml(segs, msg.curr_turn, false);
if (msg.stopped) html += `<p><em>[${escapeHtml(t('status.stopped'))}]</em></p>`;
el.innerHTML = `<div class="bubble md">${html}</div>`;
postRenderEnhance(el.querySelector('.bubble'));
}
else if (msg.role === 'error') el.innerHTML = `<div class="bubble err">${escapeHtml(msg.content)}</div>`;
else el.innerHTML = `<div class="bubble sys">${escapeHtml(msg.content)}</div>`;
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 是正在打字的 turntw.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) + '<span class="cursor"></span>';
postRenderEnhance(cur);
} else if (!cur.querySelector('.cursor')) {
cur.insertAdjacentHTML('beforeend', '<span class="cursor"></span>');
}
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 =
`<span class="ci-dot"></span><div class="ci-main">` +
`<div class="ci-title">${pinSvg}${escapeHtml(displayTitle(sess))}</div>` +
`<div class="ci-meta">${busy ? t('status.running') : t('status.idle')}</div></div>` +
`<button class="ci-more" title="${escapeHtml(t('common.more'))}">${GA_ICON('dotsThreeVertical')}</button>`;
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 输出里的 <summary>...</summary>,
// 其次用首条用户消息纯文本,都没有时回退到 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 文本里的 <summary>...</summary>
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 = /<summary>([\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 不截断);不等 idlerunning 续交给 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: '<svg viewBox="0 0 24 24" fill="#4D6BFE" xmlns="http://www.w3.org/2000/svg"><path d="M23.748 4.651c-.254-.124-.364.113-.512.233-.051.04-.094.09-.137.137-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.155-.708-.311-.955-.65-.172-.24-.219-.509-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.094.172.187.129.323-.082.28-.18.553-.266.833-.055.179-.137.218-.328.14a5.5 5.5 0 0 1-1.737-1.179c-.857-.828-1.631-1.743-2.597-2.46a12 12 0 0 0-.689-.47c-.985-.957.13-1.743.387-1.836.27-.098.094-.433-.778-.428-.872.003-1.67.295-2.687.685a3 3 0 0 1-.465.136 9.6 9.6 0 0 0-2.883-.101c-1.885.21-3.39 1.1-4.497 2.622C.082 8.776-.231 10.854.152 13.02c.403 2.284 1.568 4.175 3.36 5.653 1.857 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.132-.284 4.994-1.86.47.234.962.328 1.78.398.629.058 1.235-.031 1.705-.129.735-.155.684-.836.418-.961-2.155-1.004-1.682-.595-2.112-.926 1.095-1.295 2.768-3.598 3.284-6.733.05-.346.115-.834.108-1.114-.004-.171.035-.238.23-.257a4.2 4.2 0 0 0 1.545-.475c1.397-.763 1.96-2.016 2.093-3.517.02-.23-.004-.467-.247-.588M11.58 18.168c-2.088-1.642-3.101-2.183-3.52-2.16-.39.024-.32.472-.234.763.09.288.207.487.371.74.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.168-1.361-.801-2.5-1.86-3.301-3.306-.775-1.393-1.225-2.888-1.299-4.482-.02-.385.094-.522.477-.592a4.7 4.7 0 0 1 1.53-.038c2.131.311 3.946 1.264 5.467 2.774.868.86 1.525 1.887 2.202 2.89.72 1.066 1.494 2.082 2.48 2.915.348.291.626.513.892.677-.802.09-2.14.109-3.055-.615zm1.001-6.44a.306.306 0 0 1 .415-.287.3.3 0 0 1 .113.074.3.3 0 0 1 .086.214c0 .17-.136.307-.308.307a.303.303 0 0 1-.306-.307m3.11 1.596c-.2.081-.4.151-.591.16a1.25 1.25 0 0 1-.798-.254c-.274-.23-.47-.358-.551-.758a1.7 1.7 0 0 1 .015-.588c.07-.327-.007-.537-.238-.727-.188-.156-.426-.199-.689-.199a.6.6 0 0 1-.254-.078.253.253 0 0 1-.114-.358 1 1 0 0 1 .192-.21c.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.392.451.462.576.685.915.176.264.336.536.446.848.066.194-.02.353-.25.45"/></svg>',
},
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: '<svg viewBox="0 0 24 24" fill="#615CED" xmlns="http://www.w3.org/2000/svg"><path d="M23.919 14.545 20.817 9.17l1.47-2.544a.56.56 0 0 0 0-.566l-1.633-2.83a.57.57 0 0 0-.49-.283h-6.207L12.487.402a.57.57 0 0 0-.49-.284H8.732a.56.56 0 0 0-.49.284L5.139 5.775h-2.94a.56.56 0 0 0-.49.284L.077 8.887a.56.56 0 0 0 0 .567L3.18 14.83l-1.47 2.545a.56.56 0 0 0 0 .566l1.634 2.83a.57.57 0 0 0 .49.283h6.205l1.47 2.545a.57.57 0 0 0 .49.284h3.266a.57.57 0 0 0 .49-.284l3.104-5.375h2.94a.57.57 0 0 0 .49-.283l1.634-2.828a.55.55 0 0 0-.004-.568M8.733.686l1.634 2.828-1.634 2.828H21.8L20.164 9.17H7.425L5.63 6.06Zm1.306 19.801-6.205-.002 1.634-2.83h3.265L2.201 6.344h3.267q3.182 5.517 6.367 11.032zm10.124-5.66L18.53 12l-6.532 11.315-1.634-2.83c2.129-3.673 4.25-7.351 6.373-11.028h3.592l3.102 5.374z"/></svg>',
},
};
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 = `<input type="radio" name="model-pick"${state.llmNo === gid ? ' checked' : ''}><span class="model-mixin-caret" data-act="toggle">${GA_ICON(expanded ? 'caretDown' : 'caretRight')}</span><span class="model-row-name">${escapeHtml(t('model.aggregation'))}</span>`;
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 = `<button type="button" class="model-member-drag" data-act="drag" title="${escapeHtml(t('model.dragReorder'))}" aria-label="${escapeHtml(t('model.dragReorder'))}"><span class="grip-dot"></span></button><span class="model-member-name">${escapeHtml(profileLabel(mName) || mName)}</span><button type="button" class="model-act model-act-del" data-act="unmix" title="${escapeHtml(t('model.removeFromMixin'))}">${GA_ICON('x')}</button>`;
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 ? '' : `<button type="button" class="model-act model-act-addmix${p.inMixin ? ' is-in' : ''}" data-act="addmix" title="${escapeHtml(p.inMixin ? t('model.alreadyInMixin') : t('model.addToMixin'))}">${GA_ICON('plus')}</button>`;
row.innerHTML = `<input type="radio" name="model-pick"${state.llmNo === id ? ' checked' : ''}><span class="model-row-name">${escapeHtml(label)}</span><span class="model-row-actions">${mixToggle}<button type="button" class="model-act" data-act="edit" title="${escapeHtml(t('common.edit'))}">${MODEL_ACT_EDIT}</button><button type="button" class="model-act model-act-del" data-act="delete" title="${escapeHtml(t('common.delete'))}">${MODEL_ACT_DEL}</button></span>`;
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 `<div class="ga-menu-item${isActive}" data-llmno="${no}">${escapeHtml(label || '')}</div>`;
});
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 `<div class="thumb" data-sid="${f.sid}"><img src="${f.dataUrl}"><button class="x" data-sid="${f.sid}" data-i18n-title="upload.removeTitle" title="">×</button></div>`;
}
const name = f.name || 'file';
const label = name.replace(/[<>&]/g, c => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
const sub = fileSubLabel(name).replace(/[<>&]/g, c => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
const path = (f.path || '').replace(/[<>&"]/g, c => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' }[c]));
const dataName = name.replace(/[<>&"]/g, c => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' }[c]));
return `<div class="file-chip pending" data-sid="${f.sid}" data-path="${path}" data-name="${dataName}"><span class="fc-icon">${GA_ICON('fileText')}</span><span class="fc-meta"><span class="fc-name">${label}</span><span class="fc-sub">${sub}</span></span><button class="x" data-sid="${f.sid}" data-i18n-title="upload.removeTitle" title="">×</button></div>`;
}).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]<br>/<div> → 换行
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;
// 内容清空后浏览器可能残留 <br>,抹掉以便 :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=`<tr><td colspan="6" style="color:var(--muted)">${t('tok.noData')}</td></tr>`;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=`<td title="${escapeHtml(s.title)}">${escapeHtml(s.title)}${s.deleted?'<span class="tok-deleted">'+t('tok.deleted')+'</span>':''}</td><td>${fmtTok(s.input)}</td><td>${fmtTok(s.output)}</td><td>${fmtTok(s.cacheCreate)}</td><td>${fmtTok(s.cacheRead)}</td><td>${sCacheRate}</td>`;
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 ? ` · <span class="tok-model-tip">${escapeHtml(p.model)}</span>` : '';
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=`<td>${fmtTime(p.ts)}${modelHtml}</td><td>${fmtTok(p.input||0)}</td><td>${fmtTok(p.output||0)}</td><td>${fmtTok(p.cacheCreate||0)}</td><td>${fmtTok(p.cacheRead||0)}</td><td>${pCacheRate}</td>`;
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 = `<tr><td colspan="6" style="color:var(--muted)">${t('tok.condOffline')}</td></tr>`;
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 = `<tr class="tok-row-conductor" title="${tip}"><td>${_ci}${t('tok.condTotal')}</td><td>${fmtTok(hIn)}</td><td>${fmtTok(hOut)}</td><td>${fmtTok(hCc)}</td><td>${fmtTok(hCr)}</td><td>${hCacheRate}</td></tr><tr class="tok-row-conductor" title="${tip}"><td>${_ci}${t('tok.condCurrent')}</td><td>${fmtTok(curIn)}</td><td>${fmtTok(curOut)}</td><td>${fmtTok(curCc)}</td><td>${fmtTok(curCr)}</td><td>${curCacheRate}</td></tr>`;
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 = '<svg class="fc-ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></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 = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4z"/></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}
<div class="chan-meta">
<b class="chan-name"></b>
<span class="kv chan-path"></span>
</div>
<span class="lr-st ${stClass} chan-status"></span>
<span class="grow"></span>
<button type="button" class="link-btn link sm" data-act="configure"></button>
<button type="button" class="link-btn link sm" data-act="logs"></button>
<button type="button" class="sw-mini${running ? ' on' : ''}" data-act="toggle" aria-pressed="${running}"><i></i></button>`;
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 += `<button type="button" class="link-btn link sm" data-act="logs"></button>`;
acts += `<button type="button" class="link-btn link sm" data-act="bridge-exit"></button>`;
} else {
acts += `<button type="button" class="link-btn link sm" data-act="bridge-start"></button>`;
}
} else if (!s.bridgeOffline) {
acts += `<button type="button" class="link-btn link sm" data-act="logs"></button>`;
if (managed) {
if (running) acts += `<button type="button" class="link-btn link sm" data-act="restart"></button>`;
if (isExtra) {
acts += `<button type="button" class="link-btn link sm${running ? ' on' : ''}" data-act="toggle" aria-pressed="${running}"></button>`;
} else {
acts += `<button type="button" class="sw-mini${running ? ' on' : ''}" data-act="toggle" aria-pressed="${running}"><i></i></button>`;
}
}
}
row.innerHTML = `
<b class="st-name"></b>
<span class="lr-st ${stClass} st-status"></span>
<span class="kv st-pid"></span>
<span class="kv st-res"></span>
<span class="grow"></span>
${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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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 = '<span class="ga-status-breathe ga-status-breathe--sm" aria-hidden="true"><span class="ga-status-breathe__ring"></span><span class="ga-status-breathe__core"></span></span>';
function collabStatusMark(status) {
switch (status) {
case 'running': return GA_STATUS_BREATHE_SM;
case 'reported': return '<span class="collab-st-ic collab-st-ic--ok" aria-hidden="true">✓</span>';
case 'paused': return '<span class="collab-st-ic collab-st-ic--pause" aria-hidden="true">⏸</span>';
case 'failed': return '<span class="collab-st-ic collab-st-ic--warn" aria-hidden="true">!</span>';
case 'terminated': return '<span class="collab-st-ic collab-st-ic--off" aria-hidden="true">×</span>';
default: return '<span class="collab-dot" aria-hidden="true"></span>';
}
}
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 = '<div class="bubble sys"><span class="collab-wait-dots" aria-hidden="true"><i></i><i></i><i></i></span></div>';
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 = `<div class="ctx-item danger">${GA_ICON('trash')}${esc(t('ctx.del'))}</div>`;
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 = `<div class="collab-drawer-backdrop"></div><aside class="collab-drawer"><div class="collab-drawer-head"><span class="collab-drawer-title">${esc(w.title)}</span><button class="modal-x collab-drawer-close">${GA_ICON('x')}</button></div><div class="collab-drawer-body"><div class="bubble md"></div></div></aside>`;
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 => `
<article class="collab-card collab-card--${w.status}" data-sid="${esc(w.id)}">
<div class="collab-card-st">${collabStatusMark(w.status)}${esc(t(ST_KEYS[w.status] || 'collab.stPaused'))}${w.updatedAt ? `<span class="collab-card-time">${esc(relTime(w.updatedAt))}</span>` : ''}</div>
<div class="collab-card-title">${esc(w.title)}</div>
<div class="collab-card-sum">${esc(w.summary)}</div>
</article>`).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 ? `<span class="collab-stat collab-stat--running">${GA_STATUS_BREATHE_SM}<span class="n">${running}</span> ${esc(t('collab.statRunning'))}</span>` : '',
done > 0 ? `<span class="collab-stat collab-stat--done"><span class="collab-rail-dot" aria-hidden="true"></span><span class="n">${done}</span> ${esc(t('collab.statDone'))}</span>` : '',
].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();
});
})();