1658 lines
64 KiB
HTML
1658 lines
64 KiB
HTML
<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<title>zcbot 控制台</title>
|
||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||
|
||
<!-- markdown + 防 XSS + 代码高亮(纯 CDN,失败优雅降级回 plain text) -->
|
||
<script src="https://cdn.jsdelivr.net/npm/marked@12/marked.min.js"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
|
||
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/highlight.min.js"></script>
|
||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/github.min.css" />
|
||
|
||
<style>
|
||
:root {
|
||
--bg: #f7f7f7;
|
||
--panel: #ffffff;
|
||
--border: #e3e3e3;
|
||
--text: #222;
|
||
--muted: #888;
|
||
--accent: #c0392b;
|
||
--accent-soft: #fde9e7;
|
||
--hover: #f0f0f0;
|
||
--code-bg: #f4f4f4;
|
||
--user-bg: #eef4fb;
|
||
--asst-bg: #ffffff;
|
||
}
|
||
* { box-sizing: border-box; }
|
||
html, body { height: 100%; margin: 0; }
|
||
body {
|
||
font: 14px/1.5 -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;
|
||
color: var(--text); background: var(--bg);
|
||
overflow: hidden; /* 视窗锁死,所有滚动在 pane 内 */
|
||
}
|
||
button, input, textarea, select {
|
||
font: inherit; color: inherit;
|
||
}
|
||
button {
|
||
background: #fff; border: 1px solid var(--border);
|
||
padding: 4px 10px; border-radius: 4px; cursor: pointer;
|
||
}
|
||
button:hover { background: var(--hover); }
|
||
button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||
button.primary:hover { filter: brightness(1.08); }
|
||
button.danger:hover { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
|
||
input, textarea, select {
|
||
background: #fff; border: 1px solid var(--border);
|
||
padding: 5px 8px; border-radius: 4px; width: 100%;
|
||
}
|
||
textarea { resize: vertical; min-height: 60px; }
|
||
a { color: var(--accent); text-decoration: none; }
|
||
a:hover { text-decoration: underline; }
|
||
|
||
/* ───── login overlay ───── */
|
||
#login {
|
||
position: fixed; inset: 0; background: rgba(0,0,0,0.4);
|
||
display: flex; align-items: center; justify-content: center; z-index: 100;
|
||
}
|
||
#login .card {
|
||
background: var(--panel); padding: 24px; border-radius: 6px;
|
||
width: 360px; box-shadow: 0 8px 24px rgba(0,0,0,.15);
|
||
}
|
||
#login h2 { margin: 0 0 16px; font-size: 18px; }
|
||
#login label { display: block; margin-top: 10px; font-size: 12px; color: var(--muted); }
|
||
#login .err { color: var(--accent); font-size: 12px; margin-top: 10px; min-height: 1em; }
|
||
#login .actions { margin-top: 14px; display: flex; gap: 8px; }
|
||
|
||
/* ───── 3-pane layout ───── */
|
||
#app { display: none; height: 100vh; }
|
||
#app.ready { display: grid; grid-template-columns: 280px 1fr 320px; grid-template-rows: auto 1fr; grid-template-areas: "head head head" "left mid right"; }
|
||
|
||
header {
|
||
grid-area: head; background: #fff; border-bottom: 1px solid var(--border);
|
||
padding: 8px 14px; display: flex; align-items: center; gap: 12px;
|
||
}
|
||
header .title { font-weight: 600; }
|
||
header .who { color: var(--muted); font-size: 12px; font-family: monospace; }
|
||
header .spacer { flex: 1; }
|
||
|
||
.pane { border-right: 1px solid var(--border); background: var(--panel); overflow: auto; min-height: 0; }
|
||
#pane-left { grid-area: left; }
|
||
/* min-height: 0 + overflow: hidden 让内部 flex 子项的 overflow: auto 真正生效(否则被默认 min-height: auto 顶出) */
|
||
#pane-mid { grid-area: mid; display: flex; flex-direction: column; border-right: 1px solid var(--border); background: var(--panel); min-height: 0; overflow: hidden; }
|
||
#pane-right { grid-area: right; border-right: none; overflow: auto; background: var(--panel); min-height: 0; }
|
||
|
||
.pane-head {
|
||
padding: 8px 12px; border-bottom: 1px solid var(--border);
|
||
display: flex; gap: 8px; align-items: center; background: #fafafa;
|
||
position: sticky; top: 0;
|
||
}
|
||
.pane-head .label { font-weight: 600; font-size: 13px; white-space: nowrap; flex-shrink: 0; }
|
||
.pane-head .spacer { flex: 1; }
|
||
|
||
/* ───── floating dropdown menu ───── */
|
||
/* 单例:position: fixed 逃出 pane overflow 裁剪;右上角触发,向下展开 */
|
||
.dd-toggle {
|
||
padding: 2px 6px; font-size: 14px; line-height: 1;
|
||
background: transparent; border: 1px solid transparent;
|
||
color: var(--muted); border-radius: 3px; cursor: pointer;
|
||
}
|
||
.dd-toggle:hover { background: var(--hover); color: var(--text); border-color: var(--border); }
|
||
#floating-menu {
|
||
display: none; position: fixed;
|
||
min-width: 132px; background: #fff;
|
||
border: 1px solid var(--border); border-radius: 4px;
|
||
box-shadow: 0 4px 14px rgba(0,0,0,0.12);
|
||
z-index: 60; padding: 4px 0;
|
||
}
|
||
#floating-menu.show { display: block; }
|
||
.dd-item {
|
||
display: block; width: 100%; text-align: left;
|
||
padding: 6px 14px; font-size: 13px; line-height: 1.4;
|
||
background: transparent; border: 0; border-radius: 0;
|
||
cursor: pointer; color: var(--text);
|
||
}
|
||
.dd-item:hover { background: var(--hover); }
|
||
.dd-item:disabled { color: var(--muted); cursor: not-allowed; opacity: 0.55; }
|
||
.dd-item:disabled:hover { background: transparent; }
|
||
.dd-item.act-complete { color: #2e7d32; }
|
||
.dd-item.act-abandon { color: #c77800; }
|
||
.dd-item.act-export { color: #1565c0; }
|
||
.dd-item.act-rename { color: #1565c0; }
|
||
.dd-item.act-download { color: #2e7d32; }
|
||
.dd-item.act-delete { color: var(--accent); }
|
||
|
||
/* ───── 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; }
|
||
|
||
/* ───── file preview modal ───── */
|
||
#file-preview-modal {
|
||
position: fixed; inset: 0; background: rgba(0,0,0,0.5);
|
||
display: none; align-items: center; justify-content: center; z-index: 90;
|
||
}
|
||
#file-preview-modal.show { display: flex; }
|
||
#file-preview-modal .card {
|
||
background: var(--panel); border-radius: 6px;
|
||
width: 90vw; height: 90vh; max-width: 1200px;
|
||
display: flex; flex-direction: column;
|
||
box-shadow: 0 8px 24px rgba(0,0,0,.2);
|
||
}
|
||
#file-preview-modal .hdr {
|
||
display: flex; align-items: center; gap: 8px;
|
||
padding: 8px 12px; border-bottom: 1px solid var(--border);
|
||
}
|
||
#file-preview-modal .hdr .name {
|
||
flex: 1; font-weight: 500; overflow: hidden;
|
||
text-overflow: ellipsis; white-space: nowrap;
|
||
}
|
||
#file-preview-modal .body {
|
||
flex: 1; overflow: auto; padding: 12px; position: relative;
|
||
}
|
||
#file-preview-modal .body.center {
|
||
display: flex; align-items: center; justify-content: center;
|
||
}
|
||
#file-preview-modal .body .ph {
|
||
color: var(--muted); font-size: 13px; text-align: center;
|
||
}
|
||
#file-preview-modal .body img.preview-img {
|
||
max-width: 100%; max-height: 100%; object-fit: contain;
|
||
display: block; margin: 0 auto;
|
||
}
|
||
#file-preview-modal .body iframe.preview-frame {
|
||
width: 100%; height: 100%; border: 0;
|
||
}
|
||
#file-preview-modal .body pre.preview-text {
|
||
margin: 0; padding: 8px; background: var(--code-bg);
|
||
border-radius: 4px; white-space: pre-wrap; word-break: break-word;
|
||
font-family: ui-monospace, "SF Mono", Consolas, monospace;
|
||
font-size: 12px; line-height: 1.5;
|
||
}
|
||
#file-preview-modal .body .md-render {
|
||
max-width: 860px; margin: 0 auto; line-height: 1.7;
|
||
}
|
||
#file-preview-modal .body .md-render pre {
|
||
background: var(--code-bg); padding: 10px; border-radius: 4px; overflow: auto;
|
||
}
|
||
#file-preview-modal .body .md-render code { background: var(--code-bg); padding: 1px 4px; border-radius: 3px; }
|
||
#file-preview-modal .body .md-render pre code { background: transparent; padding: 0; }
|
||
#file-preview-modal .body .docx-host { background: #fff; }
|
||
#file-preview-modal .body .xlsx-tabs {
|
||
display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 8px;
|
||
border-bottom: 1px solid var(--border); padding-bottom: 6px;
|
||
}
|
||
#file-preview-modal .body .xlsx-tabs button.active {
|
||
background: var(--accent-soft); border-color: var(--accent); color: var(--accent);
|
||
}
|
||
#file-preview-modal .body .xlsx-sheet {
|
||
overflow: auto;
|
||
}
|
||
#file-preview-modal .body .xlsx-sheet table {
|
||
border-collapse: collapse; font-size: 12px;
|
||
}
|
||
#file-preview-modal .body .xlsx-sheet td, #file-preview-modal .body .xlsx-sheet th {
|
||
border: 1px solid var(--border); padding: 4px 8px; white-space: nowrap;
|
||
}
|
||
|
||
.small { font-size: 12px; }
|
||
.muted { color: var(--muted); }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- ───── login overlay ───── -->
|
||
<div id="login">
|
||
<div class="card">
|
||
<h2>zcbot 登录</h2>
|
||
<label for="li-uid">user_id (UUID)</label>
|
||
<input id="li-uid" autocomplete="off" />
|
||
<label for="li-key">platform_key</label>
|
||
<input id="li-key" type="password" autocomplete="off" />
|
||
<div class="err" id="li-err"></div>
|
||
<div class="actions">
|
||
<button class="primary" id="li-go">登录</button>
|
||
</div>
|
||
<div class="small muted" style="margin-top: 12px;">
|
||
本地默认 user_id 是 sentinel(全 0)。platform_key 见服务端 env <code>PLATFORM_KEY</code>。
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ───── main 3-pane ───── -->
|
||
<div id="app">
|
||
<header>
|
||
<div class="title">zcbot</div>
|
||
<div class="who" id="hd-who"></div>
|
||
<div class="spacer"></div>
|
||
<button id="hd-new" class="primary">+ 新建任务</button>
|
||
<button id="hd-logout">退出登录</button>
|
||
</header>
|
||
|
||
<!-- left -->
|
||
<div class="pane" id="pane-left">
|
||
<div class="pane-head">
|
||
<span class="label">任务</span>
|
||
<span class="spacer"></span>
|
||
<select id="filter-status" class="small" style="width: auto;">
|
||
<option value="">(全部)</option>
|
||
<option value="active">进行中</option>
|
||
<option value="completed">已完成</option>
|
||
<option value="abandoned">已废弃</option>
|
||
</select>
|
||
<button id="btn-refresh-tasks" class="small" title="刷新">↻</button>
|
||
</div>
|
||
<div class="pane-head" style="border-top: 1px solid var(--border); gap: 6px;">
|
||
<input id="filter-q" class="small" placeholder="搜索名称/描述..." style="flex:1; padding: 3px 6px;" />
|
||
<input id="filter-wd" list="folders-datalist" class="small" placeholder="工作目录" style="flex:1; padding: 3px 6px;" />
|
||
</div>
|
||
<div class="pane-head" style="border-top: 1px solid var(--border); gap: 6px;">
|
||
<span class="small muted" style="white-space:nowrap;">排序</span>
|
||
<select id="filter-order" class="small" style="flex:1; width:auto;">
|
||
<option value="-created_at">创建时间 ↓(新→旧)</option>
|
||
<option value="created_at">创建时间 ↑(旧→新)</option>
|
||
<option value="-updated_at">更新时间 ↓</option>
|
||
<option value="updated_at">更新时间 ↑</option>
|
||
<option value="name">名称 A→Z</option>
|
||
<option value="-name">名称 Z→A</option>
|
||
<option value="status,-created_at">状态分组(同状态按时间倒序)</option>
|
||
</select>
|
||
</div>
|
||
<div id="task-list"><div class="empty">加载中…</div></div>
|
||
<div id="task-pager" class="pane-head" style="border-top: 1px solid var(--border); font-size: 11px; color: var(--muted); justify-content: space-between; display: none;">
|
||
<span id="pager-info"></span>
|
||
<span style="display:flex; gap: 4px;">
|
||
<button id="btn-prev-page" class="small">‹</button>
|
||
<button id="btn-next-page" class="small">›</button>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- middle -->
|
||
<div id="pane-mid">
|
||
<div class="pane-head">
|
||
<span class="label">对话</span>
|
||
<span class="spacer"></span>
|
||
<button id="btn-export" class="small" disabled>导出 docx</button>
|
||
<button id="btn-done" class="small" disabled>完成</button>
|
||
<button id="btn-abandon" class="small danger" disabled>废弃</button>
|
||
<button id="btn-delete-task" class="small danger" disabled title="硬删除:清 DB 行 + messages,FS 文件不动">删除</button>
|
||
</div>
|
||
<div id="chat-meta"><span class="muted">(未选中任务)</span></div>
|
||
<div id="chat-stream"><div class="empty">请在左侧选一个任务</div></div>
|
||
<form id="chat-form" style="display:none;">
|
||
<textarea id="chat-input" placeholder="输入消息…(Enter 发送,Shift+Enter 换行)"></textarea>
|
||
<div class="row">
|
||
<span class="hint" id="chat-hint">就绪</span>
|
||
<span style="flex:1;"></span>
|
||
<button type="button" class="small danger" id="chat-cancel" style="display:none;" title="停止当前流式回复(协作式 cancel,最长等 LLM 当前一轮跑完)">停止</button>
|
||
<button type="button" class="small" id="chat-upload" title="上传文件到右侧当前文件目录">⬆ 上传</button>
|
||
<button type="submit" class="primary" id="chat-send">发送</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
|
||
<!-- right -->
|
||
<div id="pane-right">
|
||
<div class="pane-head">
|
||
<span class="label">文件</span>
|
||
<span id="files-proj" class="muted small" style="margin-left:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:0 1 auto;" title=""></span>
|
||
<span class="spacer"></span>
|
||
<button id="btn-upload" class="small" title="上传文件到当前目录">⬆</button>
|
||
<button id="btn-refresh-files" class="small">↻</button>
|
||
</div>
|
||
<div id="file-crumbs" class="crumbs muted">加载中…</div>
|
||
<div id="file-list"></div>
|
||
<input type="file" id="upload-input" multiple style="display:none;" />
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ───── floating dropdown menu (single instance) ───── -->
|
||
<div id="floating-menu"></div>
|
||
|
||
<!-- ───── new task modal ───── -->
|
||
<div id="new-task-modal">
|
||
<div class="card">
|
||
<h3>新建任务</h3>
|
||
<label for="nt-name">任务名(必填)</label>
|
||
<input id="nt-name" placeholder="例如 初稿大纲" />
|
||
<label for="nt-wd">工作目录(可选,留空 → 用任务名;已有则复用,新名则新建)</label>
|
||
<input id="nt-wd" list="folders-datalist" placeholder="选已有或新建,留空则用任务名" />
|
||
<datalist id="folders-datalist"></datalist>
|
||
<div class="small muted" id="nt-wd-hint" style="margin-top:4px;min-height:1em;"></div>
|
||
<label for="nt-desc">描述(可选,任务长描述)</label>
|
||
<input id="nt-desc" />
|
||
<label for="nt-skill">智能体类型(可选)</label>
|
||
<select id="nt-skill">
|
||
<option value="">(默认 · 不限定)</option>
|
||
</select>
|
||
<div class="err" id="nt-err"></div>
|
||
<div class="actions">
|
||
<button id="nt-cancel">取消</button>
|
||
<button class="primary" id="nt-go">创建</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ───── file preview modal ───── -->
|
||
<div id="file-preview-modal">
|
||
<div class="card">
|
||
<div class="hdr">
|
||
<span class="name" id="fp-name"></span>
|
||
<span class="small muted" id="fp-meta"></span>
|
||
<button class="small" id="fp-download" title="下载原文件">下载</button>
|
||
<button class="small" id="fp-close" title="关闭 (Esc)">×</button>
|
||
</div>
|
||
<div class="body" id="fp-body"></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);
|
||
|
||
// ───── floating dropdown menu (single instance) ─────
|
||
// 用 position: fixed 单例避免被 pane overflow 裁剪;按位置算出右上角对齐
|
||
let _menuItems = null;
|
||
function showMenu(triggerEl, items) {
|
||
_menuItems = items;
|
||
const menu = $("floating-menu");
|
||
menu.innerHTML = items.map((it) => {
|
||
const cls = "dd-item " + (it.cls || "");
|
||
const dis = it.disabled ? " disabled" : "";
|
||
return `<button class="${cls}" data-act="${escapeHtml(it.act)}"${dis}>${escapeHtml(it.label)}</button>`;
|
||
}).join("");
|
||
menu.querySelectorAll(".dd-item").forEach((btn) => {
|
||
btn.onclick = (e) => {
|
||
e.stopPropagation();
|
||
const act = btn.dataset.act;
|
||
const item = _menuItems && _menuItems.find((i) => i.act === act);
|
||
hideMenu();
|
||
if (item && item.onclick) item.onclick();
|
||
};
|
||
});
|
||
// 默认右下展开;若空间不足则改向上
|
||
const rect = triggerEl.getBoundingClientRect();
|
||
menu.style.visibility = "hidden";
|
||
menu.classList.add("show");
|
||
const mh = menu.offsetHeight || 120;
|
||
menu.style.right = Math.max(4, window.innerWidth - rect.right) + "px";
|
||
menu.style.left = "auto";
|
||
if (rect.bottom + mh + 8 > window.innerHeight) {
|
||
menu.style.top = Math.max(4, rect.top - mh - 4) + "px";
|
||
} else {
|
||
menu.style.top = (rect.bottom + 4) + "px";
|
||
}
|
||
menu.style.visibility = "";
|
||
}
|
||
function hideMenu() {
|
||
_menuItems = null;
|
||
$("floating-menu").classList.remove("show");
|
||
}
|
||
document.addEventListener("click", (e) => {
|
||
if (e.target.closest(".dd-toggle")) return;
|
||
if (e.target.closest("#floating-menu")) return;
|
||
hideMenu();
|
||
}, true);
|
||
window.addEventListener("resize", hideMenu);
|
||
// 滚动 pane 时菜单位置失效,直接关
|
||
document.addEventListener("scroll", hideMenu, true);
|
||
|
||
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">加载失败:${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) {
|
||
state.tasksById = {};
|
||
for (const t of tasks) state.tasksById[t.task_id] = t;
|
||
if (!tasks.length) {
|
||
$("task-list").innerHTML = `<div class="empty">(暂无任务)</div>`;
|
||
return;
|
||
}
|
||
const statusLabels = { active: "进行中", completed: "已完成", abandoned: "已废弃" };
|
||
const html = tasks.map((t) => {
|
||
const active = state.taskId === t.task_id ? " active" : "";
|
||
// 主行 = 任务名(必填字段);副行 = 工作目录 + description(都按需显示)
|
||
const taskName = t.name || "(未命名)";
|
||
const wdName = t.working_dir ? t.working_dir.split("/").filter(Boolean).pop() : "";
|
||
const desc = t.description || "";
|
||
const statusLabel = statusLabels[t.status] || t.status;
|
||
return `
|
||
<div class="task-row${active}" data-tid="${t.task_id}" style="display:flex;align-items:flex-start;gap:6px;">
|
||
<div style="flex:1;min-width:0;">
|
||
<div class="desc" title="${escapeHtml(taskName)}">${escapeHtml(taskName)}</div>
|
||
${wdName ? `<div class="meta muted" title="${escapeHtml(t.working_dir || "")}">📁 ${escapeHtml(wdName)}</div>` : ""}
|
||
${desc ? `<div class="meta muted" title="${escapeHtml(desc)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block;">${escapeHtml(desc)}</div>` : ""}
|
||
<div class="meta">
|
||
<span class="badge ${t.status}">${statusLabel}</span>
|
||
${t.skill ? `<span class="muted">${escapeHtml(t.skill)}</span>` : ""}
|
||
<span>${t.n_messages || 0} 条</span>
|
||
<span>${t.tokens || 0} tok</span>
|
||
<span class="muted" style="margin-left:auto;font-family:monospace;">${t.task_id.slice(0, 8)}</span>
|
||
</div>
|
||
</div>
|
||
<button class="dd-toggle task-menu" data-tid="${t.task_id}" title="任务操作">⋯</button>
|
||
</div>
|
||
`;
|
||
}).join("");
|
||
$("task-list").innerHTML = html;
|
||
$("task-list").querySelectorAll(".task-row").forEach((el) => {
|
||
el.onclick = (e) => {
|
||
if (e.target.closest(".dd-toggle")) return; // 菜单按钮点击不触发选中
|
||
selectTask(el.dataset.tid);
|
||
};
|
||
});
|
||
$("task-list").querySelectorAll(".task-menu").forEach((btn) => {
|
||
btn.onclick = (e) => {
|
||
e.stopPropagation();
|
||
const t = state.tasksById[btn.dataset.tid];
|
||
if (!t) return;
|
||
showMenu(btn, taskMenuItems(t));
|
||
};
|
||
});
|
||
}
|
||
|
||
function taskMenuItems(t) {
|
||
const isActive = t.status === "active";
|
||
const hasMsg = (t.n_messages || 0) > 0;
|
||
return [
|
||
{ act: "complete", label: "完成", cls: "act-complete", disabled: !isActive,
|
||
onclick: () => setTaskStatus(t.task_id, "completed", t.name || "(未命名)") },
|
||
{ act: "abandon", label: "废弃", cls: "act-abandon", disabled: !isActive,
|
||
onclick: () => setTaskStatus(t.task_id, "abandoned", t.name || "(未命名)") },
|
||
{ act: "export", label: "导出 docx", cls: "act-export", disabled: !hasMsg,
|
||
onclick: () => exportTask(t.task_id) },
|
||
{ act: "delete", label: "删除", cls: "act-delete",
|
||
onclick: () => deleteTask(t.task_id, t.name || "(未命名)", t.n_messages || 0) },
|
||
];
|
||
}
|
||
|
||
// 任何筛选 / 排序变化都 reset page=1 重拉;刷新按钮保持当前页;翻页只动 page
|
||
$("filter-status").onchange = resetPageAndReload;
|
||
$("filter-order").onchange = resetPageAndReload;
|
||
$("btn-refresh-tasks").onclick = loadTaskList;
|
||
$("btn-prev-page").onclick = () => { if (state.taskPage > 1) { state.taskPage--; loadTaskList(); } };
|
||
$("btn-next-page").onclick = () => {
|
||
const lastPage = Math.max(1, Math.ceil(state.taskTotal / state.taskPageSize));
|
||
if (state.taskPage < lastPage) { state.taskPage++; loadTaskList(); }
|
||
};
|
||
|
||
// 搜索 / 工作目录筛选:debounce 300ms,避免每个字符都打 API
|
||
let _filterDebounce = null;
|
||
function scheduleFilter() {
|
||
clearTimeout(_filterDebounce);
|
||
_filterDebounce = setTimeout(resetPageAndReload, 300);
|
||
}
|
||
$("filter-q").addEventListener("input", scheduleFilter);
|
||
$("filter-wd").addEventListener("input", scheduleFilter);
|
||
// 工作目录输入框打开 enterApp 时拉一次 datalist(modal 也复用同一 list)
|
||
async function ensureFoldersLoaded() {
|
||
if ($("folders-datalist").children.length === 0) await loadFolderSuggestions();
|
||
}
|
||
$("filter-wd").addEventListener("focus", ensureFoldersLoaded);
|
||
|
||
// ───── select task ─────
|
||
async function selectTask(tid) {
|
||
if (state.evtSrc) { state.evtSrc.close(); state.evtSrc = null; }
|
||
state.taskId = tid;
|
||
document.querySelectorAll(".task-row").forEach((el) => {
|
||
el.classList.toggle("active", el.dataset.tid === tid);
|
||
});
|
||
try {
|
||
const meta = await api("GET", "/v1/tasks/" + tid);
|
||
state.taskMeta = meta;
|
||
renderChatMeta();
|
||
await loadMessages();
|
||
// 文件面板自动跳到该 task 的 working_dir(user_root 下一级子目录),
|
||
// 不强绑定 — 用户可点 crumb 回上层看 user_root 其他目录
|
||
const wdName = meta.working_dir ? meta.working_dir.split("/").filter(Boolean).pop() : "";
|
||
state.filesPath = wdName || "";
|
||
await loadFiles();
|
||
} catch (e) {
|
||
if (e.status === 401) { logout(); return; }
|
||
$("chat-stream").innerHTML = `<div class="empty">加载失败:${escapeHtml(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
function renderChatMeta() {
|
||
const t = state.taskMeta;
|
||
if (!t) { $("chat-meta").innerHTML = `<span class="muted">(未选中任务)</span>`; return; }
|
||
const wdName = t.working_dir ? t.working_dir.split("/").filter(Boolean).pop() : "";
|
||
const taskName = t.name || "(未命名)";
|
||
const statusLabel = { active: "进行中", completed: "已完成", abandoned: "已废弃" }[t.status] || t.status;
|
||
$("chat-meta").innerHTML = `
|
||
<span style="font-weight:600;" title="${escapeHtml(taskName)}">${escapeHtml(taskName)}</span>
|
||
<span class="badge ${t.status}">${statusLabel}</span>
|
||
${wdName ? `<span class="muted" title="${escapeHtml(t.working_dir)}">📁 ${escapeHtml(wdName)}</span>` : ""}
|
||
${t.skill ? `<span class="muted">${escapeHtml(t.skill)}</span>` : ""}
|
||
<span class="tid">${t.task_id.slice(0, 8)}</span>
|
||
${t.description ? `<span class="muted">${escapeHtml(t.description)}</span>` : ""}
|
||
<span class="spacer"></span>
|
||
<span class="muted small">${t.n_messages || 0} 条 · ${t.tokens || 0} tok</span>
|
||
`;
|
||
const active = t.status === "active";
|
||
$("chat-form").style.display = active ? "flex" : "none";
|
||
$("btn-done").disabled = !active;
|
||
$("btn-abandon").disabled = !active;
|
||
$("btn-delete-task").disabled = false; // delete 不限 status(用户显式 confirm)
|
||
$("btn-export").disabled = (t.n_messages || 0) === 0;
|
||
}
|
||
|
||
async function loadMessages() {
|
||
const data = await api("GET", `/v1/tasks/${state.taskId}/messages`);
|
||
renderMessages(data.messages);
|
||
}
|
||
|
||
function renderMessages(msgs) {
|
||
const wrap = $("chat-stream");
|
||
wrap.innerHTML = "";
|
||
if (!msgs.length) {
|
||
wrap.innerHTML = `<div class="empty">(暂无消息 · 在下方输入开始对话)</div>`;
|
||
return;
|
||
}
|
||
for (const m of msgs) {
|
||
const p = m.payload || {};
|
||
const role = p.role || "?";
|
||
if (role === "system") continue; // 不显示 system
|
||
if (role === "tool") {
|
||
// 嵌进上一个 assistant 的 tool_call(简化:直接独立显示)
|
||
const card = document.createElement("div");
|
||
card.className = "msg tool";
|
||
const txt = typeof p.content === "string" ? p.content : JSON.stringify(p.content);
|
||
card.innerHTML = `
|
||
<div class="role">工具调用 · ${escapeHtml(p.name || "")}</div>
|
||
<details class="tool-call"><summary>结果(${(txt || "").length} 字符)</summary><pre>${escapeHtml(txt || "")}</pre></details>
|
||
`;
|
||
wrap.appendChild(card);
|
||
continue;
|
||
}
|
||
const card = document.createElement("div");
|
||
card.className = "msg " + role;
|
||
const roleLabel = { user: "我", assistant: "助手", error: "错误" }[role] || role;
|
||
let html = `<div class="role">${roleLabel}</div>`;
|
||
if (typeof p.content === "string" && p.content) {
|
||
html += `<div class="body">${renderMd(p.content)}</div>`;
|
||
}
|
||
if (Array.isArray(p.tool_calls) && p.tool_calls.length) {
|
||
for (const tc of p.tool_calls) {
|
||
const fn = (tc.function && tc.function.name) || "?";
|
||
let args = "";
|
||
try {
|
||
args = JSON.stringify(JSON.parse((tc.function && tc.function.arguments) || "{}"), null, 2);
|
||
} catch (e) { args = (tc.function && tc.function.arguments) || ""; }
|
||
html += `
|
||
<details class="tool-call"><summary>工具调用:${escapeHtml(fn)}</summary><pre>${escapeHtml(args)}</pre></details>
|
||
`;
|
||
}
|
||
}
|
||
card.innerHTML = html;
|
||
highlightIn(card);
|
||
wrap.appendChild(card);
|
||
}
|
||
wrap.scrollTop = wrap.scrollHeight;
|
||
}
|
||
|
||
// ───── send + SSE ─────
|
||
$("chat-form").addEventListener("submit", (e) => { e.preventDefault(); sendMessage(); });
|
||
$("chat-input").addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendMessage(); }
|
||
});
|
||
|
||
async function sendMessage() {
|
||
if (!state.taskId) return;
|
||
const content = $("chat-input").value.trim();
|
||
if (!content) return;
|
||
$("chat-send").disabled = true;
|
||
$("chat-hint").textContent = "发送中…";
|
||
try {
|
||
// 立刻渲染 user 消息卡(乐观)
|
||
const wrap = $("chat-stream");
|
||
const userCard = document.createElement("div");
|
||
userCard.className = "msg user";
|
||
userCard.innerHTML = `<div class="role">我</div><div class="body">${escapeHtml(content)}</div>`;
|
||
wrap.appendChild(userCard);
|
||
|
||
// assistant 流式占位卡
|
||
const asstCard = document.createElement("div");
|
||
asstCard.className = "msg assistant";
|
||
asstCard.innerHTML = `<div class="role">助手</div><div class="body streaming"></div>`;
|
||
wrap.appendChild(asstCard);
|
||
wrap.scrollTop = wrap.scrollHeight;
|
||
|
||
const r = await api("POST", `/v1/tasks/${state.taskId}/messages`, { content });
|
||
$("chat-input").value = "";
|
||
state.streaming = true;
|
||
$("chat-cancel").style.display = "";
|
||
streamSse(r.events_url, asstCard);
|
||
} catch (e) {
|
||
if (e.status === 401) { logout(); return; }
|
||
appendErrorCard(e.message);
|
||
$("chat-send").disabled = false;
|
||
$("chat-hint").textContent = "就绪";
|
||
}
|
||
}
|
||
|
||
async function cancelCurrentTask() {
|
||
if (!state.taskId || !state.streaming) return;
|
||
const btn = $("chat-cancel");
|
||
btn.disabled = true;
|
||
$("chat-hint").textContent = "停止中…";
|
||
try {
|
||
await api("POST", `/v1/tasks/${state.taskId}/cancel`);
|
||
// 不重置 streaming / 按钮 — 等 SSE 的 cancelled / done 走完一并清
|
||
} catch (e) {
|
||
if (e.status === 401) { logout(); return; }
|
||
// 409 = 已结束 / 已 cancelling,不算错;其他贴 toast
|
||
if (e.status !== 409) appendErrorCard("cancel: " + e.message);
|
||
btn.disabled = false;
|
||
$("chat-hint").textContent = "就绪";
|
||
}
|
||
}
|
||
|
||
$("chat-cancel").addEventListener("click", cancelCurrentTask);
|
||
|
||
function streamSse(url, asstCard) {
|
||
// EventSource 不支持自定义 header,token 走 query string(?token=...)
|
||
// 这里 SSE 走 same-origin,token 经 URL 传给后端 — 但当前后端只读 Authorization 头
|
||
// 简单做法:走带 token 的 fetch + ReadableStream 替代 EventSource
|
||
fetchSse(url, asstCard).catch((e) => appendErrorCard("sse: " + e.message));
|
||
}
|
||
|
||
async function fetchSse(url, asstCard) {
|
||
const body = asstCard.querySelector(".body");
|
||
const ctx = { acc: "", body, pending: false };
|
||
try {
|
||
const r = await fetch(url, {
|
||
headers: { "Authorization": "Bearer " + state.token, "Accept": "text/event-stream" },
|
||
});
|
||
if (!r.ok) throw new Error(r.status + " " + r.statusText);
|
||
const reader = r.body.getReader();
|
||
const dec = new TextDecoder();
|
||
let buf = "";
|
||
$("chat-hint").textContent = "接收中…";
|
||
while (true) {
|
||
const { value, done } = await reader.read();
|
||
if (done) break;
|
||
buf += dec.decode(value, { stream: true });
|
||
while (true) {
|
||
const idx = buf.indexOf("\n\n");
|
||
if (idx < 0) break;
|
||
const frame = buf.slice(0, idx);
|
||
buf = buf.slice(idx + 2);
|
||
const ev = parseSseFrame(frame);
|
||
if (!ev) continue;
|
||
handleSseEvent(ev, asstCard, ctx);
|
||
if (ev.event === "done" || ev.event === "error") break;
|
||
}
|
||
}
|
||
// 最终定稿 + 代码高亮(流式中不 highlight,省 CPU)
|
||
body.innerHTML = renderMd(ctx.acc);
|
||
highlightIn(asstCard);
|
||
} finally {
|
||
body.classList.remove("streaming");
|
||
$("chat-send").disabled = false;
|
||
$("chat-hint").textContent = "就绪";
|
||
state.streaming = false;
|
||
const cb = $("chat-cancel");
|
||
cb.style.display = "none";
|
||
cb.disabled = false;
|
||
}
|
||
// 刷新 task meta + messages(拿真实持久化的);失败路径已退出,这里不再跑
|
||
loadTaskList();
|
||
await loadMessages();
|
||
loadFiles(); // 回复结束后右侧文件面板同步刷新(可能有新写入 / 修改的产物)
|
||
}
|
||
|
||
function parseSseFrame(frame) {
|
||
const lines = frame.split("\n");
|
||
let event = "msg"; let dataLines = [];
|
||
for (const ln of lines) {
|
||
if (ln.startsWith(":")) continue; // comment
|
||
if (ln.startsWith("event:")) event = ln.slice(6).trim();
|
||
else if (ln.startsWith("data:")) dataLines.push(ln.slice(5).replace(/^ /, ""));
|
||
}
|
||
if (!dataLines.length) return { event, data: null };
|
||
const raw = dataLines.join("\n");
|
||
let data = null;
|
||
try { data = JSON.parse(raw); } catch (e) { data = raw; }
|
||
return { event, data };
|
||
}
|
||
|
||
function handleSseEvent(ev, asstCard, ctx) {
|
||
const t = ev.event;
|
||
const stream = $("chat-stream");
|
||
// 用户拖到上面看历史时不抢滚动,只在贴底时跟流
|
||
const nearBottom = stream.scrollHeight - stream.scrollTop - stream.clientHeight < 120;
|
||
if (t === "text" && ev.data && ev.data.delta) {
|
||
ctx.acc += ev.data.delta;
|
||
// rAF 节流:每帧最多 1 次重渲染,流式 token 高频时不抖
|
||
if (!ctx.pending) {
|
||
ctx.pending = true;
|
||
requestAnimationFrame(() => {
|
||
ctx.body.innerHTML = renderMd(ctx.acc);
|
||
ctx.pending = false;
|
||
if (nearBottom) stream.scrollTop = stream.scrollHeight;
|
||
});
|
||
}
|
||
} else if (t === "tool_call") {
|
||
const fn = (ev.data && ev.data.name) || "?";
|
||
const args = (ev.data && ev.data.arguments) || "";
|
||
const det = document.createElement("details");
|
||
det.className = "tool-call";
|
||
det.innerHTML = `<summary>工具调用:${escapeHtml(fn)}</summary><pre>${escapeHtml(typeof args === "string" ? args : JSON.stringify(args, null, 2))}</pre>`;
|
||
asstCard.appendChild(det);
|
||
} else if (t === "tool_result") {
|
||
const txt = (ev.data && ev.data.result) || "";
|
||
const det = document.createElement("details");
|
||
det.className = "tool-call";
|
||
det.innerHTML = `<summary>工具结果</summary><pre>${escapeHtml(typeof txt === "string" ? txt : JSON.stringify(txt, null, 2))}</pre>`;
|
||
asstCard.appendChild(det);
|
||
scheduleFilesRefresh(); // 工具调用结果回来,FS 可能被改了,debounce 刷新右侧
|
||
} else if (t === "cancelled") {
|
||
const badge = document.createElement("div");
|
||
badge.className = "cancelled-badge";
|
||
badge.textContent = "已停止";
|
||
asstCard.appendChild(badge);
|
||
} else if (t === "error") {
|
||
const msg = (ev.data && (ev.data.msg || ev.data.error)) || JSON.stringify(ev.data);
|
||
appendErrorCard(msg);
|
||
}
|
||
if (nearBottom) stream.scrollTop = stream.scrollHeight;
|
||
}
|
||
|
||
function appendErrorCard(msg) {
|
||
const card = document.createElement("div");
|
||
card.className = "msg error";
|
||
card.innerHTML = `<div class="role">错误</div><div class="body">${escapeHtml(msg)}</div>`;
|
||
$("chat-stream").appendChild(card);
|
||
$("chat-stream").scrollTop = $("chat-stream").scrollHeight;
|
||
}
|
||
|
||
// ───── done / abandon / delete / export ─────
|
||
$("btn-done").onclick = () => state.taskId && setTaskStatus(state.taskId, "completed", (state.taskMeta && state.taskMeta.name) || "");
|
||
$("btn-abandon").onclick = () => state.taskId && setTaskStatus(state.taskId, "abandoned", (state.taskMeta && state.taskMeta.name) || "");
|
||
$("btn-delete-task").onclick = () => {
|
||
if (!state.taskId) return;
|
||
const t = state.taskMeta || {};
|
||
deleteTask(state.taskId, t.name || "(未命名)", t.n_messages || 0);
|
||
};
|
||
$("btn-export").onclick = () => state.taskId && exportTask(state.taskId);
|
||
|
||
async function setTaskStatus(tid, status, name) {
|
||
const labels = { completed: "已完成", abandoned: "已废弃" };
|
||
if (!confirm(`确认将「${name || tid.slice(0,8)}」置为「${labels[status] || status}」?`)) return;
|
||
try {
|
||
await api("PATCH", "/v1/tasks/" + tid, { status });
|
||
if (state.taskId === tid) await selectTask(tid);
|
||
loadTaskList();
|
||
} catch (e) {
|
||
if (e.status === 401) { logout(); return; }
|
||
alert("操作失败:" + e.message);
|
||
}
|
||
}
|
||
|
||
async function deleteTask(tid, name, nMsg) {
|
||
if (!confirm(`确认硬删除任务「${name}」(${nMsg} 条消息)?\n\n将清掉对话历史和 DB 行,但工作目录下的文件保留。`)) return;
|
||
try {
|
||
await api("DELETE", "/v1/tasks/" + tid);
|
||
if (state.taskId === tid) {
|
||
if (state.evtSrc) { state.evtSrc.close(); state.evtSrc = null; }
|
||
state.taskId = null;
|
||
state.taskMeta = null;
|
||
$("chat-meta").innerHTML = `<span class="muted">(未选中任务)</span>`;
|
||
$("chat-stream").innerHTML = `<div class="empty">请在左侧选一个任务</div>`;
|
||
$("chat-form").style.display = "none";
|
||
$("btn-done").disabled = true;
|
||
$("btn-abandon").disabled = true;
|
||
$("btn-delete-task").disabled = true;
|
||
$("btn-export").disabled = true;
|
||
}
|
||
loadTaskList();
|
||
loadFiles();
|
||
} catch (e) {
|
||
if (e.status === 401) { logout(); return; }
|
||
alert("删除失败:" + e.message);
|
||
}
|
||
}
|
||
|
||
function exportTask(tid) {
|
||
fetch("/v1/tasks/" + tid + "/export", {
|
||
headers: { "Authorization": "Bearer " + state.token },
|
||
}).then(async (r) => {
|
||
if (!r.ok) { alert("导出失败:" + r.status); return; }
|
||
const blob = await r.blob();
|
||
const a = document.createElement("a");
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = "chat_" + tid.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();
|
||
$("chat-upload").onclick = () => $("upload-input").click();
|
||
$("upload-input").addEventListener("change", uploadSelected);
|
||
|
||
// 工具调用返回时,右侧文件可能有新增/修改 — debounce 500ms 刷新,避免每次 tool_result 都 hit API
|
||
let _filesRefreshTimer = null;
|
||
function scheduleFilesRefresh() {
|
||
clearTimeout(_filesRefreshTimer);
|
||
_filesRefreshTimer = setTimeout(() => { loadFiles(); }, 500);
|
||
}
|
||
|
||
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] || "";
|
||
// 名称过长时显示前 11 字符 + …,完整名留 title 提示(避免顶栏挤压"文件"换行)
|
||
const projShort = projName.length > 12 ? projName.slice(0, 11) + "…" : projName;
|
||
$("files-proj").textContent = projShort ? "· " + projShort : "· (根目录)";
|
||
$("files-proj").title = projName || 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;
|
||
}
|
||
state.entriesByRel = {};
|
||
for (const e of data.entries) state.entriesByRel[e.rel] = e;
|
||
$("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="dd-toggle file-menu" data-rel="${escapeHtml(e.rel)}" 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 { openFilePreview(rel); }
|
||
};
|
||
});
|
||
$("file-list").querySelectorAll(".file-menu").forEach((btn) => {
|
||
btn.onclick = (ev) => {
|
||
ev.stopPropagation();
|
||
const e = state.entriesByRel[btn.dataset.rel];
|
||
if (!e) return;
|
||
showMenu(btn, fileMenuItems(e));
|
||
};
|
||
});
|
||
}
|
||
|
||
function fileMenuItems(e) {
|
||
const items = [
|
||
{ act: "rename", label: "重命名", cls: "act-rename",
|
||
onclick: () => renameFile(e.rel, e.name, e.is_dir) },
|
||
];
|
||
if (!e.is_dir) {
|
||
items.push({ act: "download", label: "下载", cls: "act-download",
|
||
onclick: () => downloadFile(e.rel) });
|
||
}
|
||
items.push({ act: "delete", label: "删除", cls: "act-delete",
|
||
onclick: () => deleteFile(e.rel, e.name, e.is_dir) });
|
||
return items;
|
||
}
|
||
|
||
async function deleteFile(rel, name, isDir) {
|
||
const what = isDir ? "目录" : "文件";
|
||
const tip = isDir
|
||
? "\n(非空目录会失败;若为顶层目录且仍被 task 引用,需先删 task)"
|
||
: "";
|
||
if (!confirm(`确认删除${what} "${name}"?` + tip)) return;
|
||
try {
|
||
await api("POST", "/v1/files/delete", { path: rel });
|
||
await loadFiles();
|
||
// 删的若是顶层目录,folders 列表也得跟着变;子级删除走这里也无副作用
|
||
await loadFolderSuggestions();
|
||
} catch (e) {
|
||
if (e.status === 401) { logout(); return; }
|
||
alert("删除失败:" + e.message);
|
||
}
|
||
}
|
||
|
||
async function renameFile(rel, name, isDir) {
|
||
const what = isDir ? "目录" : "文件";
|
||
const newName = prompt(`将${what} "${name}" 重命名为:`, name);
|
||
if (newName == null) return;
|
||
const trimmed = newName.trim();
|
||
if (!trimmed || trimmed === name) return;
|
||
try {
|
||
const res = await api("POST", "/v1/files/rename", { path: rel, new_name: trimmed });
|
||
// 面板若停在被改名的子树里,做前缀替换继续停留在等价位置
|
||
if (state.filesPath === rel) {
|
||
state.filesPath = res.new;
|
||
} else if (state.filesPath && state.filesPath.startsWith(rel + "/")) {
|
||
state.filesPath = res.new + state.filesPath.slice(rel.length);
|
||
}
|
||
await loadFolderSuggestions();
|
||
// 顶层目录改名 → tasks_updated>0,任务列表 / 当前 task 头里的 working_dir 都得刷
|
||
if (res && res.tasks_updated > 0) {
|
||
await loadTaskList();
|
||
if (state.taskId) { await selectTask(state.taskId); return; }
|
||
}
|
||
await loadFiles();
|
||
} catch (e) {
|
||
if (e.status === 401) { logout(); return; }
|
||
alert("重命名失败:" + e.message);
|
||
}
|
||
}
|
||
|
||
function downloadFile(rel) {
|
||
fetch("/v1/files/download?path=" + encodeURIComponent(rel), {
|
||
headers: { "Authorization": "Bearer " + state.token },
|
||
}).then(async (r) => {
|
||
if (!r.ok) { alert("下载失败:" + r.status); return; }
|
||
const blob = await r.blob();
|
||
const a = document.createElement("a");
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = rel.split("/").pop() || "file";
|
||
document.body.appendChild(a); a.click();
|
||
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
|
||
});
|
||
}
|
||
|
||
// ───── file preview ─────
|
||
const PREVIEW_TEXT_MAX = 2 * 1024 * 1024;
|
||
const PREVIEW_BIN_MAX = 50 * 1024 * 1024;
|
||
|
||
const _scriptCache = new Map();
|
||
function loadScript(src) {
|
||
if (_scriptCache.has(src)) return _scriptCache.get(src);
|
||
const p = new Promise((resolve, reject) => {
|
||
const s = document.createElement("script");
|
||
s.src = src;
|
||
s.onload = () => resolve();
|
||
s.onerror = () => { _scriptCache.delete(src); reject(new Error("load failed: " + src)); };
|
||
document.head.appendChild(s);
|
||
});
|
||
_scriptCache.set(src, p);
|
||
return p;
|
||
}
|
||
|
||
const _previewBlobUrls = new Set();
|
||
function _trackBlobUrl(blob, mime) {
|
||
const b = mime ? new Blob([blob], { type: mime }) : blob;
|
||
const url = URL.createObjectURL(b);
|
||
_previewBlobUrls.add(url);
|
||
return url;
|
||
}
|
||
function _flushBlobUrls() {
|
||
for (const u of _previewBlobUrls) URL.revokeObjectURL(u);
|
||
_previewBlobUrls.clear();
|
||
}
|
||
|
||
const _EXT_GROUPS = {
|
||
image: new Set(["jpg","jpeg","png","gif","webp","bmp","svg","ico"]),
|
||
pdf: new Set(["pdf"]),
|
||
md: new Set(["md","markdown"]),
|
||
text: new Set([
|
||
"txt","log","json","jsonl","yaml","yml","toml","ini","csv","tsv",
|
||
"py","js","mjs","ts","jsx","tsx","go","rs","java","c","cc","cpp","h","hpp",
|
||
"html","htm","xml","css","scss","sh","bash","zsh","sql","conf","env",
|
||
]),
|
||
docx: new Set(["docx"]),
|
||
xlsx: new Set(["xlsx","xls"]),
|
||
};
|
||
function _categorize(rel) {
|
||
const m = /\.([a-z0-9]+)$/i.exec(rel);
|
||
const ext = m ? m[1].toLowerCase() : "";
|
||
for (const [cat, set] of Object.entries(_EXT_GROUPS)) if (set.has(ext)) return cat;
|
||
return "fallback";
|
||
}
|
||
|
||
let _fpCurrentRel = null;
|
||
|
||
async function openFilePreview(rel) {
|
||
_fpCurrentRel = rel;
|
||
const name = rel.split("/").pop() || rel;
|
||
$("fp-name").textContent = name;
|
||
$("fp-meta").textContent = "";
|
||
const body = $("fp-body");
|
||
body.className = "body center";
|
||
body.innerHTML = `<div class="ph">加载中…</div>`;
|
||
$("file-preview-modal").classList.add("show");
|
||
|
||
const cat = _categorize(rel);
|
||
try {
|
||
const r = await fetch("/v1/files/download?path=" + encodeURIComponent(rel), {
|
||
headers: { "Authorization": "Bearer " + state.token },
|
||
});
|
||
if (!r.ok) throw new Error("HTTP " + r.status);
|
||
const blob = await r.blob();
|
||
$("fp-meta").textContent = humanSize(blob.size);
|
||
|
||
if (cat === "text" || cat === "md") {
|
||
if (blob.size > PREVIEW_TEXT_MAX) {
|
||
_showFallback(`文件过大 (${humanSize(blob.size)}),请下载查看`);
|
||
return;
|
||
}
|
||
const text = await blob.text();
|
||
if (cat === "md") _showMarkdown(text);
|
||
else _showText(text);
|
||
return;
|
||
}
|
||
if (blob.size > PREVIEW_BIN_MAX) {
|
||
_showFallback(`文件过大 (${humanSize(blob.size)}),请下载查看`);
|
||
return;
|
||
}
|
||
if (cat === "image") _showImage(blob);
|
||
else if (cat === "pdf") _showPdf(blob);
|
||
else if (cat === "docx") await _showDocx(blob);
|
||
else if (cat === "xlsx") await _showXlsx(blob);
|
||
else _showFallback("暂不支持在线预览此格式,请下载查看");
|
||
} catch (e) {
|
||
if (e.status === 401) { closeFilePreview(); logout(); return; }
|
||
_showFallback("加载失败:" + e.message);
|
||
}
|
||
}
|
||
|
||
function _showImage(blob) {
|
||
const url = _trackBlobUrl(blob);
|
||
const body = $("fp-body");
|
||
body.className = "body center";
|
||
body.innerHTML = "";
|
||
const img = document.createElement("img");
|
||
img.className = "preview-img";
|
||
img.src = url;
|
||
body.appendChild(img);
|
||
}
|
||
|
||
function _showPdf(blob) {
|
||
const url = _trackBlobUrl(blob, "application/pdf");
|
||
const body = $("fp-body");
|
||
body.className = "body";
|
||
body.innerHTML = `<iframe class="preview-frame" src="${url}"></iframe>`;
|
||
}
|
||
|
||
function _showText(text) {
|
||
const body = $("fp-body");
|
||
body.className = "body";
|
||
body.innerHTML = "";
|
||
const pre = document.createElement("pre");
|
||
pre.className = "preview-text";
|
||
pre.textContent = text;
|
||
body.appendChild(pre);
|
||
}
|
||
|
||
function _showMarkdown(text) {
|
||
const body = $("fp-body");
|
||
body.className = "body";
|
||
body.innerHTML = `<div class="md-render">${renderMd(text)}</div>`;
|
||
highlightIn(body);
|
||
}
|
||
|
||
async function _showDocx(blob) {
|
||
const body = $("fp-body");
|
||
body.className = "body center";
|
||
body.innerHTML = `<div class="ph">解析 docx 中…</div>`;
|
||
try {
|
||
await loadScript("/static/vendor/jszip.min.js");
|
||
await loadScript("/static/vendor/docx-preview.min.js");
|
||
} catch (e) {
|
||
_showFallback("docx 解析库加载失败:" + e.message);
|
||
return;
|
||
}
|
||
if (!window.docx || !window.docx.renderAsync) {
|
||
_showFallback("docx 解析库不可用");
|
||
return;
|
||
}
|
||
body.className = "body";
|
||
body.innerHTML = `<div class="docx-host"></div>`;
|
||
try {
|
||
await window.docx.renderAsync(blob, body.querySelector(".docx-host"), null, {
|
||
inWrapper: false,
|
||
ignoreLastRenderedPageBreak: true,
|
||
});
|
||
} catch (e) {
|
||
_showFallback("docx 渲染失败:" + e.message);
|
||
}
|
||
}
|
||
|
||
async function _showXlsx(blob) {
|
||
const body = $("fp-body");
|
||
body.className = "body center";
|
||
body.innerHTML = `<div class="ph">解析表格中…</div>`;
|
||
try {
|
||
await loadScript("/static/vendor/xlsx.full.min.js");
|
||
} catch (e) {
|
||
_showFallback("xlsx 解析库加载失败:" + e.message);
|
||
return;
|
||
}
|
||
if (!window.XLSX || !window.XLSX.read) {
|
||
_showFallback("xlsx 解析库不可用");
|
||
return;
|
||
}
|
||
let wb;
|
||
try {
|
||
const ab = await blob.arrayBuffer();
|
||
wb = window.XLSX.read(ab, { type: "array" });
|
||
} catch (e) {
|
||
_showFallback("xlsx 解析失败:" + e.message);
|
||
return;
|
||
}
|
||
const names = wb.SheetNames || [];
|
||
if (!names.length) { _showFallback("xlsx 内无 sheet"); return; }
|
||
body.className = "body";
|
||
const tabsHtml = names.map((n, i) =>
|
||
`<button class="small xlsx-tab${i===0?" active":""}" data-i="${i}">${escapeHtml(n)}</button>`
|
||
).join("");
|
||
body.innerHTML = `<div class="xlsx-tabs">${tabsHtml}</div><div class="xlsx-sheet" id="fp-xlsx-sheet"></div>`;
|
||
const render = (i) => {
|
||
const ws = wb.Sheets[names[i]];
|
||
$("fp-xlsx-sheet").innerHTML = window.XLSX.utils.sheet_to_html(ws);
|
||
};
|
||
body.querySelectorAll(".xlsx-tab").forEach((btn) => {
|
||
btn.onclick = () => {
|
||
body.querySelectorAll(".xlsx-tab").forEach((b) => b.classList.remove("active"));
|
||
btn.classList.add("active");
|
||
render(parseInt(btn.dataset.i));
|
||
};
|
||
});
|
||
render(0);
|
||
}
|
||
|
||
function _showFallback(msg) {
|
||
const body = $("fp-body");
|
||
body.className = "body center";
|
||
body.innerHTML = "";
|
||
const ph = document.createElement("div");
|
||
ph.className = "ph";
|
||
ph.textContent = msg;
|
||
const br = document.createElement("br");
|
||
const dl = document.createElement("button");
|
||
dl.className = "primary";
|
||
dl.textContent = "下载原文件";
|
||
dl.style.marginTop = "12px";
|
||
dl.onclick = () => { if (_fpCurrentRel) downloadFile(_fpCurrentRel); };
|
||
ph.appendChild(document.createElement("br"));
|
||
ph.appendChild(br);
|
||
ph.appendChild(dl);
|
||
body.appendChild(ph);
|
||
}
|
||
|
||
function closeFilePreview() {
|
||
$("file-preview-modal").classList.remove("show");
|
||
$("fp-body").innerHTML = "";
|
||
_flushBlobUrls();
|
||
_fpCurrentRel = null;
|
||
}
|
||
|
||
$("fp-close").onclick = closeFilePreview;
|
||
$("fp-download").onclick = () => { if (_fpCurrentRel) downloadFile(_fpCurrentRel); };
|
||
$("file-preview-modal").addEventListener("click", (e) => {
|
||
if (e.target.id === "file-preview-modal") closeFilePreview();
|
||
});
|
||
document.addEventListener("keydown", (e) => {
|
||
if (e.key === "Escape" && $("file-preview-modal").classList.contains("show")) {
|
||
closeFilePreview();
|
||
}
|
||
});
|
||
|
||
async function uploadSelected() {
|
||
const inp = $("upload-input");
|
||
const files = Array.from(inp.files || []);
|
||
if (!files.length) return;
|
||
const fd = new FormData();
|
||
fd.append("path", state.filesPath || "");
|
||
for (const f of files) fd.append("files", f);
|
||
try {
|
||
const r = await fetch("/v1/files/upload", {
|
||
method: "POST",
|
||
headers: { "Authorization": "Bearer " + state.token },
|
||
body: fd,
|
||
});
|
||
if (!r.ok) {
|
||
const d = await r.json().catch(() => ({}));
|
||
throw new Error(d.detail || (r.status + " 上传失败"));
|
||
}
|
||
await loadFiles();
|
||
} catch (e) {
|
||
alert("上传失败:" + e.message);
|
||
} finally {
|
||
inp.value = ""; // 允许重新选同名文件
|
||
}
|
||
}
|
||
|
||
// ───── new task ─────
|
||
$("hd-new").onclick = async () => {
|
||
$("nt-name").value = ""; $("nt-wd").value = "";
|
||
$("nt-desc").value = ""; $("nt-skill").value = "";
|
||
$("nt-err").textContent = "";
|
||
$("nt-wd-hint").textContent = "";
|
||
$("new-task-modal").classList.add("show");
|
||
await Promise.all([loadFolderSuggestions(), loadSkillOptions()]);
|
||
$("nt-name").focus();
|
||
};
|
||
$("nt-cancel").onclick = () => $("new-task-modal").classList.remove("show");
|
||
$("nt-go").onclick = async () => {
|
||
const name = $("nt-name").value.trim();
|
||
const working_dir = $("nt-wd").value.trim();
|
||
const desc = $("nt-desc").value.trim();
|
||
const skill = $("nt-skill").value;
|
||
$("nt-err").textContent = "";
|
||
if (!name) { $("nt-err").textContent = "任务名为必填项"; return; }
|
||
try {
|
||
const t = await api("POST", "/v1/tasks", { name, working_dir, description: desc, skill });
|
||
$("new-task-modal").classList.remove("show");
|
||
await loadTaskList();
|
||
selectTask(t.task_id);
|
||
} catch (e) {
|
||
if (e.status === 401) { logout(); return; }
|
||
$("nt-err").textContent = e.message;
|
||
}
|
||
};
|
||
|
||
// 工作目录 autocomplete:打开 modal 时拉一次,输入时实时提示"复用 / 新建"
|
||
async function loadFolderSuggestions() {
|
||
try {
|
||
const data = await api("GET", "/v1/folders");
|
||
const dl = $("folders-datalist");
|
||
dl.innerHTML = (data.folders || []).map((f) => {
|
||
const tag = f.n_tasks ? `${f.n_tasks} 个任务` : `空目录`;
|
||
return `<option value="${escapeHtml(f.name)}" data-n="${f.n_tasks}" label="${escapeHtml(tag)}"></option>`;
|
||
}).join("");
|
||
} catch (e) {
|
||
// 静默 — datalist 留空不影响用户输入
|
||
}
|
||
}
|
||
|
||
// 智能体类型下拉:skill registry 服务器端静态,首次加载后缓存到 state.skills
|
||
async function loadSkillOptions() {
|
||
const sel = $("nt-skill");
|
||
if (!state.skills) {
|
||
try {
|
||
const data = await api("GET", "/v1/skills");
|
||
state.skills = data.skills || [];
|
||
} catch (e) {
|
||
state.skills = []; // 静默兜底,select 仍保留"(默认)"项
|
||
}
|
||
}
|
||
// 渲染:第一项固定为"默认"(空 value),其后逐 skill 一项
|
||
const opts = ['<option value="">(默认 · 不限定)</option>'];
|
||
for (const s of state.skills) {
|
||
const label = `${s.name}${s.description ? " — " + s.description : ""}`;
|
||
opts.push(`<option value="${escapeHtml(s.name)}" title="${escapeHtml(s.description || "")}">${escapeHtml(label)}</option>`);
|
||
}
|
||
sel.innerHTML = opts.join("");
|
||
sel.value = ""; // hd-new 已清空,这里幂等再保一次
|
||
}
|
||
|
||
$("nt-wd").addEventListener("input", () => {
|
||
const v = $("nt-wd").value.trim();
|
||
const hint = $("nt-wd-hint");
|
||
if (!v) {
|
||
const fallback = $("nt-name").value.trim();
|
||
hint.textContent = fallback ? `留空 → 用任务名「${fallback}」作目录` : "";
|
||
return;
|
||
}
|
||
const opt = $("folders-datalist").querySelector(`option[value="${CSS.escape(v)}"]`);
|
||
if (opt) {
|
||
const n = parseInt(opt.dataset.n) || 0;
|
||
hint.innerHTML = `<span style="color:var(--accent);">→ 复用已有目录</span> · ${n} 个任务`;
|
||
} else {
|
||
hint.innerHTML = `<span class="muted">→ 新建目录 ${escapeHtml(v)}</span>`;
|
||
}
|
||
});
|
||
$("nt-name").addEventListener("input", () => {
|
||
// 任务名输入时,若工作目录为空,提示 fallback 文案动态更新
|
||
if (!$("nt-wd").value.trim()) {
|
||
const fallback = $("nt-name").value.trim();
|
||
$("nt-wd-hint").textContent = fallback ? `留空 → 用任务名「${fallback}」作目录` : "";
|
||
}
|
||
});
|
||
|
||
// ───── boot ─────
|
||
if (state.token) {
|
||
// 已有 token:试探一下,失败回登录页
|
||
enterApp();
|
||
} else {
|
||
$("li-uid").value = SENTINEL;
|
||
$("li-uid").focus();
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|