zcbot/web/static/dev.html

994 lines
39 KiB
HTML
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.

<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>zcbot dev</title>
<meta name="viewport" content="width=device-width,initial-scale=1" />
<!-- markdown + 防 XSS + 代码高亮(纯 CDN,失败优雅降级回 plain text) -->
<script src="https://cdn.jsdelivr.net/npm/marked@12/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/highlight.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/github.min.css" />
<style>
:root {
--bg: #f7f7f7;
--panel: #ffffff;
--border: #e3e3e3;
--text: #222;
--muted: #888;
--accent: #c0392b;
--accent-soft: #fde9e7;
--hover: #f0f0f0;
--code-bg: #f4f4f4;
--user-bg: #eef4fb;
--asst-bg: #ffffff;
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
font: 14px/1.5 -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;
color: var(--text); background: var(--bg);
overflow: hidden; /* 视窗锁死,所有滚动在 pane 内 */
}
button, input, textarea, select {
font: inherit; color: inherit;
}
button {
background: #fff; border: 1px solid var(--border);
padding: 4px 10px; border-radius: 4px; cursor: pointer;
}
button:hover { background: var(--hover); }
button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
button.primary:hover { filter: brightness(1.08); }
button.danger:hover { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
input, textarea, select {
background: #fff; border: 1px solid var(--border);
padding: 5px 8px; border-radius: 4px; width: 100%;
}
textarea { resize: vertical; min-height: 60px; }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
/* ───── login overlay ───── */
#login {
position: fixed; inset: 0; background: rgba(0,0,0,0.4);
display: flex; align-items: center; justify-content: center; z-index: 100;
}
#login .card {
background: var(--panel); padding: 24px; border-radius: 6px;
width: 360px; box-shadow: 0 8px 24px rgba(0,0,0,.15);
}
#login h2 { margin: 0 0 16px; font-size: 18px; }
#login label { display: block; margin-top: 10px; font-size: 12px; color: var(--muted); }
#login .err { color: var(--accent); font-size: 12px; margin-top: 10px; min-height: 1em; }
#login .actions { margin-top: 14px; display: flex; gap: 8px; }
/* ───── 3-pane layout ───── */
#app { display: none; height: 100vh; }
#app.ready { display: grid; grid-template-columns: 280px 1fr 320px; grid-template-rows: auto 1fr; grid-template-areas: "head head head" "left mid right"; }
header {
grid-area: head; background: #fff; border-bottom: 1px solid var(--border);
padding: 8px 14px; display: flex; align-items: center; gap: 12px;
}
header .title { font-weight: 600; }
header .who { color: var(--muted); font-size: 12px; font-family: monospace; }
header .spacer { flex: 1; }
.pane { border-right: 1px solid var(--border); background: var(--panel); overflow: auto; min-height: 0; }
#pane-left { grid-area: left; }
/* min-height: 0 + overflow: hidden 让内部 flex 子项的 overflow: auto 真正生效(否则被默认 min-height: auto 顶出) */
#pane-mid { grid-area: mid; display: flex; flex-direction: column; border-right: 1px solid var(--border); background: var(--panel); min-height: 0; overflow: hidden; }
#pane-right { grid-area: right; border-right: none; overflow: auto; background: var(--panel); min-height: 0; }
.pane-head {
padding: 8px 12px; border-bottom: 1px solid var(--border);
display: flex; gap: 8px; align-items: center; background: #fafafa;
position: sticky; top: 0;
}
.pane-head .label { font-weight: 600; font-size: 13px; }
.pane-head .spacer { flex: 1; }
/* ───── task list ───── */
.task-row {
padding: 8px 12px; border-bottom: 1px solid var(--border); cursor: pointer;
}
.task-row:hover { background: var(--hover); }
.task-row.active { background: var(--accent-soft); border-left: 3px solid var(--accent); padding-left: 9px; }
.task-row .desc { font-weight: 500; color: var(--text); margin-bottom: 2px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.task-row .meta { font-size: 11px; color: var(--muted); display: flex; gap: 8px; }
.task-row .badge {
display: inline-block; padding: 0 6px; border-radius: 8px; font-size: 11px;
background: #eef; color: #336;
}
.badge.completed { background: #e8f5e9; color: #2e7d32; }
.badge.abandoned { background: #fde9e7; color: var(--accent); }
.badge.active { background: #eef; color: #336; }
.empty { padding: 24px; color: var(--muted); text-align: center; font-size: 13px; }
/* ───── chat ───── */
#chat-meta { padding: 8px 12px; border-bottom: 1px solid var(--border); background: #fafafa;
font-size: 12px; color: var(--muted); display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
#chat-meta .tid { font-family: monospace; color: var(--text); }
#chat-meta .spacer { flex: 1; }
#chat-stream {
flex: 1; overflow-y: auto; overflow-x: hidden; padding: 12px;
display: flex; flex-direction: column; gap: 8px;
min-height: 0; /* 同上,允许在 flex 容器里收缩 + 触发自身滚动 */
}
.msg {
border: 1px solid var(--border); border-radius: 4px; padding: 8px 12px;
max-width: 92%;
}
.msg.user { background: var(--user-bg); align-self: flex-end; }
.msg.assistant, .msg.system, .msg.tool, .msg.error { background: var(--asst-bg); align-self: flex-start; }
.msg.error { border-color: var(--accent); background: var(--accent-soft); color: var(--accent); }
.msg .role { font-size: 11px; color: var(--muted); margin-bottom: 2px; font-family: monospace; }
.msg .body { word-wrap: break-word; font-size: 14px; line-height: 1.55; }
.msg .body.streaming::after { content: "▌"; color: var(--accent); animation: blink 1s infinite; }
@keyframes blink { 0%,49% { opacity: 1; } 50%,100% { opacity: 0; } }
/* markdown 输出元素紧凑化 */
.msg .body > :first-child { margin-top: 0; }
.msg .body > :last-child { margin-bottom: 0; }
.msg .body p { margin: 0.4em 0; }
.msg .body h1, .msg .body h2, .msg .body h3, .msg .body h4 {
margin: 0.8em 0 0.3em; line-height: 1.3;
}
.msg .body h1 { font-size: 1.4em; }
.msg .body h2 { font-size: 1.25em; }
.msg .body h3 { font-size: 1.1em; }
.msg .body h4 { font-size: 1em; font-weight: 600; }
.msg .body ul, .msg .body ol { margin: 0.4em 0; padding-left: 1.6em; }
.msg .body li { margin: 0.15em 0; }
.msg .body li > p { margin: 0.15em 0; }
.msg .body blockquote {
margin: 0.4em 0; padding: 4px 12px; border-left: 3px solid var(--accent);
background: var(--accent-soft); color: #555;
}
.msg .body code:not(pre code) {
background: var(--code-bg); padding: 1px 5px; border-radius: 3px;
font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
font-size: 0.92em;
}
.msg .body pre {
margin: 0.5em 0; padding: 10px; background: #f6f8fa; border-radius: 4px;
overflow-x: auto; font-size: 12.5px; line-height: 1.4;
}
.msg .body pre code {
font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
background: transparent; padding: 0;
}
.msg .body table {
border-collapse: collapse; margin: 0.5em 0; font-size: 13px;
}
.msg .body th, .msg .body td {
border: 1px solid var(--border); padding: 4px 8px;
}
.msg .body th { background: #fafafa; font-weight: 600; }
.msg .body a { color: var(--accent); }
.msg .body img { max-width: 100%; }
.msg .body hr { border: none; border-top: 1px solid var(--border); margin: 0.8em 0; }
.tool-call {
margin-top: 6px; font-family: ui-monospace, Consolas, monospace; font-size: 12px;
}
.tool-call summary {
cursor: pointer; padding: 4px 6px; background: var(--code-bg); border-radius: 3px;
color: #555;
}
.tool-call summary:hover { background: #ebebeb; }
.tool-call pre {
margin: 4px 0 0; padding: 8px; background: var(--code-bg); border-radius: 3px;
overflow-x: auto; max-height: 300px; white-space: pre-wrap;
}
#chat-form {
border-top: 1px solid var(--border); padding: 10px; background: #fafafa;
display: flex; flex-direction: column; gap: 6px;
flex-shrink: 0; /* 输入区固定在底,不被消息挤压 */
}
#chat-form .row { display: flex; gap: 8px; }
#chat-form textarea { flex: 1; }
#chat-form .hint { font-size: 11px; color: var(--muted); }
/* ───── files ───── */
.crumbs { padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 12px; background: #fafafa; }
.crumbs a { margin-right: 4px; }
.file-row {
display: flex; padding: 6px 12px; border-bottom: 1px solid var(--border);
align-items: center; gap: 8px;
}
.file-row:hover { background: var(--hover); }
.file-row .name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-row .size { font-size: 11px; color: var(--muted); font-family: monospace; }
.ico-dir::before { content: "▸ "; color: var(--accent); }
.ico-file::before { content: "· "; color: var(--muted); }
/* ───── new task modal ───── */
#new-task-modal {
position: fixed; inset: 0; background: rgba(0,0,0,0.4);
display: none; align-items: center; justify-content: center; z-index: 80;
}
#new-task-modal.show { display: flex; }
#new-task-modal .card {
background: var(--panel); padding: 20px; border-radius: 6px;
width: 420px; box-shadow: 0 8px 24px rgba(0,0,0,.15);
}
#new-task-modal h3 { margin: 0 0 12px; font-size: 16px; }
#new-task-modal label { display: block; margin-top: 8px; font-size: 12px; color: var(--muted); }
#new-task-modal .err { color: var(--accent); font-size: 12px; margin-top: 8px; min-height: 1em; }
#new-task-modal .actions { margin-top: 14px; display: flex; gap: 8px; justify-content: flex-end; }
.small { font-size: 12px; }
.muted { color: var(--muted); }
</style>
</head>
<body>
<!-- ───── login overlay ───── -->
<div id="login">
<div class="card">
<h2>zcbot dev login</h2>
<label for="li-uid">user_id (UUID)</label>
<input id="li-uid" autocomplete="off" />
<label for="li-key">platform_key</label>
<input id="li-key" type="password" autocomplete="off" />
<div class="err" id="li-err"></div>
<div class="actions">
<button class="primary" id="li-go">登录</button>
</div>
<div class="small muted" style="margin-top: 12px;">
本地默认 user_id 是 sentinel(全 0)。platform_key 见服务端 env <code>PLATFORM_KEY</code>
</div>
</div>
</div>
<!-- ───── main 3-pane ───── -->
<div id="app">
<header>
<div class="title">zcbot</div>
<div class="who" id="hd-who"></div>
<div class="spacer"></div>
<button id="hd-new" class="primary">+ new task</button>
<button id="hd-logout">logout</button>
</header>
<!-- left -->
<div class="pane" id="pane-left">
<div class="pane-head">
<span class="label">tasks</span>
<span class="spacer"></span>
<select id="filter-status" class="small" style="width: auto;">
<option value="">(all)</option>
<option value="active">active</option>
<option value="completed">completed</option>
<option value="abandoned">abandoned</option>
</select>
<button id="btn-refresh-tasks" class="small"></button>
</div>
<div id="task-list"><div class="empty">loading…</div></div>
</div>
<!-- middle -->
<div id="pane-mid">
<div class="pane-head">
<span class="label">chat</span>
<span class="spacer"></span>
<button id="btn-export" class="small" disabled>export .docx</button>
<button id="btn-done" class="small" disabled>done</button>
<button id="btn-abandon" class="small danger" disabled>abandon</button>
<button id="btn-delete-task" class="small danger" disabled title="硬删除:清 DB 行 + messages,FS 文件不动">delete</button>
</div>
<div id="chat-meta"><span class="muted">(no task selected)</span></div>
<div id="chat-stream"><div class="empty">select a task on the left</div></div>
<form id="chat-form" style="display:none;">
<textarea id="chat-input" placeholder="输入消息…(Enter 发送,Shift+Enter 换行)"></textarea>
<div class="row">
<span class="hint" id="chat-hint">ready</span>
<span style="flex:1;"></span>
<button type="submit" class="primary" id="chat-send">send</button>
</div>
</form>
</div>
<!-- right -->
<div id="pane-right">
<div class="pane-head">
<span class="label">files</span>
<span id="files-proj" class="muted small" style="margin-left:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:180px;"></span>
<span class="spacer"></span>
<button id="btn-refresh-files" class="small" disabled></button>
</div>
<div id="file-crumbs" class="crumbs muted">(no task selected)</div>
<div id="file-list"></div>
</div>
</div>
<!-- ───── new task modal ───── -->
<div id="new-task-modal">
<div class="card">
<h3>新建 task</h3>
<label for="nt-name">任务名 (必填)</label>
<input id="nt-name" placeholder="例如 初稿大纲" />
<label for="nt-wd">工作目录 (可选,留空 → 用任务名;已有则复用,新名则新建)</label>
<input id="nt-wd" list="folders-datalist" placeholder="选已有或新建,留空 fallback 用任务名" />
<datalist id="folders-datalist"></datalist>
<div class="small muted" id="nt-wd-hint" style="margin-top:4px;min-height:1em;"></div>
<label for="nt-desc">描述 (可选,task 长描述)</label>
<input id="nt-desc" />
<label for="nt-skill">skill (可选,智能体类型,如 coding / ppt / proposal)</label>
<input id="nt-skill" />
<div class="err" id="nt-err"></div>
<div class="actions">
<button id="nt-cancel">取消</button>
<button class="primary" id="nt-go">创建</button>
</div>
</div>
</div>
<script>
const SENTINEL = "00000000-0000-0000-0000-000000000000";
const LS_TOKEN = "zcbot.token";
const LS_UID = "zcbot.user_id";
const state = {
token: localStorage.getItem(LS_TOKEN) || "",
userId: localStorage.getItem(LS_UID) || "",
taskId: null,
taskMeta: null,
filesPath: "",
evtSrc: null,
};
// ───── helpers ─────
const $ = (id) => document.getElementById(id);
async function api(method, path, body) {
const opts = { method, headers: {} };
if (state.token) opts.headers["Authorization"] = "Bearer " + state.token;
if (body !== undefined) {
opts.headers["Content-Type"] = "application/json";
opts.body = JSON.stringify(body);
}
const r = await fetch(path, opts);
let data = null;
const ct = r.headers.get("content-type") || "";
if (ct.includes("application/json")) {
try { data = await r.json(); } catch (e) {}
} else {
data = await r.text();
}
if (!r.ok) {
const msg = (data && data.detail) || data || (r.status + " " + r.statusText);
const err = new Error(typeof msg === "string" ? msg : JSON.stringify(msg));
err.status = r.status;
throw err;
}
return data;
}
function humanSize(n) {
if (n == null) return "";
if (n < 1024) return n + " B";
if (n < 1024*1024) return (n/1024).toFixed(1) + " K";
if (n < 1024*1024*1024) return (n/1024/1024).toFixed(1) + " M";
return (n/1024/1024/1024).toFixed(1) + " G";
}
function fmtTime(iso) {
if (!iso) return "";
try { return new Date(iso).toLocaleString(); } catch (e) { return iso; }
}
function escapeHtml(s) {
return (s || "").replace(/[&<>"']/g, (c) => (
{ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]
));
}
// ───── markdown 渲染 ─────
// 三个 CDN 库任一缺失 → 优雅降级回 <pre>escapeHtml</pre>(plain text wrap)
if (window.marked && window.marked.setOptions) {
window.marked.setOptions({ gfm: true, breaks: true, headerIds: false, mangle: false });
}
function renderMd(text) {
const raw = String(text || "");
if (!window.marked || !window.marked.parse) {
return `<pre style="white-space:pre-wrap;word-break:break-word;font-family:inherit;margin:0;">${escapeHtml(raw)}</pre>`;
}
let html = window.marked.parse(raw);
if (window.DOMPurify) {
html = window.DOMPurify.sanitize(html, { USE_PROFILES: { html: true } });
}
return html;
}
function highlightIn(container) {
if (!window.hljs || !container) return;
container.querySelectorAll("pre code").forEach((b) => {
if (b.dataset.hl === "1") return;
try { window.hljs.highlightElement(b); b.dataset.hl = "1"; } catch (e) {}
});
}
// ───── login ─────
$("li-uid").value = state.userId || SENTINEL;
$("li-go").onclick = doLogin;
$("li-key").addEventListener("keydown", (e) => { if (e.key === "Enter") doLogin(); });
$("li-uid").addEventListener("keydown", (e) => { if (e.key === "Enter") $("li-key").focus(); });
async function doLogin() {
const uid = $("li-uid").value.trim();
const key = $("li-key").value;
$("li-err").textContent = "";
if (!uid || !key) {
$("li-err").textContent = "user_id 与 platform_key 都要填";
return;
}
try {
const r = await fetch("/v1/auth/login", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_id: uid, platform_key: key }),
});
if (!r.ok) {
const d = await r.json().catch(() => ({}));
throw new Error(d.detail || (r.status + " login failed"));
}
const data = await r.json();
state.token = data.token;
state.userId = data.user_id;
localStorage.setItem(LS_TOKEN, state.token);
localStorage.setItem(LS_UID, state.userId);
enterApp();
} catch (e) {
$("li-err").textContent = e.message;
}
}
function logout() {
state.token = ""; state.userId = "";
localStorage.removeItem(LS_TOKEN);
localStorage.removeItem(LS_UID);
if (state.evtSrc) state.evtSrc.close();
location.reload();
}
$("hd-logout").onclick = logout;
// ───── enter app ─────
function enterApp() {
$("login").style.display = "none";
$("app").classList.add("ready");
$("hd-who").textContent = state.userId;
loadTaskList();
}
async function loadTaskList() {
const filter = $("filter-status").value;
const qs = filter ? "?status=" + filter : "";
try {
const data = await api("GET", "/v1/tasks" + qs);
renderTaskList(data.tasks);
} catch (e) {
if (e.status === 401) { logout(); return; }
$("task-list").innerHTML = `<div class="empty">load failed: ${escapeHtml(e.message)}</div>`;
}
}
function renderTaskList(tasks) {
if (!tasks.length) {
$("task-list").innerHTML = `<div class="empty">(no tasks)</div>`;
return;
}
const html = tasks.map((t) => {
const active = state.taskId === t.task_id ? " active" : "";
// 主行 = 任务名(必填字段);副行 = 工作目录 + description(都按需显示)
const taskName = t.name || "(unnamed)";
const wdName = t.working_dir ? t.working_dir.split("/").filter(Boolean).pop() : "";
const desc = t.description || "";
return `
<div class="task-row${active}" data-tid="${t.task_id}">
<div class="desc" title="${escapeHtml(taskName)}">${escapeHtml(taskName)}</div>
${wdName ? `<div class="meta muted" title="${escapeHtml(t.working_dir || "")}">📁 ${escapeHtml(wdName)}</div>` : ""}
${desc ? `<div class="meta muted" title="${escapeHtml(desc)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block;">${escapeHtml(desc)}</div>` : ""}
<div class="meta">
<span class="badge ${t.status}">${t.status}</span>
${t.skill ? `<span class="muted">${escapeHtml(t.skill)}</span>` : ""}
<span>${t.n_messages || 0} msg</span>
<span>${t.tokens || 0} tok</span>
<span class="muted" style="margin-left:auto;font-family:monospace;">${t.task_id.slice(0, 8)}</span>
</div>
</div>
`;
}).join("");
$("task-list").innerHTML = html;
$("task-list").querySelectorAll(".task-row").forEach((el) => {
el.onclick = () => selectTask(el.dataset.tid);
});
}
$("filter-status").onchange = loadTaskList;
$("btn-refresh-tasks").onclick = loadTaskList;
// ───── select task ─────
async function selectTask(tid) {
if (state.evtSrc) { state.evtSrc.close(); state.evtSrc = null; }
state.taskId = tid;
document.querySelectorAll(".task-row").forEach((el) => {
el.classList.toggle("active", el.dataset.tid === tid);
});
try {
const meta = await api("GET", "/v1/tasks/" + tid);
state.taskMeta = meta;
renderChatMeta();
await loadMessages();
state.filesPath = "";
await loadFiles();
} catch (e) {
if (e.status === 401) { logout(); return; }
$("chat-stream").innerHTML = `<div class="empty">load failed: ${escapeHtml(e.message)}</div>`;
}
}
function renderChatMeta() {
const t = state.taskMeta;
if (!t) { $("chat-meta").innerHTML = `<span class="muted">(no task selected)</span>`; return; }
const wdName = t.working_dir ? t.working_dir.split("/").filter(Boolean).pop() : "";
const taskName = t.name || "(unnamed)";
$("chat-meta").innerHTML = `
<span style="font-weight:600;" title="${escapeHtml(taskName)}">${escapeHtml(taskName)}</span>
<span class="badge ${t.status}">${t.status}</span>
${wdName ? `<span class="muted" title="${escapeHtml(t.working_dir)}">📁 ${escapeHtml(wdName)}</span>` : ""}
${t.skill ? `<span class="muted">${escapeHtml(t.skill)}</span>` : ""}
<span class="tid">${t.task_id.slice(0, 8)}</span>
${t.description ? `<span class="muted">${escapeHtml(t.description)}</span>` : ""}
<span class="spacer"></span>
<span class="muted small">${t.n_messages || 0} msg · ${t.tokens || 0} tok</span>
`;
const active = t.status === "active";
$("chat-form").style.display = active ? "flex" : "none";
$("btn-done").disabled = !active;
$("btn-abandon").disabled = !active;
$("btn-delete-task").disabled = false; // delete 不限 status(用户显式 confirm)
$("btn-export").disabled = (t.n_messages || 0) === 0;
$("btn-refresh-files").disabled = false;
}
async function loadMessages() {
const data = await api("GET", `/v1/tasks/${state.taskId}/messages`);
renderMessages(data.messages);
}
function renderMessages(msgs) {
const wrap = $("chat-stream");
wrap.innerHTML = "";
if (!msgs.length) {
wrap.innerHTML = `<div class="empty">(no messages yet · send something below)</div>`;
return;
}
for (const m of msgs) {
const p = m.payload || {};
const role = p.role || "?";
if (role === "system") continue; // 不显示 system
if (role === "tool") {
// 嵌进上一个 assistant 的 tool_call(简化:直接独立显示)
const card = document.createElement("div");
card.className = "msg tool";
const txt = typeof p.content === "string" ? p.content : JSON.stringify(p.content);
card.innerHTML = `
<div class="role">tool · ${escapeHtml(p.name || "")}</div>
<details class="tool-call"><summary>result (${(txt || "").length} chars)</summary><pre>${escapeHtml(txt || "")}</pre></details>
`;
wrap.appendChild(card);
continue;
}
const card = document.createElement("div");
card.className = "msg " + role;
let html = `<div class="role">${role}</div>`;
if (typeof p.content === "string" && p.content) {
html += `<div class="body">${renderMd(p.content)}</div>`;
}
if (Array.isArray(p.tool_calls) && p.tool_calls.length) {
for (const tc of p.tool_calls) {
const fn = (tc.function && tc.function.name) || "?";
let args = "";
try {
args = JSON.stringify(JSON.parse((tc.function && tc.function.arguments) || "{}"), null, 2);
} catch (e) { args = (tc.function && tc.function.arguments) || ""; }
html += `
<details class="tool-call"><summary>tool_call: ${escapeHtml(fn)}</summary><pre>${escapeHtml(args)}</pre></details>
`;
}
}
card.innerHTML = html;
highlightIn(card);
wrap.appendChild(card);
}
wrap.scrollTop = wrap.scrollHeight;
}
// ───── send + SSE ─────
$("chat-form").addEventListener("submit", (e) => { e.preventDefault(); sendMessage(); });
$("chat-input").addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendMessage(); }
});
async function sendMessage() {
if (!state.taskId) return;
const content = $("chat-input").value.trim();
if (!content) return;
$("chat-send").disabled = true;
$("chat-hint").textContent = "sending…";
try {
// 立刻渲染 user 消息卡(乐观)
const wrap = $("chat-stream");
const userCard = document.createElement("div");
userCard.className = "msg user";
userCard.innerHTML = `<div class="role">user</div><div class="body">${escapeHtml(content)}</div>`;
wrap.appendChild(userCard);
// assistant 流式占位卡
const asstCard = document.createElement("div");
asstCard.className = "msg assistant";
asstCard.innerHTML = `<div class="role">assistant</div><div class="body streaming"></div>`;
wrap.appendChild(asstCard);
wrap.scrollTop = wrap.scrollHeight;
const r = await api("POST", `/v1/tasks/${state.taskId}/messages`, { content });
$("chat-input").value = "";
streamSse(r.events_url, asstCard);
} catch (e) {
if (e.status === 401) { logout(); return; }
appendErrorCard(e.message);
$("chat-send").disabled = false;
$("chat-hint").textContent = "ready";
}
}
function streamSse(url, asstCard) {
// EventSource 不支持自定义 header,token 走 query string(?token=...)
// 这里 SSE 走 same-origin,token 经 URL 传给后端 — 但当前后端只读 Authorization 头
// 简单做法:走带 token 的 fetch + ReadableStream 替代 EventSource
fetchSse(url, asstCard).catch((e) => appendErrorCard("sse: " + e.message));
}
async function fetchSse(url, asstCard) {
const body = asstCard.querySelector(".body");
const r = await fetch(url, {
headers: { "Authorization": "Bearer " + state.token, "Accept": "text/event-stream" },
});
if (!r.ok) throw new Error(r.status + " " + r.statusText);
const reader = r.body.getReader();
const dec = new TextDecoder();
let buf = "";
// 流式 markdown:累积 raw 文本 → rAF 节流重渲染整段 body
const ctx = { acc: "", body, pending: false };
$("chat-hint").textContent = "streaming…";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
while (true) {
const idx = buf.indexOf("\n\n");
if (idx < 0) break;
const frame = buf.slice(0, idx);
buf = buf.slice(idx + 2);
const ev = parseSseFrame(frame);
if (!ev) continue;
handleSseEvent(ev, asstCard, ctx);
if (ev.event === "done" || ev.event === "error") break;
}
}
// 最终定稿 + 代码高亮(流式中不 highlight,省 CPU)
body.innerHTML = renderMd(ctx.acc);
body.classList.remove("streaming");
highlightIn(asstCard);
$("chat-send").disabled = false;
$("chat-hint").textContent = "ready";
// 刷新 task meta + messages(拿真实持久化的)
loadTaskList();
await loadMessages();
}
function parseSseFrame(frame) {
const lines = frame.split("\n");
let event = "msg"; let dataLines = [];
for (const ln of lines) {
if (ln.startsWith(":")) continue; // comment
if (ln.startsWith("event:")) event = ln.slice(6).trim();
else if (ln.startsWith("data:")) dataLines.push(ln.slice(5).replace(/^ /, ""));
}
if (!dataLines.length) return { event, data: null };
const raw = dataLines.join("\n");
let data = null;
try { data = JSON.parse(raw); } catch (e) { data = raw; }
return { event, data };
}
function handleSseEvent(ev, asstCard, ctx) {
const t = ev.event;
const stream = $("chat-stream");
// 用户拖到上面看历史时不抢滚动,只在贴底时跟流
const nearBottom = stream.scrollHeight - stream.scrollTop - stream.clientHeight < 120;
if (t === "text" && ev.data && ev.data.delta) {
ctx.acc += ev.data.delta;
// rAF 节流:每帧最多 1 次重渲染,流式 token 高频时不抖
if (!ctx.pending) {
ctx.pending = true;
requestAnimationFrame(() => {
ctx.body.innerHTML = renderMd(ctx.acc);
ctx.pending = false;
if (nearBottom) stream.scrollTop = stream.scrollHeight;
});
}
} else if (t === "tool_call") {
const fn = (ev.data && ev.data.name) || "?";
const args = (ev.data && ev.data.arguments) || "";
const det = document.createElement("details");
det.className = "tool-call";
det.innerHTML = `<summary>tool_call: ${escapeHtml(fn)}</summary><pre>${escapeHtml(typeof args === "string" ? args : JSON.stringify(args, null, 2))}</pre>`;
asstCard.appendChild(det);
} else if (t === "tool_result") {
const txt = (ev.data && ev.data.result) || "";
const det = document.createElement("details");
det.className = "tool-call";
det.innerHTML = `<summary>tool_result</summary><pre>${escapeHtml(typeof txt === "string" ? txt : JSON.stringify(txt, null, 2))}</pre>`;
asstCard.appendChild(det);
} else if (t === "error") {
const msg = (ev.data && (ev.data.msg || ev.data.error)) || JSON.stringify(ev.data);
appendErrorCard(msg);
}
if (nearBottom) stream.scrollTop = stream.scrollHeight;
}
function appendErrorCard(msg) {
const card = document.createElement("div");
card.className = "msg error";
card.innerHTML = `<div class="role">error</div><div class="body">${escapeHtml(msg)}</div>`;
$("chat-stream").appendChild(card);
$("chat-stream").scrollTop = $("chat-stream").scrollHeight;
}
// ───── done / abandon / delete / export ─────
$("btn-done").onclick = () => patchStatus("completed");
$("btn-abandon").onclick = () => patchStatus("abandoned");
$("btn-delete-task").onclick = deleteCurrentTask;
async function patchStatus(status) {
if (!state.taskId) return;
if (!confirm("确认置为 " + status + "?")) return;
try {
await api("PATCH", "/v1/tasks/" + state.taskId, { status });
await selectTask(state.taskId);
loadTaskList();
} catch (e) {
if (e.status === 401) { logout(); return; }
alert("failed: " + e.message);
}
}
async function deleteCurrentTask() {
if (!state.taskId) return;
const t = state.taskMeta;
const projName = (t && t.working_dir) ? t.working_dir.split("/").filter(Boolean).pop() : state.taskId.slice(0, 8);
const nMsg = (t && t.n_messages) || 0;
if (!confirm(`确认硬删除 task "${projName}" (${nMsg} 条消息)?\n\n会清掉对话历史和 DB 行,但工作目录下的文件保留。`)) return;
try {
await api("DELETE", "/v1/tasks/" + state.taskId);
// 清空 chat / files 面板,回到初始态
if (state.evtSrc) { state.evtSrc.close(); state.evtSrc = null; }
state.taskId = null;
state.taskMeta = null;
state.filesPath = "";
$("chat-meta").innerHTML = `<span class="muted">(no task selected)</span>`;
$("chat-stream").innerHTML = `<div class="empty">select a task on the left</div>`;
$("chat-form").style.display = "none";
$("file-crumbs").innerHTML = `<span class="muted">(no task selected)</span>`;
$("file-list").innerHTML = "";
$("files-proj").textContent = "";
$("btn-done").disabled = true;
$("btn-abandon").disabled = true;
$("btn-delete-task").disabled = true;
$("btn-export").disabled = true;
$("btn-refresh-files").disabled = true;
loadTaskList();
} catch (e) {
if (e.status === 401) { logout(); return; }
alert("delete failed: " + e.message);
}
}
$("btn-export").onclick = () => {
if (!state.taskId) return;
// 同源下载:把 token 注入临时 fetch,blob 落地再触发下载
fetch("/v1/tasks/" + state.taskId + "/export", {
headers: { "Authorization": "Bearer " + state.token },
}).then(async (r) => {
if (!r.ok) { alert("export failed: " + r.status); return; }
const blob = await r.blob();
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "chat_" + state.taskId.slice(0, 8) + ".docx";
document.body.appendChild(a); a.click();
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
});
};
// ───── files ─────
$("btn-refresh-files").onclick = () => loadFiles();
async function loadFiles() {
if (!state.taskId) return;
try {
const qs = state.filesPath ? "?path=" + encodeURIComponent(state.filesPath) : "";
const data = await api("GET", `/v1/tasks/${state.taskId}/files` + qs);
renderFiles(data);
} catch (e) {
if (e.status === 401) { logout(); return; }
if (e.status === 400) {
$("file-crumbs").innerHTML = `<span class="muted">(no working_dir bound)</span>`;
$("file-list").innerHTML = "";
} else {
$("file-crumbs").innerHTML = `<span class="muted">${escapeHtml(e.message)}</span>`;
$("file-list").innerHTML = "";
}
}
}
function renderFiles(data) {
// pane-head 显示项目名(working_dir 末段),让用户清楚"现在看的是哪个项目里的文件"
const projName = (state.taskMeta && state.taskMeta.working_dir)
? state.taskMeta.working_dir.split("/").filter(Boolean).pop() : "";
$("files-proj").textContent = projName ? "· " + projName : "";
$("files-proj").title = (state.taskMeta && state.taskMeta.working_dir) || "";
// crumbs root 用项目名替代 "/",更直观
const cr = data.crumbs.map((c, i) => {
const label = (i === 0 && projName) ? projName : c.label;
const isLast = i === data.crumbs.length - 1;
if (isLast) return `<span>${escapeHtml(label)}</span>`;
return `<a href="#" data-rel="${escapeHtml(c.rel)}">${escapeHtml(label)}</a> /`;
}).join(" ");
$("file-crumbs").innerHTML = cr || `<span class="muted">/</span>`;
$("file-crumbs").querySelectorAll("a").forEach((a) => {
a.onclick = (e) => { e.preventDefault(); state.filesPath = a.dataset.rel; loadFiles(); };
});
if (!data.exists) {
$("file-list").innerHTML = `<div class="empty">(目录尚未创建)</div>`;
return;
}
if (!data.entries.length) {
$("file-list").innerHTML = `<div class="empty">(空目录)</div>`;
return;
}
$("file-list").innerHTML = data.entries.map((e) => {
const cls = e.is_dir ? "ico-dir" : "ico-file";
return `
<div class="file-row">
<span class="${cls} name" data-rel="${escapeHtml(e.rel)}" data-isdir="${e.is_dir}">
${escapeHtml(e.name)}
</span>
<span class="size">${humanSize(e.size)}</span>
<button class="small danger del-file" data-rel="${escapeHtml(e.rel)}" data-name="${escapeHtml(e.name)}" data-isdir="${e.is_dir}" title="删(非空目录会失败)">×</button>
</div>
`;
}).join("");
$("file-list").querySelectorAll(".name").forEach((el) => {
el.style.cursor = "pointer";
el.onclick = () => {
const rel = el.dataset.rel;
if (el.dataset.isdir === "true") { state.filesPath = rel; loadFiles(); }
else { downloadFile(rel); }
};
});
$("file-list").querySelectorAll(".del-file").forEach((btn) => {
btn.onclick = (ev) => { ev.stopPropagation(); deleteFile(btn.dataset.rel, btn.dataset.name, btn.dataset.isdir === "true"); };
});
}
async function deleteFile(rel, name, isDir) {
const what = isDir ? "目录" : "文件";
if (!confirm(`确认删除${what} "${name}"?` + (isDir ? "\n(非空目录会失败,先清里面再删)" : ""))) return;
try {
await api("POST", `/v1/tasks/${state.taskId}/files/delete`, { path: rel });
await loadFiles();
} catch (e) {
if (e.status === 401) { logout(); return; }
alert("delete failed: " + e.message);
}
}
function downloadFile(rel) {
fetch(`/v1/tasks/${state.taskId}/files/download?path=` + encodeURIComponent(rel), {
headers: { "Authorization": "Bearer " + state.token },
}).then(async (r) => {
if (!r.ok) { alert("download failed: " + r.status); return; }
const blob = await r.blob();
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = rel.split("/").pop() || "file";
document.body.appendChild(a); a.click();
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
});
}
// ───── new task ─────
$("hd-new").onclick = async () => {
$("nt-name").value = ""; $("nt-wd").value = "";
$("nt-desc").value = ""; $("nt-skill").value = "";
$("nt-err").textContent = "";
$("nt-wd-hint").textContent = "";
$("new-task-modal").classList.add("show");
await loadFolderSuggestions(); // 拉已有目录填 datalist
$("nt-name").focus();
};
$("nt-cancel").onclick = () => $("new-task-modal").classList.remove("show");
$("nt-go").onclick = async () => {
const name = $("nt-name").value.trim();
const working_dir = $("nt-wd").value.trim();
const desc = $("nt-desc").value.trim();
const skill = $("nt-skill").value.trim();
$("nt-err").textContent = "";
if (!name) { $("nt-err").textContent = "任务名 必填"; return; }
try {
const t = await api("POST", "/v1/tasks", { name, working_dir, description: desc, skill });
$("new-task-modal").classList.remove("show");
await loadTaskList();
selectTask(t.task_id);
} catch (e) {
if (e.status === 401) { logout(); return; }
$("nt-err").textContent = e.message;
}
};
// 工作目录 autocomplete:打开 modal 时拉一次,输入时实时提示"复用 / 新建"
async function loadFolderSuggestions() {
try {
const data = await api("GET", "/v1/folders");
const dl = $("folders-datalist");
dl.innerHTML = (data.folders || []).map((f) => {
const tag = f.n_tasks ? `${f.n_tasks} 个 task` : `空目录`;
return `<option value="${escapeHtml(f.name)}" data-n="${f.n_tasks}" label="${escapeHtml(tag)}"></option>`;
}).join("");
} catch (e) {
// 静默 — datalist 留空不影响用户输入
}
}
$("nt-wd").addEventListener("input", () => {
const v = $("nt-wd").value.trim();
const hint = $("nt-wd-hint");
if (!v) {
const fallback = $("nt-name").value.trim();
hint.textContent = fallback ? `留空 → 用任务名「${fallback}」作目录` : "";
return;
}
const opt = $("folders-datalist").querySelector(`option[value="${CSS.escape(v)}"]`);
if (opt) {
const n = parseInt(opt.dataset.n) || 0;
hint.innerHTML = `<span style="color:var(--accent);">→ 复用已有目录</span> · ${n} 个 task`;
} else {
hint.innerHTML = `<span class="muted">→ 新建目录 ${escapeHtml(v)}</span>`;
}
});
$("nt-name").addEventListener("input", () => {
// 任务名输入时,若工作目录为空,提示 fallback 文案动态更新
if (!$("nt-wd").value.trim()) {
const fallback = $("nt-name").value.trim();
$("nt-wd-hint").textContent = fallback ? `留空 → 用任务名「${fallback}」作目录` : "";
}
});
// ───── boot ─────
if (state.token) {
// 已有 token:试探一下,失败回登录页
enterApp();
} else {
$("li-uid").value = SENTINEL;
$("li-uid").focus();
}
</script>
</body>
</html>