1134 lines
45 KiB
HTML
1134 lines
45 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" />
|
||
|
||
<!-- 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 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" 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">loading…</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">chat</span>
|
||
<span class="spacer"></span>
|
||
<button id="btn-export" class="small" disabled>export .docx</button>
|
||
<button id="btn-done" class="small" disabled>done</button>
|
||
<button id="btn-abandon" class="small danger" disabled>abandon</button>
|
||
<button id="btn-delete-task" class="small danger" disabled title="硬删除:清 DB 行 + messages,FS 文件不动">delete</button>
|
||
</div>
|
||
<div id="chat-meta"><span class="muted">(no task selected)</span></div>
|
||
<div id="chat-stream"><div class="empty">select a task on the left</div></div>
|
||
<form id="chat-form" style="display:none;">
|
||
<textarea id="chat-input" placeholder="输入消息…(Enter 发送,Shift+Enter 换行)"></textarea>
|
||
<div class="row">
|
||
<span class="hint" id="chat-hint">ready</span>
|
||
<span style="flex:1;"></span>
|
||
<button type="button" class="small danger" id="chat-cancel" style="display:none;" title="停止当前流式回复(协作式 cancel,最长等 LLM 当前一轮跑完)">stop</button>
|
||
<button type="submit" class="primary" id="chat-send">send</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
|
||
<!-- right -->
|
||
<div id="pane-right">
|
||
<div class="pane-head">
|
||
<span class="label">files</span>
|
||
<span id="files-proj" class="muted small" style="margin-left:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:180px;"></span>
|
||
<span class="spacer"></span>
|
||
<button id="btn-upload" class="small" title="上传文件到当前目录">⬆</button>
|
||
<button id="btn-refresh-files" class="small">↻</button>
|
||
</div>
|
||
<div id="file-crumbs" class="crumbs muted">loading…</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>新建 task</h3>
|
||
<label for="nt-name">任务名 (必填)</label>
|
||
<input id="nt-name" placeholder="例如 初稿大纲" />
|
||
<label for="nt-wd">工作目录 (可选,留空 → 用任务名;已有则复用,新名则新建)</label>
|
||
<input id="nt-wd" list="folders-datalist" placeholder="选已有或新建,留空 fallback 用任务名" />
|
||
<datalist id="folders-datalist"></datalist>
|
||
<div class="small muted" id="nt-wd-hint" style="margin-top:4px;min-height:1em;"></div>
|
||
<label for="nt-desc">描述 (可选,task 长描述)</label>
|
||
<input id="nt-desc" />
|
||
<label for="nt-skill">skill (可选,智能体类型,如 coding / ppt / proposal)</label>
|
||
<input id="nt-skill" />
|
||
<div class="err" id="nt-err"></div>
|
||
<div class="actions">
|
||
<button id="nt-cancel">取消</button>
|
||
<button class="primary" id="nt-go">创建</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const SENTINEL = "00000000-0000-0000-0000-000000000000";
|
||
const LS_TOKEN = "zcbot.token";
|
||
const LS_UID = "zcbot.user_id";
|
||
|
||
const state = {
|
||
token: localStorage.getItem(LS_TOKEN) || "",
|
||
userId: localStorage.getItem(LS_UID) || "",
|
||
taskId: null,
|
||
taskMeta: null,
|
||
filesPath: "",
|
||
evtSrc: null,
|
||
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) => (
|
||
{ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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">load failed: ${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">(no tasks)</div>`;
|
||
return;
|
||
}
|
||
const html = tasks.map((t) => {
|
||
const active = state.taskId === t.task_id ? " active" : "";
|
||
// 主行 = 任务名(必填字段);副行 = 工作目录 + description(都按需显示)
|
||
const taskName = t.name || "(unnamed)";
|
||
const wdName = t.working_dir ? t.working_dir.split("/").filter(Boolean).pop() : "";
|
||
const desc = t.description || "";
|
||
return `
|
||
<div class="task-row${active}" data-tid="${t.task_id}">
|
||
<div class="desc" title="${escapeHtml(taskName)}">${escapeHtml(taskName)}</div>
|
||
${wdName ? `<div class="meta muted" title="${escapeHtml(t.working_dir || "")}">📁 ${escapeHtml(wdName)}</div>` : ""}
|
||
${desc ? `<div class="meta muted" title="${escapeHtml(desc)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block;">${escapeHtml(desc)}</div>` : ""}
|
||
<div class="meta">
|
||
<span class="badge ${t.status}">${t.status}</span>
|
||
${t.skill ? `<span class="muted">${escapeHtml(t.skill)}</span>` : ""}
|
||
<span>${t.n_messages || 0} msg</span>
|
||
<span>${t.tokens || 0} tok</span>
|
||
<span class="muted" style="margin-left:auto;font-family:monospace;">${t.task_id.slice(0, 8)}</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join("");
|
||
$("task-list").innerHTML = html;
|
||
$("task-list").querySelectorAll(".task-row").forEach((el) => {
|
||
el.onclick = () => selectTask(el.dataset.tid);
|
||
});
|
||
}
|
||
|
||
// 任何筛选 / 排序变化都 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">load failed: ${escapeHtml(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
function renderChatMeta() {
|
||
const t = state.taskMeta;
|
||
if (!t) { $("chat-meta").innerHTML = `<span class="muted">(no task selected)</span>`; return; }
|
||
const wdName = t.working_dir ? t.working_dir.split("/").filter(Boolean).pop() : "";
|
||
const taskName = t.name || "(unnamed)";
|
||
$("chat-meta").innerHTML = `
|
||
<span style="font-weight:600;" title="${escapeHtml(taskName)}">${escapeHtml(taskName)}</span>
|
||
<span class="badge ${t.status}">${t.status}</span>
|
||
${wdName ? `<span class="muted" title="${escapeHtml(t.working_dir)}">📁 ${escapeHtml(wdName)}</span>` : ""}
|
||
${t.skill ? `<span class="muted">${escapeHtml(t.skill)}</span>` : ""}
|
||
<span class="tid">${t.task_id.slice(0, 8)}</span>
|
||
${t.description ? `<span class="muted">${escapeHtml(t.description)}</span>` : ""}
|
||
<span class="spacer"></span>
|
||
<span class="muted small">${t.n_messages || 0} msg · ${t.tokens || 0} tok</span>
|
||
`;
|
||
const active = t.status === "active";
|
||
$("chat-form").style.display = active ? "flex" : "none";
|
||
$("btn-done").disabled = !active;
|
||
$("btn-abandon").disabled = !active;
|
||
$("btn-delete-task").disabled = false; // delete 不限 status(用户显式 confirm)
|
||
$("btn-export").disabled = (t.n_messages || 0) === 0;
|
||
}
|
||
|
||
async function loadMessages() {
|
||
const data = await api("GET", `/v1/tasks/${state.taskId}/messages`);
|
||
renderMessages(data.messages);
|
||
}
|
||
|
||
function renderMessages(msgs) {
|
||
const wrap = $("chat-stream");
|
||
wrap.innerHTML = "";
|
||
if (!msgs.length) {
|
||
wrap.innerHTML = `<div class="empty">(no messages yet · send something below)</div>`;
|
||
return;
|
||
}
|
||
for (const m of msgs) {
|
||
const p = m.payload || {};
|
||
const role = p.role || "?";
|
||
if (role === "system") continue; // 不显示 system
|
||
if (role === "tool") {
|
||
// 嵌进上一个 assistant 的 tool_call(简化:直接独立显示)
|
||
const card = document.createElement("div");
|
||
card.className = "msg tool";
|
||
const txt = typeof p.content === "string" ? p.content : JSON.stringify(p.content);
|
||
card.innerHTML = `
|
||
<div class="role">tool · ${escapeHtml(p.name || "")}</div>
|
||
<details class="tool-call"><summary>result (${(txt || "").length} chars)</summary><pre>${escapeHtml(txt || "")}</pre></details>
|
||
`;
|
||
wrap.appendChild(card);
|
||
continue;
|
||
}
|
||
const card = document.createElement("div");
|
||
card.className = "msg " + role;
|
||
let html = `<div class="role">${role}</div>`;
|
||
if (typeof p.content === "string" && p.content) {
|
||
html += `<div class="body">${renderMd(p.content)}</div>`;
|
||
}
|
||
if (Array.isArray(p.tool_calls) && p.tool_calls.length) {
|
||
for (const tc of p.tool_calls) {
|
||
const fn = (tc.function && tc.function.name) || "?";
|
||
let args = "";
|
||
try {
|
||
args = JSON.stringify(JSON.parse((tc.function && tc.function.arguments) || "{}"), null, 2);
|
||
} catch (e) { args = (tc.function && tc.function.arguments) || ""; }
|
||
html += `
|
||
<details class="tool-call"><summary>tool_call: ${escapeHtml(fn)}</summary><pre>${escapeHtml(args)}</pre></details>
|
||
`;
|
||
}
|
||
}
|
||
card.innerHTML = html;
|
||
highlightIn(card);
|
||
wrap.appendChild(card);
|
||
}
|
||
wrap.scrollTop = wrap.scrollHeight;
|
||
}
|
||
|
||
// ───── send + SSE ─────
|
||
$("chat-form").addEventListener("submit", (e) => { e.preventDefault(); sendMessage(); });
|
||
$("chat-input").addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendMessage(); }
|
||
});
|
||
|
||
async function sendMessage() {
|
||
if (!state.taskId) return;
|
||
const content = $("chat-input").value.trim();
|
||
if (!content) return;
|
||
$("chat-send").disabled = true;
|
||
$("chat-hint").textContent = "sending…";
|
||
try {
|
||
// 立刻渲染 user 消息卡(乐观)
|
||
const wrap = $("chat-stream");
|
||
const userCard = document.createElement("div");
|
||
userCard.className = "msg user";
|
||
userCard.innerHTML = `<div class="role">user</div><div class="body">${escapeHtml(content)}</div>`;
|
||
wrap.appendChild(userCard);
|
||
|
||
// assistant 流式占位卡
|
||
const asstCard = document.createElement("div");
|
||
asstCard.className = "msg assistant";
|
||
asstCard.innerHTML = `<div class="role">assistant</div><div class="body streaming"></div>`;
|
||
wrap.appendChild(asstCard);
|
||
wrap.scrollTop = wrap.scrollHeight;
|
||
|
||
const r = await api("POST", `/v1/tasks/${state.taskId}/messages`, { content });
|
||
$("chat-input").value = "";
|
||
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 = "ready";
|
||
}
|
||
}
|
||
|
||
async function cancelCurrentTask() {
|
||
if (!state.taskId || !state.streaming) return;
|
||
const btn = $("chat-cancel");
|
||
btn.disabled = true;
|
||
$("chat-hint").textContent = "cancelling…";
|
||
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 = "ready";
|
||
}
|
||
}
|
||
|
||
$("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 = "streaming…";
|
||
while (true) {
|
||
const { value, done } = await reader.read();
|
||
if (done) break;
|
||
buf += dec.decode(value, { stream: true });
|
||
while (true) {
|
||
const idx = buf.indexOf("\n\n");
|
||
if (idx < 0) break;
|
||
const frame = buf.slice(0, idx);
|
||
buf = buf.slice(idx + 2);
|
||
const ev = parseSseFrame(frame);
|
||
if (!ev) continue;
|
||
handleSseEvent(ev, asstCard, ctx);
|
||
if (ev.event === "done" || ev.event === "error") break;
|
||
}
|
||
}
|
||
// 最终定稿 + 代码高亮(流式中不 highlight,省 CPU)
|
||
body.innerHTML = renderMd(ctx.acc);
|
||
highlightIn(asstCard);
|
||
} finally {
|
||
body.classList.remove("streaming");
|
||
$("chat-send").disabled = false;
|
||
$("chat-hint").textContent = "ready";
|
||
state.streaming = false;
|
||
const cb = $("chat-cancel");
|
||
cb.style.display = "none";
|
||
cb.disabled = false;
|
||
}
|
||
// 刷新 task meta + messages(拿真实持久化的);失败路径已退出,这里不再跑
|
||
loadTaskList();
|
||
await loadMessages();
|
||
}
|
||
|
||
function parseSseFrame(frame) {
|
||
const lines = frame.split("\n");
|
||
let event = "msg"; let dataLines = [];
|
||
for (const ln of lines) {
|
||
if (ln.startsWith(":")) continue; // comment
|
||
if (ln.startsWith("event:")) event = ln.slice(6).trim();
|
||
else if (ln.startsWith("data:")) dataLines.push(ln.slice(5).replace(/^ /, ""));
|
||
}
|
||
if (!dataLines.length) return { event, data: null };
|
||
const raw = dataLines.join("\n");
|
||
let data = null;
|
||
try { data = JSON.parse(raw); } catch (e) { data = raw; }
|
||
return { event, data };
|
||
}
|
||
|
||
function handleSseEvent(ev, asstCard, ctx) {
|
||
const t = ev.event;
|
||
const stream = $("chat-stream");
|
||
// 用户拖到上面看历史时不抢滚动,只在贴底时跟流
|
||
const nearBottom = stream.scrollHeight - stream.scrollTop - stream.clientHeight < 120;
|
||
if (t === "text" && ev.data && ev.data.delta) {
|
||
ctx.acc += ev.data.delta;
|
||
// rAF 节流:每帧最多 1 次重渲染,流式 token 高频时不抖
|
||
if (!ctx.pending) {
|
||
ctx.pending = true;
|
||
requestAnimationFrame(() => {
|
||
ctx.body.innerHTML = renderMd(ctx.acc);
|
||
ctx.pending = false;
|
||
if (nearBottom) stream.scrollTop = stream.scrollHeight;
|
||
});
|
||
}
|
||
} else if (t === "tool_call") {
|
||
const fn = (ev.data && ev.data.name) || "?";
|
||
const args = (ev.data && ev.data.arguments) || "";
|
||
const det = document.createElement("details");
|
||
det.className = "tool-call";
|
||
det.innerHTML = `<summary>tool_call: ${escapeHtml(fn)}</summary><pre>${escapeHtml(typeof args === "string" ? args : JSON.stringify(args, null, 2))}</pre>`;
|
||
asstCard.appendChild(det);
|
||
} else if (t === "tool_result") {
|
||
const txt = (ev.data && ev.data.result) || "";
|
||
const det = document.createElement("details");
|
||
det.className = "tool-call";
|
||
det.innerHTML = `<summary>tool_result</summary><pre>${escapeHtml(typeof txt === "string" ? txt : JSON.stringify(txt, null, 2))}</pre>`;
|
||
asstCard.appendChild(det);
|
||
} else if (t === "cancelled") {
|
||
const badge = document.createElement("div");
|
||
badge.className = "cancelled-badge";
|
||
badge.textContent = "已停止(stopped by user)";
|
||
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">error</div><div class="body">${escapeHtml(msg)}</div>`;
|
||
$("chat-stream").appendChild(card);
|
||
$("chat-stream").scrollTop = $("chat-stream").scrollHeight;
|
||
}
|
||
|
||
// ───── done / abandon / delete / export ─────
|
||
$("btn-done").onclick = () => patchStatus("completed");
|
||
$("btn-abandon").onclick = () => patchStatus("abandoned");
|
||
$("btn-delete-task").onclick = deleteCurrentTask;
|
||
|
||
async function patchStatus(status) {
|
||
if (!state.taskId) return;
|
||
if (!confirm("确认置为 " + status + "?")) return;
|
||
try {
|
||
await api("PATCH", "/v1/tasks/" + state.taskId, { status });
|
||
await selectTask(state.taskId);
|
||
loadTaskList();
|
||
} catch (e) {
|
||
if (e.status === 401) { logout(); return; }
|
||
alert("failed: " + e.message);
|
||
}
|
||
}
|
||
|
||
async function deleteCurrentTask() {
|
||
if (!state.taskId) return;
|
||
const t = state.taskMeta;
|
||
const projName = (t && t.working_dir) ? t.working_dir.split("/").filter(Boolean).pop() : state.taskId.slice(0, 8);
|
||
const nMsg = (t && t.n_messages) || 0;
|
||
if (!confirm(`确认硬删除 task "${projName}" (${nMsg} 条消息)?\n\n会清掉对话历史和 DB 行,但工作目录下的文件保留。`)) return;
|
||
try {
|
||
await api("DELETE", "/v1/tasks/" + state.taskId);
|
||
// 清 chat 面板,回到初始态;files 面板与 task 解耦,保留当前路径(FS 文件仍在)
|
||
if (state.evtSrc) { state.evtSrc.close(); state.evtSrc = null; }
|
||
state.taskId = null;
|
||
state.taskMeta = null;
|
||
$("chat-meta").innerHTML = `<span class="muted">(no task selected)</span>`;
|
||
$("chat-stream").innerHTML = `<div class="empty">select a task on the left</div>`;
|
||
$("chat-form").style.display = "none";
|
||
$("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("delete failed: " + e.message);
|
||
}
|
||
}
|
||
|
||
$("btn-export").onclick = () => {
|
||
if (!state.taskId) return;
|
||
// 同源下载:把 token 注入临时 fetch,blob 落地再触发下载
|
||
fetch("/v1/tasks/" + state.taskId + "/export", {
|
||
headers: { "Authorization": "Bearer " + state.token },
|
||
}).then(async (r) => {
|
||
if (!r.ok) { alert("export failed: " + r.status); return; }
|
||
const blob = await r.blob();
|
||
const a = document.createElement("a");
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = "chat_" + state.taskId.slice(0, 8) + ".docx";
|
||
document.body.appendChild(a); a.click();
|
||
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
|
||
});
|
||
};
|
||
|
||
// ───── files(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 : "· (user root)";
|
||
$("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("delete failed: " + e.message);
|
||
}
|
||
}
|
||
|
||
function downloadFile(rel) {
|
||
fetch("/v1/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);
|
||
});
|
||
}
|
||
|
||
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 + " upload failed"));
|
||
}
|
||
await loadFiles();
|
||
} catch (e) {
|
||
alert("upload failed: " + 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 loadFolderSuggestions(); // 拉已有目录填 datalist
|
||
$("nt-name").focus();
|
||
};
|
||
$("nt-cancel").onclick = () => $("new-task-modal").classList.remove("show");
|
||
$("nt-go").onclick = async () => {
|
||
const name = $("nt-name").value.trim();
|
||
const working_dir = $("nt-wd").value.trim();
|
||
const desc = $("nt-desc").value.trim();
|
||
const skill = $("nt-skill").value.trim();
|
||
$("nt-err").textContent = "";
|
||
if (!name) { $("nt-err").textContent = "任务名 必填"; return; }
|
||
try {
|
||
const t = await api("POST", "/v1/tasks", { name, working_dir, description: desc, skill });
|
||
$("new-task-modal").classList.remove("show");
|
||
await loadTaskList();
|
||
selectTask(t.task_id);
|
||
} catch (e) {
|
||
if (e.status === 401) { logout(); return; }
|
||
$("nt-err").textContent = e.message;
|
||
}
|
||
};
|
||
|
||
// 工作目录 autocomplete:打开 modal 时拉一次,输入时实时提示"复用 / 新建"
|
||
async function loadFolderSuggestions() {
|
||
try {
|
||
const data = await api("GET", "/v1/folders");
|
||
const dl = $("folders-datalist");
|
||
dl.innerHTML = (data.folders || []).map((f) => {
|
||
const tag = f.n_tasks ? `${f.n_tasks} 个 task` : `空目录`;
|
||
return `<option value="${escapeHtml(f.name)}" data-n="${f.n_tasks}" label="${escapeHtml(tag)}"></option>`;
|
||
}).join("");
|
||
} catch (e) {
|
||
// 静默 — datalist 留空不影响用户输入
|
||
}
|
||
}
|
||
|
||
$("nt-wd").addEventListener("input", () => {
|
||
const v = $("nt-wd").value.trim();
|
||
const hint = $("nt-wd-hint");
|
||
if (!v) {
|
||
const fallback = $("nt-name").value.trim();
|
||
hint.textContent = fallback ? `留空 → 用任务名「${fallback}」作目录` : "";
|
||
return;
|
||
}
|
||
const opt = $("folders-datalist").querySelector(`option[value="${CSS.escape(v)}"]`);
|
||
if (opt) {
|
||
const n = parseInt(opt.dataset.n) || 0;
|
||
hint.innerHTML = `<span style="color:var(--accent);">→ 复用已有目录</span> · ${n} 个 task`;
|
||
} else {
|
||
hint.innerHTML = `<span class="muted">→ 新建目录 ${escapeHtml(v)}</span>`;
|
||
}
|
||
});
|
||
$("nt-name").addEventListener("input", () => {
|
||
// 任务名输入时,若工作目录为空,提示 fallback 文案动态更新
|
||
if (!$("nt-wd").value.trim()) {
|
||
const fallback = $("nt-name").value.trim();
|
||
$("nt-wd-hint").textContent = fallback ? `留空 → 用任务名「${fallback}」作目录` : "";
|
||
}
|
||
});
|
||
|
||
// ───── boot ─────
|
||
if (state.token) {
|
||
// 已有 token:试探一下,失败回登录页
|
||
enterApp();
|
||
} else {
|
||
$("li-uid").value = SENTINEL;
|
||
$("li-uid").focus();
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|