/* ============================================================
BLACK BEAN · 黑豆 — 前端逻辑(v20260812f 全面重写)
设计原则:
1. 任何用户操作都有可见反馈(toast / 状态栏 / 聊天区),绝不静默失败
2. WebSocket 生命周期明确:心跳保活、有限次指数退避重连、会话失效自动重建、无重连风暴
3. 流式状态可自愈:卡死的流自动清理,绝不阻塞后续发送
4. 渲染永不抛异常:渲染失败降级为纯文本
============================================================ */
"use strict";
const PAGE_SIZE = 50;
// 无任何 Agent 流事件超过该时长,且当前没有运行中的工具步骤 → 判定流卡死
const STREAM_STALE_MS = 60000;
// 心跳间隔(服务端 90s 读超时,每 25s 发一次应用层 ping 刷新)
const HEARTBEAT_MS = 25000;
const INPUT_PLACEHOLDER = "描述你的任务,例如:分析 server.log 中响应时间最长的 5 条请求...";
const state = {
sessions: [],
currentId: null,
streaming: false,
activeStream: null, // 当前进行中的流(含后台协作通知的被动流)
config: null,
currentMessages: [],
hasMore: false,
loadingOlder: false,
lastEventAt: 0, // 最近一次 Agent 流事件时间戳(用于识别卡死的流)
llmConfigured: false // 是否已配置 LLM API Key(环境变量或设置页)
};
const els = {
sidebar: document.getElementById("sidebar"),
sessionList: document.getElementById("session-list"),
sessionSearch: document.getElementById("session-search"),
newChatBtn: document.getElementById("new-chat-btn"),
refreshBtn: document.getElementById("refresh-btn"),
menuBtn: document.getElementById("menu-btn"),
sessionTitle: document.getElementById("session-title"),
sessionSub: document.getElementById("session-sub"),
chat: document.getElementById("chat"),
scrollBottomBtn: document.getElementById("scroll-bottom-btn"),
emptyState: document.getElementById("empty-state"),
input: document.getElementById("input"),
sendBtn: document.getElementById("send-btn"),
stopBtn: document.getElementById("stop-btn"),
statusDot: document.getElementById("status-dot"),
statusText: document.getElementById("status-text"),
contextHint: document.getElementById("context-hint"),
ctxChip: document.getElementById("ctx-chip"),
modelChip: document.getElementById("model-chip"),
brandModel: null,
settingsBtn: document.getElementById("settings-btn"),
settingsModal: document.getElementById("settings-modal"),
dockerSocketInput: document.getElementById("docker-socket-input"),
coopEngineSelect: document.getElementById("coop-engine-select"),
coopModeSelect: document.getElementById("coop-mode-select"),
providerSelect: document.getElementById("provider-select"),
providerHint: document.getElementById("provider-hint"),
apiKeyInput: document.getElementById("api-key-input"),
baseUrlInput: document.getElementById("base-url-input"),
modelInput: document.getElementById("model-input"),
saveSettingsBtn: document.getElementById("save-settings-btn"),
resetSettingsBtn: document.getElementById("reset-settings-btn"),
closeSettingsBtn: document.getElementById("close-settings-btn"),
toast: document.getElementById("toast"),
coopPanel: document.getElementById("coop-panel"),
coopPanelHeader: document.getElementById("coop-panel-header"),
coopCount: document.getElementById("coop-count"),
coopList: document.getElementById("coop-list"),
authModal: document.getElementById("auth-modal"),
authCodeInput: document.getElementById("auth-code-input"),
authSubmitBtn: document.getElementById("auth-submit-btn"),
authError: document.getElementById("auth-error")
};
/* ============================================================
图标
============================================================ */
const ICONS = {
task:
'',
terminal:
'',
file:
'',
folder:
'',
python:
'',
docker:
'',
coop:
'',
tool:
'',
compress:
''
};
function toolIcon(name) {
if (name.startsWith("docker_")) return ICONS.docker;
if (name === "run_coop") return ICONS.coop;
if (name === "read_file" || name === "write_file") return ICONS.file;
if (name === "list_directory") return ICONS.folder;
if (name === "run_python") return ICONS.python;
return ICONS.terminal;
}
/* ============================================================
基础工具
============================================================ */
async function api(path, options = {}) {
const res = await fetch(path, {
headers: { "Content-Type": "application/json" },
...options
});
if (!res.ok) {
let message = `HTTP ${res.status}`;
try {
const data = await res.json();
message = data.error || message;
} catch (_) {}
// 授权失效(授权码被重置 / Cookie 丢失):弹窗引导重新授权
if (res.status === 401 && !path.startsWith("/api/v1/auth/")) {
showAuthModal();
}
throw new Error(message);
}
if (res.status === 204) return null;
return res.json();
}
/* ============================================================
访问授权
默认关闭;仅当服务端通过环境变量 AGENT_AUTH_CODE 启用授权时,
前端先查询 /auth/status,未授权则弹窗要求输入授权码,
验证通过后服务端签发 HttpOnly Cookie,后续 API / WS 自动携带。
============================================================ */
// 确认授权就绪:无需授权或已通过验证返回 true,否则展示授权弹窗等待验证
async function ensureAuthed() {
let status;
try {
status = await api("/api/v1/auth/status");
} catch (e) {
showToast("无法连接服务器: " + e.message, "error");
setStatus("连接失败", false, true);
return false;
}
if (!status.required || status.authorized) return true;
return showAuthModal();
}
// 读取 URL 查询参数(如 ?authcode=xxx)
function getQueryParam(name) {
try {
return new URLSearchParams(window.location.search).get(name);
} catch (e) {
return null;
}
}
// 验证成功后从地址栏清除 authcode,避免授权码残留在 URL 中
function clearAuthcodeFromUrl() {
try {
const params = new URLSearchParams(window.location.search);
if (params.has("authcode")) {
params.delete("authcode");
const qs = params.toString();
const newUrl = window.location.pathname + (qs ? "?" + qs : "") + window.location.hash;
history.replaceState(null, "", newUrl);
}
} catch (e) {
/* 忽略清理失败,不影响功能 */
}
}
// 展示授权弹窗并等待用户验证,返回是否验证成功
function showAuthModal() {
els.authError.classList.add("hidden");
// 支持 URL 查询参数 ?authcode=xxx 自动填入授权码(便于分享链接给他人直接使用)
const urlCode = getQueryParam("authcode");
els.authCodeInput.value = urlCode || "";
els.authModal.classList.remove("hidden");
els.authCodeInput.focus();
return new Promise((resolve) => {
const cleanup = () => {
els.authSubmitBtn.removeEventListener("click", onSubmit);
els.authCodeInput.removeEventListener("keydown", onKey);
};
const onSubmit = async () => {
const code = els.authCodeInput.value.trim();
if (!code) {
els.authError.textContent = "请输入授权码";
els.authError.classList.remove("hidden");
return;
}
els.authSubmitBtn.disabled = true;
try {
await api("/api/v1/auth/login", { method: "POST", body: JSON.stringify({ code }) });
clearAuthcodeFromUrl(); // 验证成功后清除地址栏授权码
els.authModal.classList.add("hidden");
cleanup();
resolve(true);
} catch (e) {
els.authError.textContent = e.message || "授权失败,请重试";
els.authError.classList.remove("hidden");
} finally {
els.authSubmitBtn.disabled = false;
}
};
const onKey = (e) => {
if (e.key === "Enter" && !e.isComposing) {
e.preventDefault();
onSubmit();
}
};
els.authSubmitBtn.addEventListener("click", onSubmit);
els.authCodeInput.addEventListener("keydown", onKey);
// URL 携带 authcode 时自动触发验证,他人打开分享链接即可直接使用
if (urlCode) {
setTimeout(() => onSubmit(), 50);
}
});
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
function showToast(message, type = "") {
els.toast.textContent = message;
els.toast.className = `toast show ${type}`;
clearTimeout(showToast.timer);
showToast.timer = setTimeout(() => {
els.toast.className = "toast";
}, 3200);
}
function setStatus(text, busy = false, error = false) {
els.statusText.textContent = text;
els.statusDot.classList.toggle("busy", busy);
els.statusDot.classList.toggle("error", error);
}
function debounce(fn, delay) {
let timer = null;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
/* ============================================================
Markdown 渲染(永不抛异常)
============================================================ */
// 行内格式:先用占位符保护行内代码,再处理图片/链接/删除线/粗体/斜体,最后还原代码
function renderInline(text) {
const codes = [];
let v = String(text).replace(/`([^`]+)`/g, (_, code) => {
codes.push(code);
return "\u0000" + (codes.length - 1) + "\u0000";
});
v = escapeHtml(v);
v = v.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, (_, alt, src) =>
`
`);
v = v.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, (_, label, href) =>
`${label}`);
v = v.replace(/~~([^~]+)~~/g, "$1");
v = v.replace(/\*\*([^*]+)\*\*/g, "$1");
v = v.replace(/(^|[^*])\*([^*\s][^*]*)\*(?!\*)/g, "$1$2");
return v.replace(/\u0000(\d+)\u0000/g, (_, i) => `${escapeHtml(codes[Number(i)])}`);
}
// 仅允许安全协议,避免 javascript: 等注入
function sanitizeUrl(url) {
const value = String(url).trim();
if (/^(https?:|mailto:|tel:|#|\/)/i.test(value)) return value;
if (/^\.{1,2}\//.test(value)) return value;
return "#";
}
// 对外安全入口:任何渲染异常都降级为纯文本,绝不阻断消息收发
function renderMarkdown(text) {
try {
return renderMarkdownRaw(text);
} catch (_) {
return `
${escapeHtml(String(text))}
`;
}
}
function renderMarkdownRaw(text) {
const parts = [];
const codeRegex = /```([\w+-]*)\r?\n?([\s\S]*?)```/g;
let lastIndex = 0;
let match;
while ((match = codeRegex.exec(text)) !== null) {
parts.push({ type: "text", text: text.slice(lastIndex, match.index) });
parts.push({
type: "code",
lang: match[1],
code: escapeHtml(match[2].replace(/\n$/, ""))
});
lastIndex = match.index + match[0].length;
}
parts.push({ type: "text", text: text.slice(lastIndex) });
return parts.map((part) => {
if (part.type === "code") {
const lang = part.lang ? ` class="language-${escapeHtml(part.lang)}"` : "";
return ``;
}
// 表格块
const tableRegex = /^(\|.+\|)\n(\|[\s:|-]+\|)\n((?:\|.+\|\n?)+)/gm;
const textHtml = part.text.replace(tableRegex, (_, header, _sep, rows) => {
const cells = (line) =>
line
.trim()
.replace(/^\||\|$/g, "")
.split("|")
.map((cell) => cell.trim());
const head = cells(header).map((c) => `${renderInline(c)} | `).join("");
const body = rows
.trim()
.split("\n")
.map((row) => `${cells(row).map((c) => `| ${renderInline(c)} | `).join("")}
`)
.join("");
return ``;
});
const lines = textHtml.split("\n");
let html = "";
let listTag = null;
for (const line of lines) {
const ulMatch = line.match(/^[-*] (.*)$/);
const olMatch = line.match(/^\d+[.)] (.*)$/);
const listMatch = ulMatch || olMatch;
const nextTag = ulMatch ? "ul" : "ol";
if (listMatch) {
if (listTag !== nextTag) {
if (listTag) html += `${listTag}>`;
html += `<${nextTag}>`;
listTag = nextTag;
}
html += `${renderInline(listMatch[1])}`;
continue;
}
if (listTag) {
html += `${listTag}>`;
listTag = null;
}
const hMatch = line.match(/^(#{1,3})\s+(.*)$/);
if (hMatch) {
const level = hMatch[1].length;
html += `${renderInline(hMatch[2])}`;
} else if (/^/.test(line)) {
html += line;
} else if (/^> ?/.test(line)) {
html += `${renderInline(line.replace(/^> ?/, ""))}
`;
} else if (/^(---+|\*\*\*+)$/.test(line)) {
html += "
";
} else if (line.trim()) {
html += `${renderInline(line)}
`;
}
}
if (listTag) html += `${listTag}>`;
return html;
}).join("");
}
function prettyArguments(raw) {
try {
return JSON.stringify(JSON.parse(raw), null, 2);
} catch (_) {
return raw;
}
}
/* ============================================================
WebSocket 实时通道
关键设计:
- 心跳保活(应用层 ping,服务端已适配刷新 90s 读超时)
- 只有"曾成功打开"的连接意外断开才自动重连(指数退避,最多 5 次)
- 从未打开的连接(如会话已删除返回 404)不自动重连,避免无限风暴;
发送时 ensureValidSession() 会自动重建会话并连接
============================================================ */
const ws = {
conn: null,
pingTimer: null,
reconnectTimer: null,
boundSession: null,
retries: 0,
MAX_RETRIES: 5,
connect(sessionId) {
if (!sessionId) return;
this.disconnect();
this.boundSession = sessionId;
// 注意:retries 不在这里清零,只在 onopen(真正建立成功)时清零,
// 否则服务端重启期间的"连接被拒"会被每次 connect 重置计数,
// 导致 MAX_RETRIES 形同虚设,产生无限重连风暴。
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const url = `${proto}//${location.host}/ws?session=${sessionId}`;
let conn;
try {
conn = new WebSocket(url);
} catch (error) {
setStatus("无法建立连接", false, true);
showToast("WebSocket 创建失败: " + error.message, "error");
return;
}
this.conn = conn;
let opened = false;
conn.onopen = () => {
opened = true;
this.retries = 0;
this.startHeartbeat();
setStatus("就绪", false, false);
};
conn.onmessage = (event) => {
let frame;
try {
frame = JSON.parse(event.data);
} catch (_) {
return;
}
handleWsEvent(frame.type, frame.data || {});
};
conn.onclose = () => {
this.stopHeartbeat();
if (this.conn !== conn) return;
this.conn = null;
if (state.streaming) {
forceResetStream(true);
}
if (opened) {
// 曾正常建立(服务端重启 / 网络波动):有限次指数退避重连
setStatus("连接已断开,正在重连…", false, true);
} else {
// 从未成功打开(404 / 服务端刚重启被拒):仍按有限次数重连。
// 服务端重启期间连接会被拒绝(opened=false),若此时放弃重连,
// 页面将永远收不到后续事件,直到用户手动刷新——应等服务恢复后自动连上。
setStatus("未连接,正在尝试重连…", false, true);
}
this.scheduleReconnect();
};
conn.onerror = () => {
// onclose 会随之触发
};
},
scheduleReconnect() {
clearTimeout(this.reconnectTimer);
if (this.retries >= this.MAX_RETRIES) {
setStatus("连接失败,发送消息时将自动重连", false, true);
return;
}
const delay = Math.min(2000 * Math.pow(2, this.retries), 16000);
this.retries++;
this.reconnectTimer = setTimeout(() => {
if (state.currentId) this.connect(state.currentId);
}, delay);
},
disconnect() {
clearTimeout(this.reconnectTimer);
this.stopHeartbeat();
if (this.conn) {
const old = this.conn;
this.conn = null;
old.onclose = null;
old.onerror = null;
try {
old.close();
} catch (_) {}
}
this.boundSession = null;
},
startHeartbeat() {
clearInterval(this.pingTimer);
this.pingTimer = setInterval(() => {
this.send({ type: "ping" });
}, HEARTBEAT_MS);
},
stopHeartbeat() {
clearInterval(this.pingTimer);
this.pingTimer = null;
},
isOpen() {
return !!this.conn && this.conn.readyState === WebSocket.OPEN;
},
send(obj) {
if (!this.isOpen()) return false;
try {
this.conn.send(JSON.stringify(obj));
return true;
} catch (_) {
return false;
}
},
sendChat(content) {
return this.send({ type: "chat", content });
},
stop() {
this.send({ type: "stop" });
},
switchSession(id) {
this.send({ type: "switch", session_id: id });
}
};
// 等待 WebSocket 连接建立(最多 timeout 毫秒),返回是否已就绪
function waitWsOpen(timeout = 6000) {
const start = Date.now();
return new Promise((resolve) => {
const check = () => {
if (ws.isOpen()) {
resolve(true);
return;
}
if (Date.now() - start > timeout) {
resolve(false);
return;
}
setTimeout(check, 100);
};
check();
});
}
/* 服务端事件分发:Agent 流式事件(与后端 agent.Event 同名) */
function handleWsEvent(type, data) {
// 心跳回包不算流事件,避免干扰"卡死"判定
if (type === "pong") return;
state.lastEventAt = Date.now();
switch (type) {
case "meta":
if (data.title) els.sessionTitle.textContent = displayTitle(data.title);
break;
case "message": {
const stream = state.activeStream;
if (!stream || !stream.els) break;
stream.text += data.delta || "";
stream.els.text.innerHTML = renderMarkdown(stream.text);
stream.els.text.classList.add("typing-caret");
setStatus("生成回答中", true);
scrollToBottom();
break;
}
case "tool": {
const stream = state.activeStream;
if (!stream) break;
if (!stream.toolRecords.has(data.call_id)) {
const rec = {
callId: data.call_id,
name: data.name,
input: data.input || "",
output: "",
success: true,
done: false,
startedAt: Date.now(),
finishedAt: null,
el: null
};
stream.toolRecords.set(data.call_id, rec);
if (stream.els) {
stream.els.steps.appendChild(createStepElement(rec));
setStatus(`执行 ${data.name} · 第 ${stream.toolRecords.size} 次`, true);
scrollToBottom();
}
}
break;
}
case "tool_result": {
const stream = state.activeStream;
const rec = stream && stream.toolRecords.get(data.call_id);
if (rec) {
rec.output = data.output || "";
rec.success = data.success;
rec.done = true;
rec.finishedAt = Date.now();
updateStepElement(rec);
setStatus(rec.success ? "工具执行完成" : "工具执行失败", true);
scrollToBottom();
}
break;
}
case "notice":
if (state.currentId) appendNotice(data.text || "提示");
break;
case "turn_start": {
const user = (data && data.user) || "";
if (state.activeStream) {
state.activeStream.userText = user;
state.activeStream.els = appendTask(user);
state.activeStream.text = "";
state.activeStream.toolRecords = new Map();
} else {
// 后台协作任务完成通知:创建被动流
state.activeStream = {
sessionId: state.currentId,
userText: user,
text: "",
toolRecords: new Map(),
els: appendTask(user),
compressed: false,
iterations: 0,
passive: true
};
setStreaming(true);
}
setStatus("Agent 处理中", true);
scrollToBottom();
break;
}
case "done": {
const stream = state.activeStream;
if (!stream) break;
stream.iterations = data.iterations || 0;
stream.compressed = !!data.compressed;
if (stream.els) {
const tools = stream.toolRecords.size;
if (tools || stream.compressed) {
setAnswerMeta(stream.els, { tools, compressed: stream.compressed });
}
finalizeTask(stream.els);
}
state.activeStream = null;
setStreaming(false);
setStatus(`完成 · 迭代 ${stream.iterations} 轮`, false, false);
refreshCoopTasks();
loadSessions();
loadMessages(0, { silent: true }).then((page) => {
if (page) {
state.currentMessages = page.messages || [];
state.hasMore = !!page.has_more;
}
});
break;
}
case "error":
if (state.activeStream) {
appendErrorMessage(data.message || "发生未知错误");
state.activeStream = null;
setStreaming(false);
setStatus("出错", false, true);
} else {
showToast(data.message || "发生未知错误", "error");
}
break;
}
}
/* ============================================================
智能滚动
============================================================ */
let userNearBottom = true;
function toggleScrollBottomBtn() {
const hasContent = els.chat.querySelector(".task, .notice, .error-message");
const show = !userNearBottom && !!hasContent;
els.scrollBottomBtn.classList.toggle("hidden", !show);
}
function scrollToBottom(force = false) {
if (!force && !userNearBottom) {
toggleScrollBottomBtn();
return;
}
requestAnimationFrame(() => {
els.chat.scrollTop = els.chat.scrollHeight;
});
}
function clearChat() {
els.chat.querySelectorAll(".task, .notice, .error-message").forEach((node) => node.remove());
els.emptyState.style.display = "flex";
}
function appendNotice(text) {
els.emptyState.style.display = "none";
const notice = document.createElement("div");
notice.className = "notice";
notice.textContent = text;
els.chat.appendChild(notice);
scrollToBottom();
}
function appendErrorMessage(text) {
els.emptyState.style.display = "none";
const error = document.createElement("div");
error.className = "error-message";
error.textContent = text;
els.chat.appendChild(error);
scrollToBottom();
}
/* ============================================================
任务块渲染
============================================================ */
function appendTask(text) {
els.emptyState.style.display = "none";
const task = document.createElement("div");
task.className = "task";
task.innerHTML = `
${ICONS.task}
${renderMarkdown(text)}
`;
els.chat.appendChild(task);
scrollToBottom();
return {
task,
steps: task.querySelector(".task-steps"),
output: task.querySelector(".task-output"),
text: task.querySelector(".assistant-text"),
meta: task.querySelector(".answer-meta")
};
}
function finalizeTask(view) {
if (!view) return;
view.text.classList.remove("typing-caret");
const hasMeta = view.meta.children.length > 0;
if (!view.text.textContent.trim() && !hasMeta) {
view.output.remove();
}
}
/* ============================================================
工具步骤时间轴
============================================================ */
function fmtStepTime(rec) {
const end = rec.finishedAt || Date.now();
const ms = Math.max(0, end - rec.startedAt);
return ms < 1000 ? `${(ms / 1000).toFixed(1)}s` : `${(ms / 1000).toFixed(0)}s`;
}
function createStepElement(rec) {
const step = document.createElement("div");
step.className = "step running" + (rec.done ? "" : " open");
step.dataset.callId = rec.callId;
step.innerHTML = `
${toolIcon(rec.name)}
${escapeHtml(rec.name)}
${fmtStepTime(rec)}
运行中
输入
${escapeHtml(prettyArguments(rec.input))}
`;
const head = step.querySelector(".step-head");
head.addEventListener("click", () => {
step.classList.toggle("open");
const text = step.querySelector(".step-toggle-text");
if (text) text.textContent = step.classList.contains("open") ? "收起" : "展开";
});
rec.el = step;
return step;
}
function updateStepElement(rec) {
const step = rec.el;
if (!step) return;
step.classList.remove("running", "success", "failed");
step.classList.add(rec.done ? (rec.success ? "success" : "failed") : "running");
step.querySelector(".step-state").textContent = rec.done ? (rec.success ? "完成" : "失败") : "运行中";
step.querySelector(".step-time").textContent = fmtStepTime(rec);
const label = step.querySelector(".step-section:nth-child(2) .step-section-label");
const outPre = step.querySelector(".step-section:nth-child(2) pre");
if (rec.done) {
label.innerHTML = `输出 ${rec.success ? "✓" : "✗"}`;
if (rec.output && rec.output.trim()) {
outPre.textContent = rec.output;
outPre.classList.remove("output-empty");
} else {
outPre.textContent = "(无输出)";
outPre.classList.add("output-empty");
}
} else {
label.textContent = "输出";
outPre.textContent = "等待执行结果...";
outPre.classList.add("output-empty");
}
if (rec.done && rec.success) step.classList.remove("open");
const toggleText = step.querySelector(".step-toggle-text");
if (toggleText) toggleText.textContent = step.classList.contains("open") ? "收起" : "展开";
}
/* 输出面板底部小结徽标 */
function setAnswerMeta(view, { tools = 0, compressed = false }) {
if (!view || !view.meta) return;
view.meta.innerHTML = "";
if (tools > 0) {
const badge = document.createElement("span");
badge.className = "meta-badge";
badge.innerHTML = `${ICONS.tool} 工具调用 ${tools} 次`;
view.meta.appendChild(badge);
}
if (compressed) {
const badge = document.createElement("span");
badge.className = "meta-badge";
badge.innerHTML = `${ICONS.compress} 上下文已压缩`;
view.meta.appendChild(badge);
}
}
/* ============================================================
会话渲染(聚合为任务块)
============================================================ */
function renderSessionMessages(messages) {
clearChat();
if (!messages.length) {
els.emptyState.style.display = "flex";
return;
}
let task = null;
const toolSteps = new Map();
for (const message of messages) {
if (message.role === "user" && message.content) {
task = appendTask(message.content);
toolSteps.clear();
} else if (message.role === "assistant" && task) {
const calls = message.tool_calls || [];
for (const call of calls) {
const rec = {
callId: call.id,
name: call.function?.name || "tool",
input: call.function?.arguments || "",
output: "",
success: true,
done: false,
startedAt: Date.now(),
finishedAt: null,
el: null
};
task.steps.appendChild(createStepElement(rec));
toolSteps.set(call.id, rec);
}
if (message.content) {
task.text.innerHTML = renderMarkdown(message.content);
task.text.classList.remove("typing-caret");
}
if (calls.length) setAnswerMeta(task, { tools: calls.length });
} else if (message.role === "tool" && toolSteps.has(message.tool_call_id)) {
const rec = toolSteps.get(message.tool_call_id);
rec.output = message.content || "";
rec.success = true;
rec.done = true;
rec.finishedAt = Date.now();
updateStepElement(rec);
}
}
document.querySelectorAll(".task").forEach((t) => {
const textEl = t.querySelector(".assistant-text");
const metaEl = t.querySelector(".answer-meta");
if (!textEl.textContent.trim() && !metaEl.children.length) {
t.querySelector(".task-output").remove();
}
});
scrollToBottom();
}
/* ============================================================
会话 / 消息加载
============================================================ */
async function loadMessages(offset, { silent = false } = {}) {
if (!state.currentId) return null;
try {
return await api(`/api/v1/sessions/${state.currentId}/messages?limit=${PAGE_SIZE}&offset=${offset}`);
} catch (e) {
if (!silent) showToast(e.message, "error");
return null;
}
}
function renderLoadEarlier() {
let btn = document.getElementById("load-earlier-btn");
if (state.hasMore) {
if (!btn) {
btn = document.createElement("button");
btn.id = "load-earlier-btn";
btn.className = "load-earlier";
btn.textContent = "加载更早消息";
btn.addEventListener("click", loadEarlier);
els.chat.prepend(btn);
}
} else if (btn) {
btn.remove();
}
}
async function loadEarlier() {
if (state.loadingOlder || !state.currentId) return;
state.loadingOlder = true;
const btn = document.getElementById("load-earlier-btn");
if (btn) {
btn.textContent = "加载中…";
btn.disabled = true;
}
const page = await loadMessages(state.currentMessages.length, { silent: true });
state.loadingOlder = false;
if (!page) {
if (btn) btn.textContent = "加载更早消息";
return;
}
const prevScroll = els.chat.scrollTop;
const prevHeight = els.chat.scrollHeight;
state.currentMessages = [...(page.messages || []), ...state.currentMessages];
state.hasMore = !!page.has_more;
renderSessionMessages(state.currentMessages);
els.chat.scrollTop = prevScroll + (els.chat.scrollHeight - prevHeight);
renderLoadEarlier();
if (btn) {
btn.textContent = "加载更早消息";
btn.disabled = false;
}
}
async function loadSessions() {
const data = await api("/api/v1/sessions");
state.sessions = data.sessions;
renderSessions();
}
function renderSessions() {
els.sessionList.innerHTML = "";
for (const session of state.sessions) {
const item = document.createElement("div");
item.className = "session-item" + (session.id === state.currentId ? " active" : "");
const main = document.createElement("div");
main.className = "session-item-main";
const title = document.createElement("span");
title.className = "session-item-title";
title.textContent = displayTitle(session.title);
const preview = document.createElement("span");
preview.className = "session-item-preview";
preview.textContent = session.preview || (session.message_count ? `${session.message_count} 条消息` : "");
main.append(title, preview);
const time = document.createElement("time");
time.textContent = formatTime(session.updated_at);
const del = document.createElement("button");
del.className = "session-delete";
del.title = "删除会话";
del.innerHTML = `
`;
del.addEventListener("click", async (event) => {
event.stopPropagation();
try {
await api(`/api/v1/sessions/${session.id}`, { method: "DELETE" });
if (state.currentId === session.id) {
state.currentId = null;
await loadSessions();
if (state.sessions.length > 0) {
await selectSession(state.sessions[0].id);
} else {
updateUrlForSession(null);
els.sessionTitle.textContent = "新任务";
els.sessionSub.textContent = "";
state.currentMessages = [];
state.hasMore = false;
renderSessionMessages([]);
renderLoadEarlier();
setStatus("就绪", false, false);
}
} else {
await loadSessions();
}
} catch (error) {
showToast(error.message, "error");
}
});
item.append(main, time, del);
item.addEventListener("click", () => selectSession(session.id));
els.sessionList.appendChild(item);
}
}
function formatTime(value) {
const date = new Date(value);
const now = new Date();
if (date.toDateString() === now.toDateString()) {
return date.toTimeString().slice(0, 5);
}
return `${date.getMonth() + 1}/${date.getDate()}`;
}
function displayTitle(title) {
return !title || title === "新对话" ? "新任务" : title;
}
function getSessionFromUrl() {
const match = window.location.pathname.match(/^\/([0-9a-f]{24})$/);
if (match) return match[1];
return new URLSearchParams(window.location.search).get("session");
}
function updateUrlForSession(id) {
window.history.replaceState(null, "", id ? `/${id}` : "/");
}
async function selectSession(id) {
state.currentId = id;
ws.switchSession(id);
const stream = state.activeStream;
if (stream && stream.passive && stream.sessionId !== id) {
state.activeStream = null;
setStreaming(false);
}
if (state.streaming && stream && stream.sessionId === id) {
updateUrlForSession(id);
try {
const s = await api(`/api/v1/sessions/${id}`);
els.sessionTitle.textContent = displayTitle(s.session?.title);
els.sessionSub.textContent = `${s.session?.messages?.length || 0} 条消息`;
} catch (_) {}
rebuildStreamView(stream);
renderSessions();
return { session: { title: null, messages: [] } };
}
setStatus("载入中…", true);
const [detail, page] = await Promise.all([
api(`/api/v1/sessions/${id}`).catch(() => null),
loadMessages(0, { silent: true })
]);
updateUrlForSession(id);
els.sessionTitle.textContent = displayTitle(detail?.session?.title);
els.sessionSub.textContent = `${detail?.session?.messages?.length ?? 0} 条消息`;
state.currentMessages = page?.messages || [];
state.hasMore = !!page?.has_more;
renderSessionMessages(state.currentMessages);
renderLoadEarlier();
renderSessions();
setStatus("就绪", false, false);
return detail;
}
// 用流状态重建正在生成的会话视图
function rebuildStreamView(stream) {
clearChat();
els.emptyState.style.display = "none";
const view = appendTask(stream.userText);
if (stream.text.trim()) {
view.text.innerHTML = renderMarkdown(stream.text);
view.text.classList.remove("typing-caret");
}
for (const rec of stream.toolRecords.values()) {
view.steps.appendChild(createStepElement(rec));
updateStepElement(rec);
}
stream.els = view;
scrollToBottom();
}
// 确保存在有效会话:currentId 缺失或已被删除时自动新建
async function ensureValidSession() {
if (!state.currentId) {
await createNewSession();
return;
}
const detail = await api(`/api/v1/sessions/${state.currentId}`).catch(() => null);
if (!detail || !detail.session) {
showToast("当前会话已失效,已自动创建新任务", "error");
await createNewSession();
}
}
async function createNewSession() {
const session = await api("/api/v1/sessions", { method: "POST" });
state.currentId = session.id;
updateUrlForSession(session.id);
await loadSessions();
}
async function openFromUrlOrCreate() {
const id = getSessionFromUrl();
if (id) {
try {
state.currentId = id;
ws.connect(id);
const detail = await api(`/api/v1/sessions/${id}`).catch(() => null);
if (detail && detail.session) {
await selectSession(id);
return;
}
state.currentId = null;
ws.disconnect();
showToast("会话不存在或已删除,已创建新任务", "error");
} catch (_) {
state.currentId = null;
ws.disconnect();
showToast("会话不存在或已删除,已创建新任务", "error");
}
}
if (!state.currentId) {
await createNewSession();
}
await selectSession(state.currentId);
}
/* ============================================================
流状态管理(可自愈)
============================================================ */
function setStreaming(streaming) {
state.streaming = streaming;
els.sendBtn.classList.toggle("hidden", streaming);
els.stopBtn.classList.toggle("hidden", !streaming);
els.input.placeholder = streaming ? "Agent 正在执行任务,请稍候..." : INPUT_PLACEHOLDER;
if (!streaming) setStatus("就绪", false, false);
}
// 强制清理流状态(中断/卡死时调用)
function forceResetStream(showNotice = false) {
state.activeStream = null;
state.streaming = false;
els.sendBtn.classList.remove("hidden");
els.stopBtn.classList.add("hidden");
els.input.placeholder = INPUT_PLACEHOLDER;
setStatus("就绪", false, false);
if (showNotice) appendNotice("任务已中断,可重新发送");
}
// 流是否仍然"活着":连接在线,且最近有事件,或有运行中的工具步骤(长任务保护)
function streamIsAlive() {
if (!ws.isOpen()) return false;
if (Date.now() - state.lastEventAt < STREAM_STALE_MS) return true;
const stream = state.activeStream;
if (stream && stream.toolRecords) {
for (const rec of stream.toolRecords.values()) {
if (!rec.done) return true;
}
}
return false;
}
function stopStreaming() {
if (!state.activeStream) return;
const stream = state.activeStream;
state.activeStream = null;
ws.stop();
setStreaming(false);
if (stream.els) finalizeTask(stream.els);
appendNotice("已停止生成");
}
/* ============================================================
消息发送(核心:任何失败都有可见反馈,绝不静默)
============================================================ */
async function sendMessage(prefill) {
const text = (prefill !== undefined ? prefill : els.input.value).trim();
if (!text) {
showToast("请输入内容后再发送", "error");
return;
}
// 未配置 LLM 时引导先配置,不发起无效对话(后端同样有兜底校验)
if (!state.llmConfigured) {
showToast("尚未配置 LLM API,请先配置 API Key 与模型", "error");
openSettings();
return;
}
// 1. 清理一切卡死状态;若确有真正在运行的流,则不打扰并提示
if (state.streaming) {
if (!streamIsAlive()) {
forceResetStream(true);
} else {
showToast("Agent 正在执行任务,请等待完成或点击停止", "error");
return;
}
}
// 2. 确保会话有效且 ws 已连接(失败都会给出可见提示)
const ready = await ensureReadyForSend();
if (!ready) return;
// 3. 正式发送
userNearBottom = true;
els.input.value = "";
autoResize();
const stream = {
sessionId: state.currentId,
userText: text,
text: "",
toolRecords: new Map(),
els: appendTask(text),
compressed: false,
iterations: 0,
passive: false
};
state.activeStream = stream;
setStreaming(true);
setStatus("Agent 处理中", true);
if (!ws.sendChat(text)) {
// 发送失败:恢复输入内容并复位状态,让用户可重试
els.input.value = text;
autoResize();
state.activeStream = null;
setStreaming(false);
showToast("消息未能发送,请重试", "error");
}
}
// 发送前准备:校验会话 → 保证连接绑定当前会话
async function ensureReadyForSend() {
try {
await ensureValidSession();
if (ws.isOpen() && ws.boundSession === state.currentId) {
return true;
}
ws.connect(state.currentId);
const opened = await waitWsOpen(6000);
if (!opened) {
setStatus("未连接", false, true);
showToast("无法连接服务器,请刷新后重试", "error");
return false;
}
return true;
} catch (error) {
showToast(error && error.message ? error.message : String(error), "error");
return false;
}
}
function autoResize() {
els.input.style.height = "auto";
els.input.style.height = Math.min(els.input.scrollHeight, 180) + "px";
}
/* ============================================================
协作任务实时状态
============================================================ */
function fmtElapsed(created, finished) {
const end = finished ? new Date(finished) : new Date();
const total = Math.max(0, Math.floor((end - new Date(created)) / 1000));
if (total < 60) return `${total}秒`;
const minutes = Math.floor(total / 60);
if (minutes < 60) return `${minutes}分${total % 60}秒`;
return `${Math.floor(minutes / 60)}时${minutes % 60}分`;
}
function renderCoopTasks(tasks) {
if (!tasks || !tasks.length) {
els.coopPanel.classList.add("hidden");
els.coopList.innerHTML = "";
return;
}
els.coopPanel.classList.remove("hidden");
els.coopList.innerHTML = "";
els.coopCount.textContent = `${tasks.length} 个`;
for (const task of tasks) {
const running = task.status === "running";
const failed = task.status === "done" && !!task.error;
const stateClass = failed ? "failed" : running ? "running" : "done";
const stateLabel = failed ? "失败" : running ? "运行中" : "完成";
const round = task.round_max > 1
? `第 ${Math.max(task.current_round || 1, 1)}/${task.round_max} 轮`
: "单轮";
const container = running && task.container_status ? ` · ${task.container_status}` : "";
const elapsed = fmtElapsed(task.created_at, task.finished_at);
const progress = task.round_max > 0
? Math.min(100, Math.round((Math.max(task.current_round || 1, 1) / task.round_max) * 100))
: 100;
const card = document.createElement("div");
card.className = `coop-card ${stateClass}`;
card.innerHTML = `
${round}${container}
ID ${escapeHtml(task.id.slice(0, 8))} · ${elapsed}
${stateLabel}
`;
els.coopList.appendChild(card);
}
}
async function refreshCoopTasks() {
if (!state.currentId) {
renderCoopTasks([]);
return;
}
try {
const data = await api(`/api/v1/coop/tasks?session=${state.currentId}`);
renderCoopTasks(data.tasks || []);
} catch (_) {}
}
/* ============================================================
配置与设置
============================================================ */
async function loadConfig() {
state.config = await api("/api/v1/config");
const ctxK = Math.round(state.config.max_context_tokens / 1000);
els.contextHint.textContent = `上下文 ${ctxK}k · 工具上限 ${state.config.max_iterations} 次`;
els.ctxChip.textContent = `${ctxK}k ctx`;
els.ctxChip.classList.remove("hidden");
try {
const lc = await api("/api/v1/llm/config");
state.llmConfigured = !!lc.api_key_configured;
els.modelChip.textContent = lc.api_key_configured ? (lc.model || state.config.model) : "模型未配置";
els.modelChip.classList.toggle("unconfigured", !lc.api_key_configured);
} catch (_) {}
}
async function loadSettings() {
try {
const dc = await api("/api/v1/docker/config");
els.dockerSocketInput.value = dc.docker_socket || "";
els.dockerSocketInput.placeholder = dc.default_socket || "";
} catch (_) {}
try {
const lc = await api("/api/v1/llm/config");
els.providerSelect.value = lc.provider === "anthropic" ? "anthropic" : "openai";
updateProviderHint();
const engineVal = lc.engine === "pigo" || lc.engine === "claude" ? lc.engine : "pi";
els.coopEngineSelect.value = engineVal;
els.coopModeSelect.value = lc.coop_mode === "docker" ? "docker" : "local";
els.apiKeyInput.value = "";
els.apiKeyInput.placeholder = lc.api_key_configured
? `已配置(${lc.api_key_masked || "****"}),留空保持不变`
: "未配置";
els.baseUrlInput.value = lc.base_url || "";
els.baseUrlInput.placeholder = lc.default_base_url || "";
els.modelInput.value = lc.model || "";
els.modelInput.placeholder = lc.default_model || "";
} catch (error) {
showToast("加载 LLM 配置失败: " + error.message, "error");
}
}
function updateProviderHint() {
const anthropic = els.providerSelect.value === "anthropic";
els.providerHint.textContent = anthropic
? "Anthropic 模式使用 x-api-key 认证,Base URL 填 https://api.anthropic.com 即可(自动补全 /v1/messages)。"
: "OpenAI 兼容模式使用 Bearer Token 认证,Base URL 填写 /v1/chat/completions 地址。";
els.baseUrlInput.placeholder = anthropic
? "https://api.anthropic.com"
: "https://api.siliconflow.cn/v1/chat/completions";
}
function openSettings() {
loadSettings();
els.settingsModal.classList.remove("hidden");
}
function closeSettings() {
els.settingsModal.classList.add("hidden");
}
async function saveSettings() {
try {
const socket = els.dockerSocketInput.value.trim();
await api("/api/v1/docker/config", {
method: "PUT",
body: JSON.stringify({ socket })
});
const apiKey = els.apiKeyInput.value.trim();
const baseUrl = els.baseUrlInput.value.trim();
const model = els.modelInput.value.trim();
const provider = els.providerSelect.value;
const engine = els.coopEngineSelect.value;
const coopMode = els.coopModeSelect.value;
const lc = await api("/api/v1/llm/config", {
method: "PUT",
body: JSON.stringify({
api_key: apiKey || null,
base_url: baseUrl || null,
model: model || null,
provider: provider || null,
engine: engine || null,
coop_mode: coopMode || null
})
});
state.llmConfigured = !!lc.api_key_configured;
els.modelChip.textContent = lc.api_key_configured ? (lc.model || state.config.model) : "模型未配置";
els.modelChip.classList.toggle("unconfigured", !lc.api_key_configured);
showToast("设置已保存");
closeSettings();
} catch (error) {
showToast("保存失败: " + error.message, "error");
}
}
async function resetSettings() {
try {
await api("/api/v1/docker/config", {
method: "PUT",
body: JSON.stringify({ socket: "" })
});
const lc = await api("/api/v1/llm/config", {
method: "PUT",
body: JSON.stringify({ api_key: "", base_url: "", model: "", provider: "", engine: "", coop_mode: "" })
});
state.llmConfigured = !!lc.api_key_configured;
els.modelChip.textContent = lc.api_key_configured ? (lc.model || state.config.model) : "模型未配置";
els.modelChip.classList.toggle("unconfigured", !lc.api_key_configured);
els.coopEngineSelect.value = lc.engine === "pigo" || lc.engine === "claude" ? lc.engine : "pi";
els.coopModeSelect.value = lc.coop_mode === "docker" ? "docker" : "local";
showToast("已恢复默认配置");
closeSettings();
} catch (error) {
showToast("保存失败: " + error.message, "error");
}
}
/* ============================================================
初始化
============================================================ */
function init() {
els.newChatBtn.addEventListener("click", async () => {
try {
await createNewSession();
await selectSession(state.currentId);
} catch (error) {
showToast(error.message, "error");
}
});
els.refreshBtn.addEventListener("click", async () => {
try {
await loadSessions();
const stream = state.activeStream;
if (state.currentId && !(state.streaming && stream && stream.sessionId === state.currentId)) {
const page = await loadMessages(0, { silent: true });
if (page) {
state.currentMessages = page.messages || [];
state.hasMore = !!page.has_more;
renderSessionMessages(state.currentMessages);
renderLoadEarlier();
}
}
showToast("会话已刷新");
} catch (error) {
showToast(error.message, "error");
}
});
els.menuBtn.addEventListener("click", () => {
els.sidebar.classList.toggle("open");
});
// 会话搜索(防抖)
els.sessionSearch.addEventListener(
"input",
debounce(async () => {
try {
const q = els.sessionSearch.value.trim();
if (!q) {
await loadSessions();
return;
}
const data = await api(`/api/v1/sessions?q=${encodeURIComponent(q)}`);
state.sessions = data.sessions;
renderSessions();
} catch (e) {
showToast(e.message, "error");
}
}, 300)
);
els.settingsBtn.addEventListener("click", openSettings);
els.closeSettingsBtn.addEventListener("click", closeSettings);
els.saveSettingsBtn.addEventListener("click", saveSettings);
els.resetSettingsBtn.addEventListener("click", resetSettings);
els.providerSelect.addEventListener("change", updateProviderHint);
els.settingsModal.addEventListener("click", (event) => {
if (event.target === els.settingsModal) closeSettings();
});
// 发送 / 停止(sendMessage 内部自带兜底,任何异常都有 toast)
els.sendBtn.addEventListener("click", () => {
try {
sendMessage();
} catch (error) {
console.error(error);
showToast("发送异常: " + (error && error.message || error), "error");
}
});
els.stopBtn.addEventListener("click", stopStreaming);
// 智能滚动
els.chat.addEventListener("scroll", () => {
userNearBottom = els.chat.scrollHeight - els.chat.scrollTop - els.chat.clientHeight < 120;
toggleScrollBottomBtn();
});
els.scrollBottomBtn.addEventListener("click", () => {
userNearBottom = true;
scrollToBottom(true);
toggleScrollBottomBtn();
});
// Esc 停止生成
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") stopStreaming();
});
// 代码块复制(事件委托)
els.chat.addEventListener("click", async (event) => {
const btn = event.target.closest(".code-copy");
if (!btn) return;
const codeEl = btn.closest(".code-block")?.querySelector("code");
if (!codeEl) return;
const text = codeEl.textContent;
try {
await navigator.clipboard.writeText(text);
} catch (_) {
const ta = document.createElement("textarea");
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
ta.remove();
}
btn.textContent = "已复制";
setTimeout(() => {
btn.textContent = "复制";
}, 1800);
});
// 协作任务面板:点击标题折叠 / 展开
els.coopPanelHeader.addEventListener("click", () => {
els.coopPanel.classList.toggle("collapsed");
});
els.input.addEventListener("input", autoResize);
els.input.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
event.preventDefault();
try {
sendMessage();
} catch (error) {
console.error(error);
showToast("发送异常: " + (error && error.message || error), "error");
}
}
});
// 空状态示例任务:点击直接投递
document.querySelectorAll(".example").forEach((btn) => {
btn.addEventListener("click", () => {
sendMessage(btn.dataset.prompt || "");
});
});
// 流卡死看门狗:每 5 秒检查一次,自动清理卡死的流,绝不让 streaming 卡住发送
setInterval(() => {
if (!state.streaming) return;
if (!streamIsAlive()) {
forceResetStream(true);
setStatus("任务中断,已自动清理", false, true);
}
}, 5000);
// 运行中工具步骤的耗时计时器
setInterval(() => {
if (!state.streaming || !state.activeStream) return;
for (const rec of state.activeStream.toolRecords.values()) {
if (!rec.done && rec.el) {
const timeEl = rec.el.querySelector(".step-time");
if (timeEl) timeEl.textContent = fmtStepTime(rec);
}
}
}, 1000);
// 每 3 秒轮询一次协作任务状态
refreshCoopTasks();
setInterval(refreshCoopTasks, 3000);
// 全局兜底:任何未捕获错误都必须可见,避免"点击没反应"
window.addEventListener("error", (event) => {
console.error(event.error || event.message);
showToast("页面脚本错误: " + (event.message || "unknown"), "error");
});
window.addEventListener("unhandledrejection", (event) => {
const reason = event.reason;
console.error(reason);
showToast("请求失败: " + ((reason && reason.message) || "unknown"), "error");
});
// 启动:确认授权 → 配置 → 会话列表 → 打开 URL 会话或新建
ensureAuthed()
.then((authed) => {
if (!authed) throw new Error("未通过授权验证,请刷新页面重试");
})
.then(loadConfig)
.then(loadSessions)
.then(openFromUrlOrCreate)
.catch((error) => {
showToast("初始化失败: " + error.message, "error");
setStatus("连接失败", false, true);
});
}
init();