zcbot/web/static/dev.html

2146 lines
83 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>zcbot 控制台</title>
<meta name="viewport" content="width=device-width,initial-scale=1" />
<!-- markdown + 防 XSS + 代码高亮(纯 CDN,失败优雅降级回 plain text) -->
<script src="https://cdn.jsdelivr.net/npm/marked@12/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/highlight.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/github.min.css" />
<style>
:root {
--bg: #f7f7f7;
--panel: #ffffff;
--border: #e3e3e3;
--text: #222;
--muted: #888;
--accent: #c0392b;
--accent-soft: #fde9e7;
--hover: #f0f0f0;
--code-bg: #f4f4f4;
--user-bg: #eef4fb;
--asst-bg: #ffffff;
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
font: 14px/1.5 -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;
color: var(--text); background: var(--bg);
overflow: hidden; /* 视窗锁死,所有滚动在 pane 内 */
}
button, input, textarea, select {
font: inherit; color: inherit;
}
button {
background: #fff; border: 1px solid var(--border);
padding: 4px 10px; border-radius: 4px; cursor: pointer;
}
button:hover { background: var(--hover); }
button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
button.primary:hover { filter: brightness(1.08); }
button.danger:hover { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
input:not([type="checkbox"]):not([type="radio"]):not([type="file"]),
textarea, select {
background: #fff; border: 1px solid var(--border);
padding: 5px 8px; border-radius: 4px; 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; }
/* ───── 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: 12px;
width: 380px;
box-shadow: 0 20px 60px rgba(0,0,0,.12), 0 2px 6px rgba(0,0,0,.04);
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: 8px;
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: 6px;
border: 1px solid var(--border); background: #fafafa;
transition: border-color .15s, background .15s, box-shadow .15s;
}
#login input:hover { background: #fff; }
#login 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; transition: opacity .15s;
}
#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: 6px; transition: filter .15s, transform .05s, box-shadow .15s;
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: color .15s, border-color .15s;
}
#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: 3px;
font-size: 11.5px;
}
/* ───── 3-pane layout ───── */
#app { display: none; height: 100vh; }
#app.ready { display: grid; grid-template-columns: 280px 1fr 320px; grid-template-rows: auto 1fr; grid-template-areas: "head head head" "left mid right"; }
header {
grid-area: head; background: #fff; border-bottom: 1px solid var(--border);
padding: 8px 14px; display: flex; align-items: center; gap: 12px;
}
header .title { font-weight: 600; }
header .who { color: var(--muted); font-size: 12px; font-family: monospace; }
header .spacer { flex: 1; }
.pane { border-right: 1px solid var(--border); background: var(--panel); overflow: auto; min-height: 0; }
#pane-left { grid-area: left; }
/* min-height: 0 + overflow: hidden 让内部 flex 子项的 overflow: auto 真正生效(否则被默认 min-height: auto 顶出) */
#pane-mid { grid-area: mid; display: flex; flex-direction: column; border-right: 1px solid var(--border); background: var(--panel); min-height: 0; overflow: hidden; }
#pane-right { grid-area: right; border-right: none; overflow: auto; background: var(--panel); min-height: 0; }
.pane-head {
padding: 8px 12px; border-bottom: 1px solid var(--border);
display: flex; gap: 8px; align-items: center; background: #fafafa;
position: sticky; top: 0;
}
.pane-head .label { font-weight: 600; font-size: 13px; white-space: nowrap; flex-shrink: 0; }
.pane-head .spacer { flex: 1; }
/* 对话顶栏按钮:语义化常态上色 — 完成 绿/导出 蓝/废弃 橙/删除 红 */
#btn-done { color: #27ae60; border-color: #a9dfbf; }
#btn-export { color: #2980b9; border-color: #aed6f1; }
#btn-abandon { color: #e67e22; border-color: #f5cba7; }
#btn-delete-task { color: #c0392b; border-color: #f5b7b1; }
#btn-done:hover:not(:disabled) { background: #e9f7ef; }
#btn-export:hover:not(:disabled) { background: #ebf5fb; }
#btn-abandon:hover:not(:disabled) { background: #fef5e7; }
#btn-delete-task:hover:not(:disabled) { background: #fdedec; }
#pane-mid > .pane-head > button.small:disabled { opacity: 0.4; cursor: not-allowed; }
/* ───── floating dropdown menu ───── */
/* 单例:position: fixed 逃出 pane overflow 裁剪;右上角触发,向下展开 */
.dd-toggle {
padding: 2px 6px; font-size: 14px; line-height: 1;
background: transparent; border: 1px solid transparent;
color: var(--muted); border-radius: 3px; cursor: pointer;
}
.dd-toggle:hover { background: var(--hover); color: var(--text); border-color: var(--border); }
#floating-menu {
display: none; position: fixed;
min-width: 132px; background: #fff;
border: 1px solid var(--border); border-radius: 4px;
box-shadow: 0 4px 14px rgba(0,0,0,0.12);
z-index: 60; padding: 4px 0;
}
#floating-menu.show { display: block; }
.dd-item {
display: block; width: 100%; text-align: left;
padding: 6px 14px; font-size: 13px; line-height: 1.4;
background: transparent; border: 0; border-radius: 0;
cursor: pointer; color: var(--text);
}
.dd-item:hover { background: var(--hover); }
.dd-item:disabled { color: var(--muted); cursor: not-allowed; opacity: 0.55; }
.dd-item:disabled:hover { background: transparent; }
.dd-item.act-complete { color: #2e7d32; }
.dd-item.act-abandon { color: #c77800; }
.dd-item.act-export { color: #1565c0; }
.dd-item.act-rename { color: #1565c0; }
.dd-item.act-download { color: #2e7d32; }
.dd-item.act-delete { color: var(--accent); }
/* ───── task list ───── */
.task-row {
padding: 8px 12px; border-bottom: 1px solid var(--border); cursor: pointer;
}
.task-row:hover { background: var(--hover); }
.task-row.active { background: var(--accent-soft); border-left: 3px solid var(--accent); padding-left: 9px; }
.task-row .desc { font-weight: 500; color: var(--text); margin-bottom: 2px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.task-row .meta { font-size: 11px; color: var(--muted); display: flex; gap: 8px; }
.task-row .badge {
display: inline-block; padding: 0 6px; border-radius: 8px; font-size: 11px;
background: #eef; color: #336;
}
.badge.completed { background: #e8f5e9; color: #2e7d32; }
.badge.abandoned { background: #fde9e7; color: var(--accent); }
.badge.active { background: #eef; color: #336; }
.empty { padding: 24px; color: var(--muted); text-align: center; font-size: 13px; }
/* ───── chat ───── */
#chat-meta { padding: 8px 12px; border-bottom: 1px solid var(--border); background: #fafafa;
font-size: 12px; color: var(--muted); display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
#chat-meta .tid { font-family: monospace; color: var(--text); }
#chat-meta .spacer { flex: 1; }
#chat-stream {
flex: 1; overflow-y: auto; overflow-x: hidden; padding: 12px;
display: flex; flex-direction: column; gap: 8px;
min-height: 0; /* 同上,允许在 flex 容器里收缩 + 触发自身滚动 */
}
.msg {
border: 1px solid var(--border); border-radius: 4px; padding: 8px 12px;
max-width: 92%;
}
.msg.user { background: var(--user-bg); align-self: flex-end; }
.msg.assistant, .msg.system, .msg.tool, .msg.error { background: var(--asst-bg); align-self: flex-start; }
.msg.error { border-color: var(--accent); background: var(--accent-soft); color: var(--accent); }
.cancelled-badge { margin-top: 8px; padding: 4px 10px; font-size: 12px; color: var(--accent); background: var(--accent-soft); border: 1px dashed var(--accent); border-radius: 4px; display: inline-block; }
.msg .role { font-size: 11px; color: var(--muted); margin-bottom: 2px; font-family: monospace; }
.msg .body { word-wrap: break-word; font-size: 14px; line-height: 1.55; }
.msg .body.streaming::after { content: "▌"; color: var(--accent); animation: blink 1s infinite; }
@keyframes blink { 0%,49% { opacity: 1; } 50%,100% { opacity: 0; } }
/* markdown 输出元素紧凑化 */
.msg .body > :first-child { margin-top: 0; }
.msg .body > :last-child { margin-bottom: 0; }
.msg .body p { margin: 0.4em 0; }
.msg .body h1, .msg .body h2, .msg .body h3, .msg .body h4 {
margin: 0.8em 0 0.3em; line-height: 1.3;
}
.msg .body h1 { font-size: 1.4em; }
.msg .body h2 { font-size: 1.25em; }
.msg .body h3 { font-size: 1.1em; }
.msg .body h4 { font-size: 1em; font-weight: 600; }
.msg .body ul, .msg .body ol { margin: 0.4em 0; padding-left: 1.6em; }
.msg .body li { margin: 0.15em 0; }
.msg .body li > p { margin: 0.15em 0; }
.msg .body blockquote {
margin: 0.4em 0; padding: 4px 12px; border-left: 3px solid var(--accent);
background: var(--accent-soft); color: #555;
}
.msg .body code:not(pre code) {
background: var(--code-bg); padding: 1px 5px; border-radius: 3px;
font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
font-size: 0.92em;
}
.msg .body pre {
margin: 0.5em 0; padding: 10px; background: #f6f8fa; border-radius: 4px;
overflow-x: auto; font-size: 12.5px; line-height: 1.4;
}
.msg .body pre code {
font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
background: transparent; padding: 0;
}
.msg .body table {
border-collapse: collapse; margin: 0.5em 0; font-size: 13px;
}
.msg .body th, .msg .body td {
border: 1px solid var(--border); padding: 4px 8px;
}
.msg .body th { background: #fafafa; font-weight: 600; }
.msg .body a { color: var(--accent); }
.msg .body img { max-width: 100%; }
.msg .body hr { border: none; border-top: 1px solid var(--border); margin: 0.8em 0; }
.tool-call {
margin-top: 6px; font-family: ui-monospace, Consolas, monospace; font-size: 12px;
}
.tool-call summary {
cursor: pointer; padding: 4px 6px; background: var(--code-bg); border-radius: 3px;
color: #555;
}
.tool-call summary:hover { background: #ebebeb; }
.tool-call pre {
margin: 4px 0 0; padding: 8px; background: var(--code-bg); border-radius: 3px;
overflow-x: auto; max-height: 300px; white-space: pre-wrap;
}
#chat-form {
border-top: 1px solid var(--border); padding: 10px; background: #fafafa;
display: flex; flex-direction: column; gap: 6px;
flex-shrink: 0; /* 输入区固定在底,不被消息挤压 */
}
#chat-form .row { display: flex; gap: 8px; }
#chat-form textarea { flex: 1; }
#chat-form .hint { font-size: 11px; color: var(--muted); }
/* ───── files ───── */
.crumbs { padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 12px; background: #fafafa; }
.crumbs a { margin-right: 4px; }
.file-row {
display: flex; padding: 6px 12px; border-bottom: 1px solid var(--border);
align-items: center; gap: 8px;
}
.file-row:hover { background: var(--hover); }
.file-row .name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-row .size { font-size: 11px; color: var(--muted); font-family: monospace; }
.ico-dir::before { content: "▸ "; color: var(--accent); }
.ico-file::before { content: "· "; color: var(--muted); }
/* 拖拽上传 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 {
position: fixed; inset: 0; background: rgba(0,0,0,0.4);
display: none; align-items: center; justify-content: center; z-index: 95;
}
#src-picker-modal.show { display: flex; }
#src-picker-modal .card {
background: var(--panel); border-radius: 6px;
width: 560px; max-height: 82vh;
display: flex; flex-direction: column;
box-shadow: 0 8px 24px rgba(0,0,0,.15);
}
#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: monospace; }
#sp-list .empty { padding: 18px; color: var(--muted); text-align: center; font-size: 12px; }
#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);
}
#sp-copy { color: #1565c0; border-color: #aed6f1; }
#sp-copy:hover:not(:disabled) { background: #ebf5fb; }
#sp-move { color: #c77800; border-color: #f5cba7; }
#sp-move:hover:not(:disabled) { background: #fef5e7; }
#sp-copy:disabled, #sp-move:disabled { opacity: 0.4; cursor: not-allowed; }
/* ───── new task modal ───── */
#new-task-modal {
position: fixed; inset: 0; background: rgba(0,0,0,0.4);
display: none; align-items: center; justify-content: center; z-index: 80;
}
#new-task-modal.show { display: flex; }
#new-task-modal .card {
background: var(--panel); padding: 20px; border-radius: 6px;
width: 420px; box-shadow: 0 8px 24px rgba(0,0,0,.15);
}
#new-task-modal h3 { margin: 0 0 12px; font-size: 16px; }
#new-task-modal label { display: block; margin-top: 8px; font-size: 12px; color: var(--muted); }
#new-task-modal .err { color: var(--accent); font-size: 12px; margin-top: 8px; min-height: 1em; }
#new-task-modal .actions { margin-top: 14px; display: flex; gap: 8px; justify-content: flex-end; }
/* ───── file preview modal ───── */
#file-preview-modal {
position: fixed; inset: 0; background: rgba(0,0,0,0.5);
display: none; align-items: center; justify-content: center; z-index: 90;
}
#file-preview-modal.show { display: flex; }
#file-preview-modal .card {
background: var(--panel); border-radius: 6px;
width: 90vw; height: 90vh; max-width: 1200px;
display: flex; flex-direction: column;
box-shadow: 0 8px 24px rgba(0,0,0,.2);
}
#file-preview-modal .hdr {
display: flex; align-items: center; gap: 8px;
padding: 8px 12px; border-bottom: 1px solid var(--border);
}
#file-preview-modal .hdr .name {
flex: 1; font-weight: 500; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap;
}
#file-preview-modal .body {
flex: 1; overflow: auto; padding: 12px; position: relative;
}
#file-preview-modal .body.center {
display: flex; align-items: center; justify-content: center;
}
#file-preview-modal .body .ph {
color: var(--muted); font-size: 13px; text-align: center;
}
#file-preview-modal .body img.preview-img {
max-width: 100%; max-height: 100%; object-fit: contain;
display: block; margin: 0 auto;
}
#file-preview-modal .body iframe.preview-frame {
width: 100%; height: 100%; border: 0;
}
#file-preview-modal .body pre.preview-text {
margin: 0; padding: 8px; background: var(--code-bg);
border-radius: 4px; white-space: pre-wrap; word-break: break-word;
font-family: ui-monospace, "SF Mono", Consolas, monospace;
font-size: 12px; line-height: 1.5;
}
#file-preview-modal .body .md-render {
max-width: 860px; margin: 0 auto; line-height: 1.7;
}
#file-preview-modal .body .md-render pre {
background: var(--code-bg); padding: 10px; border-radius: 4px; overflow: auto;
}
#file-preview-modal .body .md-render code { background: var(--code-bg); padding: 1px 4px; border-radius: 3px; }
#file-preview-modal .body .md-render pre code { background: transparent; padding: 0; }
#file-preview-modal .body .docx-host { background: #fff; }
#file-preview-modal .body .xlsx-tabs {
display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 8px;
border-bottom: 1px solid var(--border); padding-bottom: 6px;
}
#file-preview-modal .body .xlsx-tabs button.active {
background: var(--accent-soft); border-color: var(--accent); color: var(--accent);
}
#file-preview-modal .body .xlsx-sheet {
overflow: auto;
}
#file-preview-modal .body .xlsx-sheet table {
border-collapse: collapse; font-size: 12px;
}
#file-preview-modal .body .xlsx-sheet td, #file-preview-modal .body .xlsx-sheet th {
border: 1px solid var(--border); padding: 4px 8px; white-space: nowrap;
}
.small { font-size: 12px; }
.muted { color: var(--muted); }
</style>
</head>
<body>
<!-- ───── login overlay ───── -->
<div id="login">
<div class="card">
<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>
</div>
<!-- ───── main 3-pane ───── -->
<div id="app">
<header>
<div class="title">zcbot</div>
<div class="who" id="hd-who"></div>
<div class="spacer"></div>
<button id="hd-new" class="primary">+ 新建任务</button>
<button id="hd-logout">退出登录</button>
</header>
<!-- left -->
<div class="pane" id="pane-left">
<div class="pane-head">
<span class="label">任务</span>
<span class="spacer"></span>
<select id="filter-status" class="small" style="width: auto;">
<option value="">(全部)</option>
<option value="active">进行中</option>
<option value="completed">已完成</option>
<option value="abandoned">已废弃</option>
</select>
<button id="btn-refresh-tasks" class="small" title="刷新"></button>
</div>
<div class="pane-head" style="border-top: 1px solid var(--border); gap: 6px;">
<input id="filter-q" class="small" placeholder="搜索名称/描述..." style="flex:1; padding: 3px 6px;" />
<input id="filter-wd" list="folders-datalist" class="small" placeholder="工作目录" style="flex:1; padding: 3px 6px;" />
</div>
<div class="pane-head" style="border-top: 1px solid var(--border); gap: 6px;">
<span class="small muted" style="white-space:nowrap;">排序</span>
<select id="filter-order" class="small" style="flex:1; width:auto;">
<option value="-created_at">创建时间 ↓(新→旧)</option>
<option value="created_at">创建时间 ↑(旧→新)</option>
<option value="-updated_at">更新时间 ↓</option>
<option value="updated_at">更新时间 ↑</option>
<option value="name">名称 A→Z</option>
<option value="-name">名称 Z→A</option>
<option value="status,-created_at">状态分组(同状态按时间倒序)</option>
</select>
</div>
<div id="task-list"><div class="empty">加载中…</div></div>
<div id="task-pager" class="pane-head" style="border-top: 1px solid var(--border); font-size: 11px; color: var(--muted); justify-content: space-between; display: none;">
<span id="pager-info"></span>
<span style="display:flex; gap: 4px;">
<button id="btn-prev-page" class="small"></button>
<button id="btn-next-page" class="small"></button>
</span>
</div>
</div>
<!-- middle -->
<div id="pane-mid">
<div class="pane-head">
<span class="label">对话</span>
<span class="spacer"></span>
<button id="btn-export" class="small" disabled>导出对话记录</button>
<button id="btn-done" class="small" disabled>完成</button>
<button id="btn-abandon" class="small danger" disabled>废弃</button>
<button id="btn-delete-task" class="small danger" disabled title="硬删除:清 DB 行 + messages,FS 文件不动">删除</button>
</div>
<div id="chat-meta"><span class="muted">(未选中任务)</span></div>
<div id="chat-stream"><div class="empty">请在左侧选一个任务</div></div>
<form id="chat-form" style="display:none;">
<textarea id="chat-input" placeholder="输入消息…(Enter 发送,Shift+Enter 换行)"></textarea>
<div class="row">
<span class="hint" id="chat-hint">就绪</span>
<span style="flex:1;"></span>
<button type="button" class="small danger" id="chat-cancel" style="display:none;" title="停止当前流式回复(协作式 cancel,最长等 LLM 当前一轮跑完)">停止</button>
<button type="button" class="small" id="chat-upload" title="上传文件到右侧当前文件目录">⬆ 上传</button>
<button type="submit" class="primary" id="chat-send">发送</button>
</div>
</form>
</div>
<!-- right -->
<div id="pane-right">
<div class="pane-head">
<span class="label">文件</span>
<span id="files-proj" class="muted small" style="margin-left:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:0 1 auto;" title=""></span>
<span class="spacer"></span>
<button id="btn-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">
<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">
<div class="card">
<h3>新建任务</h3>
<label for="nt-name">任务名(必填)</label>
<input id="nt-name" placeholder="例如 初稿大纲" />
<label for="nt-wd">工作目录(可选,留空 → 用任务名;已有则复用,新名则新建)</label>
<input id="nt-wd" list="folders-datalist" placeholder="选已有或新建,留空则用任务名" />
<datalist id="folders-datalist"></datalist>
<div class="small muted" id="nt-wd-hint" style="margin-top:4px;min-height:1em;"></div>
<label for="nt-desc">描述(可选,任务长描述)</label>
<input id="nt-desc" />
<label for="nt-skill">智能体类型(可选)</label>
<select id="nt-skill">
<option value="">(默认 · 不限定)</option>
</select>
<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">
<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 state = {
token: localStorage.getItem(LS_TOKEN) || "",
userId: localStorage.getItem(LS_UID) || "",
userName: localStorage.getItem(LS_NAME) || "",
taskId: null,
taskMeta: null,
filesPath: "",
evtSrc: null,
streaming: false, // 当前是否在流式中;true 时显示 stop 按钮
// task list 分页 + 筛选
taskPage: 1,
taskPageSize: 20,
taskTotal: 0,
// 模型清单(GET /v1/models 一次缓存):新建对话框 + 顶栏切换下拉 + 历史小标显示名都用
models: [],
};
// ───── helpers ─────
const $ = (id) => document.getElementById(id);
// ───── floating dropdown menu (single instance) ─────
// 用 position: fixed 单例避免被 pane overflow 裁剪;按位置算出右上角对齐
let _menuItems = null;
function showMenu(triggerEl, items) {
_menuItems = items;
const menu = $("floating-menu");
menu.innerHTML = items.map((it) => {
const cls = "dd-item " + (it.cls || "");
const dis = it.disabled ? " disabled" : "";
return `<button class="${cls}" data-act="${escapeHtml(it.act)}"${dis}>${escapeHtml(it.label)}</button>`;
}).join("");
menu.querySelectorAll(".dd-item").forEach((btn) => {
btn.onclick = (e) => {
e.stopPropagation();
const act = btn.dataset.act;
const item = _menuItems && _menuItems.find((i) => i.act === act);
hideMenu();
if (item && item.onclick) item.onclick();
};
});
// 默认右下展开;若空间不足则改向上
const rect = triggerEl.getBoundingClientRect();
menu.style.visibility = "hidden";
menu.classList.add("show");
const mh = menu.offsetHeight || 120;
menu.style.right = Math.max(4, window.innerWidth - rect.right) + "px";
menu.style.left = "auto";
if (rect.bottom + mh + 8 > window.innerHeight) {
menu.style.top = Math.max(4, rect.top - mh - 4) + "px";
} else {
menu.style.top = (rect.bottom + 4) + "px";
}
menu.style.visibility = "";
}
function hideMenu() {
_menuItems = null;
$("floating-menu").classList.remove("show");
}
document.addEventListener("click", (e) => {
if (e.target.closest(".dd-toggle")) return;
if (e.target.closest("#floating-menu")) return;
hideMenu();
}, true);
window.addEventListener("resize", hideMenu);
// 滚动 pane 时菜单位置失效,直接关
document.addEventListener("scroll", hideMenu, true);
async function api(method, path, body) {
const opts = { method, headers: {} };
if (state.token) opts.headers["Authorization"] = "Bearer " + state.token;
if (body !== undefined) {
opts.headers["Content-Type"] = "application/json";
opts.body = JSON.stringify(body);
}
const r = await fetch(path, opts);
let data = null;
const ct = r.headers.get("content-type") || "";
if (ct.includes("application/json")) {
try { data = await r.json(); } catch (e) {}
} else {
data = await r.text();
}
if (!r.ok) {
const msg = (data && data.detail) || data || (r.status + " " + r.statusText);
const err = new Error(typeof msg === "string" ? msg : JSON.stringify(msg));
err.status = r.status;
throw err;
}
return data;
}
function humanSize(n) {
if (n == null) return "";
if (n < 1024) return n + " B";
if (n < 1024*1024) return (n/1024).toFixed(1) + " K";
if (n < 1024*1024*1024) return (n/1024/1024).toFixed(1) + " M";
return (n/1024/1024/1024).toFixed(1) + " G";
}
function fmtTime(iso) {
if (!iso) return "";
try { return new Date(iso).toLocaleString(); } catch (e) { return iso; }
}
function escapeHtml(s) {
return (s || "").replace(/[&<>"']/g, (c) => (
{ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]
));
}
// ───── markdown 渲染 ─────
// 三个 CDN 库任一缺失 → 优雅降级回 <pre>escapeHtml</pre>(plain text wrap)
if (window.marked && window.marked.setOptions) {
window.marked.setOptions({ gfm: true, breaks: true, headerIds: false, mangle: false });
}
function renderMd(text) {
const raw = String(text || "");
if (!window.marked || !window.marked.parse) {
return `<pre style="white-space:pre-wrap;word-break:break-word;font-family:inherit;margin:0;">${escapeHtml(raw)}</pre>`;
}
let html = window.marked.parse(raw);
if (window.DOMPurify) {
html = window.DOMPurify.sanitize(html, { USE_PROFILES: { html: true } });
}
return html;
}
function highlightIn(container) {
if (!window.hljs || !container) return;
container.querySelectorAll("pre code").forEach((b) => {
if (b.dataset.hl === "1") return;
try { window.hljs.highlightElement(b); b.dataset.hl = "1"; } catch (e) {}
});
}
// ───── login ─────
let loginTab = "pw"; // "pw" | "key";持久化 last-used tab 在 LS,刷新后默认那个
const LS_TAB = "zcbot_login_tab";
function switchLoginTab(name) {
loginTab = name;
document.querySelectorAll("#login .tabs button").forEach(b => {
b.classList.toggle("active", b.dataset.tab === name);
});
document.querySelectorAll("#login .tab-body").forEach(b => {
b.classList.toggle("active", b.id === "body-" + name);
});
localStorage.setItem(LS_TAB, name);
$("li-err").textContent = "";
// 自动 focus 第一个空 input,Enter 直接登
const firstInput = document.querySelector("#body-" + name + " input");
if (firstInput) firstInput.focus();
}
document.querySelectorAll("#login .tabs button").forEach(b => {
b.addEventListener("click", () => switchLoginTab(b.dataset.tab));
});
const savedTab = localStorage.getItem(LS_TAB);
if (savedTab === "key") switchLoginTab("key");
$("li-go").onclick = doLogin;
// 任意 input 上回车都触发登录
document.querySelectorAll("#login input").forEach(i => {
i.addEventListener("keydown", (e) => { if (e.key === "Enter") doLogin(); });
});
async function doLogin() {
$("li-err").textContent = "";
let url, body, displayLabel;
if (loginTab === "pw") {
const email = $("li-email").value.trim();
const password = $("li-password").value;
if (!email || !password) {
$("li-err").textContent = "请填邮箱和密码";
return;
}
url = "/v1/auth/login_password";
body = { email, password };
displayLabel = "email";
} else {
const uid = $("li-uid").value.trim();
const pkey = $("li-pkey").value;
if (!uid || !pkey) {
$("li-err").textContent = "请填 user_id 和 PLATFORM_KEY";
return;
}
url = "/v1/auth/login";
body = { user_id: uid, platform_key: pkey };
displayLabel = null; // 这条路径不返显示名,顶栏只显 uid 前 8 位
}
try {
const r = await fetch(url, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!r.ok) {
const d = await r.json().catch(() => ({}));
throw new Error(d.detail || (r.status + " login failed"));
}
const data = await r.json();
state.token = data.token;
state.userId = data.user_id;
state.userName = displayLabel ? (data[displayLabel] || "") : "";
localStorage.setItem(LS_TOKEN, state.token);
localStorage.setItem(LS_UID, state.userId);
if (state.userName) {
localStorage.setItem(LS_NAME, state.userName);
} else {
localStorage.removeItem(LS_NAME);
}
enterApp();
} catch (e) {
$("li-err").textContent = e.message;
}
}
function logout() {
state.token = ""; state.userId = ""; state.userName = "";
localStorage.removeItem(LS_TOKEN);
localStorage.removeItem(LS_UID);
localStorage.removeItem(LS_NAME);
if (state.evtSrc) state.evtSrc.close();
location.reload();
}
$("hd-logout").onclick = logout;
// ───── 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 下拉 + 新建对话框 + 历史小标
}
async function loadModels() {
try {
const data = await api("GET", "/v1/models");
state.models = data.models || [];
} catch (e) {
state.models = []; // 静默兜底:无模型清单时下拉不显示,不挡正常流程
}
}
async function loadTaskList() {
const params = new URLSearchParams();
params.set("page", state.taskPage);
params.set("page_size", state.taskPageSize);
const st = $("filter-status").value;
if (st) params.set("status", st);
const q = $("filter-q").value.trim();
if (q) params.set("q", q);
const wd = $("filter-wd").value.trim();
if (wd) params.set("working_dir", wd);
const ord = $("filter-order").value;
if (ord && ord !== "-created_at") params.set("ordering", ord); // 默认值不发送,URL 更干净
try {
const data = await api("GET", "/v1/tasks?" + params.toString());
state.taskTotal = data.count || 0;
state.taskPage = data.page || 1;
state.taskPageSize = data.page_size || state.taskPageSize;
renderTaskList(data.results || []);
renderPager();
} catch (e) {
if (e.status === 401) { logout(); return; }
$("task-list").innerHTML = `<div class="empty">加载失败:${escapeHtml(e.message)}</div>`;
$("task-pager").style.display = "none";
}
}
function renderPager() {
const total = state.taskTotal;
const ps = state.taskPageSize;
const page = state.taskPage;
const lastPage = Math.max(1, Math.ceil(total / ps));
if (total === 0) {
$("task-pager").style.display = "none";
return;
}
$("task-pager").style.display = "flex";
const from = (page - 1) * ps + 1;
const to = Math.min(page * ps, total);
$("pager-info").textContent = `${from}${to} / ${total} (第 ${page}/${lastPage} 页)`;
$("btn-prev-page").disabled = page <= 1;
$("btn-next-page").disabled = page >= lastPage;
}
function resetPageAndReload() {
state.taskPage = 1;
loadTaskList();
}
function renderTaskList(tasks) {
state.tasksById = {};
for (const t of tasks) state.tasksById[t.task_id] = t;
if (!tasks.length) {
$("task-list").innerHTML = `<div class="empty">(暂无任务)</div>`;
return;
}
const statusLabels = { active: "进行中", completed: "已完成", abandoned: "已废弃" };
const html = tasks.map((t) => {
const active = state.taskId === t.task_id ? " active" : "";
// 主行 = 任务名(必填字段);副行 = 工作目录 + description(都按需显示)
const taskName = t.name || "(未命名)";
const wdName = t.working_dir ? t.working_dir.split("/").filter(Boolean).pop() : "";
const desc = t.description || "";
const statusLabel = statusLabels[t.status] || t.status;
return `
<div class="task-row${active}" data-tid="${t.task_id}" style="display:flex;align-items:flex-start;gap:6px;">
<div style="flex:1;min-width:0;">
<div class="desc" title="${escapeHtml(taskName)}">${escapeHtml(taskName)}</div>
${wdName ? `<div class="meta muted" title="${escapeHtml(t.working_dir || "")}">📁 ${escapeHtml(wdName)}</div>` : ""}
${desc ? `<div class="meta muted" title="${escapeHtml(desc)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block;">${escapeHtml(desc)}</div>` : ""}
<div class="meta">
<span class="badge ${t.status}">${statusLabel}</span>
${t.skill ? `<span class="muted">${escapeHtml(t.skill)}</span>` : ""}
<span>${t.n_messages || 0} 条</span>
<span>${t.tokens || 0} tok</span>
<span class="muted" style="margin-left:auto;font-family:monospace;">${t.task_id.slice(0, 8)}</span>
</div>
</div>
<button class="dd-toggle task-menu" data-tid="${t.task_id}" title="任务操作">⋯</button>
</div>
`;
}).join("");
$("task-list").innerHTML = html;
$("task-list").querySelectorAll(".task-row").forEach((el) => {
el.onclick = (e) => {
if (e.target.closest(".dd-toggle")) return; // 菜单按钮点击不触发选中
selectTask(el.dataset.tid);
};
});
$("task-list").querySelectorAll(".task-menu").forEach((btn) => {
btn.onclick = (e) => {
e.stopPropagation();
const t = state.tasksById[btn.dataset.tid];
if (!t) return;
showMenu(btn, taskMenuItems(t));
};
});
}
function taskMenuItems(t) {
const isActive = t.status === "active";
const hasMsg = (t.n_messages || 0) > 0;
return [
{ act: "complete", label: "完成", cls: "act-complete", disabled: !isActive,
onclick: () => setTaskStatus(t.task_id, "completed", t.name || "(未命名)") },
{ act: "abandon", label: "废弃", cls: "act-abandon", disabled: !isActive,
onclick: () => setTaskStatus(t.task_id, "abandoned", t.name || "(未命名)") },
{ act: "export", label: "导出对话记录", cls: "act-export", disabled: !hasMsg,
onclick: () => exportTask(t.task_id) },
{ act: "delete", label: "删除", cls: "act-delete",
onclick: () => deleteTask(t.task_id, t.name || "(未命名)", t.n_messages || 0) },
];
}
// 任何筛选 / 排序变化都 reset page=1 重拉;刷新按钮保持当前页;翻页只动 page
$("filter-status").onchange = resetPageAndReload;
$("filter-order").onchange = resetPageAndReload;
$("btn-refresh-tasks").onclick = loadTaskList;
$("btn-prev-page").onclick = () => { if (state.taskPage > 1) { state.taskPage--; loadTaskList(); } };
$("btn-next-page").onclick = () => {
const lastPage = Math.max(1, Math.ceil(state.taskTotal / state.taskPageSize));
if (state.taskPage < lastPage) { state.taskPage++; loadTaskList(); }
};
// 搜索 / 工作目录筛选:debounce 300ms,避免每个字符都打 API
let _filterDebounce = null;
function scheduleFilter() {
clearTimeout(_filterDebounce);
_filterDebounce = setTimeout(resetPageAndReload, 300);
}
$("filter-q").addEventListener("input", scheduleFilter);
$("filter-wd").addEventListener("input", scheduleFilter);
// 工作目录输入框打开 enterApp 时拉一次 datalist(modal 也复用同一 list)
async function ensureFoldersLoaded() {
if ($("folders-datalist").children.length === 0) await loadFolderSuggestions();
}
$("filter-wd").addEventListener("focus", ensureFoldersLoaded);
// ───── select task ─────
async function selectTask(tid) {
if (state.evtSrc) { state.evtSrc.close(); state.evtSrc = null; }
state.taskId = tid;
document.querySelectorAll(".task-row").forEach((el) => {
el.classList.toggle("active", el.dataset.tid === tid);
});
try {
const meta = await api("GET", "/v1/tasks/" + tid);
state.taskMeta = meta;
renderChatMeta();
await loadMessages();
// 文件面板自动跳到该 task 的 working_dir(user_root 下一级子目录),
// 不强绑定 — 用户可点 crumb 回上层看 user_root 其他目录
const wdName = meta.working_dir ? meta.working_dir.split("/").filter(Boolean).pop() : "";
state.filesPath = wdName || "";
await loadFiles();
} catch (e) {
if (e.status === 401) { logout(); return; }
$("chat-stream").innerHTML = `<div class="empty">加载失败:${escapeHtml(e.message)}</div>`;
}
}
function renderChatMeta() {
const t = state.taskMeta;
if (!t) { $("chat-meta").innerHTML = `<span class="muted">(未选中任务)</span>`; return; }
const wdName = t.working_dir ? t.working_dir.split("/").filter(Boolean).pop() : "";
const taskName = t.name || "(未命名)";
const statusLabel = { active: "进行中", completed: "已完成", abandoned: "已废弃" }[t.status] || t.status;
$("chat-meta").innerHTML = `
<span style="font-weight:600;" title="${escapeHtml(taskName)}">${escapeHtml(taskName)}</span>
<span class="badge ${t.status}">${statusLabel}</span>
${wdName ? `<span class="muted" title="${escapeHtml(t.working_dir)}">📁 ${escapeHtml(wdName)}</span>` : ""}
${t.skill ? `<span class="muted">${escapeHtml(t.skill)}</span>` : ""}
<span class="tid">${t.task_id.slice(0, 8)}</span>
${t.description ? `<span class="muted">${escapeHtml(t.description)}</span>` : ""}
<span class="spacer"></span>
${renderModelDropdown(t)}
<span class="muted small">${t.n_messages || 0} 条 · ${t.tokens || 0} tok</span>
`;
const sel = $("chat-model-sel");
if (sel) sel.onchange = onChangeModel;
const active = t.status === "active";
$("chat-form").style.display = active ? "flex" : "none";
$("btn-done").disabled = !active;
$("btn-abandon").disabled = !active;
$("btn-delete-task").disabled = false; // delete 不限 status(用户显式 confirm)
$("btn-export").disabled = (t.n_messages || 0) === 0;
}
function renderModelDropdown(t) {
// 模型清单未加载好(或为空)时不渲染下拉,但 task 仍可正常用(后端走 task.model_profile)
if (!state.models || state.models.length === 0) return "";
const cur = t.model_profile || "";
const opts = state.models.map(m =>
`<option value="${escapeHtml(m.profile)}" ${m.profile === cur ? "selected" : ""}>${escapeHtml(m.display_name)}</option>`
).join("");
return `<span class="muted small" style="display:inline-flex;align-items:center;gap:4px;">模型 <select id="chat-model-sel" class="small" style="width:auto;padding:1px 4px;font-size:12px;" title="切换 task 模型(下条消息生效)">${opts}</select></span>`;
}
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;
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);
card.innerHTML = `
<div class="role">工具调用 · ${escapeHtml(p.name || "")}</div>
<details class="tool-call"><summary>结果(${(txt || "").length} 字符)</summary><pre>${escapeHtml(txt || "")}</pre></details>
`;
wrap.appendChild(card);
continue;
}
const card = document.createElement("div");
card.className = "msg " + role;
const roleLabel = { user: "我", assistant: "助手", error: "错误" }[role] || role;
let html = `<div class="role">${roleLabel}</div>`;
if (typeof p.content === "string" && p.content) {
html += `<div class="body">${renderMd(p.content)}</div>`;
}
if (Array.isArray(p.tool_calls) && p.tool_calls.length) {
for (const tc of p.tool_calls) {
const fn = (tc.function && tc.function.name) || "?";
let args = "";
try {
args = JSON.stringify(JSON.parse((tc.function && tc.function.arguments) || "{}"), null, 2);
} catch (e) { args = (tc.function && tc.function.arguments) || ""; }
html += `
<details class="tool-call"><summary>工具调用:${escapeHtml(fn)}</summary><pre>${escapeHtml(args)}</pre></details>
`;
}
}
card.innerHTML = html;
highlightIn(card);
wrap.appendChild(card);
}
wrap.scrollTop = wrap.scrollHeight;
}
// ───── send + SSE ─────
$("chat-form").addEventListener("submit", (e) => { e.preventDefault(); sendMessage(); });
$("chat-input").addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendMessage(); }
});
async function sendMessage() {
if (!state.taskId) return;
const content = $("chat-input").value.trim();
if (!content) return;
$("chat-send").disabled = true;
$("chat-hint").textContent = "发送中…";
try {
// 立刻渲染 user 消息卡(乐观)
const wrap = $("chat-stream");
const userCard = document.createElement("div");
userCard.className = "msg user";
userCard.innerHTML = `<div class="role">我</div><div class="body">${escapeHtml(content)}</div>`;
wrap.appendChild(userCard);
// assistant 流式占位卡
const asstCard = document.createElement("div");
asstCard.className = "msg assistant";
asstCard.innerHTML = `<div class="role">助手</div><div class="body streaming"></div>`;
wrap.appendChild(asstCard);
wrap.scrollTop = wrap.scrollHeight;
const r = await api("POST", `/v1/tasks/${state.taskId}/messages`, { content });
$("chat-input").value = "";
state.streaming = true;
$("chat-cancel").style.display = "";
streamSse(r.events_url, asstCard);
} catch (e) {
if (e.status === 401) { logout(); return; }
appendErrorCard(e.message);
$("chat-send").disabled = false;
$("chat-hint").textContent = "就绪";
}
}
async function cancelCurrentTask() {
if (!state.taskId || !state.streaming) return;
const btn = $("chat-cancel");
btn.disabled = true;
$("chat-hint").textContent = "停止中…";
try {
await api("POST", `/v1/tasks/${state.taskId}/cancel`);
// 不重置 streaming / 按钮 — 等 SSE 的 cancelled / done 走完一并清
} catch (e) {
if (e.status === 401) { logout(); return; }
// 409 = 已结束 / 已 cancelling,不算错;其他贴 toast
if (e.status !== 409) appendErrorCard("cancel: " + e.message);
btn.disabled = false;
$("chat-hint").textContent = "就绪";
}
}
$("chat-cancel").addEventListener("click", cancelCurrentTask);
function streamSse(url, asstCard) {
// EventSource 不支持自定义 header,token 走 query string(?token=...)
// 这里 SSE 走 same-origin,token 经 URL 传给后端 — 但当前后端只读 Authorization 头
// 简单做法:走带 token 的 fetch + ReadableStream 替代 EventSource
fetchSse(url, asstCard).catch((e) => appendErrorCard("sse: " + e.message));
}
async function fetchSse(url, asstCard) {
const body = asstCard.querySelector(".body");
const ctx = { acc: "", body, pending: false };
try {
const r = await fetch(url, {
headers: { "Authorization": "Bearer " + state.token, "Accept": "text/event-stream" },
});
if (!r.ok) throw new Error(r.status + " " + r.statusText);
const reader = r.body.getReader();
const dec = new TextDecoder();
let buf = "";
$("chat-hint").textContent = "接收中…";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
while (true) {
const idx = buf.indexOf("\n\n");
if (idx < 0) break;
const frame = buf.slice(0, idx);
buf = buf.slice(idx + 2);
const ev = parseSseFrame(frame);
if (!ev) continue;
handleSseEvent(ev, asstCard, ctx);
if (ev.event === "done" || ev.event === "error") break;
}
}
// 最终定稿 + 代码高亮(流式中不 highlight,省 CPU)
body.innerHTML = renderMd(ctx.acc);
highlightIn(asstCard);
} finally {
body.classList.remove("streaming");
$("chat-send").disabled = false;
$("chat-hint").textContent = "就绪";
state.streaming = false;
const cb = $("chat-cancel");
cb.style.display = "none";
cb.disabled = false;
}
// 刷新 task meta + messages(拿真实持久化的);失败路径已退出,这里不再跑
loadTaskList();
await loadMessages();
loadFiles(); // 回复结束后右侧文件面板同步刷新(可能有新写入 / 修改的产物)
}
function parseSseFrame(frame) {
const lines = frame.split("\n");
let event = "msg"; let dataLines = [];
for (const ln of lines) {
if (ln.startsWith(":")) continue; // comment
if (ln.startsWith("event:")) event = ln.slice(6).trim();
else if (ln.startsWith("data:")) dataLines.push(ln.slice(5).replace(/^ /, ""));
}
if (!dataLines.length) return { event, data: null };
const raw = dataLines.join("\n");
let data = null;
try { data = JSON.parse(raw); } catch (e) { data = raw; }
return { event, data };
}
function handleSseEvent(ev, asstCard, ctx) {
const t = ev.event;
const stream = $("chat-stream");
// 用户拖到上面看历史时不抢滚动,只在贴底时跟流
const nearBottom = stream.scrollHeight - stream.scrollTop - stream.clientHeight < 120;
if (t === "text" && ev.data && ev.data.delta) {
ctx.acc += ev.data.delta;
// rAF 节流:每帧最多 1 次重渲染,流式 token 高频时不抖
if (!ctx.pending) {
ctx.pending = true;
requestAnimationFrame(() => {
ctx.body.innerHTML = renderMd(ctx.acc);
ctx.pending = false;
if (nearBottom) stream.scrollTop = stream.scrollHeight;
});
}
} else if (t === "tool_call") {
const fn = (ev.data && ev.data.name) || "?";
const args = (ev.data && ev.data.arguments) || "";
const det = document.createElement("details");
det.className = "tool-call";
det.innerHTML = `<summary>工具调用:${escapeHtml(fn)}</summary><pre>${escapeHtml(typeof args === "string" ? args : JSON.stringify(args, null, 2))}</pre>`;
asstCard.appendChild(det);
} else if (t === "tool_result") {
const txt = (ev.data && ev.data.result) || "";
const det = document.createElement("details");
det.className = "tool-call";
det.innerHTML = `<summary>工具结果</summary><pre>${escapeHtml(typeof txt === "string" ? txt : JSON.stringify(txt, null, 2))}</pre>`;
asstCard.appendChild(det);
scheduleFilesRefresh(); // 工具调用结果回来,FS 可能被改了,debounce 刷新右侧
} else if (t === "cancelled") {
const badge = document.createElement("div");
badge.className = "cancelled-badge";
badge.textContent = "已停止";
asstCard.appendChild(badge);
} else if (t === "error") {
const msg = (ev.data && (ev.data.msg || ev.data.error)) || JSON.stringify(ev.data);
appendErrorCard(msg);
}
if (nearBottom) stream.scrollTop = stream.scrollHeight;
}
function appendErrorCard(msg) {
const card = document.createElement("div");
card.className = "msg error";
card.innerHTML = `<div class="role">错误</div><div class="body">${escapeHtml(msg)}</div>`;
$("chat-stream").appendChild(card);
$("chat-stream").scrollTop = $("chat-stream").scrollHeight;
}
// ───── done / abandon / delete / export ─────
$("btn-done").onclick = () => state.taskId && setTaskStatus(state.taskId, "completed", (state.taskMeta && state.taskMeta.name) || "");
$("btn-abandon").onclick = () => state.taskId && setTaskStatus(state.taskId, "abandoned", (state.taskMeta && state.taskMeta.name) || "");
$("btn-delete-task").onclick = () => {
if (!state.taskId) return;
const t = state.taskMeta || {};
deleteTask(state.taskId, t.name || "(未命名)", t.n_messages || 0);
};
$("btn-export").onclick = () => state.taskId && exportTask(state.taskId);
async function setTaskStatus(tid, status, name) {
const labels = { completed: "已完成", abandoned: "已废弃" };
if (!confirm(`确认将「${name || tid.slice(0,8)}」置为「${labels[status] || status}」?`)) return;
try {
await api("PATCH", "/v1/tasks/" + tid, { status });
if (state.taskId === tid) await selectTask(tid);
loadTaskList();
} catch (e) {
if (e.status === 401) { logout(); return; }
alert("操作失败:" + e.message);
}
}
async function deleteTask(tid, name, nMsg) {
if (!confirm(`确认硬删除任务「${name}」(${nMsg} 条消息)?\n\n将清掉对话历史和 DB 行,但工作目录下的文件保留。`)) return;
try {
await api("DELETE", "/v1/tasks/" + tid);
if (state.taskId === tid) {
if (state.evtSrc) { state.evtSrc.close(); state.evtSrc = null; }
state.taskId = null;
state.taskMeta = null;
$("chat-meta").innerHTML = `<span class="muted">(未选中任务)</span>`;
$("chat-stream").innerHTML = `<div class="empty">请在左侧选一个任务</div>`;
$("chat-form").style.display = "none";
$("btn-done").disabled = true;
$("btn-abandon").disabled = true;
$("btn-delete-task").disabled = true;
$("btn-export").disabled = true;
}
loadTaskList();
loadFiles();
} catch (e) {
if (e.status === 401) { logout(); return; }
alert("删除失败:" + e.message);
}
}
function exportTask(tid) {
fetch("/v1/tasks/" + tid + "/export", {
headers: { "Authorization": "Bearer " + state.token },
}).then(async (r) => {
if (!r.ok) { alert("导出失败:" + r.status); return; }
const blob = await r.blob();
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "chat_" + tid.slice(0, 8) + ".docx";
document.body.appendChild(a); a.click();
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
});
}
// ───── files(user-rooted,不绑 task) ─────
$("btn-refresh-files").onclick = () => loadFiles();
$("btn-upload").onclick = () => $("upload-input").click();
$("chat-upload").onclick = () => $("upload-input").click();
$("upload-input").addEventListener("change", uploadSelected);
// ───── 选入 modal(勾源 → 复制 / 移动到主区当前目录)─────
// 设计:目的地永远是主区 state.filesPath。弹框内浏览的 path 跟主区独立 — 用户从 A 翻到 B
// 勾几个,再翻到 C 接着勾,跨目录 selection 用 Set<rel> 全程保留;切换浏览路径不清空。
const srcPicker = { path: "", selected: new Set() };
async function openSrcPicker() {
srcPicker.path = "";
srcPicker.selected.clear();
const destLabel = state.filesPath ? "我的 / " + state.filesPath : "我的 (根目录)";
$("sp-dest").textContent = destLabel;
$("sp-dest").title = destLabel;
syncSrcCount();
$("src-picker-modal").classList.add("show");
await loadSrcPicker();
}
function closeSrcPicker() {
$("src-picker-modal").classList.remove("show");
srcPicker.path = "";
srcPicker.selected.clear();
}
async function loadSrcPicker() {
try {
const qs = srcPicker.path ? "?path=" + encodeURIComponent(srcPicker.path) : "";
const data = await api("GET", "/v1/files" + qs);
renderSrcPicker(data);
} catch (e) {
if (e.status === 401) { logout(); return; }
$("sp-list").innerHTML = `<div class="empty">${escapeHtml(e.message)}</div>`;
}
}
function renderSrcPicker(data) {
const cr = data.crumbs.map((c, i) => {
const label = i === 0 ? "我的" : c.label;
const isLast = i === data.crumbs.length - 1;
if (isLast) return `<span>${escapeHtml(label)}</span>`;
return `<a href="#" data-rel="${escapeHtml(c.rel)}">${escapeHtml(label)}</a> /`;
}).join(" ");
$("sp-crumbs").innerHTML = cr || `<span class="muted">/</span>`;
$("sp-crumbs").querySelectorAll("a").forEach((a) => {
a.onclick = (e) => { e.preventDefault(); srcPicker.path = a.dataset.rel; loadSrcPicker(); };
});
const entries = data.entries || [];
if (!data.exists) {
$("sp-list").innerHTML = `<div class="empty">(目录尚未创建)</div>`;
return;
}
if (!entries.length) {
$("sp-list").innerHTML = `<div class="empty">(空目录)</div>`;
return;
}
// 闸:当前浏览路径 == 主区目的地 → 同目录内勾选无意义(同名 409),全行 disabled
const destPath = state.filesPath || "";
const sameAsDest = srcPicker.path === destPath;
$("sp-list").innerHTML = entries.map((e) => {
const cls = e.is_dir ? "ico-dir" : "ico-file";
const checked = srcPicker.selected.has(e.rel) ? " checked" : "";
const disabled = sameAsDest ? " disabled" : "";
return `
<div class="sp-row${disabled}" data-rel="${escapeHtml(e.rel)}" data-isdir="${e.is_dir}">
<input type="checkbox" class="sp-cb" data-rel="${escapeHtml(e.rel)}"${checked}${sameAsDest ? " disabled" : ""} />
<span class="${cls} sp-name" data-rel="${escapeHtml(e.rel)}" data-isdir="${e.is_dir}">${escapeHtml(e.name)}</span>
<span class="sp-size">${humanSize(e.size)}</span>
</div>
`;
}).join("");
$("sp-list").querySelectorAll(".sp-name").forEach((el) => {
el.onclick = () => {
if (el.dataset.isdir === "true") {
srcPicker.path = el.dataset.rel;
loadSrcPicker();
}
};
});
$("sp-list").querySelectorAll(".sp-cb").forEach((cb) => {
cb.onchange = () => {
const rel = cb.dataset.rel;
if (cb.checked) srcPicker.selected.add(rel);
else srcPicker.selected.delete(rel);
syncSrcCount();
};
});
}
function syncSrcCount() {
const n = srcPicker.selected.size;
$("sp-count").textContent = String(n);
$("sp-copy").disabled = n === 0;
$("sp-move").disabled = n === 0;
}
async function doSrcTransfer(mode) {
const sources = [...srcPicker.selected];
if (!sources.length) return;
const endpoint = mode === "copy" ? "/v1/files/copy" : "/v1/files/move";
const verb = mode === "copy" ? "复制" : "移动";
try {
await api("POST", endpoint, {
paths: sources,
dest_dir: state.filesPath || "",
});
closeSrcPicker();
await loadFiles();
await loadFolderSuggestions();
} catch (e) {
if (e.status === 401) { logout(); return; }
alert(verb + "失败:" + e.message);
}
}
$("btn-src-pick").onclick = openSrcPicker;
$("sp-cancel").onclick = closeSrcPicker;
$("sp-copy").onclick = () => doSrcTransfer("copy");
$("sp-move").onclick = () => doSrcTransfer("move");
$("src-picker-modal").addEventListener("click", (e) => {
if (e.target.id === "src-picker-modal") closeSrcPicker();
});
// ───── 拖拽上传到主区(目的地 = state.filesPath)─────
// 用 enter/leave 计数避免子元素冒泡时 overlay 闪烁。
let _dragDepth = 0;
function _hasFiles(ev) {
const t = ev.dataTransfer;
if (!t) return false;
if (t.types && [...t.types].includes("Files")) return true;
return false;
}
$("pane-right").addEventListener("dragenter", (e) => {
if (!_hasFiles(e)) return;
e.preventDefault();
_dragDepth++;
$("file-droparea").classList.add("show");
});
$("pane-right").addEventListener("dragover", (e) => {
if (!_hasFiles(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
});
$("pane-right").addEventListener("dragleave", (e) => {
if (!_hasFiles(e)) return;
_dragDepth = Math.max(0, _dragDepth - 1);
if (_dragDepth === 0) $("file-droparea").classList.remove("show");
});
$("pane-right").addEventListener("drop", async (e) => {
if (!_hasFiles(e)) return;
e.preventDefault();
_dragDepth = 0;
$("file-droparea").classList.remove("show");
const files = Array.from(e.dataTransfer.files || []);
if (!files.length) return;
await uploadFiles(files);
});
// 工具调用返回时,右侧文件可能有新增/修改 — debounce 500ms 刷新,避免每次 tool_result 都 hit API
let _filesRefreshTimer = null;
function scheduleFilesRefresh() {
clearTimeout(_filesRefreshTimer);
_filesRefreshTimer = setTimeout(() => { loadFiles(); }, 500);
}
async function loadFiles() {
try {
const qs = state.filesPath ? "?path=" + encodeURIComponent(state.filesPath) : "";
const data = await api("GET", "/v1/files" + qs);
renderFiles(data);
} catch (e) {
if (e.status === 401) { logout(); return; }
$("file-crumbs").innerHTML = `<span class="muted">${escapeHtml(e.message)}</span>`;
$("file-list").innerHTML = "";
}
}
// 切换文件面板浏览路径
function navFiles(newPath) {
state.filesPath = newPath || "";
loadFiles();
}
function renderFiles(data) {
// 当前所在的"项目"= 路径第一段;空路径 → user_root,无项目上下文
const segs = (data.current || "").split("/").filter(Boolean);
const projName = segs[0] || "";
// 名称过长时显示前 11 字符 + …,完整名留 title 提示(避免顶栏挤压"文件"换行)
const projShort = projName.length > 12 ? projName.slice(0, 11) + "…" : projName;
$("files-proj").textContent = projShort ? "· " + projShort : "· (根目录)";
$("files-proj").title = projName || data.root || "";
// crumbs root 标"我的"(user_root),更直观;其余原样
const cr = data.crumbs.map((c, i) => {
const label = i === 0 ? "我的" : c.label;
const isLast = i === data.crumbs.length - 1;
if (isLast) return `<span>${escapeHtml(label)}</span>`;
return `<a href="#" data-rel="${escapeHtml(c.rel)}">${escapeHtml(label)}</a> /`;
}).join(" ");
$("file-crumbs").innerHTML = cr || `<span class="muted">/</span>`;
$("file-crumbs").querySelectorAll("a").forEach((a) => {
a.onclick = (e) => { e.preventDefault(); navFiles(a.dataset.rel); };
});
if (!data.exists) {
$("file-list").innerHTML = `<div class="empty">(目录尚未创建)</div>`;
state.entriesByRel = {};
return;
}
if (!data.entries.length) {
$("file-list").innerHTML = `<div class="empty">(空目录)</div>`;
state.entriesByRel = {};
return;
}
state.entriesByRel = {};
for (const e of data.entries) state.entriesByRel[e.rel] = e;
$("file-list").innerHTML = data.entries.map((e) => {
const cls = e.is_dir ? "ico-dir" : "ico-file";
return `
<div class="file-row" data-rel="${escapeHtml(e.rel)}">
<span class="${cls} name" data-rel="${escapeHtml(e.rel)}" data-isdir="${e.is_dir}">
${escapeHtml(e.name)}
</span>
<span class="size">${humanSize(e.size)}</span>
<button class="dd-toggle file-menu" data-rel="${escapeHtml(e.rel)}" title="文件操作">⋯</button>
</div>
`;
}).join("");
$("file-list").querySelectorAll(".name").forEach((el) => {
el.style.cursor = "pointer";
el.onclick = () => {
const rel = el.dataset.rel;
if (el.dataset.isdir === "true") { navFiles(rel); }
else { openFilePreview(rel); }
};
});
$("file-list").querySelectorAll(".file-menu").forEach((btn) => {
btn.onclick = (ev) => {
ev.stopPropagation();
const e = state.entriesByRel[btn.dataset.rel];
if (!e) return;
showMenu(btn, fileMenuItems(e));
};
});
}
function fileMenuItems(e) {
const items = [
{ act: "rename", label: "重命名", cls: "act-rename",
onclick: () => renameFile(e.rel, e.name, e.is_dir) },
];
if (!e.is_dir) {
items.push({ act: "download", label: "下载", cls: "act-download",
onclick: () => downloadFile(e.rel) });
}
items.push({ act: "delete", label: "删除", cls: "act-delete",
onclick: () => deleteFile(e.rel, e.name, e.is_dir) });
return items;
}
async function deleteFile(rel, name, isDir) {
const what = isDir ? "目录" : "文件";
const tip = isDir
? "\n(非空目录会失败;若为顶层目录且仍被 task 引用,需先删 task)"
: "";
if (!confirm(`确认删除${what} "${name}"?` + tip)) return;
try {
await api("POST", "/v1/files/delete", { path: rel });
await loadFiles();
// 删的若是顶层目录,folders 列表也得跟着变;子级删除走这里也无副作用
await loadFolderSuggestions();
} catch (e) {
if (e.status === 401) { logout(); return; }
alert("删除失败:" + e.message);
}
}
async function renameFile(rel, name, isDir) {
const what = isDir ? "目录" : "文件";
const newName = prompt(`${what} "${name}" 重命名为:`, name);
if (newName == null) return;
const trimmed = newName.trim();
if (!trimmed || trimmed === name) return;
try {
const res = await api("POST", "/v1/files/rename", { path: rel, new_name: trimmed });
// 面板若停在被改名的子树里,做前缀替换继续停留在等价位置
if (state.filesPath === rel) {
state.filesPath = res.new;
} else if (state.filesPath && state.filesPath.startsWith(rel + "/")) {
state.filesPath = res.new + state.filesPath.slice(rel.length);
}
await loadFolderSuggestions();
// 顶层目录改名 → tasks_updated>0,任务列表 / 当前 task 头里的 working_dir 都得刷
if (res && res.tasks_updated > 0) {
await loadTaskList();
if (state.taskId) { await selectTask(state.taskId); return; }
}
await loadFiles();
} catch (e) {
if (e.status === 401) { logout(); return; }
alert("重命名失败:" + e.message);
}
}
function downloadFile(rel) {
fetch("/v1/files/download?path=" + encodeURIComponent(rel), {
headers: { "Authorization": "Bearer " + state.token },
}).then(async (r) => {
if (!r.ok) { alert("下载失败:" + r.status); return; }
const blob = await r.blob();
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = rel.split("/").pop() || "file";
document.body.appendChild(a); a.click();
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
});
}
// ───── file preview ─────
const PREVIEW_TEXT_MAX = 2 * 1024 * 1024;
const PREVIEW_BIN_MAX = 50 * 1024 * 1024;
const _scriptCache = new Map();
function loadScript(src) {
if (_scriptCache.has(src)) return _scriptCache.get(src);
const p = new Promise((resolve, reject) => {
const s = document.createElement("script");
s.src = src;
s.onload = () => resolve();
s.onerror = () => { _scriptCache.delete(src); reject(new Error("load failed: " + src)); };
document.head.appendChild(s);
});
_scriptCache.set(src, p);
return p;
}
const _previewBlobUrls = new Set();
function _trackBlobUrl(blob, mime) {
const b = mime ? new Blob([blob], { type: mime }) : blob;
const url = URL.createObjectURL(b);
_previewBlobUrls.add(url);
return url;
}
function _flushBlobUrls() {
for (const u of _previewBlobUrls) URL.revokeObjectURL(u);
_previewBlobUrls.clear();
}
const _EXT_GROUPS = {
image: new Set(["jpg","jpeg","png","gif","webp","bmp","svg","ico"]),
pdf: new Set(["pdf"]),
md: new Set(["md","markdown"]),
text: new Set([
"txt","log","json","jsonl","yaml","yml","toml","ini","csv","tsv",
"py","js","mjs","ts","jsx","tsx","go","rs","java","c","cc","cpp","h","hpp",
"html","htm","xml","css","scss","sh","bash","zsh","sql","conf","env",
]),
docx: new Set(["docx"]),
xlsx: new Set(["xlsx","xls"]),
};
function _categorize(rel) {
const m = /\.([a-z0-9]+)$/i.exec(rel);
const ext = m ? m[1].toLowerCase() : "";
for (const [cat, set] of Object.entries(_EXT_GROUPS)) if (set.has(ext)) return cat;
return "fallback";
}
let _fpCurrentRel = null;
async function openFilePreview(rel) {
_fpCurrentRel = rel;
const name = rel.split("/").pop() || rel;
$("fp-name").textContent = name;
$("fp-meta").textContent = "";
const body = $("fp-body");
body.className = "body center";
body.innerHTML = `<div class="ph">加载中…</div>`;
$("file-preview-modal").classList.add("show");
const cat = _categorize(rel);
try {
const r = await fetch("/v1/files/download?path=" + encodeURIComponent(rel), {
headers: { "Authorization": "Bearer " + state.token },
});
if (!r.ok) throw new Error("HTTP " + r.status);
const blob = await r.blob();
$("fp-meta").textContent = humanSize(blob.size);
if (cat === "text" || cat === "md") {
if (blob.size > PREVIEW_TEXT_MAX) {
_showFallback(`文件过大 (${humanSize(blob.size)}),请下载查看`);
return;
}
const text = await blob.text();
if (cat === "md") _showMarkdown(text);
else _showText(text);
return;
}
if (blob.size > PREVIEW_BIN_MAX) {
_showFallback(`文件过大 (${humanSize(blob.size)}),请下载查看`);
return;
}
if (cat === "image") _showImage(blob);
else if (cat === "pdf") _showPdf(blob);
else if (cat === "docx") await _showDocx(blob);
else if (cat === "xlsx") await _showXlsx(blob);
else _showFallback("暂不支持在线预览此格式,请下载查看");
} catch (e) {
if (e.status === 401) { closeFilePreview(); logout(); return; }
_showFallback("加载失败:" + e.message);
}
}
function _showImage(blob) {
const url = _trackBlobUrl(blob);
const body = $("fp-body");
body.className = "body center";
body.innerHTML = "";
const img = document.createElement("img");
img.className = "preview-img";
img.src = url;
body.appendChild(img);
}
function _showPdf(blob) {
const url = _trackBlobUrl(blob, "application/pdf");
const body = $("fp-body");
body.className = "body";
body.innerHTML = `<iframe class="preview-frame" src="${url}"></iframe>`;
}
function _showText(text) {
const body = $("fp-body");
body.className = "body";
body.innerHTML = "";
const pre = document.createElement("pre");
pre.className = "preview-text";
pre.textContent = text;
body.appendChild(pre);
}
function _showMarkdown(text) {
const body = $("fp-body");
body.className = "body";
body.innerHTML = `<div class="md-render">${renderMd(text)}</div>`;
highlightIn(body);
}
async function _showDocx(blob) {
const body = $("fp-body");
body.className = "body center";
body.innerHTML = `<div class="ph">解析 docx 中…</div>`;
try {
await loadScript("/static/vendor/jszip.min.js");
await loadScript("/static/vendor/docx-preview.min.js");
} catch (e) {
_showFallback("docx 解析库加载失败:" + e.message);
return;
}
if (!window.docx || !window.docx.renderAsync) {
_showFallback("docx 解析库不可用");
return;
}
body.className = "body";
body.innerHTML = `<div class="docx-host"></div>`;
try {
await window.docx.renderAsync(blob, body.querySelector(".docx-host"), null, {
inWrapper: false,
ignoreLastRenderedPageBreak: true,
});
} catch (e) {
_showFallback("docx 渲染失败:" + e.message);
}
}
async function _showXlsx(blob) {
const body = $("fp-body");
body.className = "body center";
body.innerHTML = `<div class="ph">解析表格中…</div>`;
try {
await loadScript("/static/vendor/xlsx.full.min.js");
} catch (e) {
_showFallback("xlsx 解析库加载失败:" + e.message);
return;
}
if (!window.XLSX || !window.XLSX.read) {
_showFallback("xlsx 解析库不可用");
return;
}
let wb;
try {
const ab = await blob.arrayBuffer();
wb = window.XLSX.read(ab, { type: "array" });
} catch (e) {
_showFallback("xlsx 解析失败:" + e.message);
return;
}
const names = wb.SheetNames || [];
if (!names.length) { _showFallback("xlsx 内无 sheet"); return; }
body.className = "body";
const tabsHtml = names.map((n, i) =>
`<button class="small xlsx-tab${i===0?" active":""}" data-i="${i}">${escapeHtml(n)}</button>`
).join("");
body.innerHTML = `<div class="xlsx-tabs">${tabsHtml}</div><div class="xlsx-sheet" id="fp-xlsx-sheet"></div>`;
const render = (i) => {
const ws = wb.Sheets[names[i]];
$("fp-xlsx-sheet").innerHTML = window.XLSX.utils.sheet_to_html(ws);
};
body.querySelectorAll(".xlsx-tab").forEach((btn) => {
btn.onclick = () => {
body.querySelectorAll(".xlsx-tab").forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
render(parseInt(btn.dataset.i));
};
});
render(0);
}
function _showFallback(msg) {
const body = $("fp-body");
body.className = "body center";
body.innerHTML = "";
const ph = document.createElement("div");
ph.className = "ph";
ph.textContent = msg;
const br = document.createElement("br");
const dl = document.createElement("button");
dl.className = "primary";
dl.textContent = "下载原文件";
dl.style.marginTop = "12px";
dl.onclick = () => { if (_fpCurrentRel) downloadFile(_fpCurrentRel); };
ph.appendChild(document.createElement("br"));
ph.appendChild(br);
ph.appendChild(dl);
body.appendChild(ph);
}
function closeFilePreview() {
$("file-preview-modal").classList.remove("show");
$("fp-body").innerHTML = "";
_flushBlobUrls();
_fpCurrentRel = null;
}
$("fp-close").onclick = closeFilePreview;
$("fp-download").onclick = () => { if (_fpCurrentRel) downloadFile(_fpCurrentRel); };
$("file-preview-modal").addEventListener("click", (e) => {
if (e.target.id === "file-preview-modal") closeFilePreview();
});
document.addEventListener("keydown", (e) => {
if (e.key !== "Escape") 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;
const fd = new FormData();
fd.append("path", state.filesPath || "");
for (const f of files) fd.append("files", f);
try {
const r = await fetch("/v1/files/upload", {
method: "POST",
headers: { "Authorization": "Bearer " + state.token },
body: fd,
});
if (!r.ok) {
const d = await r.json().catch(() => ({}));
throw new Error(d.detail || (r.status + " 上传失败"));
}
await loadFiles();
} catch (e) {
alert("上传失败:" + e.message);
}
}
async function uploadSelected() {
const inp = $("upload-input");
const files = Array.from(inp.files || []);
try {
await uploadFiles(files);
} finally {
inp.value = ""; // 允许重新选同名文件
}
}
// ───── new task ─────
$("hd-new").onclick = async () => {
$("nt-name").value = ""; $("nt-wd").value = "";
$("nt-desc").value = ""; $("nt-skill").value = "";
$("nt-err").textContent = "";
$("nt-wd-hint").textContent = "";
$("new-task-modal").classList.add("show");
await Promise.all([loadFolderSuggestions(), loadSkillOptions(), loadModels()]);
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 working_dir = $("nt-wd").value.trim();
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;
}
};
// 工作目录 autocomplete:打开 modal 时拉一次,输入时实时提示"复用 / 新建"
async function loadFolderSuggestions() {
try {
const data = await api("GET", "/v1/folders");
const dl = $("folders-datalist");
dl.innerHTML = (data.folders || []).map((f) => {
const tag = f.n_tasks ? `${f.n_tasks} 个任务` : `空目录`;
return `<option value="${escapeHtml(f.name)}" data-n="${f.n_tasks}" label="${escapeHtml(tag)}"></option>`;
}).join("");
} catch (e) {
// 静默 — datalist 留空不影响用户输入
}
}
// 智能体类型下拉:skill registry 服务器端静态,首次加载后缓存到 state.skills
async function loadSkillOptions() {
const sel = $("nt-skill");
if (!state.skills) {
try {
const data = await api("GET", "/v1/skills");
state.skills = data.skills || [];
} catch (e) {
state.skills = []; // 静默兜底,select 仍保留"(默认)"项
}
}
// 渲染:第一项固定为"默认"(空 value),其后逐 skill 一项
const opts = ['<option value="">(默认 · 不限定)</option>'];
for (const s of state.skills) {
const label = `${s.name}${s.description ? " — " + s.description : ""}`;
opts.push(`<option value="${escapeHtml(s.name)}" title="${escapeHtml(s.description || "")}">${escapeHtml(label)}</option>`);
}
sel.innerHTML = opts.join("");
sel.value = ""; // hd-new 已清空,这里幂等再保一次
}
$("nt-wd").addEventListener("input", () => {
const v = $("nt-wd").value.trim();
const hint = $("nt-wd-hint");
if (!v) {
const fallback = $("nt-name").value.trim();
hint.textContent = fallback ? `留空 → 用任务名「${fallback}」作目录` : "";
return;
}
const opt = $("folders-datalist").querySelector(`option[value="${CSS.escape(v)}"]`);
if (opt) {
const n = parseInt(opt.dataset.n) || 0;
hint.innerHTML = `<span style="color:var(--accent);">→ 复用已有目录</span> · ${n} 个任务`;
} else {
hint.innerHTML = `<span class="muted">→ 新建目录 ${escapeHtml(v)}</span>`;
}
});
$("nt-name").addEventListener("input", () => {
// 任务名输入时,若工作目录为空,提示 fallback 文案动态更新
if (!$("nt-wd").value.trim()) {
const fallback = $("nt-name").value.trim();
$("nt-wd-hint").textContent = fallback ? `留空 → 用任务名「${fallback}」作目录` : "";
}
});
// ───── boot ─────
if (state.token) {
// 已有 token:试探一下,失败回登录页
enterApp();
} else {
$("li-token").focus();
}
</script>
</body>
</html>