789 lines
29 KiB
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) => (
|
|
{ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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>
|