zcbot/web/static/js/main.js

1394 lines
59 KiB
JavaScript
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.

// zcbot dev 控制台主逻辑(login / 任务 / 流式 / 文件 / 预览 / embed / boot)。
// 路径 1 模块化:叶子(state/format/dom/api/markdown)+ layout 已抽成独立模块;
// 本文件是剩余主体,后续步骤会继续从这里把各功能段逐个剥成独立模块。
import {
state,
LS_TOKEN, LS_UID, LS_NAME,
EMBED, EMBED_PARENT_ORIGIN, EMBED_INITIAL_TASK_ID,
} from "./state.js";
import {
escapeHtml, humanSize, fmtTime, fmtTokens,
taskUsageTooltip, formatTaskUsage, formatContextStats, formatUsageStats, fmtTimeAgo,
} from "./format.js";
import { $, showMenu } from "./dom.js";
import { api } from "./api.js";
import { renderMd, highlightIn } from "./markdown.js";
import { mqPhone, setMobileView } from "./layout.js";
import { logout, closeChpwModal } from "./auth.js";
import { openFilePreview, openPasteFilePreview, closeFilePreview, closeMiniPreview, closePreviewIfShowing, _categorize } from "./preview.js";
import { loadFiles, scheduleFilesRefresh, closeSrcPicker, uploadFiles } from "./files.js";
import { toolActivityLabel, _workingDirName, extractMediaBanner, extractArtifactRels, renderArtifactBarHtml, upgradeMediaArtifacts, ARTIFACT_PRODUCING_TOOLS, _flushMediaArtifactCache } from "./media.js";
// embed 首个 task 自动定位的一次性标志(仅 embed 段使用)
let _embedInitialTaskHandled = false;
// ───── enter app ─────
export 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 先有选项)
loadStorage(); // 顶栏存储用量(后台扫描快照,非实时)
}
// 存储用量:拉 /v1/user/storage 渲染文件面板底部进度条。用量来自后台 15min 扫描,
// 故无需高频刷新 —— enterApp 拉一次即可。无配额上限时只显已用、不画进度条(nolimit)。
async function loadStorage() {
let s;
try { s = await api("GET", "/v1/user/storage"); } catch (e) { return; }
const el = $("storage-foot");
const used = s.bytes_used || 0;
const limit = s.limit_bytes;
if (limit && limit > 0) {
const pct = Math.min(100, Math.round(used / limit * 100));
$("storage-foot-bar").style.width = pct + "%";
$("storage-foot-txt").textContent = `${humanSize(used)} / ${humanSize(limit)}`;
el.classList.remove("nolimit");
el.classList.toggle("over", used >= limit);
} else {
// 不限额:只显已用,隐藏进度条
$("storage-foot-txt").textContent = humanSize(used);
el.classList.add("nolimit");
el.classList.remove("over");
}
const when = s.scanned_at ? fmtTime(s.scanned_at) : "尚未统计";
el.title = `已用 ${humanSize(used)} · ${s.file_count || 0} 个文件\n统计于 ${when}(后台每 15 分钟扫描,非实时)`;
el.classList.add("show");
}
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 = "";
}
// embed + task_id 场景下 selectTask 可能在 loadModels 完成前就跑完 renderChatMeta,
// 此时 models 为空 → 模型下拉不渲染。loadModels 收尾时如果已选中 task,补一次 chat-meta 重渲。
if (state.taskMeta) renderChatMeta();
}
// loadTaskList:默认 reset(filters/refresh/写操作后),append=true 由 sentinel observer 触发
// 并发模型:append 受 taskLoading 互斥(避免观察器重复触发);reset 永远抢占,用 seq 丢弃过期响应
let _taskLoadSeq = 0;
export 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="${escapeHtml(taskUsageTooltip(t))}">${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 ─────
export 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();
if (meta.run_status === "running" || meta.run_status === "cancelling") {
ensureRunningTaskSubscribed(tid, `/v1/tasks/${tid}/events`);
} else {
renderLiveRunIfVisible();
}
// 文件面板自动跳到该 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>
${formatTaskUsage(t)}
${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)
// 导出 / 清空:只要选中 task 就允许点(不按 n_messages 门禁 —— 历史 bug:
// 清空后 n_messages=0 disable,但新对话进来后 taskMeta 不重渲一直 disable;
// 0 条时点击不会出错(导出空 docx / 清空 confirm 显 0 条),让 UX 一致更省心)。
$("btn-export").disabled = false;
// 清空对话:仅活跃 run 期间禁用(后端 409,confirm 通过后才报错 UX 差)
const running = t.run_status === "running" || t.run_status === "cancelling";
$("btn-clear-msgs").disabled = running;
}
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 getLiveRun(taskId) {
return taskId ? state.liveRuns.get(taskId) : null;
}
function isCurrentTaskStreaming() {
return !!getLiveRun(state.taskId);
}
function createLiveAssistantCard(run) {
const card = document.createElement("div");
card.className = "msg assistant live-run";
card.innerHTML = `<div class="role">助手</div><div class="body streaming">${run.acc ? renderMd(run.acc) : ""}</div>`;
run.card = card;
run.body = card.querySelector(".body");
return card;
}
function renderLiveRunIfVisible() {
const run = getLiveRun(state.taskId);
if (!run) {
setActionMode("idle");
return;
}
const wrap = $("chat-stream");
const card = run.card || createLiveAssistantCard(run);
if (run.body && run.acc) run.body.innerHTML = renderMd(run.acc);
if (card.parentElement !== wrap) wrap.appendChild(card);
wrap.scrollTop = wrap.scrollHeight;
setActionMode(run.cancelling ? "cancelling" : "streaming");
$("chat-hint").textContent = run.cancelling ? "停止中…" : "接收中…";
}
function ensureRunningTaskSubscribed(taskId, url) {
if (!taskId || getLiveRun(taskId)) return;
const run = {
taskId,
url,
acc: "",
pending: false,
seenRels: new Set(),
terminal: false,
card: null,
body: null,
cancelling: state.taskMeta && state.taskMeta.run_status === "cancelling",
workingDir: state.taskMeta && state.taskMeta.working_dir,
};
state.liveRuns.set(taskId, run);
state.streaming = true;
renderLiveRunIfVisible();
streamSse(url, run);
}
function setRunHint(run, text) {
if (state.taskId === run.taskId) $("chat-hint").textContent = text;
}
function renderMessages(msgs) {
const wrap = $("chat-stream");
wrap.innerHTML = "";
if (!msgs.length) {
wrap.innerHTML = `<div class="empty">(暂无消息 · 在下方输入开始对话)</div>`;
renderLiveRunIfVisible();
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 argsObj = {};
let args = "";
try {
argsObj = JSON.parse((tc.function && tc.function.arguments) || "{}");
args = JSON.stringify(argsObj, null, 2);
} catch (e) { args = (tc.function && tc.function.arguments) || ""; }
const label = toolActivityLabel(fn, argsObj);
const isProducer = ARTIFACT_PRODUCING_TOOLS.has(fn);
const rels = isProducer ? pickFresh(extractArtifactRels(args, wd)) : [];
html += `
<details class="tool-call"><summary>${escapeHtml(label)}</summary><pre>${escapeHtml(args)}</pre></details>
${renderArtifactBarHtml(rels, isProducer)}
`;
}
}
card.innerHTML = html;
highlightIn(card);
wrap.appendChild(card);
}
wrap.scrollTop = wrap.scrollHeight;
upgradeMediaArtifacts(wrap);
renderLiveRunIfVisible();
}
// ───── 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 (isCurrentTaskStreaming()) 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 (!isCurrentTaskStreaming()) sendMessage();
}
});
$("chat-input").addEventListener("input", syncOptimizeBtn);
// 粘贴含文件 → 直接上传到当前目录(复用拖拽通路);纯文本走默认
// 反馈走 chat-hint:上传中 → 已粘贴 + chip;下一次发送会自然覆盖为"发送中…"。
$("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;
hint.textContent = files.length === 1 ? `上传中:${files[0].name}` : `上传中:${files.length} 个文件…`;
const saved = await uploadFiles(files, {
onProgress: (loaded, total) => {
hint.textContent = formatUploadProgress(files, loaded, total);
},
});
if (saved && saved.length) {
hint.innerHTML = `已粘贴 ${renderPasteFileChips(saved)} <span class="muted">可在右侧文件处查看</span>`;
} else {
hint.textContent = prevHint; // 失败 alert 已弹,hint 回原
}
});
function renderPasteFileChips(saved) {
return (saved || []).map((f) => {
const rel = f.rel || f.name || "";
const name = f.name || (rel.split("/").pop() || rel);
return `<span class="paste-chip-wrap" data-rel="${escapeHtml(rel)}"><button type="button" class="art-chip paste-chip" data-rel="${escapeHtml(rel)}" title="${escapeHtml(rel)} · 点击预览">${escapeHtml(name)}</button><button type="button" class="paste-chip-del" data-rel="${escapeHtml(rel)}" title="删除该粘贴文件">×</button></span>`;
}).join("");
}
$("chat-hint").addEventListener("click", (e) => {
const del = e.target.closest && e.target.closest(".paste-chip-del[data-rel]");
if (del) {
e.stopPropagation();
deletePastedFile(del.dataset.rel, del.closest(".paste-chip-wrap"));
return;
}
const chip = e.target.closest && e.target.closest(".paste-chip[data-rel]");
if (!chip) return;
const rel = chip.dataset.rel;
if (rel) openPasteFilePreview(rel);
});
async function deletePastedFile(rel, wrap) {
if (!rel || !wrap) return;
const btn = wrap.querySelector(".paste-chip-del");
if (btn) btn.disabled = true;
try {
await api("POST", "/v1/files/delete", { path: rel, recursive: false });
closePreviewIfShowing(rel);
wrap.remove();
await loadFiles();
const hint = $("chat-hint");
if (!hint.querySelector(".paste-chip-wrap")) {
hint.innerHTML = `<span class="muted">已删除粘贴文件</span>`;
}
} catch (e) {
if (btn) btn.disabled = false;
if (e.status === 401) { logout(); return; }
alert("删除失败:" + e.message);
}
}
// 润色:同步调后端,把 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 (isCurrentTaskStreaming()) return;
const content = $("chat-input").value.trim();
if (!content) return;
setActionMode("cancelling"); // 临时锁住,等 events_url 拿到再切 streaming
$("chat-hint").textContent = "发送中…";
const taskId = state.taskId;
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 live-run";
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/${taskId}/messages`, {
content,
image_model: state.imageModel || "",
video_model: state.videoModel || "",
});
$("chat-input").value = "";
syncOptimizeBtn();
const run = {
taskId,
url: r.events_url,
acc: "",
pending: false,
seenRels: new Set(),
terminal: false,
card: asstCard,
body: asstCard.querySelector(".body"),
cancelling: false,
workingDir: state.taskMeta && state.taskMeta.working_dir,
};
state.liveRuns.set(taskId, run);
state.streaming = true;
setActionMode("streaming");
streamSse(r.events_url, run);
} catch (e) {
if (e.status === 401) { logout(); return; }
appendErrorCard(e.message);
setActionMode("idle");
$("chat-hint").textContent = "就绪";
}
}
async function cancelCurrentTask() {
const run = getLiveRun(state.taskId);
if (!state.taskId || !run) return;
run.cancelling = true;
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);
run.cancelling = false;
setActionMode("streaming");
$("chat-hint").textContent = "接收中…";
}
}
function streamSse(url, run) {
// EventSource 不支持自定义 header,token 走 query string(?token=...)
// 这里 SSE 走 same-origin,token 经 URL 传给后端 — 但当前后端只读 Authorization 头
// 简单做法:走带 token 的 fetch + ReadableStream 替代 EventSource
fetchSse(url, run).catch((e) => {
appendRunError(run, "sse: " + e.message);
});
}
function appendRunError(run, msg) {
if (state.taskId === run.taskId) {
appendErrorCard(msg);
return;
}
const host = run.card || createLiveAssistantCard(run);
const card = document.createElement("div");
card.className = "msg error";
card.innerHTML = `<div class="role">错误</div><div class="body">${escapeHtml(msg)}</div>`;
host.appendChild(card);
}
async function fetchSse(url, run) {
const body = run.body || (run.card && run.card.querySelector(".body"));
run.body = body;
const ctx = run;
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, run.card, ctx);
} catch (e) {
if (ctx.terminal) break; // 已收到 done/error,不重连
if (attempt >= backoffs.length) {
appendRunError(ctx, "连接已断开,刚才的回复可能未完成,请重发消息。");
break;
}
setRunHint(ctx, `连接断开,重连中…(${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) {
appendRunError(ctx, "连接已断开,刚才的回复可能未完成,请重发消息。");
break;
}
setRunHint(ctx, `连接断开,重连中…(${attempt + 1}/${backoffs.length})`);
await new Promise(r => setTimeout(r, backoffs[attempt]));
attempt++;
}
// 最终定稿 + 代码高亮(流式中不 highlight,省 CPU)
if (ctx.body) {
ctx.body.innerHTML = renderMd(ctx.acc);
if (ctx.card) highlightIn(ctx.card);
}
} finally {
if (ctx.body) ctx.body.classList.remove("streaming");
state.liveRuns.delete(ctx.taskId);
state.streaming = state.liveRuns.size > 0;
if (state.taskId === ctx.taskId) {
hint.textContent = ctx.lastUsageHint || "就绪";
setActionMode("idle");
}
}
// 刷新 task meta + messages(拿真实持久化的);失败路径已退出,这里不再跑
loadTaskList();
if (state.taskId === ctx.taskId) {
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 = "";
setRunHint(ctx, "接收中…");
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;
asstCard = asstCard || ctx.card || createLiveAssistantCard(ctx);
ctx.card = asstCard;
ctx.body = ctx.body || asstCard.querySelector(".body");
const stream = $("chat-stream");
const visible = state.taskId === ctx.taskId;
// 用户拖到上面看历史时不抢滚动,只在贴底时跟流
const nearBottom = visible && (stream.scrollHeight - stream.scrollTop - stream.clientHeight < 120);
if (t === "llm_start") {
ctx.contextStats = ev.data || {};
setRunHint(ctx, formatContextStats(ctx.contextStats));
} else if (t === "llm_end") {
ctx.lastUsageHint = formatUsageStats(ev.data || {}, ctx.contextStats);
setRunHint(ctx, ctx.lastUsageHint);
} else 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.args) || "";
const argsStr = typeof args === "string" ? args : JSON.stringify(args, null, 2);
const label = toolActivityLabel(fn, args);
const det = document.createElement("details");
det.className = "tool-call";
det.innerHTML = `<summary>${escapeHtml(label)}</summary><pre>${escapeHtml(argsStr)}</pre>`;
asstCard.appendChild(det);
const wd = _workingDirName(ctx.workingDir);
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(ctx.workingDir);
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);
}
if (visible) 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);
appendRunError(ctx, 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);
});
}
// ───── Esc 关弹窗栈(跨模块协调:chpw/选入/文件预览/小预览)─────
document.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
// 多模态共存:优先关靠前栈顶 — 小预览(z 96)→ 选入(z 95)→ 文件预览(z 90)→ 新任务(z 80)
if ($("chpw-modal").classList.contains("show")) { closeChpwModal(); return; }
if ($("mini-preview-modal").classList.contains("show")) { closeMiniPreview(); return; }
if ($("src-picker-modal").classList.contains("show")) { closeSrcPicker(); return; }
if ($("file-preview-modal").classList.contains("show")) { closeFilePreview(); return; }
});
// ───── 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)
export 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 ─────
export function embedPostToParent(msg) {
if (!EMBED_PARENT_ORIGIN || window.parent === window) return;
try { window.parent.postMessage(msg, EMBED_PARENT_ORIGIN); } catch (e) {}
}
export 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();
}