Files
BlackBean/web/static/app.js
T
2026-08-14 23:41:57 +08:00

1674 lines
56 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.
/* ============================================================
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:
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v12a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2H9L7 5a2 2 0 0 0-2-1H5a2 2 0 0 0-2 2z"/></svg>',
terminal:
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m4 17 6-5-6-5M12 19h8"/></svg>',
file:
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>',
folder:
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>',
python:
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="m7 4 5 8-5 8M13 4l5 8-5 8"/></svg>',
docker:
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 2a4 4 0 0 1 4 4v2h2a4 4 0 0 1 4 4v4a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4v-4a4 4 0 0 1 4-4h2V6a4 4 0 0 1 4-4z"/></svg>',
coop:
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></svg>',
tool:
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>',
compress:
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m12 2 8.5 5v10L12 22l-8.5-5V7z"/><path d="m12 22 8.5-5v-10"/><path d="m12 22V12"/></svg>'
};
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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) =>
`<img src="${escapeHtml(sanitizeUrl(src))}" alt="${escapeHtml(alt)}" loading="lazy">`);
v = v.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, (_, label, href) =>
`<a href="${escapeHtml(sanitizeUrl(href))}" target="_blank" rel="noopener">${label}</a>`);
v = v.replace(/~~([^~]+)~~/g, "<del>$1</del>");
v = v.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
v = v.replace(/(^|[^*])\*([^*\s][^*]*)\*(?!\*)/g, "$1<em>$2</em>");
return v.replace(/\u0000(\d+)\u0000/g, (_, i) => `<code>${escapeHtml(codes[Number(i)])}</code>`);
}
// 仅允许安全协议,避免 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 `<p>${escapeHtml(String(text))}</p>`;
}
}
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 `<div class="code-block"><button class="code-copy" type="button" title="复制代码">复制</button><pre><code${lang}>${part.code}</code></pre></div>`;
}
// 表格块
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) => `<th>${renderInline(c)}</th>`).join("");
const body = rows
.trim()
.split("\n")
.map((row) => `<tr>${cells(row).map((c) => `<td>${renderInline(c)}</td>`).join("")}</tr>`)
.join("");
return `<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
});
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 += `<li>${renderInline(listMatch[1])}</li>`;
continue;
}
if (listTag) {
html += `</${listTag}>`;
listTag = null;
}
const hMatch = line.match(/^(#{1,3})\s+(.*)$/);
if (hMatch) {
const level = hMatch[1].length;
html += `<h${level}>${renderInline(hMatch[2])}</h${level}>`;
} else if (/^<table>/.test(line)) {
html += line;
} else if (/^> ?/.test(line)) {
html += `<blockquote>${renderInline(line.replace(/^> ?/, ""))}</blockquote>`;
} else if (/^(---+|\*\*\*+)$/.test(line)) {
html += "<hr>";
} else if (line.trim()) {
html += `<p>${renderInline(line)}</p>`;
}
}
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 = `
<div class="task-head">
<div class="task-badge">${ICONS.task}</div>
<div class="task-text">${renderMarkdown(text)}</div>
</div>
<div class="task-steps"></div>
<div class="task-output">
<div class="assistant-text typing-caret"></div>
<div class="answer-meta"></div>
</div>
`;
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 = `
<div class="step-head">
<span class="step-dot"></span>
<span class="step-icon">${toolIcon(rec.name)}</span>
<span class="step-name">${escapeHtml(rec.name)}</span>
<span class="step-time">${fmtStepTime(rec)}</span>
<span class="step-state">运行中</span>
<button class="step-toggle" title="展开 / 收起">
<svg class="step-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="m9 6 6 6-6 6"/>
</svg>
<span class="step-toggle-text">收起</span>
</button>
</div>
<div class="step-body">
<div class="step-section">
<div class="step-section-label">输入</div>
<pre>${escapeHtml(prettyArguments(rec.input))}</pre>
</div>
<div class="step-section">
<div class="step-section-label">输出</div>
<pre class="output-empty">等待执行结果...</pre>
</div>
</div>
`;
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 = `输出 <span class="${rec.success ? "tick" : "cross"}">${rec.success ? "✓" : "✗"}</span>`;
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 = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M3 6h18M8 6V4h8v2M6 6l1 14h10l1-14M10 10v6M14 10v6"/>
</svg>
`;
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 = `
<div class="coop-card-main">
<span class="coop-card-dot ${stateClass}"></span>
<div class="coop-card-info">
<div class="coop-card-title">${round}${container}</div>
<div class="coop-card-meta">ID <code>${escapeHtml(task.id.slice(0, 8))}</code> · ${elapsed}</div>
</div>
<span class="coop-card-state ${stateClass}">${stateLabel}</span>
</div>
<div class="coop-card-progress">
<div class="coop-card-progress-bar" style="width:${progress}%"></div>
</div>`;
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();