zcbot/web/static/dev.html

1163 lines
46 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 控制台</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); }
.cancelled-badge { margin-top: 8px; padding: 4px 10px; font-size: 12px; color: var(--accent); background: var(--accent-soft); border: 1px dashed var(--accent); border-radius: 4px; display: inline-block; }
.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 登录</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">+ 新建任务</button>
<button id="hd-logout">退出登录</button>
</header>
<!-- left -->
<div class="pane" id="pane-left">
<div class="pane-head">
<span class="label">任务</span>
<span class="spacer"></span>
<select id="filter-status" class="small" style="width: auto;">
<option value="">(全部)</option>
<option value="active">进行中</option>
<option value="completed">已完成</option>
<option value="abandoned">已废弃</option>
</select>
<button id="btn-refresh-tasks" class="small" title="刷新"></button>
</div>
<div class="pane-head" style="border-top: 1px solid var(--border); gap: 6px;">
<input id="filter-q" class="small" placeholder="搜索名称/描述..." style="flex:1; padding: 3px 6px;" />
<input id="filter-wd" list="folders-datalist" class="small" placeholder="工作目录" style="flex:1; padding: 3px 6px;" />
</div>
<div class="pane-head" style="border-top: 1px solid var(--border); gap: 6px;">
<span class="small muted" style="white-space:nowrap;">排序</span>
<select id="filter-order" class="small" style="flex:1; width:auto;">
<option value="-created_at">创建时间 ↓(新→旧)</option>
<option value="created_at">创建时间 ↑(旧→新)</option>
<option value="-updated_at">更新时间 ↓</option>
<option value="updated_at">更新时间 ↑</option>
<option value="name">名称 A→Z</option>
<option value="-name">名称 Z→A</option>
<option value="status,-created_at">状态分组(同状态按时间倒序)</option>
</select>
</div>
<div id="task-list"><div class="empty">加载中…</div></div>
<div id="task-pager" class="pane-head" style="border-top: 1px solid var(--border); font-size: 11px; color: var(--muted); justify-content: space-between; display: none;">
<span id="pager-info"></span>
<span style="display:flex; gap: 4px;">
<button id="btn-prev-page" class="small"></button>
<button id="btn-next-page" class="small"></button>
</span>
</div>
</div>
<!-- middle -->
<div id="pane-mid">
<div class="pane-head">
<span class="label">对话</span>
<span class="spacer"></span>
<button id="btn-export" class="small" disabled>导出 docx</button>
<button id="btn-done" class="small" disabled>完成</button>
<button id="btn-abandon" class="small danger" disabled>废弃</button>
<button id="btn-delete-task" class="small danger" disabled title="硬删除:清 DB 行 + messages,FS 文件不动">删除</button>
</div>
<div id="chat-meta"><span class="muted">(未选中任务)</span></div>
<div id="chat-stream"><div class="empty">请在左侧选一个任务</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">就绪</span>
<span style="flex:1;"></span>
<button type="button" class="small danger" id="chat-cancel" style="display:none;" title="停止当前流式回复(协作式 cancel,最长等 LLM 当前一轮跑完)">停止</button>
<button type="submit" class="primary" id="chat-send">发送</button>
</div>
</form>
</div>
<!-- right -->
<div id="pane-right">
<div class="pane-head">
<span class="label">文件</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-upload" class="small" title="上传文件到当前目录"></button>
<button id="btn-refresh-files" class="small"></button>
</div>
<div id="file-crumbs" class="crumbs muted">加载中…</div>
<div id="file-list"></div>
<input type="file" id="upload-input" multiple style="display:none;" />
</div>
</div>
<!-- ───── new task modal ───── -->
<div id="new-task-modal">
<div class="card">
<h3>新建任务</h3>
<label for="nt-name">任务名(必填)</label>
<input id="nt-name" placeholder="例如 初稿大纲" />
<label for="nt-wd">工作目录(可选,留空 → 用任务名;已有则复用,新名则新建)</label>
<input id="nt-wd" list="folders-datalist" placeholder="选已有或新建,留空则用任务名" />
<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">描述(可选,任务长描述)</label>
<input id="nt-desc" />
<label for="nt-skill">智能体类型(可选)</label>
<select id="nt-skill">
<option value="">(默认 · 不限定)</option>
</select>
<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,
streaming: false, // 当前是否在流式中;true 时显示 stop 按钮
// task list 分页 + 筛选
taskPage: 1,
taskPageSize: 20,
taskTotal: 0,
};
// ───── 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();
loadFiles(); // 文件面板与 task 解耦 — 启动即拉 user_root
}
async function loadTaskList() {
const params = new URLSearchParams();
params.set("page", state.taskPage);
params.set("page_size", state.taskPageSize);
const st = $("filter-status").value;
if (st) params.set("status", st);
const q = $("filter-q").value.trim();
if (q) params.set("q", q);
const wd = $("filter-wd").value.trim();
if (wd) params.set("working_dir", wd);
const ord = $("filter-order").value;
if (ord && ord !== "-created_at") params.set("ordering", ord); // 默认值不发送,URL 更干净
try {
const data = await api("GET", "/v1/tasks?" + params.toString());
state.taskTotal = data.count || 0;
state.taskPage = data.page || 1;
state.taskPageSize = data.page_size || state.taskPageSize;
renderTaskList(data.results || []);
renderPager();
} catch (e) {
if (e.status === 401) { logout(); return; }
$("task-list").innerHTML = `<div class="empty">加载失败:${escapeHtml(e.message)}</div>`;
$("task-pager").style.display = "none";
}
}
function renderPager() {
const total = state.taskTotal;
const ps = state.taskPageSize;
const page = state.taskPage;
const lastPage = Math.max(1, Math.ceil(total / ps));
if (total === 0) {
$("task-pager").style.display = "none";
return;
}
$("task-pager").style.display = "flex";
const from = (page - 1) * ps + 1;
const to = Math.min(page * ps, total);
$("pager-info").textContent = `${from}${to} / ${total} (第 ${page}/${lastPage} 页)`;
$("btn-prev-page").disabled = page <= 1;
$("btn-next-page").disabled = page >= lastPage;
}
function resetPageAndReload() {
state.taskPage = 1;
loadTaskList();
}
function renderTaskList(tasks) {
if (!tasks.length) {
$("task-list").innerHTML = `<div class="empty">(暂无任务)</div>`;
return;
}
const statusLabels = { active: "进行中", completed: "已完成", abandoned: "已废弃" };
const html = tasks.map((t) => {
const active = state.taskId === t.task_id ? " active" : "";
// 主行 = 任务名(必填字段);副行 = 工作目录 + description(都按需显示)
const taskName = t.name || "(未命名)";
const wdName = t.working_dir ? t.working_dir.split("/").filter(Boolean).pop() : "";
const desc = t.description || "";
const statusLabel = statusLabels[t.status] || t.status;
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}">${statusLabel}</span>
${t.skill ? `<span class="muted">${escapeHtml(t.skill)}</span>` : ""}
<span>${t.n_messages || 0} 条</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);
});
}
// 任何筛选 / 排序变化都 reset page=1 重拉;刷新按钮保持当前页;翻页只动 page
$("filter-status").onchange = resetPageAndReload;
$("filter-order").onchange = resetPageAndReload;
$("btn-refresh-tasks").onclick = loadTaskList;
$("btn-prev-page").onclick = () => { if (state.taskPage > 1) { state.taskPage--; loadTaskList(); } };
$("btn-next-page").onclick = () => {
const lastPage = Math.max(1, Math.ceil(state.taskTotal / state.taskPageSize));
if (state.taskPage < lastPage) { state.taskPage++; loadTaskList(); }
};
// 搜索 / 工作目录筛选:debounce 300ms,避免每个字符都打 API
let _filterDebounce = null;
function scheduleFilter() {
clearTimeout(_filterDebounce);
_filterDebounce = setTimeout(resetPageAndReload, 300);
}
$("filter-q").addEventListener("input", scheduleFilter);
$("filter-wd").addEventListener("input", scheduleFilter);
// 工作目录输入框打开 enterApp 时拉一次 datalist(modal 也复用同一 list)
async function ensureFoldersLoaded() {
if ($("folders-datalist").children.length === 0) await loadFolderSuggestions();
}
$("filter-wd").addEventListener("focus", ensureFoldersLoaded);
// ───── 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();
// 文件面板自动跳到该 task 的 working_dir(user_root 下一级子目录),
// 不强绑定 — 用户可点 crumb 回上层看 user_root 其他目录
const wdName = meta.working_dir ? meta.working_dir.split("/").filter(Boolean).pop() : "";
state.filesPath = wdName || "";
await loadFiles();
} catch (e) {
if (e.status === 401) { logout(); return; }
$("chat-stream").innerHTML = `<div class="empty">加载失败:${escapeHtml(e.message)}</div>`;
}
}
function renderChatMeta() {
const t = state.taskMeta;
if (!t) { $("chat-meta").innerHTML = `<span class="muted">(未选中任务)</span>`; return; }
const wdName = t.working_dir ? t.working_dir.split("/").filter(Boolean).pop() : "";
const taskName = t.name || "(未命名)";
const statusLabel = { active: "进行中", completed: "已完成", abandoned: "已废弃" }[t.status] || t.status;
$("chat-meta").innerHTML = `
<span style="font-weight:600;" title="${escapeHtml(taskName)}">${escapeHtml(taskName)}</span>
<span class="badge ${t.status}">${statusLabel}</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} 条 · ${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;
}
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">(暂无消息 · 在下方输入开始对话)</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">工具调用 · ${escapeHtml(p.name || "")}</div>
<details class="tool-call"><summary>结果(${(txt || "").length} 字符)</summary><pre>${escapeHtml(txt || "")}</pre></details>
`;
wrap.appendChild(card);
continue;
}
const card = document.createElement("div");
card.className = "msg " + role;
const roleLabel = { user: "我", assistant: "助手", error: "错误" }[role] || role;
let html = `<div class="role">${roleLabel}</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>工具调用:${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 = "发送中…";
try {
// 立刻渲染 user 消息卡(乐观)
const wrap = $("chat-stream");
const userCard = document.createElement("div");
userCard.className = "msg user";
userCard.innerHTML = `<div class="role">我</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">助手</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 = "";
state.streaming = true;
$("chat-cancel").style.display = "";
streamSse(r.events_url, asstCard);
} catch (e) {
if (e.status === 401) { logout(); return; }
appendErrorCard(e.message);
$("chat-send").disabled = false;
$("chat-hint").textContent = "就绪";
}
}
async function cancelCurrentTask() {
if (!state.taskId || !state.streaming) return;
const btn = $("chat-cancel");
btn.disabled = true;
$("chat-hint").textContent = "停止中…";
try {
await api("POST", `/v1/tasks/${state.taskId}/cancel`);
// 不重置 streaming / 按钮 — 等 SSE 的 cancelled / done 走完一并清
} catch (e) {
if (e.status === 401) { logout(); return; }
// 409 = 已结束 / 已 cancelling,不算错;其他贴 toast
if (e.status !== 409) appendErrorCard("cancel: " + e.message);
btn.disabled = false;
$("chat-hint").textContent = "就绪";
}
}
$("chat-cancel").addEventListener("click", cancelCurrentTask);
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 ctx = { acc: "", body, pending: false };
try {
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 = "";
$("chat-hint").textContent = "接收中…";
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);
highlightIn(asstCard);
} finally {
body.classList.remove("streaming");
$("chat-send").disabled = false;
$("chat-hint").textContent = "就绪";
state.streaming = false;
const cb = $("chat-cancel");
cb.style.display = "none";
cb.disabled = false;
}
// 刷新 task meta + messages(拿真实持久化的);失败路径已退出,这里不再跑
loadTaskList();
await loadMessages();
loadFiles(); // 回复结束后右侧文件面板同步刷新(可能有新写入 / 修改的产物)
}
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>工具调用:${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>工具结果</summary><pre>${escapeHtml(typeof txt === "string" ? txt : JSON.stringify(txt, null, 2))}</pre>`;
asstCard.appendChild(det);
} else if (t === "cancelled") {
const badge = document.createElement("div");
badge.className = "cancelled-badge";
badge.textContent = "已停止";
asstCard.appendChild(badge);
} 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">错误</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;
const labels = { completed: "已完成", abandoned: "已废弃" };
if (!confirm(`确认置为「${labels[status] || 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("操作失败:" + 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(`确认硬删除任务「${projName}」(${nMsg} 条消息)?\n\n将清掉对话历史和 DB 行,但工作目录下的文件保留。`)) return;
try {
await api("DELETE", "/v1/tasks/" + state.taskId);
// 清 chat 面板,回到初始态;files 面板与 task 解耦,保留当前路径(FS 文件仍在)
if (state.evtSrc) { state.evtSrc.close(); state.evtSrc = null; }
state.taskId = null;
state.taskMeta = null;
$("chat-meta").innerHTML = `<span class="muted">(未选中任务)</span>`;
$("chat-stream").innerHTML = `<div class="empty">请在左侧选一个任务</div>`;
$("chat-form").style.display = "none";
$("btn-done").disabled = true;
$("btn-abandon").disabled = true;
$("btn-delete-task").disabled = true;
$("btn-export").disabled = true;
loadTaskList();
loadFiles(); // FS 还在,刷新当前路径(可能文件夹仍可见)
} catch (e) {
if (e.status === 401) { logout(); return; }
alert("删除失败:" + 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("导出失败:" + 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(user-rooted,不绑 task) ─────
$("btn-refresh-files").onclick = () => loadFiles();
$("btn-upload").onclick = () => $("upload-input").click();
$("upload-input").addEventListener("change", uploadSelected);
async function loadFiles() {
try {
const qs = state.filesPath ? "?path=" + encodeURIComponent(state.filesPath) : "";
const data = await api("GET", "/v1/files" + qs);
renderFiles(data);
} catch (e) {
if (e.status === 401) { logout(); return; }
$("file-crumbs").innerHTML = `<span class="muted">${escapeHtml(e.message)}</span>`;
$("file-list").innerHTML = "";
}
}
function renderFiles(data) {
// 当前所在的"项目"= 路径第一段;空路径 → user_root,无项目上下文
const segs = (data.current || "").split("/").filter(Boolean);
const projName = segs[0] || "";
$("files-proj").textContent = projName ? "· " + projName : "· (根目录)";
$("files-proj").title = data.root || "";
// crumbs root 标"我的"(user_root),更直观;其余原样
const cr = data.crumbs.map((c, i) => {
const label = i === 0 ? "我的" : 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/files/delete", { path: rel });
await loadFiles();
} catch (e) {
if (e.status === 401) { logout(); return; }
alert("删除失败:" + e.message);
}
}
function downloadFile(rel) {
fetch("/v1/files/download?path=" + encodeURIComponent(rel), {
headers: { "Authorization": "Bearer " + state.token },
}).then(async (r) => {
if (!r.ok) { alert("下载失败:" + 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);
});
}
async function uploadSelected() {
const inp = $("upload-input");
const files = Array.from(inp.files || []);
if (!files.length) return;
const fd = new FormData();
fd.append("path", state.filesPath || "");
for (const f of files) fd.append("files", f);
try {
const r = await fetch("/v1/files/upload", {
method: "POST",
headers: { "Authorization": "Bearer " + state.token },
body: fd,
});
if (!r.ok) {
const d = await r.json().catch(() => ({}));
throw new Error(d.detail || (r.status + " 上传失败"));
}
await loadFiles();
} catch (e) {
alert("上传失败:" + e.message);
} finally {
inp.value = ""; // 允许重新选同名文件
}
}
// ───── 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 Promise.all([loadFolderSuggestions(), loadSkillOptions()]);
$("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;
$("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} 个任务` : `空目录`;
return `<option value="${escapeHtml(f.name)}" data-n="${f.n_tasks}" label="${escapeHtml(tag)}"></option>`;
}).join("");
} catch (e) {
// 静默 — datalist 留空不影响用户输入
}
}
// 智能体类型下拉:skill registry 服务器端静态,首次加载后缓存到 state.skills
async function loadSkillOptions() {
const sel = $("nt-skill");
if (!state.skills) {
try {
const data = await api("GET", "/v1/skills");
state.skills = data.skills || [];
} catch (e) {
state.skills = []; // 静默兜底,select 仍保留"(默认)"项
}
}
// 渲染:第一项固定为"默认"(空 value),其后逐 skill 一项
const opts = ['<option value="">(默认 · 不限定)</option>'];
for (const s of state.skills) {
const label = `${s.name}${s.description ? " — " + s.description : ""}`;
opts.push(`<option value="${escapeHtml(s.name)}" title="${escapeHtml(s.description || "")}">${escapeHtml(label)}</option>`);
}
sel.innerHTML = opts.join("");
sel.value = ""; // hd-new 已清空,这里幂等再保一次
}
$("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} 个任务`;
} 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>