zcbot/web/static/dev.html

3158 lines
132 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>zcbot 控制台</title>
<meta name="viewport" content="width=device-width,initial-scale=1" />
<!-- markdown + 防 XSS + 代码高亮(纯 CDN,失败优雅降级回 plain text) -->
<script src="https://cdn.jsdelivr.net/npm/marked@12/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/highlight.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/github.min.css" />
<style>
:root {
--bg: #f7f7f7;
--panel: #ffffff;
--border: #e3e3e3;
--border-soft: #ececec;
--text: #222;
--muted: #888;
--accent: #c0392b;
--accent-soft: #fde9e7;
--hover: #f0f0f0;
--code-bg: #f4f4f4;
--user-bg: #eef4fb;
--asst-bg: #ffffff;
/* 语义色组:done/export/clear/abandon/delete 按钮 + dd-item + badge 共用 */
--c-green: #27ae60; --c-green-bg: #e9f7ef; --c-green-bd: #a9dfbf;
--c-blue: #2980b9; --c-blue-bg: #ebf5fb; --c-blue-bd: #aed6f1;
--c-purple: #8e44ad; --c-purple-bg: #f5eef8; --c-purple-bd: #d2b4de;
--c-orange: #e67e22; --c-orange-bg: #fef5e7; --c-orange-bd: #f5cba7;
--c-red: #c0392b; --c-red-bg: #fdedec; --c-red-bd: #f5b7b1;
/* 圆角:各档下调一档(没那么圆润) */
--r-sm: 3px; /* code / 小标签 */
--r-md: 4px; /* button / input / 消息气泡 / 卡片元素 */
--r-lg: 6px; /* modal card / 中型容器 */
--r-xl: 8px; /* 大 modal card(登录卡) */
--shadow-card: 0 12px 32px rgba(0,0,0,.18);
--shadow-card-lg: 0 20px 60px rgba(0,0,0,.12), 0 2px 6px rgba(0,0,0,.04);
--mono: ui-monospace, "Cascadia Code", "SF Mono", Consolas, monospace;
--t: all .15s;
}
* { 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: var(--r-md); cursor: pointer;
transition: var(--t);
}
button:hover:not(:disabled) { background: var(--hover); }
button:disabled { opacity: 0.4; cursor: not-allowed; }
button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
button.primary:hover { background: var(--accent); filter: brightness(1.08); }
button.danger:hover { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
input:not([type="checkbox"]):not([type="radio"]):not([type="file"]),
textarea, select {
background: #fff; border: 1px solid var(--border);
padding: 5px 8px; border-radius: var(--r-md); width: 100%;
}
input[type="checkbox"], input[type="radio"] { cursor: pointer; }
textarea { resize: vertical; min-height: 60px; }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
/* 4 个 modal 共用骨架(admin / src-picker / new-task / file-preview) */
.modal {
position: fixed; inset: 0; background: rgba(0,0,0,0.4);
display: none; align-items: center; justify-content: center;
}
.modal.show { display: flex; }
.modal > .card {
background: var(--panel); border-radius: var(--r-lg);
box-shadow: var(--shadow-card);
}
/* ───── login overlay ───── */
#login {
position: fixed; inset: 0; z-index: 100;
display: flex; align-items: center; justify-content: center;
background:
radial-gradient(1200px 600px at 15% 10%, rgba(192,57,43,0.10), transparent 60%),
radial-gradient(900px 500px at 85% 95%, rgba(52,73,94,0.10), transparent 60%),
linear-gradient(135deg, #f5f7fa 0%, #e8ecf1 100%);
}
#login .card {
background: var(--panel);
padding: 32px 36px 28px;
border-radius: var(--r-xl);
width: 380px;
box-shadow: var(--shadow-card-lg);
border: 1px solid rgba(0,0,0,.04);
animation: login-in .35s cubic-bezier(.2,.7,.2,1);
}
@keyframes login-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
#login .brand { display: flex; align-items: center; gap: 10px; margin-bottom: 4px; }
#login .brand .logo {
width: 32px; height: 32px; border-radius: var(--r-md);
background: linear-gradient(135deg, var(--accent), #8e2a20);
color: #fff; font-weight: 700; font-size: 16px;
display: flex; align-items: center; justify-content: center;
box-shadow: 0 4px 10px rgba(192,57,43,.35);
}
#login .brand .name { font-size: 18px; font-weight: 600; letter-spacing: .2px; }
#login h2 { margin: 14px 0 18px; font-size: 15px; font-weight: 500; color: var(--muted); }
#login label {
display: block; margin-top: 12px; margin-bottom: 4px;
font-size: 12px; color: var(--muted); letter-spacing: .2px;
}
#login input {
padding: 9px 12px; border-radius: var(--r-md);
border: 1px solid var(--border); background: #fafafa;
transition: var(--t);
}
#login input:hover { background: #fff; }
#login input:focus, #admin-modal input:focus {
outline: none; background: #fff; border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(192,57,43,.12);
}
#login .err { color: var(--accent); font-size: 12px; margin-top: 12px; min-height: 1em; }
#login .actions { margin-top: 18px; display: flex; gap: 8px; }
#login .actions .primary {
flex: 1; padding: 9px 14px; font-size: 14px; font-weight: 500;
border-radius: var(--r-md); transition: var(--t);
box-shadow: 0 2px 6px rgba(192,57,43,.25);
}
#login .actions .primary:hover { box-shadow: 0 4px 12px rgba(192,57,43,.35); }
#login .actions .primary:active { transform: translateY(1px); }
#login .tabs {
display: flex; border-bottom: 1px solid var(--border);
margin: 0 0 14px; gap: 4px;
}
#login .tabs button {
background: none; border: none; border-bottom: 2px solid transparent;
padding: 8px 4px; margin-right: 16px; font-size: 13px;
color: var(--muted); cursor: pointer; transition: var(--t);
}
#login .tabs button:hover { color: var(--text); background: none; }
#login .tabs button.active { color: var(--accent); border-bottom-color: var(--accent); }
#login .tab-body { display: none; }
#login .tab-body.active { display: block; animation: tab-in .2s ease-out; }
@keyframes tab-in {
from { opacity: 0; transform: translateY(2px); }
to { opacity: 1; transform: translateY(0); }
}
#login code { background: var(--code-bg); padding: 1px 5px; border-radius: var(--r-sm); font-size: 11.5px; }
#login .card-footer { margin-top: 10px; display: flex; justify-content: flex-end; }
#login .ghost-link {
color: var(--muted); font-size: 12px; text-decoration: none;
padding: 2px 4px; border-radius: var(--r-md); transition: var(--t);
}
#login .ghost-link:hover { color: var(--accent); background: var(--accent-soft); }
/* ───── admin add-user modal ───── */
#admin-modal { z-index: 110; }
#admin-modal .card { padding: 20px 24px; width: 360px; }
#admin-modal h3 { margin: 0 0 12px; font-size: 15px; }
#admin-modal label {
display: block; margin-top: 10px; margin-bottom: 4px;
font-size: 12px; color: var(--muted);
}
#admin-modal input {
width: 100%; padding: 8px 10px; border-radius: var(--r-md);
border: 1px solid var(--border); background: #fafafa;
}
#admin-modal .err { color: var(--accent); font-size: 12px; margin-top: 10px; min-height: 1em; }
#admin-modal .actions { margin-top: 14px; display: flex; gap: 8px; justify-content: flex-end; }
/* ───── 3-pane layout ───── */
#app { display: none; height: 100vh; }
#app.ready { display: grid; grid-template-columns: 320px 1fr 320px; grid-template-rows: auto 1fr; grid-template-areas: "head head head" "left mid right"; }
/* 折叠左 pane:rail 模式,列收成 40px,pane 内只留一个展开按钮(类 VS Code 范式) */
body.left-collapsed #app.ready { grid-template-columns: 40px 1fr 320px; }
body.left-collapsed #pane-left > * { display: none; }
body.left-collapsed #pane-left > .pane-head:first-child {
display: flex; justify-content: center; align-items: center;
padding: 6px 4px; border-bottom: none; background: transparent;
position: static; /* 取消 sticky,rail 太窄不需要滚 */
}
body.left-collapsed #pane-left > .pane-head:first-child > * { display: none; }
body.left-collapsed #pane-left > .pane-head:first-child > #pane-toggle-left { display: inline-block; }
header {
grid-area: head; background: #fff; border-bottom: 1px solid var(--border);
padding: 8px 14px; display: flex; align-items: center; gap: 12px;
box-shadow: 0 1px 2px rgba(0,0,0,.03);
}
header .brand { display: flex; align-items: center; gap: 8px; }
header .brand .logo {
width: 24px; height: 24px; border-radius: var(--r-md);
background: linear-gradient(135deg, var(--accent), #8e2a20);
color: #fff; font-weight: 700; font-size: 13px;
display: flex; align-items: center; justify-content: center;
box-shadow: 0 2px 6px rgba(192,57,43,.28);
}
header .title { font-weight: 600; font-size: 15px; letter-spacing: .2px; }
header .who { color: var(--muted); font-size: 12px; font-family: var(--mono); }
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; }
/* 左 pane:title 行(#fafafa)下面的 filter / sort 子行换成白底 + 极淡分隔,弱化层级 */
#pane-left .pane-head + .pane-head {
background: #fff;
border-bottom: 1px solid var(--border-soft);
}
/* 对话顶栏按钮:常态中性 + hover 上语义色 — 完成 绿/导出 蓝/清空 紫/废弃 橙/删除 红
同色组合并 selector(export ≈ sp-copy 蓝, abandon ≈ sp-move 橙) */
#btn-done:hover:not(:disabled) { color: var(--c-green); border-color: var(--c-green-bd); background: var(--c-green-bg); }
#btn-export:hover:not(:disabled),
#sp-copy:hover:not(:disabled) { color: var(--c-blue); border-color: var(--c-blue-bd); background: var(--c-blue-bg); }
#btn-clear-msgs:hover:not(:disabled) { color: var(--c-purple); border-color: var(--c-purple-bd); background: var(--c-purple-bg); }
#btn-abandon:hover:not(:disabled),
#sp-move:hover:not(:disabled) { color: var(--c-orange); border-color: var(--c-orange-bd); background: var(--c-orange-bg); }
#btn-delete-task:hover:not(:disabled) { color: var(--c-red); border-color: var(--c-red-bd); background: var(--c-red-bg); }
/* ───── 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: var(--r-sm); 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: var(--r-md);
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, .dd-item.act-download { color: var(--c-green); }
.dd-item.act-abandon { color: var(--c-orange); }
.dd-item.act-export, .dd-item.act-rename { color: var(--c-blue); }
.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; }
/* meta 行:flex nowrap + 每个子项 nowrap,防 CJK 字符在窄 pane(320px)被 shrink 后断行 */
/* tabular-nums 让数字等宽(条 / tok 计数跨行对齐) */
.task-row .meta { font-size: 11px; color: var(--muted); display: flex; gap: 8px; min-width: 0;
align-items: baseline; font-variant-numeric: tabular-nums; }
.task-row .meta > * { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
.task-row .meta .badge { flex-shrink: 0; }
/* 数字槽位:固定 min-width + 右对齐;time-ago 也锁宽 → 整个右侧组位置稳定,跨行"条/tok"才能对齐 */
.task-row .meta .num { flex-shrink: 0; text-align: right; min-width: 44px; }
.task-row .meta .num.right-group { margin-left: auto; } /* 把数字+时间整组挤到右侧 */
.task-row .meta .time-ago { flex-shrink: 0; text-align: right; min-width: 64px; }
.task-row .badge {
display: inline-block; padding: 0 6px; border-radius: var(--r-md); font-size: 11px;
background: #eef; color: #336;
}
.badge.completed { background: var(--c-green-bg); color: var(--c-green); }
.badge.abandoned { background: var(--accent-soft); 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: var(--mono); color: var(--text); }
#chat-meta .spacer { flex: 1; }
/* 同 wd 并发软警告 banner — 非阻塞,只提示中间产物互覆风险 */
#wd-concurrent-warn { padding: 6px 12px; border-bottom: 1px solid #f0c36d;
background: #fff8e1; color: #6a4500; font-size: 12px; }
#wd-concurrent-warn .tname { font-weight: 600; }
#wd-concurrent-warn .rs { font-family: var(--mono); opacity: 0.7; }
#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: var(--r-md); 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: var(--r-md); display: inline-block; }
.msg .role { font-size: 11px; color: var(--muted); margin-bottom: 2px; font-family: var(--mono); }
.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 与 file-preview .md-render 共用一组规则 */
.msg .body > :first-child, .md-render > :first-child { margin-top: 0; }
.msg .body > :last-child, .md-render > :last-child { margin-bottom: 0; }
.msg .body p, .md-render p { margin: 0.4em 0; }
.msg .body h1, .msg .body h2, .msg .body h3, .msg .body h4,
.md-render h1, .md-render h2, .md-render h3, .md-render h4 {
margin: 0.8em 0 0.3em; line-height: 1.3;
}
.msg .body h1, .md-render h1 { font-size: 1.4em; }
.msg .body h2, .md-render h2 { font-size: 1.25em; }
.msg .body h3, .md-render h3 { font-size: 1.1em; }
.msg .body h4, .md-render h4 { font-size: 1em; font-weight: 600; }
.msg .body ul, .msg .body ol, .md-render ul, .md-render ol { margin: 0.4em 0; padding-left: 1.6em; }
.msg .body li, .md-render li { margin: 0.15em 0; }
.msg .body li > p, .md-render li > p { margin: 0.15em 0; }
.msg .body blockquote, .md-render 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), .md-render code:not(pre code) {
background: var(--code-bg); padding: 1px 5px; border-radius: var(--r-sm);
font-family: var(--mono); font-size: 0.92em;
}
.msg .body pre, .md-render pre {
margin: 0.5em 0; padding: 10px; background: #f6f8fa; border-radius: var(--r-md);
overflow-x: auto; font-size: 12.5px; line-height: 1.4;
}
.msg .body pre code, .md-render pre code { font-family: var(--mono); background: transparent; padding: 0; }
.msg .body table, .md-render table { border-collapse: collapse; margin: 0.5em 0; font-size: 13px; }
.msg .body th, .msg .body td, .md-render th, .md-render td {
border: 1px solid var(--border); padding: 4px 8px;
}
.msg .body th, .md-render th { background: #fafafa; font-weight: 600; }
.msg .body a, .md-render a { color: var(--accent); }
.msg .body img, .md-render img { max-width: 100%; }
.msg .body hr, .md-render hr { border: none; border-top: 1px solid var(--border); margin: 0.8em 0; }
.tool-call { margin-top: 6px; font-family: var(--mono); font-size: 12px; }
.tool-call summary {
cursor: pointer; padding: 4px 6px; background: var(--code-bg); border-radius: var(--r-sm);
color: #555;
}
.tool-call summary:hover { background: #ebebeb; }
.tool-call pre {
margin: 4px 0 0; padding: 8px; background: var(--code-bg); border-radius: var(--r-sm);
overflow-x: auto; max-height: 300px; white-space: pre-wrap;
}
/* media tool 摘要 banner(model / size / cost / elapsed,折叠态也可见) */
.tool-banner {
display: inline-flex; flex-wrap: wrap; gap: 6px;
margin-left: 8px; font-size: 11px; vertical-align: middle;
}
.tool-banner .kv {
padding: 1px 6px; border-radius: var(--r-sm); background: #fff;
border: 1px solid var(--border); color: #555;
}
.tool-banner .kv.cost { color: #b34a4a; border-color: #e0c4c4; }
.tool-banner .kv.model { color: var(--accent); border-color: #e0c4c4; }
/* ───── artifact chips(对话内点产物预览/下载) ───── */
.artifact-bar { margin-top: 4px; display: flex; flex-wrap: wrap; gap: 4px; font-family: var(--mono); }
.art-chip {
font: inherit; font-size: 11px; line-height: 1.4;
padding: 2px 8px 2px 6px; border: 1px solid var(--border);
background: #fff; color: #555; border-radius: 999px; cursor: pointer;
max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
display: inline-flex; align-items: center; gap: 4px;
transition: var(--t);
}
.art-chip::before { content: "📄"; font-size: 11px; }
.art-chip:hover { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
/* 内联图片/视频:产物 chip 替代,fetch 完直接展示 */
.art-media {
border: 1px solid var(--border); border-radius: var(--r-md); overflow: hidden;
background: #fff; display: inline-block; line-height: 0;
}
.art-media .art-media-loading, .art-media .art-media-error {
display: inline-block; padding: 6px 10px; font-size: 11px;
color: var(--muted); line-height: 1.4; font-family: var(--mono);
}
.art-media .art-media-error { color: #b34a4a; }
.art-media img {
display: block; max-width: 360px; max-height: 280px;
width: auto; height: auto; cursor: zoom-in;
}
.art-media video {
display: block; max-width: 480px; max-height: 320px;
width: auto; height: auto; background: #000;
}
#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: var(--mono); }
.ico-dir::before { content: "▸ "; color: var(--accent); }
.ico-file::before { content: "· "; color: var(--muted); }
/* 拖拽上传 overlay:hover 整个 pane-right 时铺一层提示 */
#pane-right { position: relative; }
#file-droparea {
position: absolute; inset: 0; pointer-events: none;
display: none; align-items: center; justify-content: center;
background: rgba(192,57,43,0.06); border: 2px dashed var(--accent);
color: var(--accent); font-size: 14px; font-weight: 500;
z-index: 10;
}
#file-droparea.show { display: flex; }
/* ───── source picker modal(选入文件:勾源 + 复制/移动到主区当前目录) ───── */
#src-picker-modal { z-index: 95; }
#src-picker-modal .card {
width: 560px; max-height: 82vh;
display: flex; flex-direction: column;
}
#src-picker-modal h3 {
margin: 0; padding: 14px 18px; font-size: 16px;
border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 8px;
}
#src-picker-modal h3 .dest {
font-size: 12px; color: var(--muted); font-weight: 400;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
#src-picker-modal .hint {
padding: 8px 18px; font-size: 12px; color: var(--muted);
border-bottom: 1px solid var(--border);
}
#sp-crumbs { padding: 8px 14px; border-bottom: 1px solid var(--border); font-size: 12px; background: #fafafa; }
#sp-crumbs a { margin-right: 4px; }
#sp-list { flex: 1; overflow: auto; min-height: 240px; max-height: 50vh; }
#sp-list .sp-row {
padding: 6px 14px; border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 8px; font-size: 13px;
}
#sp-list .sp-row:hover { background: var(--hover); }
#sp-list .sp-row .sp-cb { flex-shrink: 0; margin: 0; }
#sp-list .sp-row .sp-name {
flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
cursor: pointer;
}
#sp-list .sp-row.disabled .sp-name { color: var(--muted); cursor: not-allowed; }
#sp-list .sp-row .sp-size { font-size: 11px; color: var(--muted); font-family: var(--mono); }
#src-picker-modal .actions {
padding: 12px 18px; border-top: 1px solid var(--border);
display: flex; gap: 8px; align-items: center;
}
#src-picker-modal .actions .count { flex: 1; font-size: 12px; color: var(--muted); }
/* ───── new task modal ───── */
#new-task-modal { z-index: 80; }
#new-task-modal .card { padding: 20px; width: 420px; }
#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(略深的遮罩 0.5 + 更重阴影) ─────
bottom 让出 chat-form 高度(--preview-bottom-inset 由 JS 按需写),输入区不被遮挡,可继续打字 */
#file-preview-modal {
background: rgba(0,0,0,0.5); z-index: 90;
bottom: var(--preview-bottom-inset, 0);
}
#file-preview-modal .card {
width: 90vw; height: 90vh; max-width: 1200px;
max-height: calc(100vh - var(--preview-bottom-inset, 0px) - 32px);
display: flex; flex-direction: column;
box-shadow: 0 12px 32px rgba(0,0,0,.22);
}
#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 video.preview-video {
max-width: 100%; max-height: 100%; display: block; margin: 0 auto; outline: none;
}
#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: var(--r-md); white-space: pre-wrap; word-break: break-word;
font-family: var(--mono); font-size: 12px; line-height: 1.5;
}
/* .md-render 通用样式已与 .msg .body 合并到上方 chat 段;这里只保留 file-preview 专属 */
#file-preview-modal .body .md-render { max-width: 860px; margin: 0 auto; line-height: 1.7; }
#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); }
/* ───── mobile tab nav (header) — 桌面隐藏,手机断点内显示 ───── */
.mobile-tabs { display: none; gap: 4px; }
.mobile-tabs button {
padding: 5px 10px; font-size: 12px; line-height: 1.2;
background: transparent; border: 1px solid var(--border);
border-radius: var(--r-md); color: var(--muted); cursor: pointer;
transition: var(--t);
}
.mobile-tabs button.active {
background: var(--accent-soft); border-color: var(--accent); color: var(--accent);
}
/* ───── responsive: tablet (641-1024px) ─────
断点内强制 rail(纯 CSS,不写 localStorage;回桌面用户偏好仍生效) */
@media (min-width: 641px) and (max-width: 1024px) {
#app.ready { grid-template-columns: 40px 1fr 260px; }
#pane-left > * { display: none; }
#pane-left > .pane-head:first-child {
display: flex; justify-content: center; align-items: center;
padding: 6px 4px; border-bottom: none; background: transparent;
position: static;
}
#pane-left > .pane-head:first-child > * { display: none; }
#pane-left > .pane-head:first-child > #pane-toggle-left { display: inline-block; }
}
/* ───── responsive: phone (≤ 640px) — 单列 + tab 切换 ─────
JS 在进入手机视口时会清掉 body.left-collapsed,这里无需为它写覆盖
iOS 用 100dvh 避免地址栏/工具栏挤压视口高度 */
@media (max-width: 640px) {
html, body { height: 100dvh; }
#app.ready {
height: 100dvh;
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
grid-template-areas: "head" "main";
}
/* 三 pane 默认隐藏,body.mv-* 决定显示哪个 */
#pane-left, #pane-mid, #pane-right {
grid-area: main; border-right: none; display: none;
}
body.mv-left #pane-left { display: block; }
body.mv-mid #pane-mid { display: flex; }
body.mv-right #pane-right { display: block; }
/* 折叠按钮在手机不可见 */
#pane-toggle-left { display: none !important; }
/* header 紧凑化 */
header { padding: 6px 10px; gap: 6px; flex-wrap: wrap; }
header .who { display: none; }
header .title { font-size: 14px; }
/* tab 按钮:整行铺底,order:99 让它换行到 header 第二行 */
.mobile-tabs { display: flex; order: 99; flex-basis: 100%; }
.mobile-tabs button { flex: 1; }
#hd-new { padding: 4px 8px; font-size: 12px; }
#hd-logout { padding: 4px 8px; font-size: 12px; }
/* iOS 防 focus 自动缩放:input/textarea 字号 ≥ 16 */
textarea,
input:not([type="checkbox"]):not([type="radio"]):not([type="file"]) {
font-size: 16px;
}
/* chat / 文件 微调 */
.msg { max-width: 96%; }
#chat-meta { padding: 6px 10px; gap: 8px; font-size: 11px; }
#chat-form { padding: 8px; }
.art-media img, .art-media video { max-width: 100%; }
.file-row { padding: 8px 12px; }
/* 4 个 modal 卡片自适应宽度 */
#login .card { width: min(92vw, 380px); padding: 24px 22px 22px; }
#admin-modal .card { width: min(92vw, 360px); }
#new-task-modal .card { width: min(92vw, 420px); }
#src-picker-modal .card { width: min(96vw, 560px); max-height: 88dvh; }
#file-preview-modal .card {
width: 100vw; height: 100dvh;
max-width: 100vw;
max-height: calc(100dvh - var(--preview-bottom-inset, 0px));
border-radius: 0;
}
}
</style>
</head>
<body>
<!-- ───── login overlay ───── -->
<div id="login">
<div class="card">
<div class="brand">
<div class="logo">Z</div>
<div class="name">zcbot</div>
</div>
<h2>登录到控制台</h2>
<div class="tabs">
<button data-tab="pw" class="active" id="tab-pw">邮箱密码</button>
<button data-tab="key" id="tab-key">UUID + PLATFORM_KEY</button>
</div>
<!-- tab 1: 邮箱 + 密码(默认) -->
<div class="tab-body active" id="body-pw">
<label for="li-email">邮箱</label>
<input id="li-email" type="email" autocomplete="username" placeholder="you@example.com" />
<label for="li-password">密码</label>
<input id="li-password" type="password" autocomplete="current-password" placeholder="密码" />
<div class="small muted" style="margin-top: 10px;">
管理员发用户:<code>python main.py user add --email X --password Y</code>
</div>
</div>
<!-- tab 2: UUID + PLATFORM_KEY(platform 服务端 / 调试用) -->
<div class="tab-body" id="body-key">
<label for="li-uid">user_id (UUID)</label>
<input id="li-uid" type="text" autocomplete="off" placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" />
<label for="li-pkey">PLATFORM_KEY</label>
<input id="li-pkey" type="password" autocomplete="off" placeholder="$PLATFORM_KEY env 值" />
<div class="small muted" style="margin-top: 10px;">
平台服务端机器对机器入口;手动登录用于本地调试 / 接管已有 user_id。
</div>
</div>
<div class="err" id="li-err"></div>
<div class="actions">
<button class="primary" id="li-go">登录</button>
</div>
<div class="card-footer">
<a href="#" id="open-admin-add" class="ghost-link">+ 管理员添加用户</a>
</div>
</div>
</div>
<!-- ───── admin add-user modal ───── -->
<div id="admin-modal" class="modal">
<div class="card">
<h3>添加用户</h3>
<label for="ad-email">邮箱</label>
<input id="ad-email" type="email" autocomplete="off" placeholder="new@example.com" />
<label for="ad-password">密码</label>
<input id="ad-password" type="password" autocomplete="new-password" placeholder="≥ 6 字符" />
<label for="ad-token">管理员口令</label>
<input id="ad-token" type="password" autocomplete="off" placeholder="$ZCBOT_ADMIN_TOKEN env 值" />
<div class="err" id="ad-err"></div>
<div class="actions">
<button id="ad-cancel">取消</button>
<button class="primary" id="ad-go">创建</button>
</div>
</div>
</div>
<!-- ───── main 3-pane ───── -->
<div id="app">
<header>
<div class="brand">
<div class="logo">Z</div>
<div class="title">zcbot</div>
</div>
<div class="who" id="hd-who"></div>
<div class="spacer"></div>
<button id="hd-new" class="primary">+ 新建任务</button>
<button id="hd-logout">退出登录</button>
<!-- 手机 tab(桌面 display:none):任务 / 对话 / 文件 -->
<div class="mobile-tabs" role="tablist">
<button id="mv-tab-left" data-mv="mv-left" class="active">任务</button>
<button id="mv-tab-mid" data-mv="mv-mid">对话</button>
<button id="mv-tab-right" data-mv="mv-right">文件</button>
</div>
</header>
<!-- left -->
<div class="pane" id="pane-left">
<div class="pane-head">
<span class="label">任务</span>
<span class="small muted" id="task-count" style="font-size:11px;"></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>
<button id="pane-toggle-left" class="small" title="折叠任务列表"></button>
</div>
<div class="pane-head" style="gap: 6px;">
<input id="filter-q" class="small" placeholder="搜索名称/描述..." style="flex:1; padding: 3px 6px;" />
<select id="filter-wd" class="small" style="flex:1; padding: 3px 6px;">
<option value="">(全部目录)</option>
</select>
</div>
<div class="pane-head" style="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-sentinel" style="padding:10px 12px;font-size:11px;color:var(--muted);text-align:center;min-height:1px;"></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>导出对话记录</button>
<button id="btn-clear-msgs" class="small" disabled title="清空当前任务的对话历史(messages + token 累计归零),工作目录文件保留">清空对话</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="wd-concurrent-warn" style="display:none;"></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 换行,Ctrl+V 可粘贴文件)"></textarea>
<div class="row">
<span class="hint" id="chat-hint">就绪</span>
<span style="flex:1;"></span>
<button type="button" class="small" id="chat-optimize" disabled title="用当前对话模型润色草稿(参考已选生图模型偏好)— 替换为更清晰可执行的 prompt,Ctrl+Z 可撤销">✨ 润色</button>
<button type="submit" class="primary" id="chat-action">发送</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-src-pick" class="small" title="从其他目录勾选文件 / 目录,复制或移动到当前主目录">选入…</button>
<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>
<div id="file-droparea">松开以上传到当前目录</div>
<input type="file" id="upload-input" multiple style="display:none;" />
</div>
</div>
<!-- ───── source picker modal(选入文件:勾源 → 复制/移动到主区当前目录) ───── -->
<div id="src-picker-modal" class="modal">
<div class="card">
<h3>
<span>选入到</span>
<span class="dest" id="sp-dest" title=""></span>
</h3>
<div class="hint">勾选要带入的文件 / 目录(可跨目录,选择跨切换保留);底部按钮把它们复制或移动到此处。</div>
<div id="sp-crumbs"></div>
<div id="sp-list"></div>
<div class="actions">
<span class="count">已选 <span id="sp-count">0</span></span>
<button id="sp-cancel">取消</button>
<button id="sp-copy" disabled title="复制(新副本,源保留)">复制到此处</button>
<button id="sp-move" disabled title="移动(源消失;working_dir 顶层目录不可移)">移动到此处</button>
</div>
</div>
</div>
<!-- ───── floating dropdown menu (single instance) ───── -->
<div id="floating-menu"></div>
<!-- ───── new task modal ───── -->
<div id="new-task-modal" class="modal">
<div class="card">
<h3>新建任务</h3>
<label for="nt-name">任务名(必填)</label>
<input id="nt-name" placeholder="例如 初稿大纲" />
<label for="nt-wd-sel">工作目录</label>
<select id="nt-wd-sel">
<option value="__new__">+ 新建(跟随任务名)</option>
</select>
<input id="nt-wd-new" placeholder="新目录名" style="margin-top:6px;" />
<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>
<label for="nt-model">模型</label>
<select id="nt-model"></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" class="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 LS_TOKEN = "zcbot.token";
const LS_UID = "zcbot.user_id";
const LS_NAME = "zcbot.name";
const LS_LEFT_COLLAPSED = "zcbot.left-collapsed";
const state = {
token: localStorage.getItem(LS_TOKEN) || "",
userId: localStorage.getItem(LS_UID) || "",
userName: localStorage.getItem(LS_NAME) || "",
taskId: null,
taskMeta: null,
filesPath: "",
// 同 wd 内除自己外其他活跃 task(run_status in running/cancelling),供 banner 显示
concurrentWarnings: [],
evtSrc: null,
streaming: false, // 当前是否在流式中;true 时显示 stop 按钮
// task list 滚动加载 + 筛选
taskPage: 0, // 已加载到的最后一页(0 = 未加载)
taskPageSize: 20,
taskTotal: 0,
taskLoaded: 0, // 已渲染条数(用于 has-more 判断)
taskLoading: false, // 在途请求标记,防 observer 重复触发
taskHasMore: true,
// 模型清单(GET /v1/models 一次缓存):新建对话框 + 顶栏切换下拉 + 历史小标显示名都用
models: [],
// 图像生成模型清单(GET /v1/image_models;ARK_API_KEY 未设也会拿到 yaml 元数据)
imageModels: [],
// 当前选中的图像生成 variant key(per-session,不入 DB);默认 = imageModels[0].variant
// (=yaml 第一个 = agent_builder fallback)。下次 send 消息时随 POST body 带给 backend。
imageModel: "",
// 视频生成模型清单(GET /v1/video_models)+ 当前选中 variant。同 imageModels 范式。
videoModels: [],
videoModel: "",
// 润色按钮进行中标记:防止双击,同时让 syncOptimizeBtn 在 in-flight 期间不覆盖
// disabled 状态(否则用户键入 input 会把按钮从"润色中"误启回 enabled)
optimizing: false,
};
// ───── 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; }
}
// 紧凑 token 显示:<1k 原数,<10k 一位小数 k,>=10k 整数 k,>=1M 一位小数 M
// 目的:让列表行 "N tok" 槽位宽度有上限,跨行对齐
function fmtTokens(n) {
n = n || 0;
if (n < 1000) return String(n);
if (n < 10000) return (n / 1000).toFixed(1) + "k";
if (n < 1000000) return Math.round(n / 1000) + "k";
return (n / 1000000).toFixed(1) + "M";
}
// 相对时间(任务列表用):刚刚 / N 分钟前 / N 小时前 / 昨天 HH:MM / MM-DD / YYYY-MM-DD
function fmtTimeAgo(iso) {
if (!iso) return "";
let d;
try { d = new Date(iso); } catch (e) { return iso; }
if (isNaN(d.getTime())) return iso;
const now = new Date();
const diffSec = Math.floor((now - d) / 1000);
if (diffSec < 0) return d.toLocaleString(); // 时钟漂移兜底
if (diffSec < 60) return "刚刚";
if (diffSec < 3600) return `${Math.floor(diffSec / 60)} 分钟前`;
if (diffSec < 86400 && now.getDate() === d.getDate()) return `${Math.floor(diffSec / 3600)} 小时前`;
const pad = (n) => String(n).padStart(2, "0");
const hhmm = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
const yest = new Date(now); yest.setDate(now.getDate() - 1);
if (d.getFullYear() === yest.getFullYear() && d.getMonth() === yest.getMonth() && d.getDate() === yest.getDate()) {
return `昨天 ${hhmm}`;
}
if (d.getFullYear() === now.getFullYear()) return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${hhmm}`;
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
function escapeHtml(s) {
return (s || "").replace(/[&<>"']/g, (c) => (
{ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]
));
}
// ───── markdown 渲染 ─────
// 三个 CDN 库任一缺失 → 优雅降级回 <pre>escapeHtml</pre>(plain text wrap)
if (window.marked && window.marked.setOptions) {
window.marked.setOptions({ gfm: true, breaks: true, headerIds: false, mangle: false });
}
function renderMd(text) {
const raw = String(text || "");
if (!window.marked || !window.marked.parse) {
return `<pre style="white-space:pre-wrap;word-break:break-word;font-family:inherit;margin:0;">${escapeHtml(raw)}</pre>`;
}
let html = window.marked.parse(raw);
if (window.DOMPurify) {
html = window.DOMPurify.sanitize(html, { USE_PROFILES: { html: true } });
}
return html;
}
function highlightIn(container) {
if (!window.hljs || !container) return;
container.querySelectorAll("pre code").forEach((b) => {
if (b.dataset.hl === "1") return;
try { window.hljs.highlightElement(b); b.dataset.hl = "1"; } catch (e) {}
});
}
// ───── login ─────
let loginTab = "pw"; // "pw" | "key";持久化 last-used tab 在 LS,刷新后默认那个
const LS_TAB = "zcbot_login_tab";
function switchLoginTab(name) {
loginTab = name;
document.querySelectorAll("#login .tabs button").forEach(b => {
b.classList.toggle("active", b.dataset.tab === name);
});
document.querySelectorAll("#login .tab-body").forEach(b => {
b.classList.toggle("active", b.id === "body-" + name);
});
localStorage.setItem(LS_TAB, name);
$("li-err").textContent = "";
// 自动 focus 第一个空 input,Enter 直接登
const firstInput = document.querySelector("#body-" + name + " input");
if (firstInput) firstInput.focus();
}
document.querySelectorAll("#login .tabs button").forEach(b => {
b.addEventListener("click", () => switchLoginTab(b.dataset.tab));
});
const savedTab = localStorage.getItem(LS_TAB);
if (savedTab === "key") switchLoginTab("key");
$("li-go").onclick = doLogin;
// 任意 input 上回车都触发登录
document.querySelectorAll("#login input").forEach(i => {
i.addEventListener("keydown", (e) => { if (e.key === "Enter") doLogin(); });
});
async function doLogin() {
$("li-err").textContent = "";
let url, body, displayLabel;
if (loginTab === "pw") {
const email = $("li-email").value.trim();
const password = $("li-password").value;
if (!email || !password) {
$("li-err").textContent = "请填邮箱和密码";
return;
}
url = "/v1/auth/login_password";
body = { email, password };
displayLabel = "email";
} else {
const uid = $("li-uid").value.trim();
const pkey = $("li-pkey").value;
if (!uid || !pkey) {
$("li-err").textContent = "请填 user_id 和 PLATFORM_KEY";
return;
}
url = "/v1/auth/login";
body = { user_id: uid, platform_key: pkey };
displayLabel = null; // 这条路径不返显示名,顶栏只显 uid 前 8 位
}
try {
const r = await fetch(url, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
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;
state.userName = displayLabel ? (data[displayLabel] || "") : "";
localStorage.setItem(LS_TOKEN, state.token);
localStorage.setItem(LS_UID, state.userId);
if (state.userName) {
localStorage.setItem(LS_NAME, state.userName);
} else {
localStorage.removeItem(LS_NAME);
}
enterApp();
} catch (e) {
$("li-err").textContent = e.message;
}
}
function logout() {
state.token = ""; state.userId = ""; state.userName = "";
localStorage.removeItem(LS_TOKEN);
localStorage.removeItem(LS_UID);
localStorage.removeItem(LS_NAME);
if (state.evtSrc) state.evtSrc.close();
location.reload();
}
$("hd-logout").onclick = logout;
// ───── admin add-user ─────
// 入口在登录页右下角链接;弹窗收 email/password/admin_token 三项,POST /v1/auth/admin/create_user。
// 成功后不自动登录(让用户自己用新账号登,逻辑清晰),只回填邮箱到登录表单 + 提示。
function openAdminModal() {
$("ad-email").value = "";
$("ad-password").value = "";
$("ad-token").value = "";
$("ad-err").textContent = "";
$("admin-modal").classList.add("show");
$("ad-email").focus();
}
function closeAdminModal() {
$("admin-modal").classList.remove("show");
}
$("open-admin-add").onclick = (e) => { e.preventDefault(); openAdminModal(); };
$("ad-cancel").onclick = closeAdminModal;
$("admin-modal").addEventListener("click", (e) => {
if (e.target.id === "admin-modal") closeAdminModal(); // 点遮罩关闭
});
// 任一 input 上回车触发提交
document.querySelectorAll("#admin-modal input").forEach(i => {
i.addEventListener("keydown", (e) => { if (e.key === "Enter") doAdminAdd(); });
});
async function doAdminAdd() {
$("ad-err").textContent = "";
const email = $("ad-email").value.trim();
const password = $("ad-password").value;
const admin_token = $("ad-token").value;
if (!email || !password || !admin_token) {
$("ad-err").textContent = "请填邮箱、密码、管理员口令";
return;
}
if (password.length < 6) {
$("ad-err").textContent = "密码至少 6 字符";
return;
}
try {
const r = await fetch("/v1/auth/admin/create_user", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password, admin_token }),
});
if (!r.ok) {
const d = await r.json().catch(() => ({}));
throw new Error(d.detail || (r.status + " create failed"));
}
const data = await r.json();
closeAdminModal();
// 切到邮箱密码 tab,回填邮箱,提示一下
switchLoginTab("pw");
$("li-email").value = data.email || email;
$("li-password").value = "";
$("li-password").focus();
$("li-err").style.color = "var(--muted)"; // 临时降级为提示色
$("li-err").textContent = `已创建 ${data.email},请登录`;
setTimeout(() => { $("li-err").style.color = ""; }, 4000);
} catch (e) {
$("ad-err").textContent = e.message;
}
}
$("ad-go").onclick = doAdminAdd;
// ───── 左 pane 折叠 toggle(rail 模式 + localStorage 持久化) ─────
// 折叠 = pane 收成 40px rail,只留 #pane-toggle-left 一直可点;按钮符号根据状态翻向
function applyLeftCollapsed(collapsed) {
document.body.classList.toggle("left-collapsed", collapsed);
const btn = $("pane-toggle-left");
btn.textContent = collapsed ? "" : "";
btn.title = collapsed ? "展开任务列表" : "折叠任务列表";
}
$("pane-toggle-left").onclick = () => {
const next = !document.body.classList.contains("left-collapsed");
localStorage.setItem(LS_LEFT_COLLAPSED, next ? "1" : "");
applyLeftCollapsed(next);
};
applyLeftCollapsed(localStorage.getItem(LS_LEFT_COLLAPSED) === "1");
// ───── 手机视图切换(单列 + tab) ─────
// body.mv-{left,mid,right} 控制当前显示的 pane;桌面下三 pane 都可见,本函数仅维护 class
// 进入手机视口时清掉 left-collapsed(只 DOM,不动 localStorage —— 回桌面用户偏好仍生效)
const mqPhone = window.matchMedia("(max-width: 640px)");
function setMobileView(view) {
// view ∈ "mv-left" | "mv-mid" | "mv-right"
document.body.classList.remove("mv-left", "mv-mid", "mv-right");
document.body.classList.add(view);
for (const b of document.querySelectorAll(".mobile-tabs button")) {
b.classList.toggle("active", b.dataset.mv === view);
}
}
function applyMobileMode() {
if (mqPhone.matches) {
// 手机:清掉桌面 rail 状态,默认显示任务列表(若未设过)
document.body.classList.remove("left-collapsed");
if (!document.body.matches(".mv-left, .mv-mid, .mv-right")) {
setMobileView("mv-left");
}
} else {
// 桌面/平板:恢复 localStorage 的 collapsed 偏好(平板靠 @media 强制 rail,不需依赖 class)
applyLeftCollapsed(localStorage.getItem(LS_LEFT_COLLAPSED) === "1");
}
}
mqPhone.addEventListener("change", applyMobileMode);
applyMobileMode();
for (const b of document.querySelectorAll(".mobile-tabs button")) {
b.onclick = () => setMobileView(b.dataset.mv);
}
// ───── enter app ─────
function enterApp() {
$("login").style.display = "none";
$("app").classList.add("ready");
// 显示「name · uuid 前 8 位」;name 缺失(老 token 升级前)只显 uuid
const uid8 = (state.userId || "").slice(0, 8);
$("hd-who").textContent = state.userName ? `${state.userName} · ${uid8}` : state.userId;
$("hd-who").title = state.userId;
loadTaskList();
loadFiles(); // 文件面板与 task 解耦 — 启动即拉 user_root
loadModels(); // 模型清单缓存:chat-meta 下拉 + 新建对话框 + 历史小标
loadFolderSuggestions(); // 灌 filter-wd select(modal 打开时会重拉,这里让左 pane 先有选项)
}
async function loadModels() {
try {
const data = await api("GET", "/v1/models");
state.models = data.models || [];
} catch (e) {
state.models = []; // 静默兜底:无模型清单时下拉不显示,不挡正常流程
}
try {
const data = await api("GET", "/v1/image_models");
state.imageModels = data.models || [];
// 默认锁定第一个(=agent_builder fallback);用户后续切换就会更新
if (!state.imageModel) {
const def = state.imageModels.find(m => m.is_default) || state.imageModels[0];
state.imageModel = def ? def.variant : "";
}
} catch (e) {
state.imageModels = [];
state.imageModel = "";
}
try {
const data = await api("GET", "/v1/video_models");
state.videoModels = data.models || [];
if (!state.videoModel) {
const def = state.videoModels.find(m => m.is_default) || state.videoModels[0];
state.videoModel = def ? def.variant : "";
}
} catch (e) {
state.videoModels = [];
state.videoModel = "";
}
}
// loadTaskList:默认 reset(filters/refresh/写操作后),append=true 由 sentinel observer 触发
// 并发模型:append 受 taskLoading 互斥(避免观察器重复触发);reset 永远抢占,用 seq 丢弃过期响应
let _taskLoadSeq = 0;
async function loadTaskList({ append = false } = {}) {
if (append && (state.taskLoading || !state.taskHasMore)) return;
const mySeq = ++_taskLoadSeq;
const nextPage = append ? state.taskPage + 1 : 1;
const params = new URLSearchParams();
params.set("page", nextPage);
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 更干净
state.taskLoading = true;
setSentinel(append ? "加载中…" : "");
try {
const data = await api("GET", "/v1/tasks?" + params.toString());
if (mySeq !== _taskLoadSeq) return; // 已被更新的请求 supersede,丢弃
state.taskTotal = data.count || 0;
state.taskPage = data.page || nextPage;
state.taskPageSize = data.page_size || state.taskPageSize;
const results = data.results || [];
if (!append) state.taskLoaded = 0;
state.taskLoaded += results.length;
state.taskHasMore = state.taskLoaded < state.taskTotal;
renderTaskList(results, append);
renderTaskCount();
} catch (e) {
if (mySeq !== _taskLoadSeq) return;
if (e.status === 401) { logout(); return; }
if (!append) {
$("task-list").innerHTML = `<div class="empty">加载失败:${escapeHtml(e.message)}</div>`;
state.taskHasMore = false;
}
setSentinel(`加载失败:${e.message}`);
} finally {
if (mySeq === _taskLoadSeq) state.taskLoading = false;
}
}
function renderTaskCount() {
$("task-count").textContent = state.taskTotal > 0 ? `${state.taskTotal}` : "";
if (state.taskTotal === 0) setSentinel("");
else if (!state.taskHasMore) setSentinel(state.taskPage > 1 ? "— 已加载全部 —" : "");
else setSentinel(""); // 还有更多 → 留空,observer 触发时再填"加载中"
}
function setSentinel(text) {
$("task-sentinel").textContent = text || "";
}
function renderTaskList(tasks, append = false) {
if (!append) state.tasksById = {};
for (const t of tasks) state.tasksById[t.task_id] = t;
if (!append && !tasks.length) {
$("task-list").innerHTML = `<div class="empty">(暂无任务)</div>`;
return;
}
if (append && !tasks.length) return; // 末页空 batch,不动 DOM
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;
const rowTitle = `${taskName}\n${t.task_id}`; // hover 出全名 + 完整 id(替代 meta 里被去掉的 id8)
return `
<div class="task-row${active}" data-tid="${t.task_id}" title="${escapeHtml(rowTitle)}" style="display:flex;align-items:flex-start;gap:6px;">
<div style="flex:1;min-width:0;">
<div class="desc">${escapeHtml(taskName)}</div>
${wdName ? `<div class="meta muted" title="${escapeHtml(t.working_dir || "")}" style="display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">📁 ${escapeHtml(wdName)}</div>` : ""}
${desc ? `<div class="meta muted" title="${escapeHtml(desc)}" style="display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escapeHtml(desc)}</div>` : ""}
<div class="meta">
<span class="badge ${t.status}">${statusLabel}</span>
${t.skill ? `<span class="muted" title="${escapeHtml(t.skill)}">${escapeHtml(t.skill)}</span>` : ""}
<span class="num right-group">${t.n_messages || 0} 条</span>
<span class="num" title="${(t.tokens || 0).toLocaleString()} tokens">${fmtTokens(t.tokens)} tok</span>
<span class="muted time-ago" title="${escapeHtml(fmtTime(t.updated_at))}">${escapeHtml(fmtTimeAgo(t.updated_at))}</span>
</div>
</div>
<button class="dd-toggle task-menu" data-tid="${t.task_id}" title="任务操作">⋯</button>
</div>
`;
}).join("");
const listEl = $("task-list");
let newRows;
if (append) {
const tmp = document.createElement("div");
tmp.innerHTML = html;
newRows = Array.from(tmp.children);
newRows.forEach((el) => listEl.appendChild(el));
} else {
listEl.innerHTML = html;
newRows = Array.from(listEl.querySelectorAll(".task-row"));
}
newRows.forEach((el) => {
if (!el.classList || !el.classList.contains("task-row")) return;
el.onclick = (e) => {
if (e.target.closest(".dd-toggle")) return; // 菜单按钮点击不触发选中
selectTask(el.dataset.tid);
};
const btn = el.querySelector(".task-menu");
if (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: "导出对话记录", 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(loadTaskList 默认 append=false);追加由 sentinel observer 触发
$("filter-status").onchange = () => loadTaskList();
$("filter-order").onchange = () => loadTaskList();
$("filter-wd").onchange = () => loadTaskList(); // select 选完立即筛
$("btn-refresh-tasks").onclick = () => loadTaskList();
// 搜索 q 是 text input → 300ms debounce 避免每字符打 API
let _filterDebounce = null;
$("filter-q").addEventListener("input", () => {
clearTimeout(_filterDebounce);
_filterDebounce = setTimeout(() => loadTaskList(), 300);
});
// 滚动加载:左 pane 整体是 scroll 容器(.pane{overflow:auto}),用 #pane-left 作 root
// rootMargin 提前 200px 触发,体感更顺;阈值 0 即可(刚进入即触发,append 期间 taskLoading 自带防抖)
const _taskScrollObserver = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && state.taskHasMore && !state.taskLoading) {
loadTaskList({ append: true });
}
}, { root: $("pane-left"), rootMargin: "200px 0px" });
_taskScrollObserver.observe($("task-sentinel"));
// ───── select task ─────
async function selectTask(tid) {
if (state.evtSrc) { state.evtSrc.close(); state.evtSrc = null; }
// 切 task 清掉上个 task 累积的 inline media blob URL — 新 task 的 rel 不同,
// 旧 URL 留着只占内存。同 task 切回(tid === state.taskId)不算切换,跳过。
if (state.taskId && state.taskId !== tid) _flushMediaArtifactCache();
state.taskId = tid;
document.querySelectorAll(".task-row").forEach((el) => {
el.classList.toggle("active", el.dataset.tid === tid);
});
// 手机视图:选中任务自动切到对话面板(桌面 mqPhone 不命中 → no-op)
if (mqPhone.matches) setMobileView("mv-mid");
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();
refreshConcurrentWarnings(); // 同 wd 其他 task 活跃软警告 — 后台 fire-and-forget
} catch (e) {
if (e.status === 401) { logout(); return; }
$("chat-stream").innerHTML = `<div class="empty">加载失败:${escapeHtml(e.message)}</div>`;
}
}
// 拉同 wd 内除自己外仍 running/cancelling 的 task,渲染软警告 banner。
// 同 wd 多 task 同时跑频率近 0(用户工作流以"同项目对话历史轨迹"为主,不并发),
// 这里只做提示,不挡发送(对应 DESIGN §7.8 / §7.9 "信任 + 软警告 + 承认边界")。
async function refreshConcurrentWarnings() {
const t = state.taskMeta;
if (!t || !t.working_dir) { state.concurrentWarnings = []; renderConcurrentWarning(); return; }
const wdName = t.working_dir.split("/").filter(Boolean).pop();
if (!wdName) { state.concurrentWarnings = []; renderConcurrentWarning(); return; }
const params = new URLSearchParams();
params.set("working_dir", wdName);
params.set("run_status", "running,cancelling");
params.set("page_size", "10");
try {
const data = await api("GET", "/v1/tasks?" + params.toString());
const others = (data.results || []).filter(r => r.task_id !== t.task_id);
state.concurrentWarnings = others;
} catch (e) {
// 警告失败不影响主功能,静默
state.concurrentWarnings = [];
}
renderConcurrentWarning();
}
function renderConcurrentWarning() {
const el = $("wd-concurrent-warn");
const others = state.concurrentWarnings;
if (!others || others.length === 0) { el.style.display = "none"; el.innerHTML = ""; return; }
const head = others[0];
const t = state.taskMeta;
const wdName = t && t.working_dir ? t.working_dir.split("/").filter(Boolean).pop() : "";
const more = others.length > 1 ? `${others.length}` : "";
el.innerHTML = `⚠ 项目 "${escapeHtml(wdName)}" 内 task <span class="tname">${escapeHtml(head.name || "(未命名)")}</span> 正在 <span class="rs">${escapeHtml(head.run_status)}</span>${more} — 并发写同名中间产物可能互覆,建议等它结束再发`;
el.style.display = "block";
}
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;
// wdName 与 taskName 相同时(留空 fallback,多数场景)不重复显示 📁;
// 不同时(用户显式指定共享目录 / 改了 name)才挂 📁,提示"项目归属"
const wdBadge = (wdName && wdName !== taskName)
? `<span class="muted" title="${escapeHtml(t.working_dir)}">📁 ${escapeHtml(wdName)}</span>`
: "";
$("chat-meta").innerHTML = `
<span style="font-weight:600;" title="${escapeHtml(taskName)}">${escapeHtml(taskName)}</span>
<span class="badge ${t.status}">${statusLabel}</span>
${wdBadge}
${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>
${renderModelDropdown(t)}
${renderImageModelDropdown()}
${renderVideoModelDropdown()}
`;
const sel = $("chat-model-sel");
if (sel) sel.onchange = onChangeModel;
const imgSel = $("chat-image-model-sel");
if (imgSel) imgSel.onchange = onChangeImageModel;
const vidSel = $("chat-video-model-sel");
if (vidSel) vidSel.onchange = onChangeVideoModel;
const active = t.status === "active";
$("chat-form").style.display = active ? "flex" : "none";
syncOptimizeBtn();
$("btn-done").disabled = !active;
$("btn-abandon").disabled = !active;
$("btn-delete-task").disabled = false; // delete 不限 status(用户显式 confirm)
$("btn-export").disabled = (t.n_messages || 0) === 0;
// 清空对话:活跃 run 期间禁用(后端 409),无消息也无意义
const running = t.run_status === "running" || t.run_status === "cancelling";
$("btn-clear-msgs").disabled = running || (t.n_messages || 0) === 0;
}
function renderModelDropdown(t) {
// 模型清单未加载好(或为空)时不渲染下拉,但 task 仍可正常用(后端走 task.model_profile)
if (!state.models || state.models.length === 0) return "";
const cur = t.model_profile || "";
const opts = state.models.map(m =>
`<option value="${escapeHtml(m.profile)}" ${m.profile === cur ? "selected" : ""}>${escapeHtml(m.display_name)}</option>`
).join("");
return `<span class="muted small" style="display:inline-flex;align-items:center;gap:4px;">模型 <select id="chat-model-sel" class="small" style="width:auto;padding:1px 4px;font-size:12px;" title="切换 task 模型(下条消息生效)">${opts}</select></span>`;
}
function renderImageModelDropdown() {
// imageModels 为空(yaml 无 image variant)→ 不画下拉。注意不依赖 ARK_API_KEY 是否设了
// —— 这里只是展示元数据,真正调用时 backend 那边没 key 自然 tool 不挂(用户不会
// 在没 key 的环境点出图,prompt 里 seedream 工具压根不在 schema)。
if (!state.imageModels || state.imageModels.length === 0) return "";
const cur = state.imageModel || "";
const opts = state.imageModels.map(m =>
`<option value="${escapeHtml(m.variant)}" ${m.variant === cur ? "selected" : ""}>${escapeHtml(m.display_name)}</option>`
).join("");
return `<span class="muted small" style="display:inline-flex;align-items:center;gap:4px;">生图 <select id="chat-image-model-sel" class="small" style="width:auto;padding:1px 4px;font-size:12px;" title="下一条消息触发生图时使用的模型(本地选择,不入库)">${opts}</select></span>`;
}
function onChangeImageModel(ev) {
// 纯前端 state,不 PATCH;选中值随下一次 POST /v1/tasks/{id}/messages 的 image_model 字段一起发
state.imageModel = ev.target.value || "";
$("chat-hint").textContent = `生图模型 → ${ev.target.options[ev.target.selectedIndex].text}`;
}
function renderVideoModelDropdown() {
// 同 renderImageModelDropdown:videoModels 为空 → 不画。yaml 无 video 段 / 后端
// /v1/video_models 返空时下拉不出现,seedance tool 也不会在 schema 里。
if (!state.videoModels || state.videoModels.length === 0) return "";
const cur = state.videoModel || "";
const opts = state.videoModels.map(m =>
`<option value="${escapeHtml(m.variant)}" ${m.variant === cur ? "selected" : ""}>${escapeHtml(m.display_name)}</option>`
).join("");
return `<span class="muted small" style="display:inline-flex;align-items:center;gap:4px;">生视频 <select id="chat-video-model-sel" class="small" style="width:auto;padding:1px 4px;font-size:12px;" title="下一条消息触发生视频时使用的模型(本地选择,不入库)">${opts}</select></span>`;
}
function onChangeVideoModel(ev) {
state.videoModel = ev.target.value || "";
$("chat-hint").textContent = `生视频模型 → ${ev.target.options[ev.target.selectedIndex].text}`;
}
async function onChangeModel(ev) {
const sel = ev.target;
const newProfile = sel.value;
const t = state.taskMeta;
if (!t || !newProfile || newProfile === t.model_profile) return;
const oldProfile = t.model_profile || "";
try {
const updated = await api("PATCH", `/v1/tasks/${t.task_id}`, { model_profile: newProfile });
state.taskMeta = updated;
const running = updated.run_status === "running" || updated.run_status === "cancelling";
$("chat-hint").textContent = running
? `已切到 ${newProfile} · 当前 run 跑完后生效`
: `已切到 ${newProfile}`;
} catch (e) {
sel.value = oldProfile; // PATCH 失败 UI 回滚
$("chat-hint").textContent = `切换失败:${e.message}`;
}
}
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;
}
// 模型切换点小标:assistant 行的 model_profile 与上一个 assistant 不同就插一行分隔
// (含首条);避免每条都标制造噪声。空 model_profile(历史旧数据)不画。
let lastAsstModel = null;
// chip 去重:同一路径在 tool 结果里挂过 inline 图后,assistant 正文 echo 同路径不再重挂。
// chronological 遍历,首次出现保留(tool 结果常在前),后续重复过滤掉。
const seenRels = new Set();
const pickFresh = (rels) => {
const fresh = [];
for (const r of rels) {
if (seenRels.has(r)) continue;
seenRels.add(r);
fresh.push(r);
}
return fresh;
};
for (const m of msgs) {
const p = m.payload || {};
const role = p.role || "?";
if (role === "system") continue; // 不显示 system
if (role === "assistant" && m.model_profile && m.model_profile !== lastAsstModel) {
const dn = (state.models.find(x => x.profile === m.model_profile) || {}).display_name || m.model_profile;
const sep = document.createElement("div");
sep.className = "model-switch muted";
sep.style.cssText = "margin:8px 0;text-align:center;font-size:11px;letter-spacing:0.5px;";
sep.textContent = `── ${dn} ──`;
wrap.appendChild(sep);
lastAsstModel = m.model_profile;
}
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);
const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir);
const banner = extractMediaBanner(p.name || "", txt || "");
// 工具结果只有产物工具(seedream/seedance)挂 chip + inline 大图;通用工具
// (grep/read/glob/shell)echo 的路径是"引用"不是"产物",不挂以免噪声。
const isProducer = ARTIFACT_PRODUCING_TOOLS.has(p.name || "");
const rels = isProducer ? pickFresh(extractArtifactRels(txt || "", wd)) : [];
card.innerHTML = `
<div class="role">工具调用 · ${escapeHtml(p.name || "")}</div>
<details class="tool-call"><summary>结果(${(txt || "").length} 字符)${banner}</summary><pre>${escapeHtml(txt || "")}</pre></details>
${renderArtifactBarHtml(rels, isProducer)}
`;
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>`;
// assistant 正文里 echo 的 <wd>/... 路径**永远**挂 chip(绕开 seenRels —— 上面
// tool 结果可能 inline 过同图,但 chip 是小按钮无视觉污染,助手回复里有可
// 点的"产物锚点"比没有好);强制 allowInlineMedia=false 防止大图被重复 inline。
if (role === "assistant") {
const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir);
html += renderArtifactBarHtml(extractArtifactRels(p.content, wd), false);
}
}
if (Array.isArray(p.tool_calls) && p.tool_calls.length) {
const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir);
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) || ""; }
const isProducer = ARTIFACT_PRODUCING_TOOLS.has(fn);
const rels = isProducer ? pickFresh(extractArtifactRels(args, wd)) : [];
html += `
<details class="tool-call"><summary>工具调用:${escapeHtml(fn)}</summary><pre>${escapeHtml(args)}</pre></details>
${renderArtifactBarHtml(rels, isProducer)}
`;
}
}
card.innerHTML = html;
highlightIn(card);
wrap.appendChild(card);
}
wrap.scrollTop = wrap.scrollHeight;
upgradeMediaArtifacts(wrap);
}
// ───── send + SSE ─────
// 发送 / 停止 单按钮:idle → 发送(primary 红实心);streaming → 停止(danger 红边);
// cancelling 是过渡态 — 用户点过停止后到 SSE 收到 cancelled/done 之间。
function setActionMode(mode) {
const btn = $("chat-action");
btn.classList.remove("primary", "danger");
if (mode === "idle") {
btn.textContent = "发送";
btn.classList.add("primary");
btn.disabled = false;
btn.title = "";
} else if (mode === "streaming") {
btn.textContent = "停止";
btn.classList.add("danger");
btn.disabled = false;
btn.title = "停止当前流式回复";
} else if (mode === "cancelling") {
btn.textContent = "停止中…";
btn.classList.add("danger");
btn.disabled = true;
}
}
function chatAction() {
if (state.streaming) cancelCurrentTask();
else sendMessage();
}
$("chat-form").addEventListener("submit", (e) => { e.preventDefault(); chatAction(); });
$("chat-input").addEventListener("keydown", (e) => {
// streaming 期间 Enter 不触发停止 —— 用户可能正在编辑下一条草稿,误触发风险高
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (!state.streaming) sendMessage();
}
});
$("chat-input").addEventListener("input", syncOptimizeBtn);
// 粘贴含文件 → 直接上传到当前目录(复用拖拽通路);纯文本走默认
// 反馈走 chat-hint:上传中 → 已粘贴 N 个 → 4s 回原 hint(同 optimizePrompt 救回范式,
// 不破坏 streaming/optimizing 期间的状态广播)
let _pasteHintTimer = null;
$("chat-input").addEventListener("paste", async (e) => {
const files = Array.from(e.clipboardData?.files || []);
if (!files.length) return;
e.preventDefault();
const hint = $("chat-hint");
const prevHint = hint.textContent;
clearTimeout(_pasteHintTimer);
hint.textContent = files.length === 1 ? `上传中:${files[0].name}` : `上传中:${files.length} 个文件…`;
const ok = await uploadFiles(files);
if (ok) {
const names = files.map(f => f.name).join(", ");
hint.textContent = files.length === 1 ? `已粘贴:${names}` : `已粘贴 ${files.length} 个:${names}`;
_pasteHintTimer = setTimeout(() => {
if (hint.textContent.startsWith("已粘贴")) hint.textContent = prevHint;
}, 4000);
} else {
hint.textContent = prevHint; // 失败 alert 已弹,hint 回原
}
});
// 润色:同步调后端,把 textarea 内容替成优化后文本。用 execCommand('insertText')
// 接 textarea 原生 undo 栈 — Ctrl+Z 一次回到原文。streaming 期间允许并行(后端
// 不与主对话 run 互斥,各跑各的 LLM)。
function syncOptimizeBtn() {
const btn = $("chat-optimize");
if (!btn) return;
if (state.optimizing) return; // 进行中不在这条路径切
const has = ($("chat-input").value || "").trim().length > 0;
btn.disabled = !has || !state.taskId;
}
async function optimizePrompt() {
if (state.optimizing) return;
if (!state.taskId) return;
const ta = $("chat-input");
const original = (ta.value || "").trim();
if (!original) return;
const btn = $("chat-optimize");
state.optimizing = true;
btn.disabled = true;
const oldLabel = btn.textContent;
btn.textContent = "润色中…";
const oldHint = $("chat-hint").textContent;
$("chat-hint").textContent = "润色中…";
try {
const r = await api("POST", `/v1/tasks/${state.taskId}/optimize_prompt`, {
text: ta.value, // 不 trim — 后端再 strip;保留尾部 newline 让用户感受不变
image_model: state.imageModel || "",
video_model: state.videoModel || "",
});
const optimized = (r.optimized || "").trim();
if (!optimized) throw new Error("空结果");
// execCommand('insertText') 把"全选 + 替换"作为一个 undo 单元接入 textarea 原生栈
ta.focus();
ta.select();
const ok = document.execCommand("insertText", false, optimized);
if (!ok) {
// execCommand 在某些环境(contentEditable=false 旧 Firefox)失败 — 兜底直接赋值
// 这种情况下 Ctrl+Z 失效,但功能不阻塞;贴提示让用户知道
ta.value = optimized;
$("chat-hint").textContent = "已润色(本浏览器不支持撤销,需自行保留草稿)";
} else {
const cost = typeof r.cost_cny === "number" ? r.cost_cny.toFixed(4) : "?";
$("chat-hint").textContent = `已润色 · ${r.tokens_in || 0}+${r.tokens_out || 0} tok · ¥${cost} · Ctrl+Z 撤销`;
}
} catch (e) {
if (e.status === 401) { logout(); return; }
$("chat-hint").textContent = `润色失败:${e.message}`;
} finally {
state.optimizing = false;
btn.textContent = oldLabel;
syncOptimizeBtn();
}
}
$("chat-optimize").onclick = optimizePrompt;
// 对话流里 artifact chip / 内联 img 点击委托 — 复用右栏文件预览 modal(modal 内自带"下载")。
// 视频走原生 <video controls>:点击=播放/暂停,全屏走浏览器自带按钮,不进 modal —
// 弹个 modal 反而打断播放,不如交给浏览器。
$("chat-stream").addEventListener("click", (e) => {
const chip = e.target.closest && e.target.closest(".art-chip");
if (chip) {
const rel = chip.dataset.rel;
if (rel) openFilePreview(rel);
return;
}
const inlineImg = e.target.closest && e.target.closest(".art-media-image[data-rel]");
if (inlineImg) {
const rel = inlineImg.dataset.rel;
if (rel) openFilePreview(rel);
}
});
async function sendMessage() {
if (!state.taskId) return;
if (state.streaming) return;
const content = $("chat-input").value.trim();
if (!content) return;
setActionMode("cancelling"); // 临时锁住,等 events_url 拿到再切 streaming
$("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,
image_model: state.imageModel || "",
video_model: state.videoModel || "",
});
$("chat-input").value = "";
syncOptimizeBtn();
state.streaming = true;
setActionMode("streaming");
streamSse(r.events_url, asstCard);
} catch (e) {
if (e.status === 401) { logout(); return; }
appendErrorCard(e.message);
setActionMode("idle");
$("chat-hint").textContent = "就绪";
}
}
async function cancelCurrentTask() {
if (!state.taskId || !state.streaming) return;
setActionMode("cancelling");
$("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);
setActionMode("streaming");
$("chat-hint").textContent = "接收中…";
}
}
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, seenRels: new Set(), terminal: false };
const hint = $("chat-hint");
// 重连:reader 异常 / 自然 EOF 但未收到 done/error 时,GET events 重订阅。
// 后端 stream_events 重连入口会校验 run_status,task 已不活跃直接吐 done 关流;
// 这里 3 次失败再放弃,覆盖 systemctl restart 的 1~2s 抖动 + reaper 跑完的窗口。
const backoffs = [1000, 2000, 4000];
let attempt = 0;
try {
while (true) {
try {
await consumeSseStream(url, asstCard, ctx);
} catch (e) {
if (ctx.terminal) break; // 已收到 done/error,不重连
if (attempt >= backoffs.length) {
appendErrorCard("连接已断开,刚才的回复可能未完成,请重发消息。");
break;
}
hint.textContent = `连接断开,重连中…(${attempt + 1}/${backoffs.length})`;
await new Promise(r => setTimeout(r, backoffs[attempt]));
attempt++;
continue;
}
// consumeSseStream 正常返回:reader 收到 EOF
if (ctx.terminal) break; // 正常收尾(看到 done/error)
// 未见 done/error 就 EOF → 服务端中途关流(进程被杀 / nginx 切),重连
if (attempt >= backoffs.length) {
appendErrorCard("连接已断开,刚才的回复可能未完成,请重发消息。");
break;
}
hint.textContent = `连接断开,重连中…(${attempt + 1}/${backoffs.length})`;
await new Promise(r => setTimeout(r, backoffs[attempt]));
attempt++;
}
// 最终定稿 + 代码高亮(流式中不 highlight,省 CPU)
body.innerHTML = renderMd(ctx.acc);
highlightIn(asstCard);
} finally {
body.classList.remove("streaming");
hint.textContent = "就绪";
state.streaming = false;
setActionMode("idle");
}
// 刷新 task meta + messages(拿真实持久化的);失败路径已退出,这里不再跑
loadTaskList();
await loadMessages();
loadFiles(); // 回复结束后右侧文件面板同步刷新(可能有新写入 / 修改的产物)
refreshConcurrentWarnings(); // 自己 task 收尾,顺便清/更新 banner(同 wd 邻居可能也变了)
}
async function consumeSseStream(url, asstCard, ctx) {
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) return;
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") {
ctx.terminal = true;
return;
}
}
}
}
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 argsStr = typeof args === "string" ? args : JSON.stringify(args, null, 2);
const det = document.createElement("details");
det.className = "tool-call";
det.innerHTML = `<summary>工具调用:${escapeHtml(fn)}</summary><pre>${escapeHtml(argsStr)}</pre>`;
asstCard.appendChild(det);
const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir);
const isProducer = ARTIFACT_PRODUCING_TOOLS.has(fn);
const fresh = isProducer
? extractArtifactRels(argsStr, wd).filter(r => !ctx.seenRels.has(r))
: [];
fresh.forEach(r => ctx.seenRels.add(r));
const barHtml = renderArtifactBarHtml(fresh, isProducer);
if (barHtml) {
asstCard.insertAdjacentHTML("beforeend", barHtml);
if (isProducer) upgradeMediaArtifacts(asstCard);
}
} else if (t === "tool_result") {
const txt = (ev.data && ev.data.result) || "";
const txtStr = typeof txt === "string" ? txt : JSON.stringify(txt, null, 2);
const toolName = (ev.data && ev.data.name) || "";
const banner = extractMediaBanner(toolName, txtStr);
const det = document.createElement("details");
det.className = "tool-call";
det.innerHTML = `<summary>工具结果${banner}</summary><pre>${escapeHtml(txtStr)}</pre>`;
asstCard.appendChild(det);
const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir);
const isProducer = ARTIFACT_PRODUCING_TOOLS.has(toolName);
const fresh = isProducer
? extractArtifactRels(txtStr, wd).filter(r => !ctx.seenRels.has(r))
: [];
fresh.forEach(r => ctx.seenRels.add(r));
const barHtml = renderArtifactBarHtml(fresh, isProducer);
if (barHtml) {
asstCard.insertAdjacentHTML("beforeend", barHtml);
if (isProducer) upgradeMediaArtifacts(asstCard);
}
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);
$("btn-clear-msgs").onclick = () => {
if (!state.taskId) return;
const t = state.taskMeta || {};
clearMessages(state.taskId, t.name || "(未命名)", t.n_messages || 0);
};
async function clearMessages(tid, name, nMsg) {
if (!confirm(`确认清空「${name}」的对话(${nMsg} 条消息)?\n\n将删除全部对话历史并重置 token 计数;工作目录下的文件保留。`)) return;
try {
const updated = await api("POST", "/v1/tasks/" + tid + "/clear");
if (state.taskId === tid) {
state.taskMeta = updated;
renderChatMeta();
renderMessages([]);
$("chat-hint").textContent = "对话已清空";
}
loadTaskList();
} catch (e) {
if (e.status === 401) { logout(); return; }
alert("清空失败:" + e.message);
}
}
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;
state.concurrentWarnings = [];
renderConcurrentWarning();
$("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;
$("btn-clear-msgs").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();
$("upload-input").addEventListener("change", uploadSelected);
// ───── 选入 modal(勾源 → 复制 / 移动到主区当前目录)─────
// 设计:目的地永远是主区 state.filesPath。弹框内浏览的 path 跟主区独立 — 用户从 A 翻到 B
// 勾几个,再翻到 C 接着勾,跨目录 selection 用 Set<rel> 全程保留;切换浏览路径不清空。
const srcPicker = { path: "", selected: new Set() };
async function openSrcPicker() {
srcPicker.path = "";
srcPicker.selected.clear();
const destLabel = state.filesPath ? "我的 / " + state.filesPath : "我的 (根目录)";
$("sp-dest").textContent = destLabel;
$("sp-dest").title = destLabel;
syncSrcCount();
$("src-picker-modal").classList.add("show");
await loadSrcPicker();
}
function closeSrcPicker() {
$("src-picker-modal").classList.remove("show");
srcPicker.path = "";
srcPicker.selected.clear();
}
async function loadSrcPicker() {
try {
const qs = srcPicker.path ? "?path=" + encodeURIComponent(srcPicker.path) : "";
const data = await api("GET", "/v1/files" + qs);
renderSrcPicker(data);
} catch (e) {
if (e.status === 401) { logout(); return; }
$("sp-list").innerHTML = `<div class="empty">${escapeHtml(e.message)}</div>`;
}
}
function renderSrcPicker(data) {
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(" ");
$("sp-crumbs").innerHTML = cr || `<span class="muted">/</span>`;
$("sp-crumbs").querySelectorAll("a").forEach((a) => {
a.onclick = (e) => { e.preventDefault(); srcPicker.path = a.dataset.rel; loadSrcPicker(); };
});
const entries = data.entries || [];
if (!data.exists) {
$("sp-list").innerHTML = `<div class="empty">(目录尚未创建)</div>`;
return;
}
if (!entries.length) {
$("sp-list").innerHTML = `<div class="empty">(空目录)</div>`;
return;
}
// 闸:当前浏览路径 == 主区目的地 → 同目录内勾选无意义(同名 409),全行 disabled
const destPath = state.filesPath || "";
const sameAsDest = srcPicker.path === destPath;
$("sp-list").innerHTML = entries.map((e) => {
const cls = e.is_dir ? "ico-dir" : "ico-file";
const checked = srcPicker.selected.has(e.rel) ? " checked" : "";
const disabled = sameAsDest ? " disabled" : "";
return `
<div class="sp-row${disabled}" data-rel="${escapeHtml(e.rel)}" data-isdir="${e.is_dir}">
<input type="checkbox" class="sp-cb" data-rel="${escapeHtml(e.rel)}"${checked}${sameAsDest ? " disabled" : ""} />
<span class="${cls} sp-name" data-rel="${escapeHtml(e.rel)}" data-isdir="${e.is_dir}">${escapeHtml(e.name)}</span>
<span class="sp-size">${humanSize(e.size)}</span>
</div>
`;
}).join("");
$("sp-list").querySelectorAll(".sp-name").forEach((el) => {
el.onclick = () => {
if (el.dataset.isdir === "true") {
srcPicker.path = el.dataset.rel;
loadSrcPicker();
}
};
});
$("sp-list").querySelectorAll(".sp-cb").forEach((cb) => {
cb.onchange = () => {
const rel = cb.dataset.rel;
if (cb.checked) srcPicker.selected.add(rel);
else srcPicker.selected.delete(rel);
syncSrcCount();
};
});
}
function syncSrcCount() {
const n = srcPicker.selected.size;
$("sp-count").textContent = String(n);
$("sp-copy").disabled = n === 0;
$("sp-move").disabled = n === 0;
}
async function doSrcTransfer(mode) {
const sources = [...srcPicker.selected];
if (!sources.length) return;
const endpoint = mode === "copy" ? "/v1/files/copy" : "/v1/files/move";
const verb = mode === "copy" ? "复制" : "移动";
try {
await api("POST", endpoint, {
paths: sources,
dest_dir: state.filesPath || "",
});
closeSrcPicker();
await loadFiles();
await loadFolderSuggestions();
} catch (e) {
if (e.status === 401) { logout(); return; }
alert(verb + "失败:" + e.message);
}
}
$("btn-src-pick").onclick = openSrcPicker;
$("sp-cancel").onclick = closeSrcPicker;
$("sp-copy").onclick = () => doSrcTransfer("copy");
$("sp-move").onclick = () => doSrcTransfer("move");
$("src-picker-modal").addEventListener("click", (e) => {
if (e.target.id === "src-picker-modal") closeSrcPicker();
});
// ───── 拖拽上传到主区(目的地 = state.filesPath)─────
// 用 enter/leave 计数避免子元素冒泡时 overlay 闪烁。
let _dragDepth = 0;
function _hasFiles(ev) {
const t = ev.dataTransfer;
if (!t) return false;
if (t.types && [...t.types].includes("Files")) return true;
return false;
}
$("pane-right").addEventListener("dragenter", (e) => {
if (!_hasFiles(e)) return;
e.preventDefault();
_dragDepth++;
$("file-droparea").classList.add("show");
});
$("pane-right").addEventListener("dragover", (e) => {
if (!_hasFiles(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
});
$("pane-right").addEventListener("dragleave", (e) => {
if (!_hasFiles(e)) return;
_dragDepth = Math.max(0, _dragDepth - 1);
if (_dragDepth === 0) $("file-droparea").classList.remove("show");
});
$("pane-right").addEventListener("drop", async (e) => {
if (!_hasFiles(e)) return;
e.preventDefault();
_dragDepth = 0;
$("file-droparea").classList.remove("show");
const files = Array.from(e.dataTransfer.files || []);
if (!files.length) return;
await uploadFiles(files);
});
// 工具调用返回时,右侧文件可能有新增/修改 — 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 navFiles(newPath) {
state.filesPath = newPath || "";
loadFiles();
}
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(); navFiles(a.dataset.rel); };
});
if (!data.exists) {
$("file-list").innerHTML = `<div class="empty">(目录尚未创建)</div>`;
state.entriesByRel = {};
return;
}
if (!data.entries.length) {
$("file-list").innerHTML = `<div class="empty">(空目录)</div>`;
state.entriesByRel = {};
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" data-rel="${escapeHtml(e.rel)}">
<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") { navFiles(rel); }
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) {
let recursive = false;
if (!isDir) {
if (!confirm(`确认删除文件 "${name}"?`)) return;
} else {
// 探一下目录内容:空目录走普通 rmdir;非空才递归,二次确认显示条目数
let entries;
try {
const data = await api("GET", "/v1/files?path=" + encodeURIComponent(rel));
entries = data.entries || [];
} catch (e) {
if (e.status === 401) { logout(); return; }
alert("读目录失败:" + e.message);
return;
}
if (entries.length === 0) {
if (!confirm(`确认删除空目录 "${name}"?`)) return;
} else {
const hasSub = entries.some((x) => x.is_dir);
const tip = hasSub ? "(含子目录)" : "";
if (!confirm(
`目录 "${name}" 含 ${entries.length}${tip},` +
`将递归删除全部内容,不可恢复。\n` +
`(若为顶层目录且仍被 task 引用,需先删 task)\n确认?`
)) return;
recursive = true;
}
}
try {
await api("POST", "/v1/files/delete", { path: rel, recursive });
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);
}
}
// ───── artifact 抽取(对话内 chip → 复用文件预览 modal) ─────
// task.working_dir 在 DB 是 `workspace/users/<uuid>/<name>` 形态(to_db_path),
// 不是 user_root 相对。这里取最后一段作为 chip 抽取锚点 —— 等价于 user_root 下
// 一级子目录名(同 filesPath 的 wdName 语义)。外部 --working-dir 是绝对路径,
// 文件不在 user_root,backend files API 拒访问 → 不挂 chip。
function _workingDirName(workingDir) {
if (!workingDir) return "";
const wd = String(workingDir).replace(/\\+/g, "/");
if (wd.startsWith("/") || /^[A-Za-z]:/.test(wd)) return ""; // 绝对 = 外部目录,跳过
const segs = wd.split("/").filter(Boolean);
return segs[segs.length - 1] || "";
}
// 产物工具白名单:**工具 I/O** 维度,只有这些工具的 tool_call args / tool_result
// 里 echo 的路径才挂 chip 条 + 图片/视频 inline 大图;通用工具(grep/read/glob/
// shell)echo 的路径是"引用"不是"产物",完全不挂(避免把 grep 命中的老 figures/
// foo.png 当新产物展示)。**assistant 正文不受此限** —— 助手回复里任何 echo 的
// 路径无条件挂 chip(`allowInlineMedia=false`,只 chip 不 inline,跟上面 tool 结果
// 可能已 inline 的同图不冲突);用户视角"助手提到的文件理应能点开",chip 是
// 可发现性入口,小图标无视觉污染。
// 注:与 extractMediaBanner 的"媒体 banner"白名单是不同维度 —— 将来若新增
// "生成 docx 的工具",入这里但不入 banner 白名单。
const ARTIFACT_PRODUCING_TOOLS = new Set(["seedream", "seedance"]);
// 从 tool args / result / assistant 正文里抓 working_dir 下的文件路径,归一为 user_root 相对。
// 启发式:把 \ 一律归 /,然后找以 `<wdName>/` 打头的串,要求最后一段含 . (像文件)。
// 从 seedream/seedance tool_result 第一行 banner 抽 model/size/cost/elapsed,
// 拼一行 .tool-banner HTML 挂在 details summary 旁。匹配失败返 ""(不渲染)。
// 协议:tool 返回串首行格式 `[<tool>] key=value · key=value · ...`
function extractMediaBanner(toolName, resultText) {
if (!resultText) return "";
if (toolName !== "seedream" && toolName !== "seedance") return "";
const firstLine = String(resultText).split("\n", 1)[0] || "";
// 抓 key=value(value 可含空格 / : / ., 用 · 或行尾结束)
const re = /(\w+)=([^·\n]+?)(?=\s*·|\s*$)/g;
const kvs = {};
let m;
while ((m = re.exec(firstLine)) !== null) {
kvs[m[1]] = m[2].trim();
}
if (!kvs.model && !kvs.cost) return "";
// model 文本太长(`doubao-seedream-5-0-260128`)→ 截短易读形式
const model = (kvs.model || "").replace(/^doubao-/, "").replace(/-\d{6,}$/, "");
const parts = [];
if (model) parts.push(`<span class="kv model">${escapeHtml(model)}</span>`);
if (kvs.size) parts.push(`<span class="kv">${escapeHtml(kvs.size)}</span>`);
if (kvs.cost) parts.push(`<span class="kv cost">${escapeHtml(kvs.cost)}</span>`);
if (kvs.elapsed) parts.push(`<span class="kv">${escapeHtml(kvs.elapsed)}</span>`);
return parts.length ? `<span class="tool-banner">${parts.join("")}</span>` : "";
}
function extractArtifactRels(text, workingDir) {
if (!text || !workingDir) return [];
const wd = String(workingDir).replace(/\\+/g, "/").replace(/^\/+|\/+$/g, "");
if (!wd) return [];
const norm = String(text).replace(/\\+/g, "/");
const wdEsc = wd.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const seen = new Set();
const out = [];
// 规范形式:<wdName>/<...>/<file>.<ext> —— 当前协议(system prompt 强约束助手照抄 tool `saved:` 行)
// lead 边界:行首或非 path-字符;tail 截到空白/引号/括号等
{
const re = new RegExp(
"(?:^|[\\s\"'`/=:,()<>\\[\\]{}|])(" + wdEsc + "/[^\\s\"'`<>(){}\\[\\]|]+)",
"g"
);
let m;
while ((m = re.exec(norm)) !== null) {
let rel = m[1];
rel = rel.replace(/[.,;:!?)\]}>。,;:!?)]+$/, ""); // 剥尾标点(中英)
const tail = rel.slice(wd.length + 1);
if (!tail) continue;
const last = tail.split("/").pop() || "";
if (!last.includes(".")) continue; // 看着像目录的不挂 chip
if (seen.has(rel)) continue;
seen.add(rel);
out.push(rel);
}
}
// ───── 一次性兼容:协议刚性化前的历史简写消息 ─────
// system prompt 改硬约束之前,助手按 SKILL 旧文案 echo 过 `videos/xxx.mp4` /
// `figures/xxx.png` 这种裸形式 —— 那些消息已存 DB 改不动,前端这里 prepend
// <wdName> 把它们拼成 user_root rel 才能挂 chip。**白名单显式枚举不扩展**:
// 新产物 skill 走 system 协议必出全形式,这一层只服务**历史消息**渲染。
// 长期(老消息归档/不再回看)整段可删。
const LEGACY_PRODUCT_DIRS = ["videos", "figures"];
for (const dir of LEGACY_PRODUCT_DIRS) {
const dirEsc = dir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// lead 边界跟主规则一致但去掉 `/` —— 否则 <wd>/videos/xxx 里的 videos/xxx 会被重复
// 匹配(虽然 seen 去重,但浪费 cycles)
const re = new RegExp(
"(?:^|[\\s\"'`=:,()<>\\[\\]{}|])(" + dirEsc + "/[^\\s\"'`<>(){}\\[\\]|]+)",
"g"
);
let m;
while ((m = re.exec(norm)) !== null) {
let tail = m[1];
tail = tail.replace(/[.,;:!?)\]}>。,;:!?)]+$/, "");
const last = tail.split("/").pop() || "";
if (!last.includes(".")) continue;
const rel = wd + "/" + tail;
if (seen.has(rel)) continue;
seen.add(rel);
out.push(rel);
}
}
return out;
}
// allowInlineMedia 控制图片/视频是否升级为内联 <img>/<video>:产物工具
// (seedream/seedance)+ assistant 正文传 true,通用工具(grep/read/shell/glob)
// 结果里 echo 的路径传 false → 图片/视频也走 chip 按钮(点开仍弹预览 modal),
// 这样既不会把无关老图占整屏,又保留"路径可点"的可发现性。
function renderArtifactBarHtml(rels, allowInlineMedia = true) {
if (!rels || !rels.length) return "";
const items = rels.map((rel) => {
const name = rel.split("/").pop() || rel;
const cat = _categorize(rel);
if (allowInlineMedia && (cat === "image" || cat === "video")) {
// 占位元素;插入 DOM 后 upgradeMediaArtifacts 异步 fetch blob → 填 <img>/<video>。
// 不在这里发请求避免 string-build 阶段失控的并发;upgrade 走 DOM walk 一次。
return `<span class="art-media art-media-${cat}" data-rel="${escapeHtml(rel)}" data-cat="${cat}" title="${escapeHtml(rel)}"><span class="art-media-loading">${escapeHtml(name)} 加载中…</span></span>`;
}
return `<button type="button" class="art-chip" data-rel="${escapeHtml(rel)}" title="${escapeHtml(rel)} · 点击预览(可下载)">${escapeHtml(name)}</button>`;
}).join("");
return `<div class="artifact-bar">${items}</div>`;
}
// rel → Promise<blob-url>。auth 是 Bearer header,不能直接 <img src=>,只能 fetch
// 拿 blob 再转 URL。同 rel 在同会话内复用,免重复拉。task 切换 / logout 时
// _flushMediaArtifactCache 清掉旧 URL 防泄漏。
const _mediaArtifactCache = new Map();
function _fetchMediaBlobUrl(rel) {
if (_mediaArtifactCache.has(rel)) return _mediaArtifactCache.get(rel);
const p = fetch("/v1/files/download?path=" + encodeURIComponent(rel), {
headers: { "Authorization": "Bearer " + state.token },
}).then(async (r) => {
if (!r.ok) throw new Error("HTTP " + r.status);
const blob = await r.blob();
return URL.createObjectURL(blob);
});
_mediaArtifactCache.set(rel, p);
return p;
}
function _flushMediaArtifactCache() {
for (const p of _mediaArtifactCache.values()) {
p.then((u) => URL.revokeObjectURL(u)).catch(() => {});
}
_mediaArtifactCache.clear();
}
// DOM walk:把所有 .art-media[data-rel] 占位换成 <img> / <video>。
// renderMessages / SSE 插入完后调一次;重复调用幂等(已 upgrade 过的 set data-upgraded 跳过)。
function upgradeMediaArtifacts(root) {
const nodes = (root || document).querySelectorAll(".art-media[data-rel]:not([data-upgraded])");
nodes.forEach((node) => {
node.dataset.upgraded = "1";
const rel = node.dataset.rel;
const cat = node.dataset.cat;
_fetchMediaBlobUrl(rel).then((url) => {
node.innerHTML = "";
if (cat === "image") {
const img = document.createElement("img");
img.src = url;
img.alt = rel.split("/").pop() || rel;
img.loading = "lazy"; // 浏览器懒解码(已在 viewport 内立即可见,远处暂不解)
node.appendChild(img);
} else if (cat === "video") {
const v = document.createElement("video");
v.src = url;
v.controls = true;
v.preload = "metadata";
node.appendChild(v);
}
}).catch((e) => {
node.innerHTML = `<span class="art-media-error">${escapeHtml(rel.split("/").pop() || rel)} 加载失败:${escapeHtml(e.message || String(e))}</span>`;
});
});
}
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"]),
video: new Set(["mp4","webm","mov","mkv","m4v"]),
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>`;
// 让出聊天输入区高度,弹框不遮挡 chat-form(无活动任务时 cf 隐藏,inset = 0)
const cf = $("chat-form");
const inset = (cf && cf.offsetParent) ? cf.offsetHeight : 0;
$("file-preview-modal").style.setProperty("--preview-bottom-inset", inset + "px");
$("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 === "video") _showVideo(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 _showVideo(blob) {
const url = _trackBlobUrl(blob);
const body = $("fp-body");
body.className = "body center";
body.innerHTML = "";
const v = document.createElement("video");
v.className = "preview-video";
v.src = url;
v.controls = true;
v.autoplay = true;
body.appendChild(v);
}
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");
$("file-preview-modal").style.removeProperty("--preview-bottom-inset");
$("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") return;
// 多模态共存:优先关靠前栈顶 — 选入(z 95)→ 文件预览(z 90)→ 新任务(z 80)
if ($("src-picker-modal").classList.contains("show")) { closeSrcPicker(); return; }
if ($("file-preview-modal").classList.contains("show")) { closeFilePreview(); return; }
});
async function uploadFiles(files) {
if (!files || !files.length) return false;
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();
return true;
} catch (e) {
alert("上传失败:" + e.message);
return false;
}
}
async function uploadSelected() {
const inp = $("upload-input");
const files = Array.from(inp.files || []);
try {
await uploadFiles(files);
} finally {
inp.value = ""; // 允许重新选同名文件
}
}
// ───── new task ─────
// wd 二级 input 默认跟随 name;用户一旦手改二级 input → 脱钩;清空二级 input 重置 flag
let wdManuallyEdited = false;
$("hd-new").onclick = async () => {
$("nt-name").value = "";
$("nt-wd-sel").value = "__new__"; // 默认选 sentinel
$("nt-wd-new").value = "";
$("nt-wd-new").style.display = ""; // sentinel 选中态 → 二级 input 可见
$("nt-desc").value = ""; $("nt-skill").value = "";
$("nt-err").textContent = "";
$("nt-wd-hint").textContent = "";
wdManuallyEdited = false;
$("new-task-modal").classList.add("show");
await Promise.all([loadFolderSuggestions(), loadSkillOptions(), loadModels()]);
$("nt-wd-sel").value = "__new__"; // populateFolderSelects 重渲后再保险一次
populateModelSelect();
$("nt-name").focus();
};
function populateModelSelect() {
const sel = $("nt-model");
const models = state.models || [];
if (models.length === 0) {
sel.innerHTML = `<option value="">(默认)</option>`;
return;
}
sel.innerHTML = models.map(m =>
`<option value="${escapeHtml(m.profile)}" ${m.is_default ? "selected" : ""}>${escapeHtml(m.display_name)}</option>`
).join("");
}
$("nt-cancel").onclick = () => $("new-task-modal").classList.remove("show");
$("nt-go").onclick = async () => {
const name = $("nt-name").value.trim();
const sel = $("nt-wd-sel").value;
// sentinel:用二级 input 值,空则 fallback name;选已有目录:直接用 value
const working_dir = sel === "__new__"
? ($("nt-wd-new").value.trim() || name)
: sel;
const desc = $("nt-desc").value.trim();
const skill = $("nt-skill").value;
const model_profile = $("nt-model").value;
$("nt-err").textContent = "";
if (!name) { $("nt-err").textContent = "任务名为必填项"; return; }
try {
const t = await api("POST", "/v1/tasks",
{ name, working_dir, description: desc, skill, model_profile });
$("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;
}
};
// 工作目录:拉数据 + 灌两个 select(顶部 filter-wd 和 modal nt-wd-sel)
async function loadFolderSuggestions() {
try {
const data = await api("GET", "/v1/folders");
state.folders = data.folders || [];
} catch (e) {
state.folders = state.folders || [];
}
populateFolderSelects();
}
// 灌 filter-wd + nt-wd-sel options;保留当前选中值
function populateFolderSelects() {
const folders = state.folders || [];
// 顶部 filter:第一项 "(全部目录)" sentinel
const filterSel = $("filter-wd");
const filterCur = filterSel.value;
const filterOpts = ['<option value="">(全部目录)</option>'];
for (const f of folders) {
const tag = f.n_tasks ? `${f.n_tasks} 个任务` : `空目录`;
filterOpts.push(`<option value="${escapeHtml(f.name)}">${escapeHtml(f.name)}${escapeHtml(tag)}</option>`);
}
filterSel.innerHTML = filterOpts.join("");
filterSel.value = filterCur; // 重渲后恢复选中
// modal wd:第一项 "+ 新建(跟随任务名)" sentinel(label 由 updateSentinelLabel 实时刷)
const wdSel = $("nt-wd-sel");
const wdCur = wdSel.value || "__new__";
const wdOpts = [`<option value="__new__">+ 新建(跟随任务名)</option>`];
for (const f of folders) {
const tag = f.n_tasks ? `${f.n_tasks} 个任务` : `空目录`;
wdOpts.push(`<option value="${escapeHtml(f.name)}">${escapeHtml(f.name)}${escapeHtml(tag)}</option>`);
}
wdSel.innerHTML = wdOpts.join("");
wdSel.value = wdCur;
updateSentinelLabel(); // 用最新的 name 刷 sentinel
}
// 智能体类型下拉: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 已清空,这里幂等再保一次
}
// === modal wd select + 二级 input 联动 ===
// select 选 "__new__" sentinel → 显示二级 input(默认值跟随 name);选已有目录 → 隐藏二级 input
// wdManuallyEdited:用户改过二级 input 后置 true,name 不再覆盖;清空二级 input 重置 false
function updateSentinelLabel() {
// sentinel 永远是 select 第一项,labels 实时含 name 让用户一眼知会建什么
const sel = $("nt-wd-sel");
const opt = sel.options[0];
if (!opt || opt.value !== "__new__") return;
const name = $("nt-name").value.trim();
opt.textContent = name ? `+ 新建「${name}` : `+ 新建(跟随任务名)`;
}
function updateWdHint() {
const hint = $("nt-wd-hint");
const sel = $("nt-wd-sel").value;
if (sel === "__new__") {
const v = $("nt-wd-new").value.trim();
const name = $("nt-name").value.trim();
const target = v || name;
if (!target) { hint.textContent = ""; return; }
// 用户手输的新名恰好命中已有目录 → 提示会复用而非新建
const collision = (state.folders || []).find(f => f.name === target);
if (collision) {
const n = collision.n_tasks || 0;
hint.innerHTML = `<span style="color:var(--accent);">! 已有同名目录,将复用</span> · ${n} 个任务`;
} else {
hint.innerHTML = `<span class="muted">→ 新建目录 ${escapeHtml(target)}</span>`;
}
} else {
const f = (state.folders || []).find(x => x.name === sel);
const n = f ? (f.n_tasks || 0) : 0;
hint.innerHTML = `<span style="color:var(--accent);">→ 复用已有目录</span> · ${n} 个任务`;
}
}
// name 改变 → 更新 sentinel label;若未脱钩且当前是 sentinel,二级 input 跟随 name
$("nt-name").addEventListener("input", () => {
updateSentinelLabel();
if (!wdManuallyEdited && $("nt-wd-sel").value === "__new__") {
$("nt-wd-new").value = $("nt-name").value;
}
updateWdHint();
});
// wd select 切换 → 切显示二级 input + 刷 hint
$("nt-wd-sel").addEventListener("change", () => {
const v = $("nt-wd-sel").value;
if (v === "__new__") {
$("nt-wd-new").style.display = "";
if (!wdManuallyEdited) $("nt-wd-new").value = $("nt-name").value;
} else {
$("nt-wd-new").style.display = "none";
}
updateWdHint();
});
// 二级 input 改变 → 非空视为手动修改;清空重置 flag 但保持空(避免 backspace 想换名时被打断)
$("nt-wd-new").addEventListener("input", () => {
wdManuallyEdited = $("nt-wd-new").value.trim() !== "";
updateWdHint();
});
// ───── boot ─────
if (state.token) {
// 已有 token:试探一下,失败回登录页
enterApp();
} else {
$("li-token").focus();
}
</script>
</body>
</html>