"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 = '发送一条消息开始对话
按 Enter 发送 · Shift+Enter 换行