/* ============================================================
   BLACK BEAN · 黑豆 — 前端逻辑（v20260812f 全面重写）
   设计原则：
   1. 任何用户操作都有可见反馈（toast / 状态栏 / 聊天区），绝不静默失败
   2. WebSocket 生命周期明确：心跳保活、有限次指数退避重连、会话失效自动重建、无重连风暴
   3. 流式状态可自愈：卡死的流自动清理，绝不阻塞后续发送
   4. 渲染永不抛异常：渲染失败降级为纯文本
   ============================================================ */

"use strict";

const PAGE_SIZE = 50;
// 无任何 Agent 流事件超过该时长，且当前没有运行中的工具步骤 → 判定流卡死
const STREAM_STALE_MS = 60000;
// 心跳间隔（服务端 90s 读超时，每 25s 发一次应用层 ping 刷新）
const HEARTBEAT_MS = 25000;
const INPUT_PLACEHOLDER = "描述你的任务，例如：分析 server.log 中响应时间最长的 5 条请求...";

const state = {
  sessions: [],
  currentId: null,
  streaming: false,
  activeStream: null, // 当前进行中的流（含后台协作通知的被动流）
  config: null,
  currentMessages: [],
  hasMore: false,
  loadingOlder: false,
  lastEventAt: 0, // 最近一次 Agent 流事件时间戳（用于识别卡死的流）
  llmConfigured: false // 是否已配置 LLM API Key（环境变量或设置页）
};

const els = {
  sidebar: document.getElementById("sidebar"),
  sessionList: document.getElementById("session-list"),
  sessionSearch: document.getElementById("session-search"),
  newChatBtn: document.getElementById("new-chat-btn"),
  refreshBtn: document.getElementById("refresh-btn"),
  menuBtn: document.getElementById("menu-btn"),
  sessionTitle: document.getElementById("session-title"),
  sessionSub: document.getElementById("session-sub"),
  chat: document.getElementById("chat"),
  scrollBottomBtn: document.getElementById("scroll-bottom-btn"),
  emptyState: document.getElementById("empty-state"),
  input: document.getElementById("input"),
  sendBtn: document.getElementById("send-btn"),
  stopBtn: document.getElementById("stop-btn"),
  statusDot: document.getElementById("status-dot"),
  statusText: document.getElementById("status-text"),
  contextHint: document.getElementById("context-hint"),
  ctxChip: document.getElementById("ctx-chip"),
  modelChip: document.getElementById("model-chip"),
  brandModel: null,
  settingsBtn: document.getElementById("settings-btn"),
  settingsModal: document.getElementById("settings-modal"),
  dockerSocketInput: document.getElementById("docker-socket-input"),
  coopEngineSelect: document.getElementById("coop-engine-select"),
  coopModeSelect: document.getElementById("coop-mode-select"),
  providerSelect: document.getElementById("provider-select"),
  providerHint: document.getElementById("provider-hint"),
  apiKeyInput: document.getElementById("api-key-input"),
  baseUrlInput: document.getElementById("base-url-input"),
  modelInput: document.getElementById("model-input"),
  saveSettingsBtn: document.getElementById("save-settings-btn"),
  resetSettingsBtn: document.getElementById("reset-settings-btn"),
  closeSettingsBtn: document.getElementById("close-settings-btn"),
  toast: document.getElementById("toast"),
  coopPanel: document.getElementById("coop-panel"),
  coopPanelHeader: document.getElementById("coop-panel-header"),
  coopCount: document.getElementById("coop-count"),
  coopList: document.getElementById("coop-list"),
  authModal: document.getElementById("auth-modal"),
  authCodeInput: document.getElementById("auth-code-input"),
  authSubmitBtn: document.getElementById("auth-submit-btn"),
  authError: document.getElementById("auth-error")
};

/* ============================================================
   图标
   ============================================================ */
const ICONS = {
  task:
    '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v12a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2H9L7 5a2 2 0 0 0-2-1H5a2 2 0 0 0-2 2z"/></svg>',
  terminal:
    '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m4 17 6-5-6-5M12 19h8"/></svg>',
  file:
    '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>',
  folder:
    '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>',
  python:
    '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="m7 4 5 8-5 8M13 4l5 8-5 8"/></svg>',
  docker:
    '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 2a4 4 0 0 1 4 4v2h2a4 4 0 0 1 4 4v4a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4v-4a4 4 0 0 1 4-4h2V6a4 4 0 0 1 4-4z"/></svg>',
  coop:
    '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></svg>',
  tool:
    '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>',
  compress:
    '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m12 2 8.5 5v10L12 22l-8.5-5V7z"/><path d="m12 22 8.5-5v-10"/><path d="m12 22V12"/></svg>'
};

function toolIcon(name) {
  if (name.startsWith("docker_")) return ICONS.docker;
  if (name === "run_coop") return ICONS.coop;
  if (name === "read_file" || name === "write_file") return ICONS.file;
  if (name === "list_directory") return ICONS.folder;
  if (name === "run_python") return ICONS.python;
  return ICONS.terminal;
}

/* ============================================================
   基础工具
   ============================================================ */

async function api(path, options = {}) {
  const res = await fetch(path, {
    headers: { "Content-Type": "application/json" },
    ...options
  });
  if (!res.ok) {
    let message = `HTTP ${res.status}`;
    try {
      const data = await res.json();
      message = data.error || message;
    } catch (_) {}
    // 授权失效（授权码被重置 / Cookie 丢失）：弹窗引导重新授权
    if (res.status === 401 && !path.startsWith("/api/v1/auth/")) {
      showAuthModal();
    }
    throw new Error(message);
  }
  if (res.status === 204) return null;
  return res.json();
}

/* ============================================================
   访问授权
   默认关闭；仅当服务端通过环境变量 AGENT_AUTH_CODE 启用授权时，
   前端先查询 /auth/status，未授权则弹窗要求输入授权码，
   验证通过后服务端签发 HttpOnly Cookie，后续 API / WS 自动携带。
   ============================================================ */

// 确认授权就绪：无需授权或已通过验证返回 true，否则展示授权弹窗等待验证
async function ensureAuthed() {
  let status;
  try {
    status = await api("/api/v1/auth/status");
  } catch (e) {
    showToast("无法连接服务器: " + e.message, "error");
    setStatus("连接失败", false, true);
    return false;
  }
  if (!status.required || status.authorized) return true;
  return showAuthModal();
}

// 读取 URL 查询参数（如 ?authcode=xxx）
function getQueryParam(name) {
  try {
    return new URLSearchParams(window.location.search).get(name);
  } catch (e) {
    return null;
  }
}

// 验证成功后从地址栏清除 authcode，避免授权码残留在 URL 中
function clearAuthcodeFromUrl() {
  try {
    const params = new URLSearchParams(window.location.search);
    if (params.has("authcode")) {
      params.delete("authcode");
      const qs = params.toString();
      const newUrl = window.location.pathname + (qs ? "?" + qs : "") + window.location.hash;
      history.replaceState(null, "", newUrl);
    }
  } catch (e) {
    /* 忽略清理失败，不影响功能 */
  }
}

// 展示授权弹窗并等待用户验证，返回是否验证成功
function showAuthModal() {
  els.authError.classList.add("hidden");
  // 支持 URL 查询参数 ?authcode=xxx 自动填入授权码（便于分享链接给他人直接使用）
  const urlCode = getQueryParam("authcode");
  els.authCodeInput.value = urlCode || "";
  els.authModal.classList.remove("hidden");
  els.authCodeInput.focus();
  return new Promise((resolve) => {
    const cleanup = () => {
      els.authSubmitBtn.removeEventListener("click", onSubmit);
      els.authCodeInput.removeEventListener("keydown", onKey);
    };
    const onSubmit = async () => {
      const code = els.authCodeInput.value.trim();
      if (!code) {
        els.authError.textContent = "请输入授权码";
        els.authError.classList.remove("hidden");
        return;
      }
      els.authSubmitBtn.disabled = true;
      try {
        await api("/api/v1/auth/login", { method: "POST", body: JSON.stringify({ code }) });
        clearAuthcodeFromUrl(); // 验证成功后清除地址栏授权码
        els.authModal.classList.add("hidden");
        cleanup();
        resolve(true);
      } catch (e) {
        els.authError.textContent = e.message || "授权失败，请重试";
        els.authError.classList.remove("hidden");
      } finally {
        els.authSubmitBtn.disabled = false;
      }
    };
    const onKey = (e) => {
      if (e.key === "Enter" && !e.isComposing) {
        e.preventDefault();
        onSubmit();
      }
    };
    els.authSubmitBtn.addEventListener("click", onSubmit);
    els.authCodeInput.addEventListener("keydown", onKey);
    // URL 携带 authcode 时自动触发验证，他人打开分享链接即可直接使用
    if (urlCode) {
      setTimeout(() => onSubmit(), 50);
    }
  });
}

function escapeHtml(value) {
  return String(value)
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#39;");
}

function showToast(message, type = "") {
  els.toast.textContent = message;
  els.toast.className = `toast show ${type}`;
  clearTimeout(showToast.timer);
  showToast.timer = setTimeout(() => {
    els.toast.className = "toast";
  }, 3200);
}

function setStatus(text, busy = false, error = false) {
  els.statusText.textContent = text;
  els.statusDot.classList.toggle("busy", busy);
  els.statusDot.classList.toggle("error", error);
}

function debounce(fn, delay) {
  let timer = null;
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
}

/* ============================================================
   Markdown 渲染（永不抛异常）
   ============================================================ */

// 行内格式：先用占位符保护行内代码，再处理图片/链接/删除线/粗体/斜体，最后还原代码
function renderInline(text) {
  const codes = [];
  let v = String(text).replace(/`([^`]+)`/g, (_, code) => {
    codes.push(code);
    return "\u0000" + (codes.length - 1) + "\u0000";
  });
  v = escapeHtml(v);
  v = v.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, (_, alt, src) =>
    `<img src="${escapeHtml(sanitizeUrl(src))}" alt="${escapeHtml(alt)}" loading="lazy">`);
  v = v.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, (_, label, href) =>
    `<a href="${escapeHtml(sanitizeUrl(href))}" target="_blank" rel="noopener">${label}</a>`);
  v = v.replace(/~~([^~]+)~~/g, "<del>$1</del>");
  v = v.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
  v = v.replace(/(^|[^*])\*([^*\s][^*]*)\*(?!\*)/g, "$1<em>$2</em>");
  return v.replace(/\u0000(\d+)\u0000/g, (_, i) => `<code>${escapeHtml(codes[Number(i)])}</code>`);
}

// 仅允许安全协议，避免 javascript: 等注入
function sanitizeUrl(url) {
  const value = String(url).trim();
  if (/^(https?:|mailto:|tel:|#|\/)/i.test(value)) return value;
  if (/^\.{1,2}\//.test(value)) return value;
  return "#";
}

// 对外安全入口：任何渲染异常都降级为纯文本，绝不阻断消息收发
function renderMarkdown(text) {
  try {
    return renderMarkdownRaw(text);
  } catch (_) {
    return `<p>${escapeHtml(String(text))}</p>`;
  }
}

function renderMarkdownRaw(text) {
  const parts = [];
  const codeRegex = /```([\w+-]*)\r?\n?([\s\S]*?)```/g;
  let lastIndex = 0;
  let match;
  while ((match = codeRegex.exec(text)) !== null) {
    parts.push({ type: "text", text: text.slice(lastIndex, match.index) });
    parts.push({
      type: "code",
      lang: match[1],
      code: escapeHtml(match[2].replace(/\n$/, ""))
    });
    lastIndex = match.index + match[0].length;
  }
  parts.push({ type: "text", text: text.slice(lastIndex) });

  return parts.map((part) => {
    if (part.type === "code") {
      const lang = part.lang ? ` class="language-${escapeHtml(part.lang)}"` : "";
      return `<div class="code-block"><button class="code-copy" type="button" title="复制代码">复制</button><pre><code${lang}>${part.code}</code></pre></div>`;
    }

    // 表格块
    const tableRegex = /^(\|.+\|)\n(\|[\s:|-]+\|)\n((?:\|.+\|\n?)+)/gm;
    const textHtml = part.text.replace(tableRegex, (_, header, _sep, rows) => {
      const cells = (line) =>
        line
          .trim()
          .replace(/^\||\|$/g, "")
          .split("|")
          .map((cell) => cell.trim());
      const head = cells(header).map((c) => `<th>${renderInline(c)}</th>`).join("");
      const body = rows
        .trim()
        .split("\n")
        .map((row) => `<tr>${cells(row).map((c) => `<td>${renderInline(c)}</td>`).join("")}</tr>`)
        .join("");
      return `<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
    });

    const lines = textHtml.split("\n");
    let html = "";
    let listTag = null;
    for (const line of lines) {
      const ulMatch = line.match(/^[-*] (.*)$/);
      const olMatch = line.match(/^\d+[.)] (.*)$/);
      const listMatch = ulMatch || olMatch;
      const nextTag = ulMatch ? "ul" : "ol";
      if (listMatch) {
        if (listTag !== nextTag) {
          if (listTag) html += `</${listTag}>`;
          html += `<${nextTag}>`;
          listTag = nextTag;
        }
        html += `<li>${renderInline(listMatch[1])}</li>`;
        continue;
      }
      if (listTag) {
        html += `</${listTag}>`;
        listTag = null;
      }
      const hMatch = line.match(/^(#{1,3})\s+(.*)$/);
      if (hMatch) {
        const level = hMatch[1].length;
        html += `<h${level}>${renderInline(hMatch[2])}</h${level}>`;
      } else if (/^<table>/.test(line)) {
        html += line;
      } else if (/^> ?/.test(line)) {
        html += `<blockquote>${renderInline(line.replace(/^> ?/, ""))}</blockquote>`;
      } else if (/^(---+|\*\*\*+)$/.test(line)) {
        html += "<hr>";
      } else if (line.trim()) {
        html += `<p>${renderInline(line)}</p>`;
      }
    }
    if (listTag) html += `</${listTag}>`;
    return html;
  }).join("");
}

function prettyArguments(raw) {
  try {
    return JSON.stringify(JSON.parse(raw), null, 2);
  } catch (_) {
    return raw;
  }
}

/* ============================================================
   WebSocket 实时通道
   关键设计：
   - 心跳保活（应用层 ping，服务端已适配刷新 90s 读超时）
   - 只有"曾成功打开"的连接意外断开才自动重连（指数退避，最多 5 次）
   - 从未打开的连接（如会话已删除返回 404）不自动重连，避免无限风暴；
     发送时 ensureValidSession() 会自动重建会话并连接
   ============================================================ */
const ws = {
  conn: null,
  pingTimer: null,
  reconnectTimer: null,
  boundSession: null,
  retries: 0,
  MAX_RETRIES: 5,

  connect(sessionId) {
    if (!sessionId) return;
    this.disconnect();
    this.boundSession = sessionId;
    // 注意：retries 不在这里清零，只在 onopen（真正建立成功）时清零，
    // 否则服务端重启期间的"连接被拒"会被每次 connect 重置计数，
    // 导致 MAX_RETRIES 形同虚设，产生无限重连风暴。
    const proto = location.protocol === "https:" ? "wss:" : "ws:";
    const url = `${proto}//${location.host}/ws?session=${sessionId}`;
    let conn;
    try {
      conn = new WebSocket(url);
    } catch (error) {
      setStatus("无法建立连接", false, true);
      showToast("WebSocket 创建失败: " + error.message, "error");
      return;
    }
    this.conn = conn;
    let opened = false;

    conn.onopen = () => {
      opened = true;
      this.retries = 0;
      this.startHeartbeat();
      setStatus("就绪", false, false);
    };

    conn.onmessage = (event) => {
      let frame;
      try {
        frame = JSON.parse(event.data);
      } catch (_) {
        return;
      }
      handleWsEvent(frame.type, frame.data || {});
    };

    conn.onclose = () => {
      this.stopHeartbeat();
      if (this.conn !== conn) return;
      this.conn = null;
      if (state.streaming) {
        forceResetStream(true);
      }
      if (opened) {
        // 曾正常建立（服务端重启 / 网络波动）：有限次指数退避重连
        setStatus("连接已断开，正在重连…", false, true);
      } else {
        // 从未成功打开（404 / 服务端刚重启被拒）：仍按有限次数重连。
        // 服务端重启期间连接会被拒绝（opened=false），若此时放弃重连，
        // 页面将永远收不到后续事件，直到用户手动刷新——应等服务恢复后自动连上。
        setStatus("未连接，正在尝试重连…", false, true);
      }
      this.scheduleReconnect();
    };

    conn.onerror = () => {
      // onclose 会随之触发
    };
  },

  scheduleReconnect() {
    clearTimeout(this.reconnectTimer);
    if (this.retries >= this.MAX_RETRIES) {
      setStatus("连接失败，发送消息时将自动重连", false, true);
      return;
    }
    const delay = Math.min(2000 * Math.pow(2, this.retries), 16000);
    this.retries++;
    this.reconnectTimer = setTimeout(() => {
      if (state.currentId) this.connect(state.currentId);
    }, delay);
  },

  disconnect() {
    clearTimeout(this.reconnectTimer);
    this.stopHeartbeat();
    if (this.conn) {
      const old = this.conn;
      this.conn = null;
      old.onclose = null;
      old.onerror = null;
      try {
        old.close();
      } catch (_) {}
    }
    this.boundSession = null;
  },

  startHeartbeat() {
    clearInterval(this.pingTimer);
    this.pingTimer = setInterval(() => {
      this.send({ type: "ping" });
    }, HEARTBEAT_MS);
  },

  stopHeartbeat() {
    clearInterval(this.pingTimer);
    this.pingTimer = null;
  },

  isOpen() {
    return !!this.conn && this.conn.readyState === WebSocket.OPEN;
  },

  send(obj) {
    if (!this.isOpen()) return false;
    try {
      this.conn.send(JSON.stringify(obj));
      return true;
    } catch (_) {
      return false;
    }
  },

  sendChat(content) {
    return this.send({ type: "chat", content });
  },

  stop() {
    this.send({ type: "stop" });
  },

  switchSession(id) {
    this.send({ type: "switch", session_id: id });
  }
};

// 等待 WebSocket 连接建立（最多 timeout 毫秒），返回是否已就绪
function waitWsOpen(timeout = 6000) {
  const start = Date.now();
  return new Promise((resolve) => {
    const check = () => {
      if (ws.isOpen()) {
        resolve(true);
        return;
      }
      if (Date.now() - start > timeout) {
        resolve(false);
        return;
      }
      setTimeout(check, 100);
    };
    check();
  });
}

/* 服务端事件分发：Agent 流式事件（与后端 agent.Event 同名） */
function handleWsEvent(type, data) {
  // 心跳回包不算流事件，避免干扰"卡死"判定
  if (type === "pong") return;
  state.lastEventAt = Date.now();

  switch (type) {
    case "meta":
      if (data.title) els.sessionTitle.textContent = displayTitle(data.title);
      break;

    case "message": {
      const stream = state.activeStream;
      if (!stream || !stream.els) break;
      stream.text += data.delta || "";
      stream.els.text.innerHTML = renderMarkdown(stream.text);
      stream.els.text.classList.add("typing-caret");
      setStatus("生成回答中", true);
      scrollToBottom();
      break;
    }

    case "tool": {
      const stream = state.activeStream;
      if (!stream) break;
      if (!stream.toolRecords.has(data.call_id)) {
        const rec = {
          callId: data.call_id,
          name: data.name,
          input: data.input || "",
          output: "",
          success: true,
          done: false,
          startedAt: Date.now(),
          finishedAt: null,
          el: null
        };
        stream.toolRecords.set(data.call_id, rec);
        if (stream.els) {
          stream.els.steps.appendChild(createStepElement(rec));
          setStatus(`执行 ${data.name} · 第 ${stream.toolRecords.size} 次`, true);
          scrollToBottom();
        }
      }
      break;
    }

    case "tool_result": {
      const stream = state.activeStream;
      const rec = stream && stream.toolRecords.get(data.call_id);
      if (rec) {
        rec.output = data.output || "";
        rec.success = data.success;
        rec.done = true;
        rec.finishedAt = Date.now();
        updateStepElement(rec);
        setStatus(rec.success ? "工具执行完成" : "工具执行失败", true);
        scrollToBottom();
      }
      break;
    }

    case "notice":
      if (state.currentId) appendNotice(data.text || "提示");
      break;

    case "turn_start": {
      const user = (data && data.user) || "";
      if (state.activeStream) {
        state.activeStream.userText = user;
        state.activeStream.els = appendTask(user);
        state.activeStream.text = "";
        state.activeStream.toolRecords = new Map();
      } else {
        // 后台协作任务完成通知：创建被动流
        state.activeStream = {
          sessionId: state.currentId,
          userText: user,
          text: "",
          toolRecords: new Map(),
          els: appendTask(user),
          compressed: false,
          iterations: 0,
          passive: true
        };
        setStreaming(true);
      }
      setStatus("Agent 处理中", true);
      scrollToBottom();
      break;
    }

    case "done": {
      const stream = state.activeStream;
      if (!stream) break;
      stream.iterations = data.iterations || 0;
      stream.compressed = !!data.compressed;
      if (stream.els) {
        const tools = stream.toolRecords.size;
        if (tools || stream.compressed) {
          setAnswerMeta(stream.els, { tools, compressed: stream.compressed });
        }
        finalizeTask(stream.els);
      }
      state.activeStream = null;
      setStreaming(false);
      setStatus(`完成 · 迭代 ${stream.iterations} 轮`, false, false);
      refreshCoopTasks();
      loadSessions();
      loadMessages(0, { silent: true }).then((page) => {
        if (page) {
          state.currentMessages = page.messages || [];
          state.hasMore = !!page.has_more;
        }
      });
      break;
    }

    case "error":
      if (state.activeStream) {
        appendErrorMessage(data.message || "发生未知错误");
        state.activeStream = null;
        setStreaming(false);
        setStatus("出错", false, true);
      } else {
        showToast(data.message || "发生未知错误", "error");
      }
      break;
  }
}

/* ============================================================
   智能滚动
   ============================================================ */
let userNearBottom = true;

function toggleScrollBottomBtn() {
  const hasContent = els.chat.querySelector(".task, .notice, .error-message");
  const show = !userNearBottom && !!hasContent;
  els.scrollBottomBtn.classList.toggle("hidden", !show);
}

function scrollToBottom(force = false) {
  if (!force && !userNearBottom) {
    toggleScrollBottomBtn();
    return;
  }
  requestAnimationFrame(() => {
    els.chat.scrollTop = els.chat.scrollHeight;
  });
}

function clearChat() {
  els.chat.querySelectorAll(".task, .notice, .error-message").forEach((node) => node.remove());
  els.emptyState.style.display = "flex";
}

function appendNotice(text) {
  els.emptyState.style.display = "none";
  const notice = document.createElement("div");
  notice.className = "notice";
  notice.textContent = text;
  els.chat.appendChild(notice);
  scrollToBottom();
}

function appendErrorMessage(text) {
  els.emptyState.style.display = "none";
  const error = document.createElement("div");
  error.className = "error-message";
  error.textContent = text;
  els.chat.appendChild(error);
  scrollToBottom();
}

/* ============================================================
   任务块渲染
   ============================================================ */

function appendTask(text) {
  els.emptyState.style.display = "none";
  const task = document.createElement("div");
  task.className = "task";
  task.innerHTML = `
    <div class="task-head">
      <div class="task-badge">${ICONS.task}</div>
      <div class="task-text">${renderMarkdown(text)}</div>
    </div>
    <div class="task-steps"></div>
    <div class="task-output">
      <div class="assistant-text typing-caret"></div>
      <div class="answer-meta"></div>
    </div>
  `;
  els.chat.appendChild(task);
  scrollToBottom();
  return {
    task,
    steps: task.querySelector(".task-steps"),
    output: task.querySelector(".task-output"),
    text: task.querySelector(".assistant-text"),
    meta: task.querySelector(".answer-meta")
  };
}

function finalizeTask(view) {
  if (!view) return;
  view.text.classList.remove("typing-caret");
  const hasMeta = view.meta.children.length > 0;
  if (!view.text.textContent.trim() && !hasMeta) {
    view.output.remove();
  }
}

/* ============================================================
   工具步骤时间轴
   ============================================================ */

function fmtStepTime(rec) {
  const end = rec.finishedAt || Date.now();
  const ms = Math.max(0, end - rec.startedAt);
  return ms < 1000 ? `${(ms / 1000).toFixed(1)}s` : `${(ms / 1000).toFixed(0)}s`;
}

function createStepElement(rec) {
  const step = document.createElement("div");
  step.className = "step running" + (rec.done ? "" : " open");
  step.dataset.callId = rec.callId;
  step.innerHTML = `
    <div class="step-head">
      <span class="step-dot"></span>
      <span class="step-icon">${toolIcon(rec.name)}</span>
      <span class="step-name">${escapeHtml(rec.name)}</span>
      <span class="step-time">${fmtStepTime(rec)}</span>
      <span class="step-state">运行中</span>
      <button class="step-toggle" title="展开 / 收起">
        <svg class="step-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
          <path d="m9 6 6 6-6 6"/>
        </svg>
        <span class="step-toggle-text">收起</span>
      </button>
    </div>
    <div class="step-body">
      <div class="step-section">
        <div class="step-section-label">输入</div>
        <pre>${escapeHtml(prettyArguments(rec.input))}</pre>
      </div>
      <div class="step-section">
        <div class="step-section-label">输出</div>
        <pre class="output-empty">等待执行结果...</pre>
      </div>
    </div>
  `;
  const head = step.querySelector(".step-head");
  head.addEventListener("click", () => {
    step.classList.toggle("open");
    const text = step.querySelector(".step-toggle-text");
    if (text) text.textContent = step.classList.contains("open") ? "收起" : "展开";
  });
  rec.el = step;
  return step;
}

function updateStepElement(rec) {
  const step = rec.el;
  if (!step) return;
  step.classList.remove("running", "success", "failed");
  step.classList.add(rec.done ? (rec.success ? "success" : "failed") : "running");
  step.querySelector(".step-state").textContent = rec.done ? (rec.success ? "完成" : "失败") : "运行中";
  step.querySelector(".step-time").textContent = fmtStepTime(rec);

  const label = step.querySelector(".step-section:nth-child(2) .step-section-label");
  const outPre = step.querySelector(".step-section:nth-child(2) pre");
  if (rec.done) {
    label.innerHTML = `输出 <span class="${rec.success ? "tick" : "cross"}">${rec.success ? "✓" : "✗"}</span>`;
    if (rec.output && rec.output.trim()) {
      outPre.textContent = rec.output;
      outPre.classList.remove("output-empty");
    } else {
      outPre.textContent = "（无输出）";
      outPre.classList.add("output-empty");
    }
  } else {
    label.textContent = "输出";
    outPre.textContent = "等待执行结果...";
    outPre.classList.add("output-empty");
  }
  if (rec.done && rec.success) step.classList.remove("open");
  const toggleText = step.querySelector(".step-toggle-text");
  if (toggleText) toggleText.textContent = step.classList.contains("open") ? "收起" : "展开";
}

/* 输出面板底部小结徽标 */
function setAnswerMeta(view, { tools = 0, compressed = false }) {
  if (!view || !view.meta) return;
  view.meta.innerHTML = "";
  if (tools > 0) {
    const badge = document.createElement("span");
    badge.className = "meta-badge";
    badge.innerHTML = `${ICONS.tool} 工具调用 ${tools} 次`;
    view.meta.appendChild(badge);
  }
  if (compressed) {
    const badge = document.createElement("span");
    badge.className = "meta-badge";
    badge.innerHTML = `${ICONS.compress} 上下文已压缩`;
    view.meta.appendChild(badge);
  }
}

/* ============================================================
   会话渲染（聚合为任务块）
   ============================================================ */

function renderSessionMessages(messages) {
  clearChat();
  if (!messages.length) {
    els.emptyState.style.display = "flex";
    return;
  }

  let task = null;
  const toolSteps = new Map();
  for (const message of messages) {
    if (message.role === "user" && message.content) {
      task = appendTask(message.content);
      toolSteps.clear();
    } else if (message.role === "assistant" && task) {
      const calls = message.tool_calls || [];
      for (const call of calls) {
        const rec = {
          callId: call.id,
          name: call.function?.name || "tool",
          input: call.function?.arguments || "",
          output: "",
          success: true,
          done: false,
          startedAt: Date.now(),
          finishedAt: null,
          el: null
        };
        task.steps.appendChild(createStepElement(rec));
        toolSteps.set(call.id, rec);
      }
      if (message.content) {
        task.text.innerHTML = renderMarkdown(message.content);
        task.text.classList.remove("typing-caret");
      }
      if (calls.length) setAnswerMeta(task, { tools: calls.length });
    } else if (message.role === "tool" && toolSteps.has(message.tool_call_id)) {
      const rec = toolSteps.get(message.tool_call_id);
      rec.output = message.content || "";
      rec.success = true;
      rec.done = true;
      rec.finishedAt = Date.now();
      updateStepElement(rec);
    }
  }
  document.querySelectorAll(".task").forEach((t) => {
    const textEl = t.querySelector(".assistant-text");
    const metaEl = t.querySelector(".answer-meta");
    if (!textEl.textContent.trim() && !metaEl.children.length) {
      t.querySelector(".task-output").remove();
    }
  });
  scrollToBottom();
}

/* ============================================================
   会话 / 消息加载
   ============================================================ */

async function loadMessages(offset, { silent = false } = {}) {
  if (!state.currentId) return null;
  try {
    return await api(`/api/v1/sessions/${state.currentId}/messages?limit=${PAGE_SIZE}&offset=${offset}`);
  } catch (e) {
    if (!silent) showToast(e.message, "error");
    return null;
  }
}

function renderLoadEarlier() {
  let btn = document.getElementById("load-earlier-btn");
  if (state.hasMore) {
    if (!btn) {
      btn = document.createElement("button");
      btn.id = "load-earlier-btn";
      btn.className = "load-earlier";
      btn.textContent = "加载更早消息";
      btn.addEventListener("click", loadEarlier);
      els.chat.prepend(btn);
    }
  } else if (btn) {
    btn.remove();
  }
}

async function loadEarlier() {
  if (state.loadingOlder || !state.currentId) return;
  state.loadingOlder = true;
  const btn = document.getElementById("load-earlier-btn");
  if (btn) {
    btn.textContent = "加载中…";
    btn.disabled = true;
  }
  const page = await loadMessages(state.currentMessages.length, { silent: true });
  state.loadingOlder = false;
  if (!page) {
    if (btn) btn.textContent = "加载更早消息";
    return;
  }
  const prevScroll = els.chat.scrollTop;
  const prevHeight = els.chat.scrollHeight;
  state.currentMessages = [...(page.messages || []), ...state.currentMessages];
  state.hasMore = !!page.has_more;
  renderSessionMessages(state.currentMessages);
  els.chat.scrollTop = prevScroll + (els.chat.scrollHeight - prevHeight);
  renderLoadEarlier();
  if (btn) {
    btn.textContent = "加载更早消息";
    btn.disabled = false;
  }
}

async function loadSessions() {
  const data = await api("/api/v1/sessions");
  state.sessions = data.sessions;
  renderSessions();
}

function renderSessions() {
  els.sessionList.innerHTML = "";
  for (const session of state.sessions) {
    const item = document.createElement("div");
    item.className = "session-item" + (session.id === state.currentId ? " active" : "");

    const main = document.createElement("div");
    main.className = "session-item-main";
    const title = document.createElement("span");
    title.className = "session-item-title";
    title.textContent = displayTitle(session.title);
    const preview = document.createElement("span");
    preview.className = "session-item-preview";
    preview.textContent = session.preview || (session.message_count ? `${session.message_count} 条消息` : "");
    main.append(title, preview);

    const time = document.createElement("time");
    time.textContent = formatTime(session.updated_at);

    const del = document.createElement("button");
    del.className = "session-delete";
    del.title = "删除会话";
    del.innerHTML = `
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
        <path d="M3 6h18M8 6V4h8v2M6 6l1 14h10l1-14M10 10v6M14 10v6"/>
      </svg>
    `;
    del.addEventListener("click", async (event) => {
      event.stopPropagation();
      try {
        await api(`/api/v1/sessions/${session.id}`, { method: "DELETE" });
        if (state.currentId === session.id) {
          state.currentId = null;
          await loadSessions();
          if (state.sessions.length > 0) {
            await selectSession(state.sessions[0].id);
          } else {
            updateUrlForSession(null);
            els.sessionTitle.textContent = "新任务";
            els.sessionSub.textContent = "";
            state.currentMessages = [];
            state.hasMore = false;
            renderSessionMessages([]);
            renderLoadEarlier();
            setStatus("就绪", false, false);
          }
        } else {
          await loadSessions();
        }
      } catch (error) {
        showToast(error.message, "error");
      }
    });

    item.append(main, time, del);
    item.addEventListener("click", () => selectSession(session.id));
    els.sessionList.appendChild(item);
  }
}

function formatTime(value) {
  const date = new Date(value);
  const now = new Date();
  if (date.toDateString() === now.toDateString()) {
    return date.toTimeString().slice(0, 5);
  }
  return `${date.getMonth() + 1}/${date.getDate()}`;
}

function displayTitle(title) {
  return !title || title === "新对话" ? "新任务" : title;
}

function getSessionFromUrl() {
  const match = window.location.pathname.match(/^\/([0-9a-f]{24})$/);
  if (match) return match[1];
  return new URLSearchParams(window.location.search).get("session");
}

function updateUrlForSession(id) {
  window.history.replaceState(null, "", id ? `/${id}` : "/");
}

async function selectSession(id) {
  state.currentId = id;
  ws.switchSession(id);
  const stream = state.activeStream;
  if (stream && stream.passive && stream.sessionId !== id) {
    state.activeStream = null;
    setStreaming(false);
  }
  if (state.streaming && stream && stream.sessionId === id) {
    updateUrlForSession(id);
    try {
      const s = await api(`/api/v1/sessions/${id}`);
      els.sessionTitle.textContent = displayTitle(s.session?.title);
      els.sessionSub.textContent = `${s.session?.messages?.length || 0} 条消息`;
    } catch (_) {}
    rebuildStreamView(stream);
    renderSessions();
    return { session: { title: null, messages: [] } };
  }
  setStatus("载入中…", true);
  const [detail, page] = await Promise.all([
    api(`/api/v1/sessions/${id}`).catch(() => null),
    loadMessages(0, { silent: true })
  ]);
  updateUrlForSession(id);
  els.sessionTitle.textContent = displayTitle(detail?.session?.title);
  els.sessionSub.textContent = `${detail?.session?.messages?.length ?? 0} 条消息`;
  state.currentMessages = page?.messages || [];
  state.hasMore = !!page?.has_more;
  renderSessionMessages(state.currentMessages);
  renderLoadEarlier();
  renderSessions();
  setStatus("就绪", false, false);
  return detail;
}

// 用流状态重建正在生成的会话视图
function rebuildStreamView(stream) {
  clearChat();
  els.emptyState.style.display = "none";
  const view = appendTask(stream.userText);
  if (stream.text.trim()) {
    view.text.innerHTML = renderMarkdown(stream.text);
    view.text.classList.remove("typing-caret");
  }
  for (const rec of stream.toolRecords.values()) {
    view.steps.appendChild(createStepElement(rec));
    updateStepElement(rec);
  }
  stream.els = view;
  scrollToBottom();
}

// 确保存在有效会话：currentId 缺失或已被删除时自动新建
async function ensureValidSession() {
  if (!state.currentId) {
    await createNewSession();
    return;
  }
  const detail = await api(`/api/v1/sessions/${state.currentId}`).catch(() => null);
  if (!detail || !detail.session) {
    showToast("当前会话已失效，已自动创建新任务", "error");
    await createNewSession();
  }
}

async function createNewSession() {
  const session = await api("/api/v1/sessions", { method: "POST" });
  state.currentId = session.id;
  updateUrlForSession(session.id);
  await loadSessions();
}

async function openFromUrlOrCreate() {
  const id = getSessionFromUrl();
  if (id) {
    try {
      state.currentId = id;
      ws.connect(id);
      const detail = await api(`/api/v1/sessions/${id}`).catch(() => null);
      if (detail && detail.session) {
        await selectSession(id);
        return;
      }
      state.currentId = null;
      ws.disconnect();
      showToast("会话不存在或已删除，已创建新任务", "error");
    } catch (_) {
      state.currentId = null;
      ws.disconnect();
      showToast("会话不存在或已删除，已创建新任务", "error");
    }
  }
  if (!state.currentId) {
    await createNewSession();
  }
  await selectSession(state.currentId);
}

/* ============================================================
   流状态管理（可自愈）
   ============================================================ */

function setStreaming(streaming) {
  state.streaming = streaming;
  els.sendBtn.classList.toggle("hidden", streaming);
  els.stopBtn.classList.toggle("hidden", !streaming);
  els.input.placeholder = streaming ? "Agent 正在执行任务，请稍候..." : INPUT_PLACEHOLDER;
  if (!streaming) setStatus("就绪", false, false);
}

// 强制清理流状态（中断/卡死时调用）
function forceResetStream(showNotice = false) {
  state.activeStream = null;
  state.streaming = false;
  els.sendBtn.classList.remove("hidden");
  els.stopBtn.classList.add("hidden");
  els.input.placeholder = INPUT_PLACEHOLDER;
  setStatus("就绪", false, false);
  if (showNotice) appendNotice("任务已中断，可重新发送");
}

// 流是否仍然"活着"：连接在线，且最近有事件，或有运行中的工具步骤（长任务保护）
function streamIsAlive() {
  if (!ws.isOpen()) return false;
  if (Date.now() - state.lastEventAt < STREAM_STALE_MS) return true;
  const stream = state.activeStream;
  if (stream && stream.toolRecords) {
    for (const rec of stream.toolRecords.values()) {
      if (!rec.done) return true;
    }
  }
  return false;
}

function stopStreaming() {
  if (!state.activeStream) return;
  const stream = state.activeStream;
  state.activeStream = null;
  ws.stop();
  setStreaming(false);
  if (stream.els) finalizeTask(stream.els);
  appendNotice("已停止生成");
}

/* ============================================================
   消息发送（核心：任何失败都有可见反馈，绝不静默）
   ============================================================ */

async function sendMessage(prefill) {
  const text = (prefill !== undefined ? prefill : els.input.value).trim();
  if (!text) {
    showToast("请输入内容后再发送", "error");
    return;
  }

  // 未配置 LLM 时引导先配置，不发起无效对话（后端同样有兜底校验）
  if (!state.llmConfigured) {
    showToast("尚未配置 LLM API，请先配置 API Key 与模型", "error");
    openSettings();
    return;
  }

  // 1. 清理一切卡死状态；若确有真正在运行的流，则不打扰并提示
  if (state.streaming) {
    if (!streamIsAlive()) {
      forceResetStream(true);
    } else {
      showToast("Agent 正在执行任务，请等待完成或点击停止", "error");
      return;
    }
  }

  // 2. 确保会话有效且 ws 已连接（失败都会给出可见提示）
  const ready = await ensureReadyForSend();
  if (!ready) return;

  // 3. 正式发送
  userNearBottom = true;
  els.input.value = "";
  autoResize();

  const stream = {
    sessionId: state.currentId,
    userText: text,
    text: "",
    toolRecords: new Map(),
    els: appendTask(text),
    compressed: false,
    iterations: 0,
    passive: false
  };
  state.activeStream = stream;
  setStreaming(true);
  setStatus("Agent 处理中", true);

  if (!ws.sendChat(text)) {
    // 发送失败：恢复输入内容并复位状态，让用户可重试
    els.input.value = text;
    autoResize();
    state.activeStream = null;
    setStreaming(false);
    showToast("消息未能发送，请重试", "error");
  }
}

// 发送前准备：校验会话 → 保证连接绑定当前会话
async function ensureReadyForSend() {
  try {
    await ensureValidSession();
    if (ws.isOpen() && ws.boundSession === state.currentId) {
      return true;
    }
    ws.connect(state.currentId);
    const opened = await waitWsOpen(6000);
    if (!opened) {
      setStatus("未连接", false, true);
      showToast("无法连接服务器，请刷新后重试", "error");
      return false;
    }
    return true;
  } catch (error) {
    showToast(error && error.message ? error.message : String(error), "error");
    return false;
  }
}

function autoResize() {
  els.input.style.height = "auto";
  els.input.style.height = Math.min(els.input.scrollHeight, 180) + "px";
}

/* ============================================================
   协作任务实时状态
   ============================================================ */

function fmtElapsed(created, finished) {
  const end = finished ? new Date(finished) : new Date();
  const total = Math.max(0, Math.floor((end - new Date(created)) / 1000));
  if (total < 60) return `${total}秒`;
  const minutes = Math.floor(total / 60);
  if (minutes < 60) return `${minutes}分${total % 60}秒`;
  return `${Math.floor(minutes / 60)}时${minutes % 60}分`;
}

function renderCoopTasks(tasks) {
  if (!tasks || !tasks.length) {
    els.coopPanel.classList.add("hidden");
    els.coopList.innerHTML = "";
    return;
  }
  els.coopPanel.classList.remove("hidden");
  els.coopList.innerHTML = "";
  els.coopCount.textContent = `${tasks.length} 个`;

  for (const task of tasks) {
    const running = task.status === "running";
    const failed = task.status === "done" && !!task.error;
    const stateClass = failed ? "failed" : running ? "running" : "done";
    const stateLabel = failed ? "失败" : running ? "运行中" : "完成";
    const round = task.round_max > 1
      ? `第 ${Math.max(task.current_round || 1, 1)}/${task.round_max} 轮`
      : "单轮";
    const container = running && task.container_status ? ` · ${task.container_status}` : "";
    const elapsed = fmtElapsed(task.created_at, task.finished_at);
    const progress = task.round_max > 0
      ? Math.min(100, Math.round((Math.max(task.current_round || 1, 1) / task.round_max) * 100))
      : 100;

    const card = document.createElement("div");
    card.className = `coop-card ${stateClass}`;
    card.innerHTML = `
      <div class="coop-card-main">
        <span class="coop-card-dot ${stateClass}"></span>
        <div class="coop-card-info">
          <div class="coop-card-title">${round}${container}</div>
          <div class="coop-card-meta">ID <code>${escapeHtml(task.id.slice(0, 8))}</code> · ${elapsed}</div>
        </div>
        <span class="coop-card-state ${stateClass}">${stateLabel}</span>
      </div>
      <div class="coop-card-progress">
        <div class="coop-card-progress-bar" style="width:${progress}%"></div>
      </div>`;
    els.coopList.appendChild(card);
  }
}

async function refreshCoopTasks() {
  if (!state.currentId) {
    renderCoopTasks([]);
    return;
  }
  try {
    const data = await api(`/api/v1/coop/tasks?session=${state.currentId}`);
    renderCoopTasks(data.tasks || []);
  } catch (_) {}
}

/* ============================================================
   配置与设置
   ============================================================ */

async function loadConfig() {
  state.config = await api("/api/v1/config");
  const ctxK = Math.round(state.config.max_context_tokens / 1000);
  els.contextHint.textContent = `上下文 ${ctxK}k · 工具上限 ${state.config.max_iterations} 次`;
  els.ctxChip.textContent = `${ctxK}k ctx`;
  els.ctxChip.classList.remove("hidden");
  try {
    const lc = await api("/api/v1/llm/config");
    state.llmConfigured = !!lc.api_key_configured;
    els.modelChip.textContent = lc.api_key_configured ? (lc.model || state.config.model) : "模型未配置";
    els.modelChip.classList.toggle("unconfigured", !lc.api_key_configured);
  } catch (_) {}
}

async function loadSettings() {
  try {
    const dc = await api("/api/v1/docker/config");
    els.dockerSocketInput.value = dc.docker_socket || "";
    els.dockerSocketInput.placeholder = dc.default_socket || "";
  } catch (_) {}
  try {
    const lc = await api("/api/v1/llm/config");
    els.providerSelect.value = lc.provider === "anthropic" ? "anthropic" : "openai";
    updateProviderHint();
    const engineVal = lc.engine === "pigo" || lc.engine === "claude" ? lc.engine : "pi";
    els.coopEngineSelect.value = engineVal;
    els.coopModeSelect.value = lc.coop_mode === "docker" ? "docker" : "local";
    els.apiKeyInput.value = "";
    els.apiKeyInput.placeholder = lc.api_key_configured
      ? `已配置（${lc.api_key_masked || "****"}），留空保持不变`
      : "未配置";
    els.baseUrlInput.value = lc.base_url || "";
    els.baseUrlInput.placeholder = lc.default_base_url || "";
    els.modelInput.value = lc.model || "";
    els.modelInput.placeholder = lc.default_model || "";
  } catch (error) {
    showToast("加载 LLM 配置失败: " + error.message, "error");
  }
}

function updateProviderHint() {
  const anthropic = els.providerSelect.value === "anthropic";
  els.providerHint.textContent = anthropic
    ? "Anthropic 模式使用 x-api-key 认证，Base URL 填 https://api.anthropic.com 即可（自动补全 /v1/messages）。"
    : "OpenAI 兼容模式使用 Bearer Token 认证，Base URL 填写 /v1/chat/completions 地址。";
  els.baseUrlInput.placeholder = anthropic
    ? "https://api.anthropic.com"
    : "https://api.siliconflow.cn/v1/chat/completions";
}

function openSettings() {
  loadSettings();
  els.settingsModal.classList.remove("hidden");
}

function closeSettings() {
  els.settingsModal.classList.add("hidden");
}

async function saveSettings() {
  try {
    const socket = els.dockerSocketInput.value.trim();
    await api("/api/v1/docker/config", {
      method: "PUT",
      body: JSON.stringify({ socket })
    });

    const apiKey = els.apiKeyInput.value.trim();
    const baseUrl = els.baseUrlInput.value.trim();
    const model = els.modelInput.value.trim();
    const provider = els.providerSelect.value;
    const engine = els.coopEngineSelect.value;
    const coopMode = els.coopModeSelect.value;
    const lc = await api("/api/v1/llm/config", {
      method: "PUT",
      body: JSON.stringify({
        api_key: apiKey || null,
        base_url: baseUrl || null,
        model: model || null,
        provider: provider || null,
        engine: engine || null,
        coop_mode: coopMode || null
      })
    });
    state.llmConfigured = !!lc.api_key_configured;
    els.modelChip.textContent = lc.api_key_configured ? (lc.model || state.config.model) : "模型未配置";
    els.modelChip.classList.toggle("unconfigured", !lc.api_key_configured);
    showToast("设置已保存");
    closeSettings();
  } catch (error) {
    showToast("保存失败: " + error.message, "error");
  }
}

async function resetSettings() {
  try {
    await api("/api/v1/docker/config", {
      method: "PUT",
      body: JSON.stringify({ socket: "" })
    });
    const lc = await api("/api/v1/llm/config", {
      method: "PUT",
      body: JSON.stringify({ api_key: "", base_url: "", model: "", provider: "", engine: "", coop_mode: "" })
    });
    state.llmConfigured = !!lc.api_key_configured;
    els.modelChip.textContent = lc.api_key_configured ? (lc.model || state.config.model) : "模型未配置";
    els.modelChip.classList.toggle("unconfigured", !lc.api_key_configured);
    els.coopEngineSelect.value = lc.engine === "pigo" || lc.engine === "claude" ? lc.engine : "pi";
    els.coopModeSelect.value = lc.coop_mode === "docker" ? "docker" : "local";
    showToast("已恢复默认配置");
    closeSettings();
  } catch (error) {
    showToast("保存失败: " + error.message, "error");
  }
}

/* ============================================================
   初始化
   ============================================================ */

function init() {
  els.newChatBtn.addEventListener("click", async () => {
    try {
      await createNewSession();
      await selectSession(state.currentId);
    } catch (error) {
      showToast(error.message, "error");
    }
  });

  els.refreshBtn.addEventListener("click", async () => {
    try {
      await loadSessions();
      const stream = state.activeStream;
      if (state.currentId && !(state.streaming && stream && stream.sessionId === state.currentId)) {
        const page = await loadMessages(0, { silent: true });
        if (page) {
          state.currentMessages = page.messages || [];
          state.hasMore = !!page.has_more;
          renderSessionMessages(state.currentMessages);
          renderLoadEarlier();
        }
      }
      showToast("会话已刷新");
    } catch (error) {
      showToast(error.message, "error");
    }
  });

  els.menuBtn.addEventListener("click", () => {
    els.sidebar.classList.toggle("open");
  });

  // 会话搜索（防抖）
  els.sessionSearch.addEventListener(
    "input",
    debounce(async () => {
      try {
        const q = els.sessionSearch.value.trim();
        if (!q) {
          await loadSessions();
          return;
        }
        const data = await api(`/api/v1/sessions?q=${encodeURIComponent(q)}`);
        state.sessions = data.sessions;
        renderSessions();
      } catch (e) {
        showToast(e.message, "error");
      }
    }, 300)
  );

  els.settingsBtn.addEventListener("click", openSettings);
  els.closeSettingsBtn.addEventListener("click", closeSettings);
  els.saveSettingsBtn.addEventListener("click", saveSettings);
  els.resetSettingsBtn.addEventListener("click", resetSettings);
  els.providerSelect.addEventListener("change", updateProviderHint);
  els.settingsModal.addEventListener("click", (event) => {
    if (event.target === els.settingsModal) closeSettings();
  });

  // 发送 / 停止（sendMessage 内部自带兜底，任何异常都有 toast）
  els.sendBtn.addEventListener("click", () => {
    try {
      sendMessage();
    } catch (error) {
      console.error(error);
      showToast("发送异常: " + (error && error.message || error), "error");
    }
  });
  els.stopBtn.addEventListener("click", stopStreaming);

  // 智能滚动
  els.chat.addEventListener("scroll", () => {
    userNearBottom = els.chat.scrollHeight - els.chat.scrollTop - els.chat.clientHeight < 120;
    toggleScrollBottomBtn();
  });

  els.scrollBottomBtn.addEventListener("click", () => {
    userNearBottom = true;
    scrollToBottom(true);
    toggleScrollBottomBtn();
  });

  // Esc 停止生成
  document.addEventListener("keydown", (e) => {
    if (e.key === "Escape") stopStreaming();
  });

  // 代码块复制（事件委托）
  els.chat.addEventListener("click", async (event) => {
    const btn = event.target.closest(".code-copy");
    if (!btn) return;
    const codeEl = btn.closest(".code-block")?.querySelector("code");
    if (!codeEl) return;
    const text = codeEl.textContent;
    try {
      await navigator.clipboard.writeText(text);
    } catch (_) {
      const ta = document.createElement("textarea");
      ta.value = text;
      document.body.appendChild(ta);
      ta.select();
      document.execCommand("copy");
      ta.remove();
    }
    btn.textContent = "已复制";
    setTimeout(() => {
      btn.textContent = "复制";
    }, 1800);
  });

  // 协作任务面板：点击标题折叠 / 展开
  els.coopPanelHeader.addEventListener("click", () => {
    els.coopPanel.classList.toggle("collapsed");
  });

  els.input.addEventListener("input", autoResize);
  els.input.addEventListener("keydown", (event) => {
    if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
      event.preventDefault();
      try {
        sendMessage();
      } catch (error) {
        console.error(error);
        showToast("发送异常: " + (error && error.message || error), "error");
      }
    }
  });

  // 空状态示例任务：点击直接投递
  document.querySelectorAll(".example").forEach((btn) => {
    btn.addEventListener("click", () => {
      sendMessage(btn.dataset.prompt || "");
    });
  });

  // 流卡死看门狗：每 5 秒检查一次，自动清理卡死的流，绝不让 streaming 卡住发送
  setInterval(() => {
    if (!state.streaming) return;
    if (!streamIsAlive()) {
      forceResetStream(true);
      setStatus("任务中断，已自动清理", false, true);
    }
  }, 5000);

  // 运行中工具步骤的耗时计时器
  setInterval(() => {
    if (!state.streaming || !state.activeStream) return;
    for (const rec of state.activeStream.toolRecords.values()) {
      if (!rec.done && rec.el) {
        const timeEl = rec.el.querySelector(".step-time");
        if (timeEl) timeEl.textContent = fmtStepTime(rec);
      }
    }
  }, 1000);

  // 每 3 秒轮询一次协作任务状态
  refreshCoopTasks();
  setInterval(refreshCoopTasks, 3000);

  // 全局兜底：任何未捕获错误都必须可见，避免"点击没反应"
  window.addEventListener("error", (event) => {
    console.error(event.error || event.message);
    showToast("页面脚本错误: " + (event.message || "unknown"), "error");
  });
  window.addEventListener("unhandledrejection", (event) => {
    const reason = event.reason;
    console.error(reason);
    showToast("请求失败: " + ((reason && reason.message) || "unknown"), "error");
  });

  // 启动：确认授权 → 配置 → 会话列表 → 打开 URL 会话或新建
  ensureAuthed()
    .then((authed) => {
      if (!authed) throw new Error("未通过授权验证，请刷新页面重试");
    })
    .then(loadConfig)
    .then(loadSessions)
    .then(openFromUrlOrCreate)
    .catch((error) => {
      showToast("初始化失败: " + error.message, "error");
      setStatus("连接失败", false, true);
    });
}

init();
