// GenericAgent 桌面版 —— bridge 适配 + 业务 UI（HTTP 命令 / WS 状态 / i18n）。
// 文案全部走 i18n：静态用 data-i18n / data-i18n-ph / data-i18n-title，
// 动态用 t(key)。dev 标注层与发给 agent 的预设 prompt 不进 UI 字典。
'use strict';

/* ═══════════════ 端口/URL 常量 ═══════════════
   bridge / conductor 的端口和 origin 都集中在这里。要换端口、要切同源、
   要让 bridge 代理 conductor —— 改这一块即可,下面所有 URL 引用全都跟着走。
   *_ORIGIN 不带尾巴 path,调用方自己拼 "/sessions" "/ws" 等。 */
const BRIDGE_PORT = 14168;
const CONDUCTOR_PORT = 8900;
const BRIDGE_ORIGIN = `${location.protocol}//${location.hostname}:${BRIDGE_PORT}`;
const BRIDGE_WS_ORIGIN = `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.hostname}:${BRIDGE_PORT}`;
const CONDUCTOR_ORIGIN = `${location.protocol}//${location.hostname}:${CONDUCTOR_PORT}`;
const CONDUCTOR_WS_ORIGIN = `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.hostname}:${CONDUCTOR_PORT}`;

/* ═══════════════ 进程状态 store ═══════════════ */
const _serviceById = {};
const _serviceListeners = new Set();

function _serviceList() {
  return Object.values(_serviceById).sort((a, b) => String(a.id).localeCompare(String(b.id)));
}

function _serviceNotify() {
  const items = _serviceList();
  for (const cb of _serviceListeners) {
    try { cb(items, _serviceById); } catch (e) { console.error('[service-store]', e); }
  }
}

const gaServiceStore = {
  applySnapshot(services) {
    for (const k of Object.keys(_serviceById)) delete _serviceById[k];
    for (const s of services || []) {
      if (s && s.id) _serviceById[s.id] = s;
    }
    _serviceNotify();
  },
  applyChanged(service) {
    if (service && service.id) _serviceById[service.id] = service;
    _serviceNotify();
  },
  onServices(cb) {
    _serviceListeners.add(cb);
    cb(_serviceList(), _serviceById);
    return () => _serviceListeners.delete(cb);
  },
  list: _serviceList,
  get: (id) => _serviceById[id],
};

let bridgeUiOffline = false;

/* ═══════════════ Bridge 适配（HTTP 命令 + WS 状态） ═══════════════ */
(function initGaBridge() {
  const listeners = new Map();
  let ws = null;
  let cachedBridgeReady = null;
  let wsRetries = 0;
  let wsRetryTimer = null;
  const bridgeBase = BRIDGE_ORIGIN;
  const wsUrl = `${BRIDGE_WS_ORIGIN}/ws`;

  function on(channel, cb) {
    if (typeof cb !== 'function') return () => {};
    if (!listeners.has(channel)) listeners.set(channel, new Set());
    listeners.get(channel).add(cb);
    if (channel === 'bridge-ready' && cachedBridgeReady) {
      try { cb(cachedBridgeReady); } catch (err) { console.error('[ga bridge] replay bridge-ready', err); }
    }
    return () => listeners.get(channel)?.delete(cb);
  }

  function emit(channel, payload) {
    if (channel === 'bridge-ready') cachedBridgeReady = payload;
    const set = listeners.get(channel);
    if (!set) return;
    for (const cb of Array.from(set)) {
      try { cb(payload); } catch (err) { console.error('[ga bridge]', channel, err); }
    }
  }

  function handleServiceWs(msg) {
    if (msg.type === 'services.snapshot') gaServiceStore.applySnapshot(msg.services);
    else if (msg.type === 'service.changed') gaServiceStore.applyChanged(msg.service);
    emit('service-state', msg);
  }

  const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
  const tauriInvoke = (name, args = {}) => {
    const invoke = window.__TAURI__?.core?.invoke;
    if (!invoke) throw new Error('Tauri IPC is not available');
    return invoke(name, args);
  };

  async function http(path, options = {}) {
    const headers = Object.assign({}, options.headers || {});
    const init = Object.assign({}, options, { headers });
    if (init.body && typeof init.body !== 'string') {
      headers['Content-Type'] = headers['Content-Type'] || 'application/json';
      init.body = JSON.stringify(init.body);
    }
    const res = await fetch(`${bridgeBase}${path}`, init);
    const text = await res.text();
    let data = null;
    try { data = text ? JSON.parse(text) : {}; } catch (_) { data = { raw: text }; }
    if (!res.ok) {
      const err = new Error((data && (data.error || data.message)) || `${res.status} ${res.statusText}`);
      err.status = res.status;
      err.data = data;
      throw err;
    }
    return data;
  }

  async function waitBridgeStatus(timeoutMs = 20000) {
    const deadline = Date.now() + timeoutMs;
    let lastErr = null;
    while (Date.now() < deadline) {
      try {
        const status = await http('/status');
        wsRetries = 0;
        connectWs();
        return status;
      } catch (err) {
        lastErr = err;
        await sleep(350);
      }
    }
    throw lastErr || new Error('Bridge did not become ready');
  }

  function connectWs() {
    if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return;
    if (wsRetryTimer) { clearTimeout(wsRetryTimer); wsRetryTimer = null; }
    try {
      ws = new WebSocket(wsUrl);
      ws.addEventListener('open', () => { wsRetries = 0; emit('bridge-log', 'WS connected'); });
      ws.addEventListener('message', (ev) => {
        let msg;
        try { msg = JSON.parse(ev.data); } catch (_) { return; }
        if (msg.type === 'bridge-ready') emit('bridge-ready', msg);
        else if (msg.type === 'services.snapshot' || msg.type === 'service.changed') handleServiceWs(msg);
        else if (msg.type === 'session-state') emit('bridge-notification', msg);
        else if (msg.type === 'bridge-log') emit('bridge-log', msg.payload || msg);
        else if (msg.type === 'bridge-error') emit('bridge-error', msg.payload || msg);
      });
      ws.addEventListener('close', () => { emit('bridge-closed', { reason: 'ws-closed' }); scheduleWsReconnect(); });
      ws.addEventListener('error', () => emit('bridge-error', { type: 'ws-error', message: 'WebSocket error' }));
    } catch (err) {
      emit('bridge-error', { type: 'ws-error', message: err.message || String(err) });
      scheduleWsReconnect();
    }
  }

  /* WS 自动重连(指数退避,封顶 30s)。手机浏览器后台会被 OS 掐 WS,
     不重连的话回到前台还是死连接。`visibilitychange` 那一段是回前台立刻重连。 */
  function scheduleWsReconnect() {
    if (wsRetryTimer) clearTimeout(wsRetryTimer);
    if (typeof document !== 'undefined' && document.hidden) return; // 后台等回前台再连
    const delay = Math.min(30000, 1000 * Math.pow(2, wsRetries));
    wsRetries++;
    wsRetryTimer = setTimeout(() => { wsRetryTimer = null; connectWs(); }, delay);
  }
  if (typeof document !== 'undefined') {
    document.addEventListener('visibilitychange', () => {
      if (!document.hidden && (!ws || ws.readyState >= WebSocket.CLOSING)) {
        wsRetries = 0; connectWs();
      }
    });
  }

  async function rpc(method, params = {}) {
    switch (method) {
      case 'app/status': return http('/status');
      case 'app/config/get': return http('/config');
      case 'app/config/save': return http('/config', { method: 'POST', body: params || {} });
      case 'get/model-profiles': return http('/model-profiles');
      case 'session/new': return http('/session/new', { method: 'POST', body: params || {} });
      case 'session/prompt': {
        const sid = params.sessionId || params.id || params.bridgeSessionId;
        if (!sid) throw new Error('session/prompt missing sessionId');
        return http(`/session/${encodeURIComponent(sid)}/prompt`, { method: 'POST', body: params || {} });
      }
      case 'session/poll': {
        const sid = params.sessionId || params.id || params.bridgeSessionId;
        if (!sid) throw new Error('session/poll missing sessionId');
        const after = params.afterId ?? params.after ?? 0;
        const limit = params.limit ?? 200;
        return http(`/session/${encodeURIComponent(sid)}/messages?after=${encodeURIComponent(after)}&limit=${encodeURIComponent(limit)}`);
      }
      case 'session/cancel': {
        const sid = params.sessionId || params.id || params.bridgeSessionId;
        if (!sid) throw new Error('session/cancel missing sessionId');
        return http(`/session/${encodeURIComponent(sid)}/cancel`, { method: 'POST', body: params || {} });
      }
      case 'app/path/open': return http('/path/open', { method: 'POST', body: params || {} });
      case 'services/start': {
        const id = params.id;
        if (!id) throw new Error('services/start missing id');
        return http('/services/start', { method: 'POST', body: { id } });
      }
      case 'services/stop': {
        const id = params.id;
        if (!id) throw new Error('services/stop missing id');
        return http('/services/stop', { method: 'POST', body: { id } });
      }
      case 'services/logs': {
        const id = params.id;
        if (!id) throw new Error('services/logs missing id');
        const tail = params.tail ?? 200;
        return http(`/services/logs?id=${encodeURIComponent(id)}&tail=${encodeURIComponent(tail)}`);
      }
      case 'services/panel': return http('/services/panel');
      case 'services/bridge/exit': return http('/services/bridge/exit', { method: 'POST' });
      case 'services/mykey/get': return http('/services/mykey');
      case 'services/mykey/save': return http('/services/mykey', { method: 'POST', body: params || {} });
      case 'app/path/selectGaRoot': return http('/config');
      case 'list_continuable_sessions': return { sessions: [] };
      case 'restore_session': throw new Error('restore_session is not implemented in web2 bridge');
      default: throw new Error(`Unknown RPC method: ${method}`);
    }
  }

  async function startService(id) {
    try {
      const res = await rpc('services/start', { id });
      if (res.service) gaServiceStore.applyChanged(res.service);
      return res;
    } catch (e) {
      if (e.data && e.data.service) gaServiceStore.applyChanged(e.data.service);
      throw e;
    }
  }

  async function stopService(id) {
    const res = await rpc('services/stop', { id });
    if (res.service) gaServiceStore.applyChanged(res.service);
    return res;
  }

  async function spawnBridge() {
    connectWs();
    try {
      const status = await http('/status');
      bridgeUiOffline = false;
      return status;
    } catch (_) {
      await tauriInvoke('start_bridge');
      const status = await waitBridgeStatus();
      bridgeUiOffline = false;
      return status;
    }
  }

  async function exitBridge() {
    const res = await rpc('services/bridge/exit');
    cachedBridgeReady = null;
    if (ws) {
      try { ws.close(); } catch (_) {}
    }
    return res;
  }

  window.ga = {
    platform: navigator.platform.toLowerCase().includes('mac') ? 'darwin' : 'win32',
    startBridge: async () => { connectWs(); return http('/status'); },
    spawnBridge,
    stopBridge: async () => ({ ok: true }),
    exitBridge,
    checkStatus: () => rpc('app/status', {}),
    getConfig: () => rpc('app/config/get', {}),
    saveConfig: (cfg) => rpc('app/config/save', cfg || {}),
    getModelProfiles: () => rpc('get/model-profiles', {}),
    selectGaRoot: () => rpc('app/path/selectGaRoot', {}),
    openMykeyTemplate: () => rpc('app/path/open', { kind: 'mykeyTemplate' }),
    openMykey: () => rpc('app/path/open', { kind: 'mykey' }),
    startService,
    stopService,
    getServiceLogs: (id, tail = 200) => rpc('services/logs', { id, tail }),
    getServicePanel: () => rpc('services/panel', {}),
    getMykeyContent: () => rpc('services/mykey/get', {}),
    saveMykeyContent: (content) => rpc('services/mykey/save', { content }),
    tauriInvoke,
    setBridgeUiOffline: (offline) => { bridgeUiOffline = !!offline; },
    pollSession: (sessionId, afterId = 0) => rpc('session/poll', { sessionId, afterId }),
    rpc,
    onBridgeMessage: (cb) => on('bridge-message', cb),
    onBridgeNotification: (cb) => on('bridge-notification', cb),
    onBridgeError: (cb) => on('bridge-error', cb),
    onBridgeClosed: (cb) => on('bridge-closed', cb),
    onBridgeReady: (cb) => on('bridge-ready', cb),
    onBridgeLog: (cb) => on('bridge-log', cb),
    onServiceState: (cb) => on('service-state', cb),
    onOpenSearch: (cb) => on('open-search', cb),
  };

  connectWs();
  http('/status').then(status => emit('bridge-ready', status))
    .catch(err => emit('bridge-error', { type: 'http-error', message: err.message || String(err) }));
})();

/* ═══════════════ i18n ═══════════════ */
const I18N = {
  zh: {
    'app.title': 'GenericAgent 桌面版',
    'brand.sub': '桌面终端',
    'nav.chat': '聊天', 'nav.services': '后台服务', 'nav.channels': '消息通道', 'nav.status': '状态面板',
    'nav.collab': '指挥家', 'nav.token': '用量',
    'foot.settings': '配置', 'foot.ver': 'GenericAgent · 桌面版',
    'chat.startTitle': '开始对话', 'chat.startSub': '直接输入，或点预设功能一键启动',
    'preset.butler.t': '指挥家', 'preset.butler.d': '复杂任务自动拆解，只需查看进度和简报',
    'preset.plan.t': 'Plan 模式', 'preset.plan.d': '加载 Plan SOP，按探索→规划→执行→验证流程',
    'preset.goal.t': 'Goal 模式', 'preset.goal.d': '设定目标，自主完成',
    'preset.autonomous.t': '自主行动', 'preset.autonomous.d': '按 SOP 规划/执行任务,产出报告(reflect/autonomous.py 同源)',
    'preset.hive.t': 'Hive 协作', 'preset.hive.d': '多 worker 协同攻坚',
    'preset.review.t': '深度复核', 'preset.review.d': '挑刺式质量把关',
    'preset.findwork.t': '找点事做', 'preset.findwork.d': '分析当前情况,推荐一批让你感兴趣的 TODO',
    'preset.mine.t': '我的·周报', 'preset.mine.d': '自定义：抓本周提交并写周报',
    'preset.add.t': '自定义', 'preset.add.d': '任意一句话存为功能',
    'composer.placeholder': 'GA 能帮你做些什么？',
    'search.placeholder': '搜索会话…', 'conv.new': '新对话',
    'ctx.pin': '置顶', 'ctx.unpin': '取消置顶', 'ctx.rename': '重命名', 'ctx.del': '删除',
    'common.close': '关闭', 'common.more': '更多', 'common.optional': '选填', 'common.save': '保存',
    'modal.preset': '预设功能', 'modal.addModel': '添加模型', 'modal.editModel': '编辑模型', 'modal.settings': '配置',
    'modal.customPreset': '自定义预设',
    'modal.editCustomPreset': '编辑任务',
    'customPreset.titlePh': '标题，例如「写周报」',
    'customPreset.promptPh': 'Prompt 内容，发送时会作为消息提交',
    'customPreset.empty': '标题和 Prompt 不能为空',
    'customPreset.removeTitle': '删除',
    'customPreset.editTitle': '编辑',
    'builtinPreset.restoreBtn': '恢复默认预设',
    'set.appearance': '外观', 'set.plainUi': '素色', 'set.fontSize': '聊天字号', 'set.lang': '语言', 'set.model': '模型', 'set.addModel': '添加模型', 'set.features': '功能', 'set.importMykey': '导入已有模型配置（mykey.py）', 'set.exportMykey': '导出当前模型配置', 'set.serviceManager': '后台服务管理',
    'shortcut.askConfirm': '是否在桌面创建 GenericAgent 快捷方式？',
    'appearance.light': '浅色', 'appearance.dark': '深色',
    'set.noModels': '暂无模型，点击下方添加',
    'lang.zh': '简体中文', 'lang.en': 'English',
    'model.name': '备注', 'model.namePh': '会显示在模型列表',
    'model.apikey': 'API Key', 'model.apikeyPh': 'sk-...', 'model.apikeyKeep': '留空则保持原 Key 不变',
    'model.apibase': 'API 地址', 'model.apibasePh': 'https://.../v1/messages',
    'model.protocol': '协议', 'model.protocolPick': '请选择…', 'model.protocolOai': 'OpenAI 兼容 (chat/completions)', 'model.protocolClaude': 'Anthropic (Claude /v1/messages)',
    'model.stream': '响应方式', 'model.streamOn': '流式', 'model.streamOff': '非流式',
    'model.model': '模型', 'model.modelPh': 'model 参数名',
    'model.modelHint': '须与中转站/官方文档中的 model 字段完全一致',
    'model.retries': '重试 (次)', 'model.connTimeout': '连接超时 (s)', 'model.readTimeout': '读取超时 (s)',
    'model.save': '保存', 'common.cancel': '取消', 'common.confirm': '确认', 'common.edit': '编辑', 'common.delete': '删除',
    'pq.title': '快速接入官方模型', 'pq.sub': '填好 API Key 即可使用', 'pq.toggle': '展开 / 收起',
    'pq.deepseekDesc': '官方 API · OpenAI 兼容', 'pq.qwenDesc': '通义千问 · 阿里云百炼',
    'guide.step1': '点击下方链接，登录后创建并复制 API Key',
    'guide.step2': '把 Key 粘贴到下方「API Key」输入框',
    'guide.step3': '点击保存，即可在模型列表中选用',
    'guide.prefillTip': '已为你预填 API 地址、协议与模型，可按需修改',
    'guide.getKey': '获取 {name} 的 API Key', 'guide.copy': '复制链接', 'guide.copied': '链接已复制',
    'err.modelSave': '保存失败', 'err.modelRequired': '请填写模型、API Key 和 API 地址',
    'err.modelDelete': '删除失败', 'err.modelDeleteLast': '至少保留一个模型',
    'confirm.modelDelete': '确定删除该模型配置？',
    'model.aggregation': '渠道组（自动故障转移）', 'model.aggregationShort': '渠道组', 'model.aggregationDesc': '按顺序尝试，失败自动切换到下一个',
    'model.emptyMixin': '尚未加入模型',
    'model.addToMixin': '加入渠道组', 'model.inMixin': '已在渠道组', 'model.removeFromMixin': '移出渠道组', 'model.alreadyInMixin': '已在渠道组中', 'model.dragReorder': '拖拽调整顺序',
    'err.mixinFailed': '操作失败',
    'page.services.title': '后台服务', 'page.services.sub': 'IM 消息通道与后台进程，集中查看、启停与日志',
    'page.channels.title': '消息通道', 'page.channels.sub': '后台 IM 进程：列表、启停与日志（同 hub.pyw）',
    'page.status.title': '状态面板', 'page.status.sub': 'hub.pyw 管理的后台进程/服务，集中查看与启停',
    'page.collab.title': '指挥家', 'page.collab.sub': '交代目标，自动拆活与跟进',
    'collab.progressTitle': '分工进度',
    'collab.progressEmpty': '还没有任务在执行。告诉指挥家你的目标后，这里会显示拆分后的处理进度。',
    'collab.placeholder': '请对指挥家描述你想完成的目标',
    'collab.guideTitle': '把要完成的事告诉指挥家',
    'collab.guideWhen': '适合需要多步处理、要花一些时间才能完成的目标。日常聊天和快问快答，请用左侧「聊天」。',
    'collab.guideStep1t': '描述目标',
    'collab.guideStep1d': '在聊天框里写下你想做的事，发给指挥家',
    'collab.guideStep2t': '自动拆解',
    'collab.guideStep2d': '指挥家自动拆解、分配任务，实时监督和调度',
    'collab.guideStep3t': '交付摘要',
    'collab.guideStep3d': '指挥家根据执行状态，呈上任务简报',
    'collab.guideStep4t': '随时调整',
    'collab.guideStep4d': '随时补充要求或细节，指挥家都会处理',
    'collab.chipProgress': '现在进展如何？',
    'collab.chipPause': '先暂停当前任务',
    'collab.chipSummary': '总结一下目前的结果',
    'collab.showProgressTitle': '查看分工进度',
    'collab.statRunning': '进行中',
    'collab.statDone': '已完成',
    'collab.plusMenu': '更多操作',
    'collab.switchMode': '切换模式',
    'collab.typing': '指挥家正在处理',
    'collab.offline': '无法连接指挥家服务，请确认后端已启动。',
    'collab.retry': '重试',
    'collab.reconnect': '连接断开，正在重连… 已保留上次任务进度。',
    'collab.reconnectIn': '{n} 秒后重试',
    'collab.stRunning': '执行中', 'collab.stReported': '已回报', 'collab.stPaused': '已暂停',
    'collab.stFailed': '遇到问题', 'collab.stTerminated': '已终止',
    'collab.summaryRunning': '正在处理中…', 'collab.summaryWait': '等待回报',
    'collab.taskFallback': '任务 {n}',
    'collab.timeJust': '刚刚',
    'collab.timeSec': '{n} 秒前',
    'collab.timeMin': '{n} 分钟前',
    'collab.timeHr': '{n} 小时前',
    'collab.timeDay': '{n} 天前',
    'page.token.title': '用量', 'page.token.sub': '每会话与累计用量及缓存率',
    'status.connecting': '正在连接…', 'status.ready': '服务在线', 'status.running': '处理中',
    'status.disconnected': '服务离线', 'status.stopped': '已停止', 'status.idle': '待命',
    'conv.emptyList': '暂无会话，点「＋ 新对话」开始', 'conv.defaultTitle': '新对话',
    'err.bridge': '服务未响应', 'err.newSession': '新建会话失败', 'err.poll': '轮询失败', 'err.stop': '停止失败',
    'err.interruptTimeout': '等待上一轮停止超时，请稍后再试',
    'sys.interruptPrev.hint': '已停止上一轮，正在处理新消息',
    'chat.interrupting': '正在停止上一轮…',
    'chat.sessionLoading': '正在加载会话…',
    'sys.stopRequested': '已请求停止',
    'slash.help': '可用命令：\n/new 新会话  /clear 清屏  /stop 停止  /settings 设置',
    'slash.unknown': '未知命令',
    'upload.hint': '上传文件：选择 / 拖拽 / 粘贴',
    'upload.button': '上传文件',
    'upload.tooLarge': '文件过大或数量超限', 'upload.empty': '跳过空文件',
    'upload.failed': '上传失败',
    'err.charLimit': '已达字数上限（{n}），发送时将自动截断', 'err.charLimitReached': '已达字数上限（{n}）', 'err.numMax': '不能超过 {n}',
    'file.openFailed': '无法打开文件',
    'file.kindGeneric': '文件',
    'file.kindDoc': '文档',
    'file.kindSheet': '表格',
    'file.kindSlide': '幻灯片',
    'file.kindCode': '代码',
    'file.kindArchive': '压缩包',
    'file.kindAudio': '音频',
    'file.kindVideo': '视频',
    'upload.removeTitle': '移除',
    'upload.dropHint': '松开以上传文件',
    'lightbox.closeTitle': '关闭',
    'fold.thinking': '思考', 'fold.tool': '工具调用', 'fold.toolResult': '工具结果', 'fold.llm': 'LLM Running', 'fold.turn': '第 {n} 轮',
    'plan.header': '计划 ({done}/{total})', 'plan.complete': '✓ 计划完成 ({n}/{n})',
    'plan.running': '计划执行中', 'plan.completeTitle': '计划完成',
    'plan.placeholder': '计划模式已激活', 'plan.waiting': '等待写入 {path} …', 'plan.overflow': '还有 {n} 项',
    'plan.current': '当前', 'plan.collapse': '收起', 'plan.expand': '展开', 'plan.details': '详情',
    'plan.capsuleRunning': '运行中', 'plan.capsuleComplete': '已完成',
    'timing.elapsed': '已运行 {t}',
    'model.auto': '自动选择',
    'model.menuLabel': '选择模型',
    'chip.plan': 'Plan',
    'chip.auto': 'Auto',
    'ch.wechat': '微信', 'ch.wecom': '企业微信', 'ch.lark': '飞书', 'ch.dingtalk': '钉钉',
    'ch.qq': 'QQ', 'ch.telegram': 'Telegram', 'ch.discord': 'Discord',
    'ch.loading': '加载中…', 'ch.empty': '未发现 IM 进程脚本',
    'ch.logEmpty': '暂无日志',
    'err.channelLoad': '加载失败', 'err.channelStart': '启动失败', 'err.channelStop': '停止失败',
    'err.mykeyImport': '导入模型配置失败',
    'err.mykeyExport': '导出模型配置失败',
    'err.channelNotConfigured': '请先在 mykey.py 中配置该平台',
    'sys.channelStarted': '已启动', 'sys.channelStopped': '已停止',
    'modal.channelLogs': '进程日志',
    'modal.mykeyConfig': 'mykey.py 配置',
    'sys.configSaved': '配置已保存',
    'sys.mykeyImported': '模型配置已导入',
    'sys.mykeyExported': '模型配置已导出',
    'st.starting': '启动中…', 'st.stopping': '停止中…', 'st.online': '在线', 'st.offline': '离线', 'st.error': '错误', 'st.running': '运行', 'st.abnormal': '异常',
    'act.configure': '配置', 'act.logs': '日志', 'act.restart': '重启', 'act.stop': '停止', 'act.start': '启动', 'act.exit': '退出',
    'act.copy': '复制', 'act.copied': '已复制', 'act.copyTex': 'TeX', 'act.send': '发送',
    'proc.imbotWechat': 'imbot · 微信', 'proc.imbotDing': 'imbot · 钉钉', 'proc.scheduler': '定时任务调度', 'proc.conductor': '指挥家',
    'cm.scheduling': '调度中', 'cm.running': '执行中', 'cm.idleSt': '空闲',
    'cm.master': '已派 3 子任务', 'cm.w1': '子任务：抓取数据', 'cm.w2': '子任务：复核结果', 'cm.sub': '等待派单',
    'tok.total': '累计', 'tok.cost': '缓存率', 'tok.today': '今日', 'tok.tabAll': '聊天', 'tok.tabConductor': '指挥家', 'tok.condTotal': '指挥家累计', 'tok.condCurrent': '指挥家本次', 'tok.condTip': '指挥家消耗不计入聊天累计', 'tok.condOffline': '指挥家服务离线', 'tok.disclaimer': '不同 API 网站的计费价格可能会有差异，请以实际网站为准。',
    'tok.colSession': '会话', 'tok.colIn': '输入', 'tok.colOut': '输出', 'tok.colCacheW': '缓存写入', 'tok.colCache': '缓存读取', 'tok.colCost': '成本',
    'tok.from': '从', 'tok.to': '到', 'tok.reset': '重置', 'tok.noData': '暂无记录', 'tok.deleted': '此会话已删除',
    'tok.pricingUnknown': '⚠ 此模型计费规则尚未明确，按默认估算',
    'tok.priceInput': '输入: $', 'tok.priceOutput': '输出: $',
    'tok.priceCacheW': '缓存写入: $', 'tok.priceCacheR': '缓存读取: $',
    'presetPrompt.goal': '进入 Goal 模式：读 L3 goal mode SOP，自主达成我接下来描述的目标。',
    'presetPrompt.plan': '进入 Plan 模式：先读 memory/plan_sop.md，按其中「探索→规划→执行→验证」流程，等我接下来描述要做的任务。',
    'presetPrompt.autonomous': '🤖 进入自主行动模式：阅读 memory/autonomous_operation_sop.md，按 SOP 选取或规划任务,独立执行并产出报告。',
    'presetPrompt.hive': '启动 Goal Hive 模式：按 hive SOP 拉起多个 worker 协同完成我接下来的目标。',
    'presetPrompt.review': '进入监察者模式：对刚才的产出严格挑刺、逐项复核并报告问题。',
    'presetPrompt.findwork': '按照自主行动的规划部分，充分分析我的情况，给我生成一批 TODO，务必让我感兴趣。',
    'presetPrompt.mine': '抓取本周的 git 提交并写一份周报。',
    'ask.banner': 'GA 等你回答',
    'ask.replyHint': '在下方输入框回复',
    'ask.placeholderOpen': '在此输入你的回答… (Enter 发送)',
  },
  en: {
    'app.title': 'GenericAgent Desktop',
    'brand.sub': 'Desktop terminal',
    'nav.chat': 'Chat', 'nav.services': 'Services', 'nav.channels': 'Channels', 'nav.status': 'Status',
    'nav.collab': 'Conductor', 'nav.token': 'Usage',
    'foot.settings': 'Settings', 'foot.ver': 'GenericAgent · Desktop',
    'chat.startTitle': 'Start a conversation', 'chat.startSub': 'Type a message, or pick a preset',
    'preset.butler.t': 'Conductor', 'preset.butler.d': 'Auto-decompose complex tasks; just check progress and briefings',
    'preset.plan.t': 'Plan mode', 'preset.plan.d': 'Load Plan SOP — explore→plan→execute→verify',
    'preset.goal.t': 'Goal mode', 'preset.goal.d': 'Set a goal, run autonomously',
    'preset.autonomous.t': 'Autonomous mode', 'preset.autonomous.d': 'Plan/execute tasks per SOP and produce reports (same as reflect/autonomous.py)',
    'preset.hive.t': 'Hive', 'preset.hive.d': 'Multi-worker collaboration',
    'preset.review.t': 'Deep review', 'preset.review.d': 'Strict quality check',
    'preset.findwork.t': 'Find me work', 'preset.findwork.d': 'Analyze my context and suggest a batch of interesting TODOs',
    'preset.mine.t': 'My · Weekly', 'preset.mine.d': 'Custom: weekly report from commits',
    'preset.add.t': 'Custom', 'preset.add.d': 'Save any prompt as a function',
    'composer.placeholder': 'What can GA do for you?',
    'search.placeholder': 'Search chats…', 'conv.new': 'New chat',
    'ctx.pin': 'Pin', 'ctx.unpin': 'Unpin', 'ctx.rename': 'Rename', 'ctx.del': 'Delete',
    'common.close': 'Close', 'common.more': 'More', 'common.optional': 'Optional', 'common.save': 'Save',
    'modal.preset': 'Presets', 'modal.addModel': 'Add model', 'modal.editModel': 'Edit model', 'modal.settings': 'Settings',
    'modal.customPreset': 'Custom preset',
    'modal.editCustomPreset': 'Edit task',
    'customPreset.titlePh': 'Title, e.g. "Weekly report"',
    'customPreset.promptPh': 'Prompt body — sent as the message when clicked',
    'customPreset.empty': 'Title and Prompt cannot be empty',
    'customPreset.removeTitle': 'Delete',
    'customPreset.editTitle': 'Edit',
    'builtinPreset.restoreBtn': 'Restore defaults',
    'set.appearance': 'Appearance', 'set.plainUi': 'Plain', 'set.fontSize': 'Chat font size', 'set.lang': 'Language', 'set.model': 'Model', 'set.addModel': 'Add model', 'set.features': 'Features', 'set.importMykey': 'Import model config (mykey.py)', 'set.exportMykey': 'Export current model config', 'set.serviceManager': 'Service manager',
    'shortcut.askConfirm': 'Create a desktop shortcut for GenericAgent?',
    'appearance.light': 'Light', 'appearance.dark': 'Dark',
    'set.noModels': 'No models yet — add one below',
    'lang.zh': '简体中文', 'lang.en': 'English',
    'model.name': 'Note', 'model.namePh': 'Shown in the model list',
    'model.apikey': 'API Key', 'model.apikeyPh': 'sk-...', 'model.apikeyKeep': 'Leave blank to keep the current key',
    'model.apibase': 'API base URL', 'model.apibasePh': 'https://.../v1/messages',
    'model.protocol': 'Protocol', 'model.protocolPick': 'Select…', 'model.protocolOai': 'OpenAI-compatible (chat/completions)', 'model.protocolClaude': 'Anthropic (Claude /v1/messages)',
    'model.stream': 'Response', 'model.streamOn': 'Stream', 'model.streamOff': 'Non-stream',
    'model.model': 'Model', 'model.modelPh': 'model parameter name',
    'model.modelHint': 'Must match the model field in your provider docs exactly',
    'model.retries': 'Retries (×)', 'model.connTimeout': 'Connect (s)', 'model.readTimeout': 'Read (s)',
    'model.save': 'Save', 'common.cancel': 'Cancel', 'common.confirm': 'Confirm', 'common.edit': 'Edit', 'common.delete': 'Delete',
    'pq.title': 'Quick connect a model', 'pq.sub': 'Add your API key to get started', 'pq.toggle': 'Expand / collapse',
    'pq.deepseekDesc': 'Official API · OpenAI-compatible', 'pq.qwenDesc': 'Tongyi Qwen · Aliyun Bailian',
    'guide.step1': 'Open the link, sign in, then create & copy your API key',
    'guide.step2': 'Paste the key into the “API Key” field below',
    'guide.step3': 'Click Save — then pick it from the model list',
    'guide.prefillTip': 'API base, protocol and model are pre-filled — edit if needed',
    'guide.getKey': 'Get your {name} API key', 'guide.copy': 'Copy link', 'guide.copied': 'Link copied',
    'err.modelSave': 'Save failed', 'err.modelRequired': 'Model, API Key and base URL are required',
    'err.modelDelete': 'Delete failed', 'err.modelDeleteLast': 'At least one model is required',
    'confirm.modelDelete': 'Delete this model profile?',
    'model.aggregation': 'Channel group (auto failover)', 'model.aggregationShort': 'Channel group', 'model.aggregationDesc': 'Tries in order, switches to the next on failure',
    'model.emptyMixin': 'No models added yet',
    'model.addToMixin': 'Add to channel', 'model.inMixin': 'In channel', 'model.removeFromMixin': 'Remove from channel', 'model.alreadyInMixin': 'Already in the channel', 'model.dragReorder': 'Drag to reorder',
    'err.mixinFailed': 'Operation failed',
    'page.services.title': 'Services', 'page.services.sub': 'IM channels and background processes — view, start/stop, logs',
    'page.channels.title': 'Channels', 'page.channels.sub': 'Background IM processes: list, start/stop, logs (hub.pyw style)',
    'page.status.title': 'Status', 'page.status.sub': 'Background processes/services managed by hub.pyw',
    'page.collab.title': 'Conductor', 'page.collab.sub': 'Describe a goal — split, delegate, and follow up',
    'collab.progressTitle': 'Progress',
    'collab.progressEmpty': 'No tasks running yet. After you describe a goal to Conductor, split tasks will appear here.',
    'collab.placeholder': 'Describe the goal you want to accomplish',
    'collab.guideTitle': 'Tell Conductor what you want done',
    'collab.guideWhen': 'Best for multi-step goals that take a while. For everyday chat and quick questions, use Chat in the sidebar.',
    'collab.guideStep1t': 'Describe your goal',
    'collab.guideStep1d': 'Write what you want done in the chat box and send it to Conductor',
    'collab.guideStep2t': 'Auto breakdown',
    'collab.guideStep2d': 'Conductor breaks down, assigns, monitors, and coordinates',
    'collab.guideStep3t': 'Summary',
    'collab.guideStep3d': 'Conductor delivers a briefing based on execution status',
    'collab.guideStep4t': 'Adjust anytime',
    'collab.guideStep4d': 'Add requirements or details anytime — Conductor handles them',
    'collab.chipProgress': 'How is it going?',
    'collab.chipPause': 'Pause current tasks',
    'collab.chipSummary': 'Summarize progress so far',
    'collab.showProgressTitle': 'View task progress',
    'collab.statRunning': 'Running',
    'collab.statDone': 'Done',
    'collab.plusMenu': 'More actions',
    'collab.switchMode': 'Switch mode',
    'collab.typing': 'Conductor is working',
    'collab.offline': 'Cannot reach the service. Make sure the backend is running.',
    'collab.retry': 'Retry',
    'collab.reconnect': 'Disconnected — reconnecting… Your last progress is kept.',
    'collab.reconnectIn': 'Retry in {n}s',
    'collab.stRunning': 'Running', 'collab.stReported': 'Reported', 'collab.stPaused': 'Paused',
    'collab.stFailed': 'Issue', 'collab.stTerminated': 'Ended',
    'collab.summaryRunning': 'Working…', 'collab.summaryWait': 'Awaiting report',
    'collab.taskFallback': 'Task {n}',
    'collab.timeJust': 'just now',
    'collab.timeSec': '{n}s ago',
    'collab.timeMin': '{n}m ago',
    'collab.timeHr': '{n}h ago',
    'collab.timeDay': '{n}d ago',
    'page.token.title': 'Usage', 'page.token.sub': 'Per-session and total usage & cache rate',
    'status.connecting': 'Connecting…', 'status.ready': 'Service online', 'status.running': 'Working…',
    'status.disconnected': 'Service offline', 'status.stopped': 'Stopped', 'status.idle': 'Standby',
    'conv.emptyList': 'No chats yet — click “＋ New chat”', 'conv.defaultTitle': 'New chat',
    'err.bridge': 'Service not responding', 'err.newSession': 'Failed to create session', 'err.poll': 'Polling failed', 'err.stop': 'Stop failed',
    'err.interruptTimeout': 'Timed out waiting for the previous reply to stop — try again',
    'sys.interruptPrev.hint': 'Previous reply stopped — processing new message',
    'chat.interrupting': 'Stopping previous reply…',
    'chat.sessionLoading': 'Loading conversation…',
    'sys.stopRequested': 'Stop requested',
    'slash.help': 'Commands:\n/new new chat  /clear clear  /stop stop  /settings settings',
    'slash.unknown': 'Unknown command',
    'upload.hint': 'Upload file: pick / drag / paste',
    'upload.button': 'Upload file',
    'upload.tooLarge': 'File too large or limit reached', 'upload.empty': 'Skipped empty file',
    'upload.failed': 'Upload failed',
    'err.charLimit': 'Character limit reached ({n}), text will be truncated on send', 'err.charLimitReached': 'Character limit reached ({n})', 'err.numMax': 'Cannot exceed {n}',
    'file.openFailed': 'Cannot open file',
    'file.kindGeneric': 'File',
    'file.kindDoc': 'Document',
    'file.kindSheet': 'Spreadsheet',
    'file.kindSlide': 'Slides',
    'file.kindCode': 'Code',
    'file.kindArchive': 'Archive',
    'file.kindAudio': 'Audio',
    'file.kindVideo': 'Video',
    'upload.removeTitle': 'Remove',
    'upload.dropHint': 'Drop to upload files',
    'lightbox.closeTitle': 'Close',
    'fold.thinking': 'Thinking', 'fold.tool': 'Tool call', 'fold.toolResult': 'Tool result', 'fold.llm': 'LLM Running', 'fold.turn': 'Turn {n}',
    'plan.header': 'Plan ({done}/{total})', 'plan.complete': '✓ Plan complete ({n}/{n})',
    'plan.running': 'Running plan', 'plan.completeTitle': 'Plan complete',
    'plan.placeholder': 'Plan mode activated', 'plan.waiting': 'waiting for {path} …', 'plan.overflow': '+{n} more',
    'plan.current': 'Now', 'plan.collapse': 'Collapse', 'plan.expand': 'Expand', 'plan.details': 'Details',
    'plan.capsuleRunning': 'Running', 'plan.capsuleComplete': 'Done',
    'timing.elapsed': 'Elapsed {t}',
    'model.auto': 'Auto',
    'model.menuLabel': 'Select model',
    'chip.plan': 'Plan',
    'chip.auto': 'Auto',
    'ch.wechat': 'WeChat', 'ch.wecom': 'WeCom', 'ch.lark': 'Lark', 'ch.dingtalk': 'DingTalk',
    'ch.qq': 'QQ', 'ch.telegram': 'Telegram', 'ch.discord': 'Discord',
    'ch.loading': 'Loading…', 'ch.empty': 'No IM process scripts found',
    'ch.logEmpty': 'No log output yet',
    'err.channelLoad': 'Failed to load', 'err.channelStart': 'Start failed', 'err.channelStop': 'Stop failed',
    'err.mykeyImport': 'Failed to import model config',
    'err.mykeyExport': 'Failed to export model config',
    'err.channelNotConfigured': 'Configure this platform in mykey.py first',
    'sys.channelStarted': 'Started', 'sys.channelStopped': 'Stopped',
    'modal.channelLogs': 'Process logs',
    'modal.mykeyConfig': 'mykey.py',
    'sys.configSaved': 'Configuration saved',
    'sys.mykeyImported': 'Model config imported',
    'sys.mykeyExported': 'Model config exported',
    'st.starting': 'Starting…', 'st.stopping': 'Stopping…', 'st.online': 'Online', 'st.offline': 'Offline', 'st.error': 'Error', 'st.running': 'Running', 'st.abnormal': 'Error',
    'act.configure': 'Configure', 'act.logs': 'Logs', 'act.restart': 'Restart', 'act.stop': 'Stop', 'act.start': 'Start', 'act.exit': 'Exit',
    'act.copy': 'Copy', 'act.copied': 'Copied', 'act.copyTex': 'TeX', 'act.send': 'Send',
    'proc.imbotWechat': 'imbot · WeChat', 'proc.imbotDing': 'imbot · DingTalk', 'proc.scheduler': 'Scheduler', 'proc.conductor': 'Conductor',
    'cm.scheduling': 'Scheduling', 'cm.running': 'Running', 'cm.idleSt': 'Idle',
    'cm.master': 'Dispatched 3 subtasks', 'cm.w1': 'Subtask: fetch data', 'cm.w2': 'Subtask: review results', 'cm.sub': 'Waiting for tasks',
    'tok.total': 'Total', 'tok.cost': 'Cache rate', 'tok.today': 'Today', 'tok.tabAll': 'Chat', 'tok.tabConductor': 'Conductor', 'tok.condTotal': 'Conductor Total', 'tok.condCurrent': 'Conductor Current', 'tok.condTip': 'Conductor usage is not included in chat totals', 'tok.condOffline': 'Service offline', 'tok.disclaimer': 'Pricing may vary by API provider. Please refer to the actual website.',
    'tok.colSession': 'Session', 'tok.colIn': 'Input', 'tok.colOut': 'Output', 'tok.colCacheW': 'Cache write', 'tok.colCache': 'Cache read', 'tok.colCost': 'Cost',
    'tok.from': 'From', 'tok.to': 'To', 'tok.reset': 'Reset', 'tok.noData': 'No records', 'tok.deleted': 'Session deleted',
    'tok.pricingUnknown': '⚠ Pricing not confirmed, using defaults',
    'tok.priceInput': 'Input: $', 'tok.priceOutput': 'Output: $',
    'tok.priceCacheW': 'Cache write: $', 'tok.priceCacheR': 'Cache read: $',
    'presetPrompt.goal': 'Enter Goal mode: read the L3 goal-mode SOP and autonomously achieve the goal I describe next.',
    'presetPrompt.plan': 'Enter Plan mode: first read memory/plan_sop.md, follow its explore→plan→execute→verify flow, and wait for the task I describe next.',
    'presetPrompt.autonomous': '🤖 Enter autonomous mode: read memory/autonomous_operation_sop.md, follow the SOP to pick or plan a task, execute independently, and produce a report.',
    'presetPrompt.hive': 'Start Goal Hive mode: per the hive SOP, spawn multiple workers to collaboratively achieve the goal I describe next.',
    'presetPrompt.review': 'Enter reviewer mode: strictly scrutinize the previous output, review item by item and report issues.',
    'presetPrompt.findwork': 'Following the autonomous planning section, analyze my situation thoroughly and generate a batch of TODOs that genuinely interest me.',
    'presetPrompt.mine': 'Collect this week\'s git commits and write a weekly report.',
    'ask.banner': 'GA is waiting for your answer',
    'ask.replyHint': 'Reply in the input below',
    'ask.placeholderOpen': 'Type your answer here… (Enter to send)',
  },
};
const LANGS = ['zh', 'en'];
const STORE = { lang: 'ga_lang', theme: 'ga_theme', appearance: 'ga_appearance', plain: 'ga_plain', fontSize: 'ga_font_size', llmNo: 'ga_llm_no' };
const APPEARANCE_IDS = ['light', 'dark'];
const CHAT_FONT_MIN = 10;
const CHAT_FONT_MAX = 20;
const CHAT_FONT_DEFAULT = 14;
const CHAT_FONT_LEGACY = { sm: 12, md: 14, lg: 16 };
const HLJS_THEME_BASE = 'https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/';

function normalizeChatFontSize(value) {
  if (typeof value === 'string' && CHAT_FONT_LEGACY[value]) return CHAT_FONT_LEGACY[value];
  const n = parseInt(value, 10);
  if (Number.isFinite(n)) return Math.min(CHAT_FONT_MAX, Math.max(CHAT_FONT_MIN, n));
  return CHAT_FONT_DEFAULT;
}

function bootUiFromDom() {
  const root = document.documentElement;
  const out = { lang: 'zh', theme: '1', appearance: 'light', plainUi: false, chatFontSize: CHAT_FONT_DEFAULT };
  if (root.lang === 'en') out.lang = 'en';
  if (root.dataset.theme) out.theme = root.dataset.theme;
  if (APPEARANCE_IDS.includes(root.dataset.appearance)) out.appearance = root.dataset.appearance;
  if (out.appearance === 'light' && root.dataset.plain === '1') out.plainUi = true;
  if (root.dataset.chatFont) out.chatFontSize = normalizeChatFontSize(root.dataset.chatFont);
  return out;
}
let { lang, theme, appearance, plainUi, chatFontSize } = bootUiFromDom();

function syncHljsTheme() {
  const link = document.getElementById('hljs-theme');
  if (link) link.href = HLJS_THEME_BASE + (appearance === 'dark' ? 'github-dark.min.css' : 'github.min.css');
  document.querySelectorAll('.bubble.md pre code').forEach(block => {
    if (typeof hljs !== 'undefined') hljs.highlightElement(block);
  });
}

/** 服务端 ui 落盘后的本地镜像，仅供 index.html 内联脚本首帧防闪；不是真相源。 */
function syncBootCache() {
  localStorage.setItem(STORE.lang, lang);
  localStorage.setItem(STORE.theme, theme);
  localStorage.setItem(STORE.appearance, appearance);
  localStorage.setItem(STORE.fontSize, String(chatFontSize));
  if (plainUi) localStorage.setItem(STORE.plain, '1');
  else localStorage.removeItem(STORE.plain);
  localStorage.setItem(STORE.llmNo, String(state.llmNo));
}
async function persistUiPrefs() {
  try {
    await window.ga.saveConfig({
      config: { lang, theme, appearance, plain: plainUi, llmNo: state.llmNo, fontSize: chatFontSize },
    });
    syncBootCache();
  } catch (_) {}
}
const bridgeHost = () => BRIDGE_ORIGIN;
async function bridgeFetch(path, opts = {}) {
  const headers = { ...(opts.headers || {}) };
  const init = { ...opts, headers };
  if (init.body && typeof init.body !== 'string') {
    headers['Content-Type'] = 'application/json';
    init.body = JSON.stringify(init.body);
  }
  const res = await fetch(`${bridgeHost()}${path}`, init);
  let data = {};
  try { data = await res.json(); } catch (_) {}
  if (!res.ok) throw new Error(data.error || data.message || res.statusText);
  return data;
}
function t(key) { return (I18N[lang] && I18N[lang][key]) || (I18N.zh[key]) || key; }
window.gaT = t;
document.addEventListener('collab:running-count', e => {
  const b = document.getElementById('collab-badge');
  if (!b) return;
  const n = e.detail?.count || 0;
  b.hidden = !n;
  b.textContent = n ? (n > 9 ? '9+' : String(n)) : '';
});
function optionalPh(key) {
  const sep = (lang === 'en') ? ', ' : '，';
  return `${t('common.optional')}${sep}${t(key)}`;
}
function applyI18n() {
  document.documentElement.lang = (lang === 'en') ? 'en' : 'zh-CN';
  document.title = t('app.title');
  document.querySelectorAll('[data-i18n]').forEach(el => { el.textContent = t(el.dataset.i18n); });
  document.querySelectorAll('[data-i18n-ph]').forEach(el => {
    const phKey = el.dataset.i18nPh;
    const val = el.hasAttribute('data-optional-ph') ? optionalPh(phKey) : t(phKey);
    if (el.isContentEditable) el.setAttribute('data-ph', val);  // contenteditable 用 :empty::before 显示
    else el.setAttribute('placeholder', val);
  });
  document.querySelectorAll('[data-i18n-title]').forEach(el => { el.setAttribute('title', t(el.dataset.i18nTitle)); });
  renderLangList();
  // 语言切换后重算激活模型 chip 文案；若当前会话已有渠道组运行态模型，保留运行态而非退回首选项
  const _ap = (state.modelProfiles || []).find(p => (p.id ?? 0) === state.llmNo);
  if (_ap) state.modelName = modelDisplayName(_ap);
  if (_ap?.kind === 'mixin' && state.liveModel?.sessionId === state.activeId) applyLiveModel(state.liveModel);
  else if (typeof updateModelChip === 'function') updateModelChip();
  window.gaRefreshModelGuide?.();
  window.collabRetranslate?.();
  syncAskUserUi();
}
// 语言对应国旗 SVG(en 用美国旗,按要求)
const FLAGS = {
  zh: '<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 = 始终显示 candidates；false = 题干已含选项/多题时不重复渲染底部列表 */
const ASK_USER_ALWAYS_SHOW_CANDIDATES = false;

/** 题干已含选项/多题，或 candidates 无法与题干对应时，不再重复渲染底部列表 */
function shouldShowAskCandidates(item) {
  if (!item || !item.candidates.length) return false;
  if (ASK_USER_ALWAYS_SHOW_CANDIDATES) return true;
  const q = item.question;
  if (/两个问题|多个问题|两道|两题/.test(q)) return false;
  if ((q.match(/问题\s*\d/gi) || []).length >= 2) return false;
  if ((q.match(/^[ \t]*\d+[.、:：)]\s+/gm) || []).length >= 2) return false;
  if ((q.match(/^[ \t]*[A-Da-d][.)]\s/mg) || []).length >= 2) return false;
  const comboN = item.candidates.filter(c => /\d+[A-Da-d]\s*\+\s*\d+[A-Da-d]/i.test(c)).length;
  if (comboN >= Math.max(1, Math.ceil(item.candidates.length * 0.5))) return false;
  // 题干里有多道问句，却把全部选项平铺在 candidates → 无法区分归属，不展示
  const qMarks = (q.match(/[？?]/g) || []).length;
  if (qMarks >= 2 && item.candidates.length > 4) return false;
  return true;
}


function renderAskUserNotice(data) {
  const item = normalizeAskUserData(data);
  if (!item) return '';
  // 单题与多题统一处理：多题的选项本就内联在题干里；单题的选项放在 candidates 里，
  // 这里把它折叠进题干，按同样的 A./B./C. 内联方式渲染，不再单独画一个编号列表。
  const question = foldAskCandidates(item);
  const qHtml = markAskOptionHtml(renderMarkdown(formatAskUserQuestion(question)));
  return `<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 是正在打字的 turn；tw.turn 之前的 DOM 一旦 frozen 就不再动。
  ensureDraftFrozenThrough(r, tw.turn);

  const tick = () => {
    const segs = Array.isArray(r.draftSegs) ? r.draftSegs : [];
    const backendTurnNow = Math.max(0, Number(r.draftTurn) || 0);
    if (tw.turn == null || tw.turn < 0) tw.turn = 0;
    if (tw.turn > backendTurnNow) tw.turn = backendTurnNow;
    r.streamTurn = tw.turn;
    const cur = segs[tw.turn] || '';

    // 当前 turn 打完，并且后端已进入后续 turn：当前 DOM 固化，然后打字机推进到下一 turn。
    if (tw.shown >= cur.length && backendTurnNow > tw.turn) {
      paintDraft(r, tw.turn, cur);
      freezeCurrentTurnDom(r, tw.turn);
      tw.turn += 1;
      tw.shown = 0;
      r.streamTurn = tw.turn;
      return;
    }

    if (tw.shown >= cur.length) return; // 中途不停止 timer，等新 partial/done。
    if (isDraftInteractFrozen(r)) return;

    const t0 = performance.now();
    const backlog = cur.length - tw.shown;
    let step = backlog > TW_CATCHUP_THRESHOLD ? TW_SPEED * TW_CATCHUP_MULTIPLIER : TW_SPEED;
    const last = tw.lastElapsed || 0;
    if (last > 60) step = Math.max(step, Math.ceil(backlog / 3));
    else step = Math.min(step, 120);
    tw.shown = Math.min(tw.shown + step, cur.length);
    paintDraft(r, tw.turn, cur.slice(0, tw.shown));
    tw.lastElapsed = performance.now() - t0;
  };

  if (snapDraftRecover(r)) {
    if (!tw.timer) tw.timer = setInterval(tick, TW_INTERVAL);
    refreshEmptyState(sess);
    return;
  }

  if (!tw.timer) tw.timer = setInterval(tick, TW_INTERVAL);
  if (!isDraftInteractFrozen(r)) tick();
  refreshEmptyState(sess);
}

function ensureDraftFrozenThrough(r, currTurn) {
  if (!r.draftEl) return;
  const segs = Array.isArray(r.draftSegs) ? r.draftSegs : [];
  const upto = Math.max(0, Number(currTurn) || 0);
  let bubble = r.draftEl.querySelector(':scope > .bubble.md');
  if (!bubble) {
    bubble = document.createElement('div');
    bubble.className = 'bubble md';
    r.draftEl.appendChild(bubble);
  }
  const cur = bubble.querySelector(':scope > .turn-cur');
  for (let turn = 0; turn < upto; turn++) {
    if (bubble.querySelector(`:scope > .turn-frozen[data-turn="${turn}"]`)) continue;
    const frozen = document.createElement('div');
    frozen.className = 'turn-frozen';
    frozen.dataset.turn = turn;
    frozen.innerHTML = renderTurnFold(segs[turn] || '', turn);
    if (cur) bubble.insertBefore(frozen, cur);
    else bubble.appendChild(frozen);
    postRenderEnhance(frozen);
  }
}

function freezeCurrentTurnDom(r, turn) {
  if (!r.draftEl) return;
  const bubble = r.draftEl.querySelector(':scope > .bubble.md');
  if (!bubble) return;
  const cur = bubble.querySelector(':scope > .turn-cur');
  if (!cur) return;
  cur.className = 'turn-frozen';
  cur.dataset.turn = turn;
  cur.innerHTML = renderTurnFold((r.draftSegs || [])[turn] || '', turn);
  postRenderEnhance(cur);
}

function paintDraft(r, turn, visibleCurrBody) {
  if (!r.draftEl || isDraftInteractFrozen(r)) return;
  const wasNear = isNearBottom();
  let bubble = r.draftEl.querySelector(':scope > .bubble.md');
  if (!bubble) {
    bubble = document.createElement('div');
    bubble.className = 'bubble md';
    r.draftEl.appendChild(bubble);
  }
  let cur = bubble.querySelector(':scope > .turn-cur');
  if (!cur) {
    cur = document.createElement('div');
    cur.className = 'turn-cur';
    bubble.appendChild(cur);
  }
  cur.dataset.turn = turn ?? 0;
  const body = visibleCurrBody || '';
  const prevBody = r._draftPaintBody || '';
  if (!tryPatchInflightToolDom(cur, body, prevBody)) {
    cur.innerHTML = renderTurnBody(body) + '<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 不截断）；不等 idle，running 续交给 pollSession */
async function hydrateSession(sess) {
  try {
    const result = await fetchSessionPoll(sess, { after: 0, limit: 0 });
    hydrateHistoryMessages(sess, result.messages);
    if (result.partial) upsert(sess, result.partial, true);
    const busy = result.status === 'running' || !!result.partial;
    setBusy(sess, busy);
    if (isActive(sess)) applyPlanPayload(sess, result.plan);
    if (busy && !rt(sess).polling) pollSession(sess);
  } catch (e) {
    showError(t('err.poll') + ': ' + (e.message || e));
    setBusy(sess, false);
  } finally {
    if (isActive(sess)) {
      restoreElapsedBadges(sess, ensureMsgs());
      syncAskUserUi();
    }
    tokPollBridge();
  }
}

async function pollSession(sess) {
  const r = rt(sess);
  if (r.polling) { r.pollAgain = true; return; }
  r.polling = true;
  r.pollAgain = false;
  /* 手机切后台再回前台时,第一拍 fetch 经常用着死连接秒挂(Failed to fetch),
     但只要给链路 1-2 秒重建,后续就稳。原版一炸就 showError,体验糟。
     改成:同一次 polling 循环里连续失败 ≥ MAX_ERRORS 次才放弃,
     单次失败做指数退避(1s / 2s / 4s / 8s),够 ride through 一次后台恢复抖动。 */
  const MAX_ERRORS = 5;
  let consecutiveErrors = 0;
  try {
    do {
      try {
        const result = await fetchSessionPoll(sess);
        consecutiveErrors = 0;
        const busy = applyPollResult(sess, result);
        if (busy) await new Promise(z => setTimeout(z, 500));
        else {
          if (r.draftEl) { r.draftEl.remove(); r.draftEl = null; r.draftSegs = null; r.draftTurn = 0; }
          resetTypewriterState(r);
          break;
        }
      } catch (innerErr) {
        consecutiveErrors++;
        if (consecutiveErrors >= MAX_ERRORS) throw innerErr;
        const backoff = Math.min(8000, 1000 * Math.pow(2, consecutiveErrors - 1));
        await new Promise(z => setTimeout(z, backoff));
      }
    } while (true);
  } catch (e) {
    showError(t('err.poll') + ': ' + (e.message || e));
    setBusy(sess, false);
  } finally {
    r.polling = false; renderSessionList();
    // 历史消息已全部加载，恢复已完成任务的耗时 badge
    if (isActive(sess)) {
      restoreElapsedBadges(sess, ensureMsgs());
      syncAskUserUi();
    }
    tokPollBridge();
    if (r.pollAgain) {
      r.pollAgain = false;
      pollSession(sess);
    }
  }
}

function removeUsedPendingFiles(usedFiles) {
  if (!usedFiles.length) return;
  const usedSids = new Set(usedFiles.map(f => f.sid));
  const touched = new Set(usedFiles.map(f => fileCtx(f)));
  state.pendingFiles = state.pendingFiles.filter(f => !usedSids.has(f.sid));
  touched.forEach(ctx => renderThumbStrip(ctx));
}

function clearDraft(sess) {
  const r = rt(sess);
  resetTypewriterState(r);
  if (r.draftEl) { r.draftEl.remove(); r.draftEl = null; r.draftSegs = null; r.draftTurn = 0; }
}

async function waitSessionIdle(sess, maxMs = 4000) {
  const start = Date.now();
  while (rt(sess).busy && Date.now() - start < maxMs) {
    await new Promise(z => setTimeout(z, 100));
  }
  return !rt(sess).busy;
}

function setSessionLoading(on) {
  if (!msgArea || !sessionLoadingEl) return;
  if (on && msgArea.classList.contains('is-loading')) return;
  msgArea.classList.toggle('is-session-loading', !!on);
  sessionLoadingEl.hidden = !on;
  if (on && sessionLoadingEl.querySelector('[data-i18n]')) {
    sessionLoadingEl.querySelector('[data-i18n]').textContent = t('chat.sessionLoading');
  }
}

function setMsgLoading(on) {
  if (msgArea) msgArea.classList.toggle('is-loading', !!on);
  if (msgLoading) {
    msgLoading.hidden = !on;
    if (on) {
      setSessionLoading(false);
      scrollBottom();
    }
  }
}

function setComposerLocked(on) {
  if (composerEl) composerEl.classList.toggle('is-locked', !!on);
  if (inputEl) inputEl.contentEditable = on ? 'false' : 'true';  // contenteditable 无 readOnly,改切 contentEditable
  if (sendBtn) {
    sendBtn.disabled = !!on;
    sendBtn.classList.toggle('is-busy', !!on);
    sendBtn.setAttribute('aria-busy', on ? 'true' : 'false');
  }
}

/** stapp.py 同款：运行中再发 → cancel 当前轮次，等 idle 后再提交新 prompt */
async function interruptBeforeSend(sess) {
  if (!rt(sess).busy) return true;
  const t0 = Date.now();
  setMsgLoading(true);
  try {
    clearDraft(sess);
    try {
      const res = await window.ga.rpc('session/cancel', { sessionId: sess.bridgeSessionId || sess.id });
      if (res?.error) throw new Error(res.error.message || res.error);
    } catch (e) {
      showChanToast(t('err.stop') + ': ' + (e.message || e), '', 'err');
      return false;
    }
    showChanToast(t('sys.interruptPrev.hint'), '', 'info');
    const idle = await waitSessionIdle(sess);
    clearDraft(sess);
    if (!idle) {
      showChanToast(t('err.interruptTimeout'), '', 'err');
      return false;
    }
    return true;
  } finally {
    const wait = Math.max(0, MIN_MSG_LOADING_MS - (Date.now() - t0));
    if (wait) await new Promise(r => setTimeout(r, wait));
    setMsgLoading(false);
  }
}

/* ═══════════════ 发送 / 取消 ═══════════════ */
async function sendPrompt(text) {
  text = String(text || '').trim();
  if (!text) return false;
  if (!state.bridgeReady) { showError(t('err.bridge')); return false; }
  if (!state.activeId) { await newSession(); if (!state.activeId) return false; }
  const sess = activeSess(); const r = rt(sess);
  if (r.busy) {
    const interrupted = await interruptBeforeSend(sess);
    if (!interrupted) return false;
  }
  // PLAN/AUTO 现在是预设功能（preset 卡片）一次性发送，不再是常驻 prefix
  const composedPrompt = expandFilePlaceholders(text).trim();
  const usedFiles = collectUsedFiles(text);
  const userMsg = { role: 'user', content: text, ts: Date.now() / 1000 };
  const previewImgs = usedFiles.filter(f => f.isImage).map(f => ({ id: 'f-' + f.sid, name: f.name, path: f.path, dataUrl: f.dataUrl || '' }));
  if (previewImgs.length) userMsg.images = previewImgs;
  const previewFiles = usedFiles.filter(f => !f.isImage).map(f => ({ id: 'f-' + f.sid, name: f.name, path: f.path }));
  if (previewFiles.length) userMsg.files = previewFiles;
  sess.messages.push(userMsg); appendMessage(sess, userMsg);
  if (isPlanPresetPrompt(text)) {
    const pr = rt(sess);
    pr.planCollapsed = false;
    pr.planShowAll = false;
    const sidHint = (sess.bridgeSessionId || sess.id || 'sess').replace(/\//g, '_');
    applyPlanPayload(sess, {
      active: true, placeholder: true, done: 0, total: 0, complete: false,
      step: '', pathHint: `plan_${sidHint}/plan.md`, items: [],
    });
  }
  sess.lastActiveTs = Date.now();
  // 仿 TUI:不再从首条消息自动改名 —— 标题在 newSession 时已设为 agent-N,
  // 之后只接受用户手动 rename。
  saveSessions();
  setBusy(sess, true);
  try {
    let sid = await ensureBridgeSession(sess);
    try {
      await bridgeFetch(`/session/${encodeURIComponent(sid)}/restore`, { method: 'POST', body: {} });
    } catch (restoreErr) {
      if (/not found/i.test(restoreErr.message || '')) {
        sess.bridgeSessionId = null;
        sid = await ensureBridgeSession(sess);
        state.sessions.delete(sess.id);
        sess.id = sess.bridgeSessionId;
        state.sessions.set(sess.id, sess);
        state.activeId = sess.id;
        localStorage.setItem('ga_active', sess.id);  // 会话 id 因 bridge 重建而变更，同步持久化
      }
    }
    const res = await window.ga.rpc('session/prompt', { sessionId: sid, prompt: composedPrompt, display: text, llmNo: state.llmNo,
      files: previewFiles, imageMetas: previewImgs.map(im => ({ name: im.name, path: im.path })) });
    if (res?.error) throw new Error(res.error.message || res.error);
    removeUsedPendingFiles(usedFiles);
    const uid = Number(res.userMessageId || res.result?.userMessageId || 0);
    if (uid) { r.seen.add(uid); r.lastId = Math.max(r.lastId, uid); }
    planPoll(sess);
    pollSession(sess);
    return true;
  } catch (e) {
    const em = { role: 'error', content: e.message || String(e) };
    sess.messages.push(em); appendMessage(sess, em);
    setBusy(sess, false);
    return false;
  }
}
async function cancelPrompt() {
  const sess = activeSess();
  if (!sess || !rt(sess).busy) return false;
  try {
    const res = await window.ga.rpc('session/cancel', { sessionId: sess.bridgeSessionId || sess.id });
    if (res?.error) throw new Error(res.error.message || res.error);
    clearDraft(sess);
    rt(sess).pollAgain = true;
    return true;
  } catch (e) { showError(t('err.stop') + ': ' + (e.message || e)); return false; }
}

/* ═══════════════ 输入区 / slash / 预设 ═══════════════ */
async function submitInput() {
  if (_submitInFlight) return;
  let text = composerText('chat');
  if (!text.trim()) return;
  if (text.trim().startsWith('/')) {
    inputEl.innerHTML = '';
    handleSlash(text.trim());
    return;
  }
  if (text.length > 20000) {
    text = text.slice(0, 20000);
    showToast(t('err.charLimit').replace('{n}', 20000), 'warn');
  }
  _submitInFlight = true;
  setComposerLocked(true);
  try {
    const sent = await sendPrompt(text);
    if (sent) {
      inputEl.innerHTML = '';
    }
  } finally {
    _submitInFlight = false;
    setComposerLocked(false);
    syncAskUserUi();
  }
}
sendBtn.addEventListener('click', (e) => {
  e.preventDefault();
  const sess = activeSess();
  if (sess && rt(sess).busy) { cancelPrompt(); return; }  // 运行中：发送键是录制键 → 纯停止
  submitInput();
});
inputEl.addEventListener('keydown', (e) => {
  if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && e.keyCode !== 229) { e.preventDefault(); submitInput(); }
});
// 输入框的 input/paste 监听统一在 bindComposerUpload(ctx) 里绑(chat + collab 通用)
function showSystem(text) {
  const sess = activeSess(); if (!sess) return;
  const m = { role: 'system', content: text };
  sess.messages.push(m); appendMessage(sess, m);
}
function showError(text) {
  const sess = activeSess();
  if (sess) { const m = { role: 'error', content: text }; sess.messages.push(m); appendMessage(sess, m); }
  else console.error(text);
}
let _toastTimer = null;
function showToast(text) {
  let el = document.getElementById('ga-toast');
  if (!el) { el = document.createElement('div'); el.id = 'ga-toast'; el.className = 'ga-toast'; document.body.appendChild(el); }
  el.textContent = text;
  el.classList.add('show');
  clearTimeout(_toastTimer);
  _toastTimer = setTimeout(() => el.classList.remove('show'), 1800);
}
async function handleSlash(cmd) {
  const name = cmd.slice(1).split(/\s+/)[0];
  switch (name) {
    case 'help': showSystem(t('slash.help')); break;
    case 'new': await newSession(); break;
    case 'clear': { const s = activeSess(); if (s) { s.messages = []; renderAllMessages(s); } break; }
    case 'stop': if (await cancelPrompt()) showSystem(t('sys.stopRequested')); break;
    case 'settings': openSettings(); break;
    default: showSystem(t('slash.unknown') + ': /' + name);
  }
}
// 预设卡：按 data-preset 解耦（与翻译后的标题无关）
document.querySelectorAll('.feature-grid').forEach(grid => {
  grid.addEventListener('click', (e) => {
    const editBtn = e.target.closest('.fc-edit');
    if (editBtn) {
      e.stopPropagation();
      const cp = state.customPresets.find(p => p.id === editBtn.dataset.editId);
      if (cp) openCustomPresetEditor(cp);
      return;
    }
    const xBtn = e.target.closest('.fc-x');
    if (xBtn) {
      e.stopPropagation();
      const kind = xBtn.dataset.removeKind;
      const id = xBtn.dataset.removeId;
      if (kind === 'builtin') hideBuiltinPreset(id);
      else if (kind === 'custom') removeCustomPreset(id);
      return;
    }
    const card = e.target.closest('.fcard');
    if (!card || !grid.contains(card)) return;
    const key = card.dataset.preset;
    if (key === 'add') { closeModals(); openModal('custom-preset-modal'); resetCustomPresetForm(); return; }
    if (card.classList.contains('fcard-custom')) {
      const id = card.dataset.id;
      const cp = state.customPresets.find(p => p.id === id);
      if (cp) { closeModals(); sendPrompt(cp.prompt); }
      return;
    }
    if (!key) { inputEl.focus(); closeModals(); return; }
    const bp = BUILTIN_PRESETS.find(p => p.key === key);
    if (bp?.navigate) { closeModals(); gaGoPage(bp.navigate); window.collabFocus?.(); return; }
    const prompt = I18N[lang]['presetPrompt.' + key] || I18N.zh['presetPrompt.' + key];
    closeModals();
    if (prompt) sendPrompt(prompt);
  });
});

/* ═══════════════ 模型 / 设置 ═══════════════ */
function updateModelChip() {
  const name = state.modelName || '';
  if (modelNameEl) modelNameEl.textContent = name;
  if (collabModelNameEl) collabModelNameEl.textContent = name;
}
function modelDisplayName(p, fallbackName) {
  if (p && p.kind === 'mixin') {
    // 静态回退显示「渠道组（首选模型名）」；运行后由 applyLiveModel 切到真实当前子模型。
    const primary = (p.members || [])[0];
    if (!primary) return t('model.aggregation');
    const open = lang === 'en' ? ' (' : '（', close = lang === 'en' ? ')' : '）';
    return `${t('model.aggregationShort')}${open}${profileLabel(primary) || primary}${close}`;
  }
  return profileLabel(fallbackName ?? (p && p.name)) || (fallbackName ?? (p && p.name)) || null;
}
async function selectModel(id, name) {
  state.llmNo = id;
  state.liveModel = null;
  const p = (state.modelProfiles || []).find(x => (x.id ?? 0) === id);
  state.modelName = modelDisplayName(p, name);
  updateModelChip();
  renderSettingsModels();
  await persistUiPrefs();
}
async function addToMixin(id) {
  try {
    const res = await bridgeFetch(`/model-profiles/${id}/mixin`, { method: 'POST', body: {} });
    if (res?.ok === false || res?.error) throw new Error(res.error || t('err.mixinFailed'));
    state.modelProfiles = normalizeProfiles(res.profiles || []);
    renderSettingsModels();
  } catch (ex) { showChanToast(t('err.mixinFailed'), ex.message || '', 'err'); }
}
async function removeFromMixin(id) {
  try {
    const res = await bridgeFetch(`/model-profiles/${id}/mixin`, { method: 'DELETE', body: {} });
    if (res?.ok === false || res?.error) throw new Error(res.error || t('err.mixinFailed'));
    state.modelProfiles = normalizeProfiles(res.profiles || []);
    renderSettingsModels();
  } catch (ex) { showChanToast(t('err.mixinFailed'), ex.message || '', 'err'); }
}
async function reorderMixin(members) {
  try {
    const res = await bridgeFetch('/model-profiles/mixin/order', { method: 'PUT', body: { members } });
    if (res?.ok === false || res?.error) throw new Error(res.error || t('err.mixinFailed'));
    state.modelProfiles = normalizeProfiles(res.profiles || []);
    renderSettingsModels();
    const active = state.modelProfiles.find(p => (p.id ?? 0) === state.llmNo);
    if (active) { state.modelName = modelDisplayName(active); updateModelChip(); }
  } catch (ex) { showChanToast(t('err.mixinFailed'), ex.message || '', 'err'); renderSettingsModels(); }
}
function flipReorder(container, mutate) {
  const rows = [...container.querySelectorAll('.model-member:not(.dragging)')];
  const first = new Map(rows.map(el => [el, el.getBoundingClientRect()]));
  mutate();
  rows.forEach(el => {
    const a = first.get(el), b = el.getBoundingClientRect();
    if (!a) return;
    const dx = a.left - b.left, dy = a.top - b.top;
    if (!dx && !dy) return;
    el.style.transition = 'none';
    el.style.transform = `translate(${dx}px, ${dy}px)`;
    requestAnimationFrame(() => {
      el.style.transition = 'transform .14s cubic-bezier(.2,.8,.2,1)';
      el.style.transform = '';
    });
  });
}
function bindMixinDrag(body, members) {
  let drag = null;
  const clear = () => {
    body.querySelectorAll('.model-member').forEach(x => x.classList.remove('dragging', 'drag-over'));
    document.body.classList.remove('mixin-dragging');
  };
  body.addEventListener('pointerdown', (e) => {
    const handle = e.target.closest('.model-member-drag');
    if (!handle || e.button !== 0) return;
    const row = handle.closest('.model-member');
    if (!row) return;
    e.preventDefault();
    handle.setPointerCapture?.(e.pointerId);
    drag = { handle, row, name: row.dataset.member, order: [...members], original: [...members], pointerId: e.pointerId, over: null };
    row.classList.add('dragging');
    document.body.classList.add('mixin-dragging');
  });
  body.addEventListener('pointermove', (e) => {
    if (!drag) return;
    const over = document.elementFromPoint(e.clientX, e.clientY)?.closest('.model-member');
    if (!over || !body.contains(over) || over === drag.row) return;
    const rect = over.getBoundingClientRect();
    const after = e.clientY > rect.top + rect.height / 2;
    const overKey = `${over.dataset.member}:${after ? 'after' : 'before'}`;
    if (overKey === drag.over) return;
    drag.over = overKey;
    const from = drag.order.indexOf(drag.name), overIdx = drag.order.indexOf(over.dataset.member);
    if (from < 0 || overIdx < 0) return;
    const [moved] = drag.order.splice(from, 1);
    let insertAt = drag.order.indexOf(over.dataset.member) + (after ? 1 : 0);
    drag.order.splice(insertAt, 0, moved);
    flipReorder(body, () => {
      if (after) body.insertBefore(drag.row, over.nextSibling);
      else body.insertBefore(drag.row, over);
    });
  });
  const finish = (e) => {
    if (!drag) return;
    drag.handle.releasePointerCapture?.(drag.pointerId);
    const changed = JSON.stringify(drag.order) !== JSON.stringify(drag.original);
    const next = drag.order;
    drag = null;
    clear();
    if (changed) reorderMixin(next);
  };
  body.addEventListener('pointerup', finish);
  body.addEventListener('pointercancel', finish);
}
const MODEL_ACT_EDIT = GA_ICON('pencilSimple');
const MODEL_ACT_DEL = GA_ICON('trash');
let editingModelId = null;

function setModelApikeyMode(isAdd) {
  const apikey = document.getElementById('model-apikey-input');
  const apikeyReq = document.querySelector('#model-apikey-label .field-req');
  if (!apikey) return;
  apikey.required = isAdd;
  apikey.dataset.i18nPh = isAdd ? 'model.apikeyPh' : 'model.apikeyKeep';
  if (isAdd) apikey.removeAttribute('data-optional-ph');
  else { apikey.value = ''; apikey.setAttribute('data-optional-ph', ''); }
  if (apikeyReq) apikeyReq.hidden = !isAdd;
}

/* ═══════════════ 官方模型快速接入（DeepSeek / 通义千问）═══════════════ */
// 预填 API 地址 / 协议 / 模型，用户只需粘贴 API Key。apibase 末尾的 /v1 会被
// 后端自动补成 /v1/chat/completions（见 mykey_template.py 的拼接规则）。
const PROVIDER_PRESETS = {
  deepseek: {
    label: 'DeepSeek', descKey: 'pq.deepseekDesc',
    protocol: 'oai', apibase: 'https://api.deepseek.com/v1',
    model: 'deepseek-v4-pro', name: 'DeepSeek',
    keyUrl: 'https://platform.deepseek.com/api_keys',
    color: '#4D6BFE', tint: 'rgba(77,107,254,.12)',
    logo: '<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();
  });
})();
