1674 lines
56 KiB
JavaScript
1674 lines
56 KiB
JavaScript
/* ============================================================
|
||
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, "&")
|
||
.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) =>
|
||
`<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();
|