"use strict"; // ─── DOM 引用 ───────────────────────────────────────────────────────────── const chat = document.getElementById("chat"); const input = document.getElementById("input"); const sendBtn = document.getElementById("sendBtn"); const abortBtn = document.getElementById("abortBtn"); const statusDot = document.getElementById("statusDot"); const statusText = document.getElementById("statusText"); const statusModel = document.getElementById("statusModel"); const headerTitle = document.getElementById("headerTitle"); const headerMeta = document.getElementById("headerMeta"); const sidebar = document.getElementById("sidebar"); const overlay = document.getElementById("sidebarOverlay"); const sessionList = document.getElementById("sessionList"); const sessionContextBar = document.getElementById("sessionContextBar"); const sessionContextPrimary = document.getElementById("sessionContextPrimary"); const sessionContextSecondary = document.getElementById("sessionContextSecondary"); const modelSelect = document.getElementById("modelSelect"); const thinkingSelect = document.getElementById("thinkingSelect"); // ─── 状态 ───────────────────────────────────────────────────────────────── let currentAssistantEl = null; let assistantBuffer = ""; let isStreaming = false; let eventSource = null; let sessions = []; let activeSessionPath = null; // 当前加载的会话文件路径 let backendSessionPath = null; let sessionActivationPromise = null; let newSessionPromise = null; const isTouchLike = window.matchMedia("(max-width: 768px)").matches || navigator.maxTouchPoints > 0; const sessionCache = new Map(); let availableModels = []; let currentModelKey = ""; let isApplyingModelSettings = false; const thinkingLevels = ["off", "minimal", "low", "medium", "high", "xhigh"]; let sessionDashboardRaf = null; /** 与 interactive footer 相同的 token 缩写 */ function formatFooterTokens(count) { const n = Number(count) || 0; if (n <= 0) return "0"; if (n < 1000) return String(Math.round(n)); if (n < 10000) return `${(n / 1000).toFixed(1)}k`; if (n < 1_000_000) return `${Math.round(n / 1000)}k`; if (n < 10_000_000) return `${(n / 1_000_000).toFixed(1)}M`; return `${Math.round(n / 1_000_000)}M`; } function renderSessionContextBar(data) { if (!sessionContextBar || !sessionContextPrimary || !sessionContextSecondary) return; if (!data || data.error || !data.stats || !data.model) { sessionContextBar.hidden = true; return; } sessionContextBar.hidden = false; const tok = data.stats.tokens || {}; const parts = []; if (tok.input) parts.push(`↑${formatFooterTokens(tok.input)}`); if (tok.output) parts.push(`↓${formatFooterTokens(tok.output)}`); if (tok.cacheRead) parts.push(`R${formatFooterTokens(tok.cacheRead)}`); if (tok.cacheWrite) parts.push(`W${formatFooterTokens(tok.cacheWrite)}`); const costNum = Number(data.stats.cost ?? 0); parts.push(`$${costNum.toFixed(3)}`); const cx = data.stats.contextUsage; const cw = cx?.contextWindow ?? data.model.contextWindow ?? 0; const pctRaw = cx?.percent; const pctStr = pctRaw != null ? Number(pctRaw).toFixed(1) : "?"; const pctNum = pctRaw != null ? Number(pctRaw) : null; const autoInd = data.autoCompactionEnabled ? " (auto)" : ""; const ctxTxt = pctStr === "?" && cw ? `?/${formatFooterTokens(cw)}${autoInd}` : `${pctStr}%/${formatFooterTokens(cw)}${autoInd}`; sessionContextPrimary.textContent = ""; const row = document.createElement("div"); row.className = "session-context-row"; const left = document.createElement("div"); left.className = "session-context-left"; left.appendChild(document.createTextNode(`${parts.join(" ")} `)); const ctxSpan = document.createElement("span"); ctxSpan.textContent = ctxTxt; if (pctNum != null) { if (pctNum > 90) ctxSpan.classList.add("ctx-danger"); else if (pctNum > 70) ctxSpan.classList.add("ctx-warn"); } left.appendChild(ctxSpan); row.appendChild(left); sessionContextPrimary.appendChild(row); const liveStreaming = isStreaming || data.isStreaming; let sub = ""; if (data.isCompacting) sub = "⚡ 正在整理上下文…"; else if (liveStreaming) sub = "⋯ 回复中…"; else if (data.turnIndex > 0) sub = `✓ Turn ${data.turnIndex} complete`; sessionContextSecondary.textContent = sub; } function modelKey(model) { return model ? `${model.provider}/${model.id}` : ""; } function modelLabel(model) { if (!model) return ""; return `${model.provider}/${model.id}`; } function getAvailableThinkingLevels(model) { if (!model?.reasoning) return ["off"]; return thinkingLevels.filter((level) => { const mapped = model.thinkingLevelMap?.[level]; if (mapped === null) return false; if (level === "xhigh") return mapped !== undefined; return true; }); } function syncThinkingOptions(model, currentLevel) { if (!thinkingSelect) return; const levels = getAvailableThinkingLevels(model); const nextOptionsKey = levels.join("|"); if (thinkingSelect.dataset.optionsKey !== nextOptionsKey) { thinkingSelect.innerHTML = ""; for (const level of levels) { const option = document.createElement("option"); option.value = level; option.textContent = level; thinkingSelect.appendChild(option); } thinkingSelect.dataset.optionsKey = nextOptionsKey; } const effectiveLevel = levels.includes(currentLevel) ? currentLevel : levels[0] || "off"; if (thinkingSelect.value !== effectiveLevel) { thinkingSelect.value = effectiveLevel; } } function updateModelControlsFromState(data) { if (!modelSelect || !thinkingSelect || !data || data.error) return; currentModelKey = modelKey(data.model); if (currentModelKey && modelSelect.value !== currentModelKey) { modelSelect.value = currentModelKey; } const thinking = data.thinkingLevel || "off"; syncThinkingOptions(data.model, thinking); const supportsThinking = !!data.model?.reasoning; thinkingSelect.disabled = !supportsThinking || isStreaming || isApplyingModelSettings; modelSelect.disabled = isStreaming || isApplyingModelSettings; } async function loadAvailableModels() { if (!modelSelect) return; try { const res = await fetch("/api/models"); const data = await res.json(); if (!res.ok || data.error) throw new Error(data.error || res.statusText); availableModels = data.models || []; modelSelect.innerHTML = ""; for (const model of availableModels) { const option = document.createElement("option"); option.value = modelKey(model); option.textContent = modelLabel(model); modelSelect.appendChild(option); } if (currentModelKey) modelSelect.value = currentModelKey; } catch (err) { modelSelect.innerHTML = ``; addMessage("system", `模型列表加载失败: ${err.message}`); } } async function applyModelSelection() { if (!modelSelect || !modelSelect.value || isApplyingModelSettings) return; const [provider, ...idParts] = modelSelect.value.split("/"); const modelId = idParts.join("/"); if (!provider || !modelId) return; isApplyingModelSettings = true; modelSelect.disabled = true; thinkingSelect.disabled = true; setLoadingState("正在切换模型"); try { const res = await fetch("/api/model", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ provider, modelId }), }); const data = await res.json().catch(() => ({})); if (!res.ok || data.error) throw new Error(data.error || res.statusText); await fetchSessionState(); setLoadingState("模型已切换"); setTimeout(() => setLoadingState(""), 1000); } catch (err) { setLoadingState(""); addMessage("system", `切换模型失败: ${err.message}`); await fetchSessionState(); } finally { isApplyingModelSettings = false; await fetchSessionState(); } } async function applyThinkingSelection() { if (!thinkingSelect || isApplyingModelSettings) return; isApplyingModelSettings = true; modelSelect.disabled = true; thinkingSelect.disabled = true; setLoadingState("正在设置推理强度"); try { const res = await fetch("/api/thinking", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ level: thinkingSelect.value }), }); const data = await res.json().catch(() => ({})); if (!res.ok || data.error) throw new Error(data.error || res.statusText); await fetchSessionState(); setLoadingState("推理强度已设置"); setTimeout(() => setLoadingState(""), 1000); } catch (err) { setLoadingState(""); addMessage("system", `设置推理强度失败: ${err.message}`); await fetchSessionState(); } finally { isApplyingModelSettings = false; await fetchSessionState(); } } function scheduleSessionDashboardRefresh() { if (sessionDashboardRaf != null) return; sessionDashboardRaf = requestAnimationFrame(() => { sessionDashboardRaf = null; fetchSessionState(); }); } function syncViewportHeight() { const viewportHeight = window.visualViewport?.height || window.innerHeight; document.documentElement.style.setProperty("--app-height", `${viewportHeight}px`); } function formatSessionTitle(title) { const text = typeof title === "string" ? title.trim() : ""; return text || "未命名"; } /** 与服务端一致的机器标签(会话 id、纯 hex 段等),不写进界面标题 */ function isMachineSessionLabel(text, headerId) { const t = (text ?? "").trim(); if (!t) return true; if (headerId && t === headerId) return true; if (/^[0-9a-f]{8,}$/i.test(t)) return true; if (/^[0-9]{10,}$/.test(t)) return true; return false; } /** 首句截取(侧边栏兜底与顶栏回填,需与 server.ts 保持一致逻辑) */ function titleFromFirstUserMessage(text, maxChars = 56) { const cleaned = String(text ?? "").replace(/\s+/g, " ").trim(); if (!cleaned) return ""; const sentenceMatch = cleaned.match(/^(.+?[。!?.!?])(\s|$)/); let candidate = sentenceMatch && sentenceMatch[1] ? sentenceMatch[1].trim() : cleaned; if (candidate.length > maxChars) { candidate = `${candidate.slice(0, maxChars).trimEnd()}…`; } return candidate; } async function activateSessionBackend(path) { const activateRes = await fetch("/api/sessions/activate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path }), }); const activateData = await activateRes.json().catch(() => ({})); if (activateRes.ok && !activateData.error) { backendSessionPath = path; return; } const activateError = activateData.error || activateRes.statusText || "Unknown error"; if (activateRes.status !== 404 && !/not found/i.test(activateError)) { throw new Error(activateError); } const fallbackRes = await fetch("/api/sessions/load", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path }), }); const fallbackData = await fallbackRes.json().catch(() => ({})); if (!fallbackRes.ok || fallbackData.error) { throw new Error(fallbackData.error || fallbackRes.statusText); } backendSessionPath = path; } // ─── 输入框自动伸缩 ─────────────────────────────────────────────────── input.addEventListener("input", () => { input.style.height = "auto"; input.style.height = Math.min(input.scrollHeight, 150) + "px"; }); input.addEventListener("keydown", (e) => { if (e.isComposing) return; if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); } }); input.addEventListener("beforeinput", (e) => { if (isTouchLike && e.inputType === "insertLineBreak") { e.preventDefault(); send(); } }); // ─── 侧边栏切换 ──────────────────────────────────────────────────────── function toggleSidebar() { const open = sidebar.classList.toggle("open"); overlay.classList.toggle("open", open); } window.addEventListener("resize", () => { syncViewportHeight(); if (window.innerWidth > 768) { sidebar.classList.remove("open"); overlay.classList.remove("open"); } }); if (window.visualViewport) { window.visualViewport.addEventListener("resize", syncViewportHeight); window.visualViewport.addEventListener("scroll", syncViewportHeight); } window.addEventListener("orientationchange", syncViewportHeight); // ─── 状态更新 ──────────────────────────────────────────────────────────── function setStatus(connected, streaming) { if (connected) { statusDot.className = streaming ? "status-dot streaming" : "status-dot connected"; statusText.textContent = streaming ? "输入中..." : "就绪"; headerMeta.textContent = streaming ? "回复中" : ""; } else { statusDot.className = "status-dot disconnected"; statusText.textContent = "未连接"; headerMeta.textContent = "未连接"; } abortBtn.style.display = streaming ? "flex" : "none"; sendBtn.disabled = streaming; } // ─── Markdown 渲染 ────────────────────────────────────────────────────── function escapeMarkdownCode(raw) { return String(raw) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function renderAssistantCodeBlock(token) { const langSlug = (((token.lang || "").match(/^\S+/) || [""])[0]).trim().toLowerCase(); const codeRaw = String(token.text ?? "").replace(/\n+$/, ""); /** marked:escaped 已为 HTML 字面量片段,Highlighter 跳过 */ const skipHl = !!token.escaped; let innerHtml; if (skipHl) { innerHtml = codeRaw; } else { try { const hl = typeof globalThis.hljs !== "undefined" ? globalThis.hljs : typeof hljs !== "undefined" ? hljs : null; if (hl && typeof hl.highlight === "function") { if (langSlug && hl.getLanguage(langSlug)) { innerHtml = hl.highlight(codeRaw, { language: langSlug }).value; } else { innerHtml = hl.highlightAuto(codeRaw).value; } } else { innerHtml = escapeMarkdownCode(codeRaw); } } catch { innerHtml = escapeMarkdownCode(codeRaw); } } const safeLangSlug = /^[a-z][a-z0-9_-]*$/i.test(langSlug) ? langSlug : ""; const codeClass = safeLangSlug ? `hljs language-${safeLangSlug}` : "hljs"; return `
${innerHtml}
\n`; } (function initMarkedCodeHighlight() { if (typeof marked === "undefined" || typeof marked.use !== "function") return; marked.use({ renderer: { code: renderAssistantCodeBlock, }, }); })(); function renderMarkdown(text) { if (!text) return ""; return marked.parse(text, { breaks: true, gfm: true }); } // ─── 消息渲染 ─────────────────────────────────────────────────────────── function removeEmptyState() { const el = chat.querySelector(".empty-state"); if (el) el.remove(); } function scrollToBottom() { chat.scrollTop = chat.scrollHeight; } function findToolCallMessageEl(toolCallId) { if (toolCallId == null || toolCallId === "") return null; const id = String(toolCallId); for (const el of chat.querySelectorAll(".msg.tool_call")) { if (el.dataset.toolCallId === id) return el; } return null; } /** summary 单行:图标 + 工具名 + 短 id(不展开整条 JSON) */ function deriveToolCallSummary(fullText) { const raw = String(fullText || "").trim(); if (!raw) return "tool"; let icon = ""; if (raw.startsWith("🔧")) icon = "🔧"; else if (raw.startsWith("✅")) icon = "✅"; else if (raw.startsWith("❌")) icon = "❌"; const rest = icon ? raw.slice(icon.length).trimStart() : raw; const idLine = rest.split("\n").find((l) => l.trimStart().startsWith("# ")); const idPart = idLine ? idLine.replace(/^\s*#\s*/, "").trim().split(/\s/)[0] : ""; const oneLine = rest.replace(/\s+/g, " "); const nameMatch = oneLine.match(/(\w+)\s*\(/); const name = nameMatch ? nameMatch[1] : "tool"; let label = idPart ? `${name} · ${idPart}` : name; if (icon) label = `${icon} ${label}`; return label; } function buildToolCallCollapsible(fullText) { const details = document.createElement("details"); details.className = "tool-call-collapsible"; const summary = document.createElement("summary"); summary.className = "tool-call-summary"; const sumSpan = document.createElement("span"); sumSpan.className = "tool-call-summary-text"; sumSpan.textContent = deriveToolCallSummary(fullText); summary.appendChild(sumSpan); const detail = document.createElement("div"); detail.className = "tool-call-detail"; const pre = document.createElement("pre"); pre.className = "tool-call-detail-pre"; pre.textContent = fullText; detail.appendChild(pre); details.appendChild(summary); details.appendChild(detail); return details; } function getToolCallFullText(el) { const pre = el.querySelector(".tool-call-detail-pre"); return pre ? pre.textContent : el.textContent; } function syncToolCallBody(el, fullText) { const txt = fullText ?? ""; if (!el.querySelector(".tool-call-collapsible")) { el.textContent = ""; el.appendChild(buildToolCallCollapsible(txt)); return; } const pre = el.querySelector(".tool-call-detail-pre"); const sum = el.querySelector(".tool-call-summary-text"); if (pre) pre.textContent = txt; if (sum) sum.textContent = deriveToolCallSummary(txt); } function appendToolCallMessage(text, toolCallId) { removeEmptyState(); const el = document.createElement("div"); el.className = "msg tool_call"; if (toolCallId != null && toolCallId !== "") el.dataset.toolCallId = String(toolCallId); el.appendChild(buildToolCallCollapsible(text || "")); chat.appendChild(el); scrollToBottom(); return el; } /** 助手消息落盘时的工具摘要;若该行已在 tool_execution 中更新则不再覆盖 */ function upsertToolCallSummary(tc) { const tid = tc.toolCallId || tc.id; const text = formatToolCall(tc); const existing = findToolCallMessageEl(tid); if (existing) { const cur = getToolCallFullText(existing).trimStart(); const running = cur.startsWith("🔧") || cur.startsWith("✅") || cur.startsWith("❌"); if (!running) syncToolCallBody(existing, text); if (tid && !existing.dataset.toolCallId) existing.dataset.toolCallId = tid; return; } appendToolCallMessage(text, tid); } function addMessage(role, content, extraClass) { removeEmptyState(); if (role === "assistant" && extraClass === "streaming") { if (!currentAssistantEl) { currentAssistantEl = document.createElement("div"); currentAssistantEl.className = "msg assistant streaming"; chat.appendChild(currentAssistantEl); } const raw = content || assistantBuffer; currentAssistantEl.innerHTML = renderMarkdown(raw || ""); scrollToBottom(); return currentAssistantEl; } const el = document.createElement("div"); el.className = `msg ${role}`; if (extraClass) el.classList.add(extraClass); if (role === "assistant") { el.innerHTML = renderMarkdown(content || ""); } else { el.textContent = content; } chat.appendChild(el); if (role !== "system") scrollToBottom(); return el; } function setLoadingState(text) { headerMeta.textContent = text || ""; } function finalizeAssistantMessage(content) { const trimmed = typeof content === "string" ? content.trim() : content ? String(content).trim() : ""; if (currentAssistantEl) { currentAssistantEl.classList.remove("streaming"); if (trimmed) { currentAssistantEl.innerHTML = renderMarkdown(trimmed); } else { currentAssistantEl.remove(); } currentAssistantEl = null; } else if (trimmed) { addMessage("assistant", trimmed); } assistantBuffer = ""; } function displayMessages(messages, isHistory = false) { for (const m of messages) { if (m.role === "user") { addMessage("user", extractContent(m.content)); } else if (m.role === "assistant") { const text = extractContent(m.content); const toolCalls = getToolCalls(m.content); if (text) addMessage("assistant", text); for (const tc of toolCalls) upsertToolCallSummary(tc); } } } function extractContent(content) { if (!content) return ""; if (typeof content === "string") return content; if (Array.isArray(content)) { return content .filter(c => c.type === "text") .map(c => c.text) .join(""); } return String(content); } function getToolCalls(content) { if (!Array.isArray(content)) return []; return content.filter(c => c.type === "toolCall"); } function formatToolCall(tc) { const name = tc.toolName || tc.name || "tool"; // pi 会话块为 { type, id, name, arguments };部分来源用 args / input const args = tc.arguments ?? tc.args ?? tc.input ?? {}; let text = `${name}(${JSON.stringify(args)})`; const shortId = tc.toolCallId || tc.id; if (shortId) text = `# ${String(shortId).slice(0, 8)}\n` + text; return text; } function formatToolResult(tr) { let text = ""; if (typeof tr.content === "string") text = tr.content; else if (Array.isArray(tr.content)) { text = tr.content.map(c => (c && c.text) || JSON.stringify(c)).join("\n"); } else if (tr.content && tr.content.text) text = tr.content.text; else text = JSON.stringify(tr.content || tr, null, 2); return text.length > 800 ? text.slice(0, 800) + "\n… (已截断)" : text; } function escHtml(s) { const div = document.createElement("div"); div.textContent = s; return div.innerHTML; } // ─── 会话列表 ─────────────────────────────────────────────────────────── async function loadSessions() { sessionList.innerHTML = '
加载中...
'; try { const res = await fetch("/api/sessions"); const data = await res.json(); sessions = data.sessions || []; renderSessionList(); } catch (err) { sessionList.innerHTML = `
加载失败: ${err.message}
`; } } function renderSessionList() { if (!sessions.length) { sessionList.innerHTML = '
暂无历史会话
'; return; } sessionList.innerHTML = sessions.map((s) => { const timeAgo = formatTimeAgo(s.modified || s.created); const isActive = activeSessionPath === s.path; const pathAttr = escHtml(s.path); const title = formatSessionTitle(s.name); return `
`; }).join(""); } async function deleteSession(event, path) { event?.stopPropagation(); if (isStreaming) { addMessage("system", "正在回复中,暂不能删除会话"); return; } const row = sessions.find((s) => s.path === path); const title = formatSessionTitle(row?.name || row?.firstMessage || "该会话"); if (!confirm(`删除会话「${title}」?此操作不可恢复。`)) return; try { const res = await fetch("/api/sessions/delete", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path }), }); const data = await res.json().catch(() => ({})); if (!res.ok || data.error) throw new Error(data.error || res.statusText); sessionCache.delete(path); sessions = sessions.filter((s) => s.path !== path); if (activeSessionPath === path) { clearChat(); backendSessionPath = null; sessionActivationPromise = null; } else { renderSessionList(); } } catch (err) { addMessage("system", `删除会话失败: ${err.message}`); } } function formatTimeAgo(isoStr) { const d = new Date(isoStr); const now = new Date(); const diffMs = now - d; const mins = Math.floor(diffMs / 60000); if (mins < 1) return "刚刚"; if (mins < 60) return `${mins} 分钟前`; const hours = Math.floor(mins / 60); if (hours < 24) return `${hours} 小时前`; const days = Math.floor(hours / 24); if (days < 30) return `${days} 天前`; return d.toLocaleDateString("zh-CN", { month: "short", day: "numeric" }); } // ─── 加载会话 ─────────────────────────────────────────────────────────── async function loadSession(path) { toggleSidebar(); // close sidebar on mobile activeSessionPath = path; renderSessionList(); setLoadingState("加载中..."); try { let payload = sessionCache.get(path); const poisonName = (p) => typeof p?.session?.name === "string" && p.session.name.trim() && isMachineSessionLabel(p.session.name.trim(), String(p.session?.id ?? "")); if (payload && poisonName(payload)) { sessionCache.delete(path); payload = null; } if (!payload) { const res = await fetch("/api/sessions/history", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path }), }); payload = await res.json(); if (payload.error) throw new Error(payload.error); sessionCache.set(path, payload); } // 先把历史内容立即渲染出来,后台再同步会话上下文 chat.innerHTML = ""; currentAssistantEl = null; assistantBuffer = ""; const sid = payload.session?.id != null ? String(payload.session.id) : ""; let headerLabel = ""; if ( typeof payload.session?.name === "string" && payload.session.name.trim() && !isMachineSessionLabel(payload.session.name.trim(), sid) ) { headerLabel = payload.session.name.trim(); } if (!headerLabel) { for (const m of payload.messages || []) { if (m.role === "user") { const fb = titleFromFirstUserMessage(extractContent(m.content)); if (fb) { headerLabel = fb; break; } } } } headerTitle.textContent = formatSessionTitle(headerLabel); displayMessages(payload.messages, true); scrollToBottom(); // 历史已可见,勿再占用顶栏「加载中…」(后台激活可能较慢) setLoadingState(""); scheduleSessionDashboardRefresh(); const activationPromise = (async () => { try { await activateSessionBackend(path); } catch (err) { addMessage("system", `会话同步失败: ${err.message}`); throw err; } finally { if (sessionActivationPromise === activationPromise) { sessionActivationPromise = null; } } })(); sessionActivationPromise = activationPromise; activationPromise.catch(() => {}); } catch (err) { setLoadingState(""); addMessage("system", `加载会话失败: ${err.message}`); } } // ─── SSE 事件流 ──────────────────────────────────────────────────────── function connectSSE() { if (eventSource) eventSource.close(); eventSource = new EventSource("/api/events"); eventSource.onopen = () => { setStatus(true, isStreaming); }; eventSource.onmessage = (e) => { try { const ev = JSON.parse(e.data); handleEvent(ev); } catch { /* ignore */ } }; eventSource.onerror = () => { setStatus(false, false); setTimeout(connectSSE, 2000); }; } function handleEvent(ev) { switch (ev.type) { case "connected": setStatus(true, false); scheduleSessionDashboardRefresh(); break; case "agent_start": isStreaming = true; setStatus(true, true); assistantBuffer = ""; currentAssistantEl = null; scheduleSessionDashboardRefresh(); break; case "agent_end": isStreaming = false; setStatus(true, false); if (currentAssistantEl) { currentAssistantEl.classList.remove("streaming"); currentAssistantEl = null; } assistantBuffer = ""; if (backendSessionPath) { sessionCache.delete(backendSessionPath); } // 后台刷新会话列表(可能有新消息产生) setTimeout(loadSessions, 1000); // 获取当前会话状态以显示模型信息 fetchSessionState(); break; case "message_start": { const m = ev.message; if (m.role === "user") { const text = extractContent(m.content); addMessage("user", text); } break; } case "message_update": { const m = ev.message; if (m.role === "assistant") { const text = extractContent(m.content); assistantBuffer = text; addMessage("assistant", text, "streaming"); } break; } case "message_end": { const m = ev.message; if (m.role === "assistant") { const text = extractContent(m.content); const toolCalls = getToolCalls(m.content); for (const tc of toolCalls) upsertToolCallSummary(tc); finalizeAssistantMessage(text); scheduleSessionDashboardRefresh(); } break; } case "tool_execution_start": { const tid = ev.toolCallId; const label = `🔧 ${ev.toolName}(${JSON.stringify(ev.args ?? {})})`; const existing = findToolCallMessageEl(tid); if (existing) { syncToolCallBody(existing, label); } else { appendToolCallMessage(label, tid); } break; } case "tool_execution_update": { updateLastToolCall(ev); break; } case "tool_execution_end": { updateLastToolCall(ev, ev.isError); if (ev.isError) { addMessage("system", `工具 ${ev.toolName} 执行出错`); } break; } } } function updateLastToolCall(ev, isError) { const els = chat.querySelectorAll(".msg.tool_call"); const last = findToolCallMessageEl(ev.toolCallId) ?? els[els.length - 1]; if (!last) return; const icon = isError === undefined ? "🔧" : isError ? "❌" : "✅"; let detail = ""; if (ev.partialResult?.content) { detail = "\n" + formatToolResult(ev.partialResult); } let full = ""; if (isError !== undefined) { const resultText = ev.result?.content ? "\n" + formatToolResult(ev.result) : ""; full = `${icon} ${ev.toolName}${resultText || detail}`; } else { full = `${icon} ${ev.toolName}${detail}`; } syncToolCallBody(last, full); } // ─── API 调用 ─────────────────────────────────────────────────────────── async function send() { const text = input.value.trim(); if (!text || isStreaming) return; if (newSessionPromise) { try { await newSessionPromise; } catch { sendBtn.disabled = false; return; } } if (activeSessionPath && backendSessionPath !== activeSessionPath) { if (sessionActivationPromise) { try { await sessionActivationPromise; } catch { return; } } else { try { await activateSessionBackend(activeSessionPath); } catch (err) { addMessage("system", `切换会话失败: ${err.message}`); sendBtn.disabled = false; return; } } } input.value = ""; input.style.height = "auto"; sendBtn.disabled = true; try { const res = await fetch("/api/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message: text }), }); if (!res.ok) { const err = await res.json().catch(() => ({ error: res.statusText })); addMessage("system", `发送失败: ${err.error}`); sendBtn.disabled = false; } } catch (err) { addMessage("system", `网络错误: ${err.message}`); sendBtn.disabled = false; } if (isTouchLike) { input.blur(); } } async function abortStream() { if (!isStreaming) return; try { await fetch("/api/abort", { method: "POST" }); } catch { /* ignore */ } } function clearChat(title = "聊天") { chat.innerHTML = `

发送一条消息开始对话

按 Enter 发送 · Shift+Enter 换行

`; currentAssistantEl = null; assistantBuffer = ""; activeSessionPath = null; headerTitle.textContent = title; if (sessionContextBar) sessionContextBar.hidden = true; renderSessionList(); } function newSession() { if (isStreaming) { addMessage("system", "正在回复中,暂不能创建新会话"); return; } if (newSessionPromise) return; statusText.textContent = "新建中..."; setLoadingState("正在创建新会话"); backendSessionPath = null; activeSessionPath = null; sessionActivationPromise = null; clearChat("新会话"); newSessionPromise = (async () => { const res = await fetch("/api/new-session", { method: "POST" }); const data = await res.json().catch(() => ({})); if (!res.ok || data.error) throw new Error(data.error || res.statusText); backendSessionPath = data.sessionFile || null; setStatus(true, false); setLoadingState("已创建"); setTimeout(() => setLoadingState(""), 1200); await fetchSessionState(); })(); newSessionPromise .catch((err) => { setStatus(true, false); setLoadingState(""); addMessage("system", `创建新会话失败: ${err.message}`); }) .finally(() => { newSessionPromise = null; }); } async function fetchSessionState() { try { const res = await fetch("/api/session-state"); if (!res.ok) return; const data = await res.json(); if (data.error) return; renderSessionContextBar(data); updateModelControlsFromState(data); if (statusModel) statusModel.textContent = ""; if (!(!activeSessionPath || backendSessionPath === activeSessionPath)) return; const sid = data.sessionId != null ? String(data.sessionId) : ""; const pathKey = backendSessionPath || activeSessionPath; const row = pathKey ? sessions.find((s) => s.path === pathKey) : null; if (data.sessionName && !isMachineSessionLabel(data.sessionName, sid)) { headerTitle.textContent = formatSessionTitle(data.sessionName); } else if (row) { if (row.name && !isMachineSessionLabel(row.name, sid)) { headerTitle.textContent = formatSessionTitle(row.name); } else if (row.firstMessage && row.firstMessage !== "(空)") { headerTitle.textContent = formatSessionTitle( titleFromFirstUserMessage(row.firstMessage), ); } } } catch { /* ignore */ } } // ─── 初始化 ───────────────────────────────────────────────────────────── syncViewportHeight(); connectSSE(); loadSessions(); loadAvailableModels(); if (modelSelect) modelSelect.addEventListener("change", applyModelSelection); if (thinkingSelect) thinkingSelect.addEventListener("change", applyThinkingSelection); requestAnimationFrame(() => { requestAnimationFrame(() => { fetchSessionState(); }); });