1116 lines
36 KiB
JavaScript
1116 lines
36 KiB
JavaScript
"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, "&")
|
||
.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 `<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();
|
||
});
|
||
});
|