zcbot/web/static/dev.html

789 lines
29 KiB
HTML

<!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" />
<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);
}
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; }
#pane-left { grid-area: left; }
#pane-mid { grid-area: mid; display: flex; flex-direction: column; border-right: 1px solid var(--border); background: var(--panel); }
#pane-right { grid-area: right; border-right: none; overflow: auto; background: var(--panel); }
.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: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px;
}
.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 { white-space: pre-wrap; word-wrap: break-word; font-family: ui-monospace, "Cascadia Code", Consolas, monospace; font-size: 13px; }
.msg .body.streaming::after { content: "▌"; color: var(--accent); animation: blink 1s infinite; }
@keyframes blink { 0%,49% { opacity: 1; } 50%,100% { opacity: 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;
}
#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>
</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 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-desc">description</label>
<input id="nt-desc" />
<label for="nt-mode">mode (可选,如 coding / writing)</label>
<input id="nt-mode" />
<label for="nt-dir">task_dir (可选,绑项目目录;留空 → 默认派生)</label>
<input id="nt-dir" placeholder="例如 D:/projects/foo 或 留空" />
<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]
));
}
// ───── 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" : "";
const desc = t.description || "(no desc)";
const dir = t.task_dir ? (" · " + t.task_dir.split("/").slice(-2).join("/")) : "";
return `
<div class="task-row${active}" data-tid="${t.task_id}">
<div class="desc" title="${escapeHtml(desc)}">${escapeHtml(desc)}</div>
<div class="meta">
<span class="badge ${t.status}">${t.status}</span>
<span>${t.n_messages || 0} msg</span>
<span>${t.tokens || 0} tok</span>
</div>
<div class="meta muted" title="${escapeHtml(t.task_dir || "")}">
${t.task_id.slice(0, 8)}${escapeHtml(dir)}
</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; }
$("chat-meta").innerHTML = `
<span class="tid">${t.task_id.slice(0, 8)}</span>
<span class="badge ${t.status}">${t.status}</span>
<span class="muted">${escapeHtml(t.description || "(no desc)")}</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-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">${escapeHtml(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;
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 = "";
let acc = "";
$("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, body, asstCard);
if (ev.event === "text" && ev.data && ev.data.delta) acc += ev.data.delta;
if (ev.event === "done" || ev.event === "error") break;
}
}
body.classList.remove("streaming");
$("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, body, asstCard) {
const t = ev.event;
if (t === "text" && ev.data && ev.data.delta) {
body.textContent += ev.data.delta;
$("chat-stream").scrollTop = $("chat-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);
}
}
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 / export ─────
$("btn-done").onclick = () => patchStatus("completed");
$("btn-abandon").onclick = () => patchStatus("abandoned");
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);
}
}
$("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 task_dir bound)</span>`;
$("file-list").innerHTML = "";
} else {
$("file-crumbs").innerHTML = `<span class="muted">${escapeHtml(e.message)}</span>`;
$("file-list").innerHTML = "";
}
}
}
function renderFiles(data) {
const cr = data.crumbs.map((c, i) => {
const isLast = i === data.crumbs.length - 1;
if (isLast) return `<span>${escapeHtml(c.label)}</span>`;
return `<a href="#" data-rel="${escapeHtml(c.rel)}">${escapeHtml(c.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>
</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); }
};
});
}
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 = () => {
$("nt-desc").value = ""; $("nt-mode").value = ""; $("nt-dir").value = "";
$("nt-err").textContent = "";
$("new-task-modal").classList.add("show");
$("nt-desc").focus();
};
$("nt-cancel").onclick = () => $("new-task-modal").classList.remove("show");
$("nt-go").onclick = async () => {
const desc = $("nt-desc").value.trim();
const mode = $("nt-mode").value.trim();
const dir = $("nt-dir").value.trim();
$("nt-err").textContent = "";
if (!desc && !dir) { $("nt-err").textContent = "description 与 task_dir 至少填一个"; return; }
try {
const t = await api("POST", "/v1/tasks", { description: desc, mode, task_dir: dir });
$("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;
}
};
// ───── boot ─────
if (state.token) {
// 已有 token:试探一下,失败回登录页
enterApp();
} else {
$("li-uid").value = SENTINEL;
$("li-uid").focus();
}
</script>
</body>
</html>