Files
sproutclaw/.pi/extensions/webui/public/app.js
2026-05-10 14:47:51 +08:00

1116 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 = `<option value="">模型加载失败</option>`;
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function renderAssistantCodeBlock(token) {
const langSlug = (((token.lang || "").match(/^\S+/) || [""])[0]).trim().toLowerCase();
const codeRaw = String(token.text ?? "").replace(/\n+$/, "");
/** markedescaped 已为 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 `<pre class="assistant-pre"><code class="${codeClass}">${innerHtml}</code></pre>\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 = '<div class="session-empty">加载中...</div>';
try {
const res = await fetch("/api/sessions");
const data = await res.json();
sessions = data.sessions || [];
renderSessionList();
} catch (err) {
sessionList.innerHTML = `<div class="session-empty">加载失败: ${err.message}</div>`;
}
}
function renderSessionList() {
if (!sessions.length) {
sessionList.innerHTML = '<div class="session-empty">暂无历史会话</div>';
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 `<div class="session-item${isActive ? " active" : ""}">
<button class="session-item-main" onclick="loadSession('${pathAttr}')">
<div class="session-item-name">${escHtml(title)}</div>
<div class="session-item-meta">${s.messageCount} 条 · ${timeAgo}</div>
</button>
<button class="session-delete-btn" title="删除会话" aria-label="删除 ${escHtml(title)}" onclick="deleteSession(event, '${pathAttr}')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
</button>
</div>`;
}).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 = `<div class="empty-state">
<div class="empty-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>
</div>
<p>发送一条消息开始对话</p>
<p class="empty-hint">按 Enter 发送 · Shift+Enter 换行</p>
</div>`;
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();
});
});