3294 lines
138 KiB
HTML
3294 lines
138 KiB
HTML
<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<title>zcbot 控制台</title>
|
||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||
|
||
<!-- markdown + 防 XSS + 代码高亮(纯 CDN,失败优雅降级回 plain text) -->
|
||
<script src="https://cdn.jsdelivr.net/npm/marked@12/marked.min.js"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
|
||
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/highlight.min.js"></script>
|
||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/github.min.css" />
|
||
|
||
<style>
|
||
:root {
|
||
--bg: #f7f7f7;
|
||
--panel: #ffffff;
|
||
--border: #e3e3e3;
|
||
--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:flex column,顶部多行 pane-head 固定,只让 #task-scroll 滚 — 滚动条不再覆盖顶栏 */
|
||
#pane-left { grid-area: left; display: flex; flex-direction: column; overflow: hidden; }
|
||
#pane-left > .pane-head { flex-shrink: 0; }
|
||
#task-scroll { flex: 1; min-height: 0; overflow: auto; }
|
||
/* 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; }
|
||
/* 模型下拉标签:桌面文字 / 手机 emoji 二选一(swap 在 @media 640px 内) */
|
||
.mdl-icon { display: none; }
|
||
/* 同 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-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: 6px; font-size: 11px; }
|
||
#chat-meta .tid { display: none; }
|
||
#chat-meta .desc {
|
||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 60vw;
|
||
}
|
||
/* 模型下拉:手机端 label 文字 → emoji */
|
||
.mdl-text { display: none; }
|
||
.mdl-icon { display: inline; }
|
||
/* 对话面板顶栏:藏 "对话" label / spacer,5 个按钮自然换行,文字 nowrap 防内部断行 */
|
||
#pane-mid > .pane-head { flex-wrap: wrap; gap: 6px; padding: 6px 8px; }
|
||
#pane-mid > .pane-head > .label,
|
||
#pane-mid > .pane-head > .spacer { display: none; }
|
||
#pane-mid > .pane-head > button { white-space: nowrap; padding: 3px 8px; }
|
||
#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;
|
||
}
|
||
}
|
||
|
||
/* ───── embed mode (?embed=1&parent_origin=...) —— 父页面 iframe 嵌入 ─────
|
||
藏左上 brand / 用户名 / 退出登录;桌面整层 header 去掉(没 mobile-tabs 切换需求);
|
||
"+ 新建任务" 由 JS 移到任务面板 pane-head。 */
|
||
body.embed-mode #login { display: none !important; }
|
||
body.embed-mode header .brand,
|
||
body.embed-mode header #hd-who,
|
||
body.embed-mode header #hd-logout { display: none; }
|
||
@media (min-width: 641px) {
|
||
body.embed-mode header { display: none; }
|
||
}
|
||
#embed-waiting {
|
||
position: fixed; inset: 0; z-index: 90;
|
||
display: none; align-items: center; justify-content: center;
|
||
background: var(--bg); color: var(--muted); font-size: 13px;
|
||
flex-direction: column; gap: 12px; padding: 24px;
|
||
}
|
||
body.embed-mode.embed-waiting #embed-waiting { display: flex; }
|
||
body.embed-mode.embed-waiting #app { visibility: hidden; }
|
||
#embed-waiting .text { text-align: center; max-width: 80%; }
|
||
#embed-waiting .err { color: var(--accent); font-size: 12px; max-width: 80%; text-align: center; min-height: 1em; }
|
||
@keyframes embed-spin { to { transform: rotate(360deg); } }
|
||
#embed-waiting .spinner {
|
||
width: 24px; height: 24px; border-radius: 50%;
|
||
border: 2px solid var(--border); border-top-color: var(--accent);
|
||
animation: embed-spin .8s linear infinite;
|
||
}
|
||
</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>
|
||
|
||
<!-- ───── embed-mode waiting overlay (token 握手中) ───── -->
|
||
<div id="embed-waiting">
|
||
<div class="spinner"></div>
|
||
<div class="text">等待登录…</div>
|
||
<div class="err"></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-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">
|
||
<button id="hd-new" class="primary" style="flex:1;">+ 新建任务</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-scroll">
|
||
<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>
|
||
</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";
|
||
|
||
// ?embed=1&parent_origin=https://... → iframe 模式;父页面用 postMessage 推 token
|
||
// 可选 task_id=<uuid>:首次签发 token 后自动定位到该 task 并加载消息
|
||
const _embedQS = new URLSearchParams(location.search);
|
||
const EMBED = _embedQS.get("embed") === "1";
|
||
const EMBED_PARENT_ORIGIN = (_embedQS.get("parent_origin") || "").trim();
|
||
const EMBED_INITIAL_TASK_ID = (_embedQS.get("task_id") || "").trim();
|
||
let _embedInitialTaskHandled = false;
|
||
|
||
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) => (
|
||
{ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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();
|
||
if (EMBED) {
|
||
embedPostToParent({ type: "zcbot-401" });
|
||
embedShowWaiting("登录已失效,等待父页面重新签发…", false);
|
||
document.body.classList.add("embed-waiting");
|
||
return;
|
||
}
|
||
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);
|
||
});
|
||
|
||
// 滚动加载:只让 task 列表区域滚,顶部标题 / 新建 / 筛选 / 排序固定。
|
||
// rootMargin 提前 200px 触发,体感更顺;阈值 0 即可(刚进入即触发,append 期间 taskLoading 自带防抖)
|
||
const _taskScrollObserver = new IntersectionObserver((entries) => {
|
||
if (entries[0].isIntersecting && state.taskHasMore && !state.taskLoading) {
|
||
loadTaskList({ append: true });
|
||
}
|
||
}, { root: $("task-scroll"), 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 desc">${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 mdl-wrap" style="display:inline-flex;align-items:center;gap:4px;"><span class="mdl-text">模型</span><span class="mdl-icon" aria-hidden="true">💬</span><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 mdl-wrap" style="display:inline-flex;align-items:center;gap:4px;"><span class="mdl-text">生图</span><span class="mdl-icon" aria-hidden="true">🖼</span><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 mdl-wrap" style="display:inline-flex;align-items:center;gap:4px;"><span class="mdl-text">生视频</span><span class="mdl-icon" aria-hidden="true">🎬</span><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" : "";
|
||
const fullTitle = e.rel || e.name;
|
||
return `
|
||
<div class="sp-row${disabled}" data-rel="${escapeHtml(e.rel)}" data-isdir="${e.is_dir}" title="${escapeHtml(fullTitle)}">
|
||
<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}" title="${escapeHtml(fullTitle)}">${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";
|
||
const fullTitle = e.rel || e.name;
|
||
return `
|
||
<div class="file-row" data-rel="${escapeHtml(e.rel)}" title="${escapeHtml(fullTitle)}">
|
||
<span class="${cls} name" data-rel="${escapeHtml(e.rel)}" data-isdir="${e.is_dir}" title="${escapeHtml(fullTitle)}">
|
||
${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();
|
||
});
|
||
|
||
// ───── embed mode ─────
|
||
function embedPostToParent(msg) {
|
||
if (!EMBED_PARENT_ORIGIN || window.parent === window) return;
|
||
try { window.parent.postMessage(msg, EMBED_PARENT_ORIGIN); } catch (e) {}
|
||
}
|
||
function embedShowWaiting(text, isErr) {
|
||
const w = $("embed-waiting");
|
||
if (!w) return;
|
||
if (isErr) {
|
||
w.querySelector(".text").textContent = "";
|
||
w.querySelector(".err").textContent = text || "";
|
||
w.querySelector(".spinner").style.display = "none";
|
||
} else {
|
||
w.querySelector(".text").textContent = text || "等待登录…";
|
||
w.querySelector(".err").textContent = "";
|
||
w.querySelector(".spinner").style.display = "";
|
||
}
|
||
}
|
||
function embedHandleMessage(e) {
|
||
if (e.origin !== EMBED_PARENT_ORIGIN) return;
|
||
const d = e.data || {};
|
||
if (d.type === "zcbot-token" && d.token && d.user_id) {
|
||
state.token = d.token;
|
||
state.userId = d.user_id;
|
||
state.userName = d.user_name || "";
|
||
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);
|
||
document.body.classList.remove("embed-waiting");
|
||
if ($("app").classList.contains("ready")) {
|
||
// 401 后重签:重载列表,不重复 enterApp / 不重复定位 task(尊重用户中间切过的选择)
|
||
loadTaskList();
|
||
} else {
|
||
enterApp();
|
||
// 首次签发:若 URL 带 task_id,定位到该 task(loadMessages 由 selectTask 触发)
|
||
if (EMBED_INITIAL_TASK_ID && !_embedInitialTaskHandled) {
|
||
_embedInitialTaskHandled = true;
|
||
selectTask(EMBED_INITIAL_TASK_ID);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
function embedInit() {
|
||
if (!EMBED_PARENT_ORIGIN) {
|
||
document.body.classList.add("embed-mode", "embed-waiting");
|
||
embedShowWaiting("embed 模式缺少 parent_origin 参数 (URL 必须形如 ?embed=1&parent_origin=https://your-portal.com)", true);
|
||
return;
|
||
}
|
||
document.body.classList.add("embed-mode");
|
||
window.addEventListener("message", embedHandleMessage);
|
||
if (state.token) {
|
||
enterApp();
|
||
if (EMBED_INITIAL_TASK_ID && !_embedInitialTaskHandled) {
|
||
_embedInitialTaskHandled = true;
|
||
selectTask(EMBED_INITIAL_TASK_ID);
|
||
}
|
||
} else {
|
||
document.body.classList.add("embed-waiting");
|
||
embedShowWaiting("等待登录…", false);
|
||
}
|
||
embedPostToParent({ type: "zcbot-ready" });
|
||
}
|
||
|
||
// ───── boot ─────
|
||
if (EMBED) {
|
||
embedInit();
|
||
} else if (state.token) {
|
||
// 已有 token:试探一下,失败回登录页
|
||
enterApp();
|
||
} else {
|
||
$("li-token").focus();
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|