2397 lines
96 KiB
JavaScript
2397 lines
96 KiB
JavaScript
// 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";
|
||
|
||
// 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;
|
||
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 ─────
|
||
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 });
|
||
if (_fpCurrentRel === rel) closeFilePreview();
|
||
if (_mpCurrentRel === rel) closeMiniPreview();
|
||
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);
|
||
});
|
||
}
|
||
|
||
// ───── files(user-rooted,不绑 task) ─────
|
||
$("btn-refresh-files").onclick = () => loadFiles();
|
||
$("btn-upload").onclick = () => $("upload-input").click();
|
||
$("upload-input").addEventListener("change", uploadSelected);
|
||
|
||
// ───── 选入 modal(勾源 → 复制 / 移动到主区当前目录)─────
|
||
// 设计:目的地永远是主区 state.filesPath。弹框内浏览的 path 跟主区独立 — 用户从 A 翻到 B
|
||
// 勾几个,再翻到 C 接着勾,跨目录 selection 用 Set<rel> 全程保留;切换浏览路径不清空。
|
||
const srcPicker = { path: "", selected: new Set() };
|
||
|
||
async function openSrcPicker() {
|
||
srcPicker.path = "";
|
||
srcPicker.selected.clear();
|
||
const destLabel = state.filesPath ? "我的 / " + state.filesPath : "我的 (根目录)";
|
||
$("sp-dest").textContent = destLabel;
|
||
$("sp-dest").title = destLabel;
|
||
syncSrcCount();
|
||
$("src-picker-modal").classList.add("show");
|
||
await loadSrcPicker();
|
||
}
|
||
|
||
function closeSrcPicker() {
|
||
$("src-picker-modal").classList.remove("show");
|
||
srcPicker.path = "";
|
||
srcPicker.selected.clear();
|
||
}
|
||
|
||
async function loadSrcPicker() {
|
||
try {
|
||
const qs = srcPicker.path ? "?path=" + encodeURIComponent(srcPicker.path) : "";
|
||
const data = await api("GET", "/v1/files" + qs);
|
||
renderSrcPicker(data);
|
||
} catch (e) {
|
||
if (e.status === 401) { logout(); return; }
|
||
$("sp-list").innerHTML = `<div class="empty">${escapeHtml(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
function renderSrcPicker(data) {
|
||
const cr = data.crumbs.map((c, i) => {
|
||
const label = i === 0 ? "我的" : c.label;
|
||
const isLast = i === data.crumbs.length - 1;
|
||
if (isLast) return `<span>${escapeHtml(label)}</span>`;
|
||
return `<a href="#" data-rel="${escapeHtml(c.rel)}">${escapeHtml(label)}</a> /`;
|
||
}).join(" ");
|
||
$("sp-crumbs").innerHTML = cr || `<span class="muted">/</span>`;
|
||
$("sp-crumbs").querySelectorAll("a").forEach((a) => {
|
||
a.onclick = (e) => { e.preventDefault(); srcPicker.path = a.dataset.rel; loadSrcPicker(); };
|
||
});
|
||
const entries = data.entries || [];
|
||
if (!data.exists) {
|
||
$("sp-list").innerHTML = `<div class="empty">(目录尚未创建)</div>`;
|
||
return;
|
||
}
|
||
if (!entries.length) {
|
||
$("sp-list").innerHTML = `<div class="empty">(空目录)</div>`;
|
||
return;
|
||
}
|
||
// 闸:当前浏览路径 == 主区目的地 → 同目录内勾选无意义(同名 409),全行 disabled
|
||
const destPath = state.filesPath || "";
|
||
const sameAsDest = srcPicker.path === destPath;
|
||
$("sp-list").innerHTML = entries.map((e) => {
|
||
const cls = e.is_dir ? "ico-dir" : "ico-file";
|
||
const checked = srcPicker.selected.has(e.rel) ? " checked" : "";
|
||
const disabled = sameAsDest ? " disabled" : "";
|
||
const fullTitle = e.rel || e.name;
|
||
return `
|
||
<div class="sp-row${disabled}" data-rel="${escapeHtml(e.rel)}" data-isdir="${e.is_dir}" title="${escapeHtml(fullTitle)}">
|
||
<input type="checkbox" class="sp-cb" data-rel="${escapeHtml(e.rel)}"${checked}${sameAsDest ? " disabled" : ""} />
|
||
<span class="${cls} sp-name" data-rel="${escapeHtml(e.rel)}" data-isdir="${e.is_dir}" title="${escapeHtml(fullTitle)}">${escapeHtml(e.name)}</span>
|
||
<span class="sp-size">${humanSize(e.size)}</span>
|
||
</div>
|
||
`;
|
||
}).join("");
|
||
$("sp-list").querySelectorAll(".sp-name").forEach((el) => {
|
||
el.onclick = () => {
|
||
if (el.dataset.isdir === "true") {
|
||
srcPicker.path = el.dataset.rel;
|
||
loadSrcPicker();
|
||
}
|
||
};
|
||
});
|
||
$("sp-list").querySelectorAll(".sp-cb").forEach((cb) => {
|
||
cb.onchange = () => {
|
||
const rel = cb.dataset.rel;
|
||
if (cb.checked) srcPicker.selected.add(rel);
|
||
else srcPicker.selected.delete(rel);
|
||
syncSrcCount();
|
||
};
|
||
});
|
||
}
|
||
|
||
function syncSrcCount() {
|
||
const n = srcPicker.selected.size;
|
||
$("sp-count").textContent = String(n);
|
||
$("sp-copy").disabled = n === 0;
|
||
$("sp-move").disabled = n === 0;
|
||
}
|
||
|
||
async function doSrcTransfer(mode) {
|
||
const sources = [...srcPicker.selected];
|
||
if (!sources.length) return;
|
||
const endpoint = mode === "copy" ? "/v1/files/copy" : "/v1/files/move";
|
||
const verb = mode === "copy" ? "复制" : "移动";
|
||
try {
|
||
await api("POST", endpoint, {
|
||
paths: sources,
|
||
dest_dir: state.filesPath || "",
|
||
});
|
||
closeSrcPicker();
|
||
await loadFiles();
|
||
await loadFolderSuggestions();
|
||
} catch (e) {
|
||
if (e.status === 401) { logout(); return; }
|
||
alert(verb + "失败:" + e.message);
|
||
}
|
||
}
|
||
|
||
$("btn-src-pick").onclick = openSrcPicker;
|
||
$("sp-cancel").onclick = closeSrcPicker;
|
||
$("sp-copy").onclick = () => doSrcTransfer("copy");
|
||
$("sp-move").onclick = () => doSrcTransfer("move");
|
||
$("src-picker-modal").addEventListener("click", (e) => {
|
||
if (e.target.id === "src-picker-modal") closeSrcPicker();
|
||
});
|
||
|
||
// ───── 拖拽上传到主区(目的地 = state.filesPath)─────
|
||
// 用 enter/leave 计数避免子元素冒泡时 overlay 闪烁。
|
||
let _dragDepth = 0;
|
||
function _hasFiles(ev) {
|
||
const t = ev.dataTransfer;
|
||
if (!t) return false;
|
||
if (t.types && [...t.types].includes("Files")) return true;
|
||
return false;
|
||
}
|
||
$("pane-right").addEventListener("dragenter", (e) => {
|
||
if (!_hasFiles(e)) return;
|
||
e.preventDefault();
|
||
_dragDepth++;
|
||
$("file-droparea").classList.add("show");
|
||
});
|
||
$("pane-right").addEventListener("dragover", (e) => {
|
||
if (!_hasFiles(e)) return;
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = "copy";
|
||
});
|
||
$("pane-right").addEventListener("dragleave", (e) => {
|
||
if (!_hasFiles(e)) return;
|
||
_dragDepth = Math.max(0, _dragDepth - 1);
|
||
if (_dragDepth === 0) $("file-droparea").classList.remove("show");
|
||
});
|
||
$("pane-right").addEventListener("drop", async (e) => {
|
||
if (!_hasFiles(e)) return;
|
||
e.preventDefault();
|
||
_dragDepth = 0;
|
||
$("file-droparea").classList.remove("show");
|
||
const files = Array.from(e.dataTransfer.files || []);
|
||
if (!files.length) return;
|
||
await uploadFilesWithPaneStatus(files);
|
||
});
|
||
|
||
// 工具调用返回时,右侧文件可能有新增/修改 — debounce 500ms 刷新,避免每次 tool_result 都 hit API
|
||
let _filesRefreshTimer = null;
|
||
function scheduleFilesRefresh() {
|
||
clearTimeout(_filesRefreshTimer);
|
||
_filesRefreshTimer = setTimeout(() => { loadFiles(); }, 500);
|
||
}
|
||
|
||
async function loadFiles() {
|
||
try {
|
||
const qs = state.filesPath ? "?path=" + encodeURIComponent(state.filesPath) : "";
|
||
const data = await api("GET", "/v1/files" + qs);
|
||
renderFiles(data);
|
||
} catch (e) {
|
||
if (e.status === 401) { logout(); return; }
|
||
$("file-crumbs").innerHTML = `<span class="muted">${escapeHtml(e.message)}</span>`;
|
||
$("file-list").innerHTML = "";
|
||
}
|
||
}
|
||
|
||
// 切换文件面板浏览路径
|
||
function navFiles(newPath) {
|
||
state.filesPath = newPath || "";
|
||
loadFiles();
|
||
}
|
||
|
||
function renderFiles(data) {
|
||
// 当前所在的"项目"= 路径第一段;空路径 → user_root,无项目上下文
|
||
const segs = (data.current || "").split("/").filter(Boolean);
|
||
const projName = segs[0] || "";
|
||
// 名称过长时显示前 11 字符 + …,完整名留 title 提示(避免顶栏挤压"文件"换行)
|
||
const projShort = projName.length > 12 ? projName.slice(0, 11) + "…" : projName;
|
||
$("files-proj").textContent = projShort ? "· " + projShort : "· (根目录)";
|
||
$("files-proj").title = projName || data.root || "";
|
||
// crumbs root 标"我的"(user_root),更直观;其余原样
|
||
const cr = data.crumbs.map((c, i) => {
|
||
const label = i === 0 ? "我的" : c.label;
|
||
const isLast = i === data.crumbs.length - 1;
|
||
if (isLast) return `<span>${escapeHtml(label)}</span>`;
|
||
return `<a href="#" data-rel="${escapeHtml(c.rel)}">${escapeHtml(label)}</a> /`;
|
||
}).join(" ");
|
||
$("file-crumbs").innerHTML = cr || `<span class="muted">/</span>`;
|
||
$("file-crumbs").querySelectorAll("a").forEach((a) => {
|
||
a.onclick = (e) => { e.preventDefault(); navFiles(a.dataset.rel); };
|
||
});
|
||
if (!data.exists) {
|
||
$("file-list").innerHTML = `<div class="empty">(目录尚未创建)</div>`;
|
||
state.entriesByRel = {};
|
||
return;
|
||
}
|
||
if (!data.entries.length) {
|
||
$("file-list").innerHTML = `<div class="empty">(空目录)</div>`;
|
||
state.entriesByRel = {};
|
||
return;
|
||
}
|
||
state.entriesByRel = {};
|
||
for (const e of data.entries) state.entriesByRel[e.rel] = e;
|
||
$("file-list").innerHTML = data.entries.map((e) => {
|
||
const cls = e.is_dir ? "ico-dir" : "ico-file";
|
||
const fullTitle = e.rel || e.name;
|
||
return `
|
||
<div class="file-row" data-rel="${escapeHtml(e.rel)}" title="${escapeHtml(fullTitle)}">
|
||
<span class="${cls} name" data-rel="${escapeHtml(e.rel)}" data-isdir="${e.is_dir}" title="${escapeHtml(fullTitle)}">
|
||
${escapeHtml(e.name)}
|
||
</span>
|
||
<span class="size">${humanSize(e.size)}</span>
|
||
<button class="dd-toggle file-menu" data-rel="${escapeHtml(e.rel)}" title="文件操作">⋯</button>
|
||
</div>
|
||
`;
|
||
}).join("");
|
||
$("file-list").querySelectorAll(".name").forEach((el) => {
|
||
el.style.cursor = "pointer";
|
||
el.onclick = () => {
|
||
const rel = el.dataset.rel;
|
||
if (el.dataset.isdir === "true") { navFiles(rel); }
|
||
else { openFilePreview(rel); }
|
||
};
|
||
});
|
||
$("file-list").querySelectorAll(".file-menu").forEach((btn) => {
|
||
btn.onclick = (ev) => {
|
||
ev.stopPropagation();
|
||
const e = state.entriesByRel[btn.dataset.rel];
|
||
if (!e) return;
|
||
showMenu(btn, fileMenuItems(e));
|
||
};
|
||
});
|
||
}
|
||
|
||
function fileMenuItems(e) {
|
||
const items = [
|
||
{ act: "rename", label: "重命名", cls: "act-rename",
|
||
onclick: () => renameFile(e.rel, e.name, e.is_dir) },
|
||
];
|
||
if (!e.is_dir) {
|
||
items.push({ act: "download", label: "下载", cls: "act-download",
|
||
onclick: () => downloadFile(e.rel) });
|
||
}
|
||
items.push({ act: "delete", label: "删除", cls: "act-delete",
|
||
onclick: () => deleteFile(e.rel, e.name, e.is_dir) });
|
||
return items;
|
||
}
|
||
|
||
async function deleteFile(rel, name, isDir) {
|
||
let recursive = false;
|
||
if (!isDir) {
|
||
if (!confirm(`确认删除文件 "${name}"?`)) return;
|
||
} else {
|
||
// 探一下目录内容:空目录走普通 rmdir;非空才递归,二次确认显示条目数
|
||
let entries;
|
||
try {
|
||
const data = await api("GET", "/v1/files?path=" + encodeURIComponent(rel));
|
||
entries = data.entries || [];
|
||
} catch (e) {
|
||
if (e.status === 401) { logout(); return; }
|
||
alert("读目录失败:" + e.message);
|
||
return;
|
||
}
|
||
if (entries.length === 0) {
|
||
if (!confirm(`确认删除空目录 "${name}"?`)) return;
|
||
} else {
|
||
const hasSub = entries.some((x) => x.is_dir);
|
||
const tip = hasSub ? "(含子目录)" : "";
|
||
if (!confirm(
|
||
`目录 "${name}" 含 ${entries.length} 项${tip},` +
|
||
`将递归删除全部内容,不可恢复。\n` +
|
||
`(若为顶层目录且仍被 task 引用,需先删 task)\n确认?`
|
||
)) return;
|
||
recursive = true;
|
||
}
|
||
}
|
||
try {
|
||
await api("POST", "/v1/files/delete", { path: rel, recursive });
|
||
await loadFiles();
|
||
// 删的若是顶层目录,folders 列表也得跟着变;子级删除走这里也无副作用
|
||
await loadFolderSuggestions();
|
||
} catch (e) {
|
||
if (e.status === 401) { logout(); return; }
|
||
alert("删除失败:" + e.message);
|
||
}
|
||
}
|
||
|
||
async function renameFile(rel, name, isDir) {
|
||
const what = isDir ? "目录" : "文件";
|
||
const newName = prompt(`将${what} "${name}" 重命名为:`, name);
|
||
if (newName == null) return;
|
||
const trimmed = newName.trim();
|
||
if (!trimmed || trimmed === name) return;
|
||
try {
|
||
const res = await api("POST", "/v1/files/rename", { path: rel, new_name: trimmed });
|
||
// 面板若停在被改名的子树里,做前缀替换继续停留在等价位置
|
||
if (state.filesPath === rel) {
|
||
state.filesPath = res.new;
|
||
} else if (state.filesPath && state.filesPath.startsWith(rel + "/")) {
|
||
state.filesPath = res.new + state.filesPath.slice(rel.length);
|
||
}
|
||
await loadFolderSuggestions();
|
||
// 顶层目录改名 → tasks_updated>0,任务列表 / 当前 task 头里的 working_dir 都得刷
|
||
if (res && res.tasks_updated > 0) {
|
||
await loadTaskList();
|
||
if (state.taskId) { await selectTask(state.taskId); return; }
|
||
}
|
||
await loadFiles();
|
||
} catch (e) {
|
||
if (e.status === 401) { logout(); return; }
|
||
alert("重命名失败:" + e.message);
|
||
}
|
||
}
|
||
|
||
// ───── artifact 抽取(对话内 chip → 复用文件预览 modal) ─────
|
||
// task.working_dir 在 DB 是 `workspace/users/<uuid>/<name>` 形态(to_db_path),
|
||
// 不是 user_root 相对。这里取最后一段作为 chip 抽取锚点 —— 等价于 user_root 下
|
||
// 一级子目录名(同 filesPath 的 wdName 语义)。外部 --working-dir 是绝对路径,
|
||
// 文件不在 user_root,backend files API 拒访问 → 不挂 chip。
|
||
// 把一次 tool_call 翻成一句中文活动描述(展示在折叠标题行,不展开就能看懂在干啥)。
|
||
// args 是后端 _execute_tool_call 解析后的参数 dict;取每个工具最能代表"在干啥"的字段。
|
||
function toolActivityLabel(name, args) {
|
||
const a = (args && typeof args === "object") ? args : {};
|
||
const clip = (v, n) => {
|
||
const s = String(v == null ? "" : v).replace(/\s+/g, " ").trim();
|
||
return s.length > n ? s.slice(0, n) + "…" : s;
|
||
};
|
||
switch (name) {
|
||
case "read": return `读取文件: ${clip(a.path, 80)}`;
|
||
case "write": return `写入文件: ${clip(a.path, 80)}`;
|
||
case "edit": return `编辑文件: ${clip(a.path, 80)}`;
|
||
case "glob": return `查找文件: ${clip(a.pattern, 60)}`;
|
||
case "grep": return `搜索内容: ${clip(a.pattern, 60)}`;
|
||
case "shell": return `执行命令: ${clip(a.command, 80)}`;
|
||
case "run_python": return `运行 Python: ${clip(a.code, 80)}`;
|
||
case "web_fetch": return `抓取网页: ${clip(a.url, 80)}`;
|
||
case "web_search": return `联网搜索: ${clip(a.query, 60)}`;
|
||
case "load_skill": return `加载技能: ${clip(a.name, 40)}`;
|
||
case "seedream": return `生成图像: ${clip(a.prompt, 60)}`;
|
||
case "seedance": return `生成视频: ${clip(a.prompt, 60)}`;
|
||
default: {
|
||
const p = clip(JSON.stringify(a), 80);
|
||
return p && p !== "{}" ? `${name} ${p}` : `工具调用: ${name}`;
|
||
}
|
||
}
|
||
}
|
||
|
||
function _workingDirName(workingDir) {
|
||
if (!workingDir) return "";
|
||
const wd = String(workingDir).replace(/\\+/g, "/");
|
||
if (wd.startsWith("/") || /^[A-Za-z]:/.test(wd)) return ""; // 绝对 = 外部目录,跳过
|
||
const segs = wd.split("/").filter(Boolean);
|
||
return segs[segs.length - 1] || "";
|
||
}
|
||
|
||
// 产物工具白名单:**工具 I/O** 维度,只有这些工具的 tool_call args / tool_result
|
||
// 里 echo 的路径才挂 chip 条 + 图片/视频 inline 大图;通用工具(grep/read/glob/
|
||
// shell)echo 的路径是"引用"不是"产物",完全不挂(避免把 grep 命中的老 figures/
|
||
// foo.png 当新产物展示)。**assistant 正文不受此限** —— 助手回复里任何 echo 的
|
||
// 路径无条件挂 chip(`allowInlineMedia=false`,只 chip 不 inline,跟上面 tool 结果
|
||
// 可能已 inline 的同图不冲突);用户视角"助手提到的文件理应能点开",chip 是
|
||
// 可发现性入口,小图标无视觉污染。
|
||
// 注:与 extractMediaBanner 的"媒体 banner"白名单是不同维度 —— 将来若新增
|
||
// "生成 docx 的工具",入这里但不入 banner 白名单。
|
||
const ARTIFACT_PRODUCING_TOOLS = new Set(["seedream", "seedance"]);
|
||
|
||
// 从 tool args / result / assistant 正文里抓 working_dir 下的文件路径,归一为 user_root 相对。
|
||
// 启发式:把 \ 一律归 /,然后找以 `<wdName>/` 打头的串,要求最后一段含 . (像文件)。
|
||
// 从 seedream/seedance tool_result 第一行 banner 抽 model/size/cost/elapsed,
|
||
// 拼一行 .tool-banner HTML 挂在 details summary 旁。匹配失败返 ""(不渲染)。
|
||
// 协议:tool 返回串首行格式 `[<tool>] key=value · key=value · ...`
|
||
function extractMediaBanner(toolName, resultText) {
|
||
if (!resultText) return "";
|
||
if (toolName !== "seedream" && toolName !== "seedance") return "";
|
||
const firstLine = String(resultText).split("\n", 1)[0] || "";
|
||
// 抓 key=value(value 可含空格 / : / ., 用 · 或行尾结束)
|
||
const re = /(\w+)=([^·\n]+?)(?=\s*·|\s*$)/g;
|
||
const kvs = {};
|
||
let m;
|
||
while ((m = re.exec(firstLine)) !== null) {
|
||
kvs[m[1]] = m[2].trim();
|
||
}
|
||
if (!kvs.model && !kvs.cost) return "";
|
||
// model 文本太长(`doubao-seedream-5-0-260128`)→ 截短易读形式
|
||
const model = (kvs.model || "").replace(/^doubao-/, "").replace(/-\d{6,}$/, "");
|
||
const parts = [];
|
||
if (model) parts.push(`<span class="kv model">${escapeHtml(model)}</span>`);
|
||
if (kvs.size) parts.push(`<span class="kv">${escapeHtml(kvs.size)}</span>`);
|
||
if (kvs.cost) parts.push(`<span class="kv cost">${escapeHtml(kvs.cost)}</span>`);
|
||
if (kvs.elapsed) parts.push(`<span class="kv">${escapeHtml(kvs.elapsed)}</span>`);
|
||
return parts.length ? `<span class="tool-banner">${parts.join("")}</span>` : "";
|
||
}
|
||
|
||
function extractArtifactRels(text, workingDir) {
|
||
if (!text || !workingDir) return [];
|
||
const wd = String(workingDir).replace(/\\+/g, "/").replace(/^\/+|\/+$/g, "");
|
||
if (!wd) return [];
|
||
const norm = String(text).replace(/\\+/g, "/");
|
||
const wdEsc = wd.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||
const seen = new Set();
|
||
const out = [];
|
||
|
||
// 规范形式:<wdName>/<...>/<file>.<ext> —— 当前协议(system prompt 强约束助手照抄 tool `saved:` 行)
|
||
// lead 边界:行首或非 path-字符;tail 截到空白/引号/括号等
|
||
{
|
||
const re = new RegExp(
|
||
"(?:^|[\\s\"'`/=:,()<>\\[\\]{}|])(" + wdEsc + "/[^\\s\"'`<>(){}\\[\\]|]+)",
|
||
"g"
|
||
);
|
||
let m;
|
||
while ((m = re.exec(norm)) !== null) {
|
||
let rel = m[1];
|
||
rel = rel.replace(/[.,;:!?)\]}>。,;:!?)]+$/, ""); // 剥尾标点(中英)
|
||
const tail = rel.slice(wd.length + 1);
|
||
if (!tail) continue;
|
||
const last = tail.split("/").pop() || "";
|
||
if (!last.includes(".")) continue; // 看着像目录的不挂 chip
|
||
if (seen.has(rel)) continue;
|
||
seen.add(rel);
|
||
out.push(rel);
|
||
}
|
||
}
|
||
|
||
// ───── 一次性兼容:协议刚性化前的历史简写消息 ─────
|
||
// system prompt 改硬约束之前,助手按 SKILL 旧文案 echo 过 `videos/xxx.mp4` /
|
||
// `figures/xxx.png` 这种裸形式 —— 那些消息已存 DB 改不动,前端这里 prepend
|
||
// <wdName> 把它们拼成 user_root rel 才能挂 chip。**白名单显式枚举不扩展**:
|
||
// 新产物 skill 走 system 协议必出全形式,这一层只服务**历史消息**渲染。
|
||
// 长期(老消息归档/不再回看)整段可删。
|
||
const LEGACY_PRODUCT_DIRS = ["videos", "figures"];
|
||
for (const dir of LEGACY_PRODUCT_DIRS) {
|
||
const dirEsc = dir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||
// lead 边界跟主规则一致但去掉 `/` —— 否则 <wd>/videos/xxx 里的 videos/xxx 会被重复
|
||
// 匹配(虽然 seen 去重,但浪费 cycles)
|
||
const re = new RegExp(
|
||
"(?:^|[\\s\"'`=:,()<>\\[\\]{}|])(" + dirEsc + "/[^\\s\"'`<>(){}\\[\\]|]+)",
|
||
"g"
|
||
);
|
||
let m;
|
||
while ((m = re.exec(norm)) !== null) {
|
||
let tail = m[1];
|
||
tail = tail.replace(/[.,;:!?)\]}>。,;:!?)]+$/, "");
|
||
const last = tail.split("/").pop() || "";
|
||
if (!last.includes(".")) continue;
|
||
const rel = wd + "/" + tail;
|
||
if (seen.has(rel)) continue;
|
||
seen.add(rel);
|
||
out.push(rel);
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// allowInlineMedia 控制图片/视频是否升级为内联 <img>/<video>:产物工具
|
||
// (seedream/seedance)+ assistant 正文传 true,通用工具(grep/read/shell/glob)
|
||
// 结果里 echo 的路径传 false → 图片/视频也走 chip 按钮(点开仍弹预览 modal),
|
||
// 这样既不会把无关老图占整屏,又保留"路径可点"的可发现性。
|
||
function renderArtifactBarHtml(rels, allowInlineMedia = true) {
|
||
if (!rels || !rels.length) return "";
|
||
const items = rels.map((rel) => {
|
||
const name = rel.split("/").pop() || rel;
|
||
const cat = _categorize(rel);
|
||
if (allowInlineMedia && (cat === "image" || cat === "video")) {
|
||
// 占位元素;插入 DOM 后 upgradeMediaArtifacts 异步 fetch blob → 填 <img>/<video>。
|
||
// 不在这里发请求避免 string-build 阶段失控的并发;upgrade 走 DOM walk 一次。
|
||
return `<span class="art-media art-media-${cat}" data-rel="${escapeHtml(rel)}" data-cat="${cat}" title="${escapeHtml(rel)}"><span class="art-media-loading">${escapeHtml(name)} 加载中…</span></span>`;
|
||
}
|
||
return `<button type="button" class="art-chip" data-rel="${escapeHtml(rel)}" title="${escapeHtml(rel)} · 点击预览(可下载)">${escapeHtml(name)}</button>`;
|
||
}).join("");
|
||
return `<div class="artifact-bar">${items}</div>`;
|
||
}
|
||
|
||
// rel → Promise<blob-url>。auth 是 Bearer header,不能直接 <img src=>,只能 fetch
|
||
// 拿 blob 再转 URL。同 rel 在同会话内复用,免重复拉。task 切换 / logout 时
|
||
// _flushMediaArtifactCache 清掉旧 URL 防泄漏。
|
||
const _mediaArtifactCache = new Map();
|
||
|
||
function _fetchMediaBlobUrl(rel) {
|
||
if (_mediaArtifactCache.has(rel)) return _mediaArtifactCache.get(rel);
|
||
const p = fetch("/v1/files/download?path=" + encodeURIComponent(rel), {
|
||
headers: { "Authorization": "Bearer " + state.token },
|
||
}).then(async (r) => {
|
||
if (!r.ok) throw new Error("HTTP " + r.status);
|
||
const blob = await r.blob();
|
||
return URL.createObjectURL(blob);
|
||
});
|
||
_mediaArtifactCache.set(rel, p);
|
||
return p;
|
||
}
|
||
|
||
function _flushMediaArtifactCache() {
|
||
for (const p of _mediaArtifactCache.values()) {
|
||
p.then((u) => URL.revokeObjectURL(u)).catch(() => {});
|
||
}
|
||
_mediaArtifactCache.clear();
|
||
}
|
||
|
||
// DOM walk:把所有 .art-media[data-rel] 占位换成 <img> / <video>。
|
||
// renderMessages / SSE 插入完后调一次;重复调用幂等(已 upgrade 过的 set data-upgraded 跳过)。
|
||
function upgradeMediaArtifacts(root) {
|
||
const nodes = (root || document).querySelectorAll(".art-media[data-rel]:not([data-upgraded])");
|
||
nodes.forEach((node) => {
|
||
node.dataset.upgraded = "1";
|
||
const rel = node.dataset.rel;
|
||
const cat = node.dataset.cat;
|
||
_fetchMediaBlobUrl(rel).then((url) => {
|
||
node.innerHTML = "";
|
||
if (cat === "image") {
|
||
const img = document.createElement("img");
|
||
img.src = url;
|
||
img.alt = rel.split("/").pop() || rel;
|
||
img.loading = "lazy"; // 浏览器懒解码(已在 viewport 内立即可见,远处暂不解)
|
||
node.appendChild(img);
|
||
} else if (cat === "video") {
|
||
const v = document.createElement("video");
|
||
v.src = url;
|
||
v.controls = true;
|
||
v.preload = "metadata";
|
||
node.appendChild(v);
|
||
}
|
||
}).catch((e) => {
|
||
node.innerHTML = `<span class="art-media-error">${escapeHtml(rel.split("/").pop() || rel)} 加载失败:${escapeHtml(e.message || String(e))}</span>`;
|
||
});
|
||
});
|
||
}
|
||
|
||
function downloadFile(rel) {
|
||
fetch("/v1/files/download?path=" + encodeURIComponent(rel), {
|
||
headers: { "Authorization": "Bearer " + state.token },
|
||
}).then(async (r) => {
|
||
if (!r.ok) { alert("下载失败:" + r.status); return; }
|
||
const blob = await r.blob();
|
||
const a = document.createElement("a");
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = rel.split("/").pop() || "file";
|
||
document.body.appendChild(a); a.click();
|
||
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
|
||
});
|
||
}
|
||
|
||
// ───── file preview ─────
|
||
const PREVIEW_TEXT_MAX = 2 * 1024 * 1024;
|
||
const PREVIEW_BIN_MAX = 50 * 1024 * 1024;
|
||
|
||
const _scriptCache = new Map();
|
||
function loadScript(src) {
|
||
if (_scriptCache.has(src)) return _scriptCache.get(src);
|
||
const p = new Promise((resolve, reject) => {
|
||
const s = document.createElement("script");
|
||
s.src = src;
|
||
s.onload = () => resolve();
|
||
s.onerror = () => { _scriptCache.delete(src); reject(new Error("load failed: " + src)); };
|
||
document.head.appendChild(s);
|
||
});
|
||
_scriptCache.set(src, p);
|
||
return p;
|
||
}
|
||
|
||
const _previewBlobUrls = new Set();
|
||
function _trackBlobUrl(blob, mime) {
|
||
const b = mime ? new Blob([blob], { type: mime }) : blob;
|
||
const url = URL.createObjectURL(b);
|
||
_previewBlobUrls.add(url);
|
||
return url;
|
||
}
|
||
function _flushBlobUrls() {
|
||
for (const u of _previewBlobUrls) URL.revokeObjectURL(u);
|
||
_previewBlobUrls.clear();
|
||
}
|
||
|
||
const _EXT_GROUPS = {
|
||
image: new Set(["jpg","jpeg","png","gif","webp","bmp","svg","ico"]),
|
||
video: new Set(["mp4","webm","mov","mkv","m4v"]),
|
||
pdf: new Set(["pdf"]),
|
||
md: new Set(["md","markdown"]),
|
||
text: new Set([
|
||
"txt","log","json","jsonl","yaml","yml","toml","ini","csv","tsv",
|
||
"py","js","mjs","ts","jsx","tsx","go","rs","java","c","cc","cpp","h","hpp",
|
||
"html","htm","xml","css","scss","sh","bash","zsh","sql","conf","env",
|
||
]),
|
||
docx: new Set(["docx"]),
|
||
xlsx: new Set(["xlsx","xls"]),
|
||
};
|
||
function _categorize(rel) {
|
||
const m = /\.([a-z0-9]+)$/i.exec(rel);
|
||
const ext = m ? m[1].toLowerCase() : "";
|
||
for (const [cat, set] of Object.entries(_EXT_GROUPS)) if (set.has(ext)) return cat;
|
||
return "fallback";
|
||
}
|
||
|
||
let _fpCurrentRel = null;
|
||
|
||
async function openFilePreview(rel) {
|
||
_fpCurrentRel = rel;
|
||
const name = rel.split("/").pop() || rel;
|
||
$("fp-name").textContent = name;
|
||
$("fp-meta").textContent = "";
|
||
const body = $("fp-body");
|
||
body.className = "body center";
|
||
body.innerHTML = `<div class="ph">加载中…</div>`;
|
||
// 让出聊天输入区高度,弹框不遮挡 chat-form(无活动任务时 cf 隐藏,inset = 0)
|
||
const cf = $("chat-form");
|
||
const inset = (cf && cf.offsetParent) ? cf.offsetHeight : 0;
|
||
$("file-preview-modal").style.setProperty("--preview-bottom-inset", inset + "px");
|
||
$("file-preview-modal").classList.add("show");
|
||
|
||
const cat = _categorize(rel);
|
||
try {
|
||
const r = await fetch("/v1/files/download?path=" + encodeURIComponent(rel), {
|
||
headers: { "Authorization": "Bearer " + state.token },
|
||
});
|
||
if (!r.ok) throw new Error("HTTP " + r.status);
|
||
const blob = await r.blob();
|
||
$("fp-meta").textContent = humanSize(blob.size);
|
||
|
||
if (cat === "text" || cat === "md") {
|
||
if (blob.size > PREVIEW_TEXT_MAX) {
|
||
_showFallback(`文件过大 (${humanSize(blob.size)}),请下载查看`);
|
||
return;
|
||
}
|
||
const text = await blob.text();
|
||
if (cat === "md") _showMarkdown(text);
|
||
else _showText(text);
|
||
return;
|
||
}
|
||
if (blob.size > PREVIEW_BIN_MAX) {
|
||
_showFallback(`文件过大 (${humanSize(blob.size)}),请下载查看`);
|
||
return;
|
||
}
|
||
if (cat === "image") _showImage(blob);
|
||
else if (cat === "video") _showVideo(blob);
|
||
else if (cat === "pdf") _showPdf(blob);
|
||
else if (cat === "docx") await _showDocx(blob);
|
||
else if (cat === "xlsx") await _showXlsx(blob);
|
||
else _showFallback("暂不支持在线预览此格式,请下载查看");
|
||
} catch (e) {
|
||
if (e.status === 401) { closeFilePreview(); logout(); return; }
|
||
_showFallback("加载失败:" + e.message);
|
||
}
|
||
}
|
||
|
||
function _showImage(blob) {
|
||
const url = _trackBlobUrl(blob);
|
||
const body = $("fp-body");
|
||
body.className = "body center";
|
||
body.innerHTML = "";
|
||
const img = document.createElement("img");
|
||
img.className = "preview-img";
|
||
img.src = url;
|
||
body.appendChild(img);
|
||
}
|
||
|
||
function _showVideo(blob) {
|
||
const url = _trackBlobUrl(blob);
|
||
const body = $("fp-body");
|
||
body.className = "body center";
|
||
body.innerHTML = "";
|
||
const v = document.createElement("video");
|
||
v.className = "preview-video";
|
||
v.src = url;
|
||
v.controls = true;
|
||
v.autoplay = true;
|
||
body.appendChild(v);
|
||
}
|
||
|
||
function _showPdf(blob) {
|
||
const url = _trackBlobUrl(blob, "application/pdf");
|
||
const body = $("fp-body");
|
||
body.className = "body";
|
||
body.innerHTML = `<iframe class="preview-frame" src="${url}"></iframe>`;
|
||
}
|
||
|
||
function _showText(text) {
|
||
const body = $("fp-body");
|
||
body.className = "body";
|
||
body.innerHTML = "";
|
||
const pre = document.createElement("pre");
|
||
pre.className = "preview-text";
|
||
pre.textContent = text;
|
||
body.appendChild(pre);
|
||
}
|
||
|
||
function _showMarkdown(text) {
|
||
const body = $("fp-body");
|
||
body.className = "body";
|
||
body.innerHTML = `<div class="md-render">${renderMd(text)}</div>`;
|
||
highlightIn(body);
|
||
}
|
||
|
||
async function _showDocx(blob) {
|
||
const body = $("fp-body");
|
||
body.className = "body center";
|
||
body.innerHTML = `<div class="ph">解析 docx 中…</div>`;
|
||
try {
|
||
await loadScript("/static/vendor/jszip.min.js");
|
||
await loadScript("/static/vendor/docx-preview.min.js");
|
||
} catch (e) {
|
||
_showFallback("docx 解析库加载失败:" + e.message);
|
||
return;
|
||
}
|
||
if (!window.docx || !window.docx.renderAsync) {
|
||
_showFallback("docx 解析库不可用");
|
||
return;
|
||
}
|
||
body.className = "body";
|
||
body.innerHTML = `<div class="docx-host"></div>`;
|
||
try {
|
||
await window.docx.renderAsync(blob, body.querySelector(".docx-host"), null, {
|
||
inWrapper: false,
|
||
ignoreLastRenderedPageBreak: true,
|
||
});
|
||
} catch (e) {
|
||
_showFallback("docx 渲染失败:" + e.message);
|
||
}
|
||
}
|
||
|
||
async function _showXlsx(blob) {
|
||
const body = $("fp-body");
|
||
body.className = "body center";
|
||
body.innerHTML = `<div class="ph">解析表格中…</div>`;
|
||
try {
|
||
await loadScript("/static/vendor/xlsx.full.min.js");
|
||
} catch (e) {
|
||
_showFallback("xlsx 解析库加载失败:" + e.message);
|
||
return;
|
||
}
|
||
if (!window.XLSX || !window.XLSX.read) {
|
||
_showFallback("xlsx 解析库不可用");
|
||
return;
|
||
}
|
||
let wb;
|
||
try {
|
||
const ab = await blob.arrayBuffer();
|
||
wb = window.XLSX.read(ab, { type: "array" });
|
||
} catch (e) {
|
||
_showFallback("xlsx 解析失败:" + e.message);
|
||
return;
|
||
}
|
||
const names = wb.SheetNames || [];
|
||
if (!names.length) { _showFallback("xlsx 内无 sheet"); return; }
|
||
body.className = "body";
|
||
const tabsHtml = names.map((n, i) =>
|
||
`<button class="small xlsx-tab${i===0?" active":""}" data-i="${i}">${escapeHtml(n)}</button>`
|
||
).join("");
|
||
body.innerHTML = `<div class="xlsx-tabs">${tabsHtml}</div><div class="xlsx-sheet" id="fp-xlsx-sheet"></div>`;
|
||
const render = (i) => {
|
||
const ws = wb.Sheets[names[i]];
|
||
$("fp-xlsx-sheet").innerHTML = window.XLSX.utils.sheet_to_html(ws);
|
||
};
|
||
body.querySelectorAll(".xlsx-tab").forEach((btn) => {
|
||
btn.onclick = () => {
|
||
body.querySelectorAll(".xlsx-tab").forEach((b) => b.classList.remove("active"));
|
||
btn.classList.add("active");
|
||
render(parseInt(btn.dataset.i));
|
||
};
|
||
});
|
||
render(0);
|
||
}
|
||
|
||
function _showFallback(msg) {
|
||
const body = $("fp-body");
|
||
body.className = "body center";
|
||
body.innerHTML = "";
|
||
const ph = document.createElement("div");
|
||
ph.className = "ph";
|
||
ph.textContent = msg;
|
||
const br = document.createElement("br");
|
||
const dl = document.createElement("button");
|
||
dl.className = "primary";
|
||
dl.textContent = "下载原文件";
|
||
dl.style.marginTop = "12px";
|
||
dl.onclick = () => { if (_fpCurrentRel) downloadFile(_fpCurrentRel); };
|
||
ph.appendChild(document.createElement("br"));
|
||
ph.appendChild(br);
|
||
ph.appendChild(dl);
|
||
body.appendChild(ph);
|
||
}
|
||
|
||
function closeFilePreview() {
|
||
$("file-preview-modal").classList.remove("show");
|
||
$("file-preview-modal").style.removeProperty("--preview-bottom-inset");
|
||
$("fp-body").innerHTML = "";
|
||
_flushBlobUrls();
|
||
_fpCurrentRel = null;
|
||
}
|
||
|
||
let _mpCurrentRel = null;
|
||
const _miniPreviewBlobUrls = new Set();
|
||
function _trackMiniBlobUrl(blob, mime) {
|
||
const b = mime ? new Blob([blob], { type: mime }) : blob;
|
||
const url = URL.createObjectURL(b);
|
||
_miniPreviewBlobUrls.add(url);
|
||
return url;
|
||
}
|
||
function _flushMiniBlobUrls() {
|
||
for (const u of _miniPreviewBlobUrls) URL.revokeObjectURL(u);
|
||
_miniPreviewBlobUrls.clear();
|
||
}
|
||
function openPasteFilePreview(rel) {
|
||
if ($("file-preview-modal").classList.contains("show")) openMiniFilePreview(rel);
|
||
else openFilePreview(rel);
|
||
}
|
||
async function openMiniFilePreview(rel) {
|
||
_mpCurrentRel = rel;
|
||
const name = rel.split("/").pop() || rel;
|
||
$("mp-name").textContent = name;
|
||
$("mp-meta").textContent = "";
|
||
const body = $("mp-body");
|
||
body.className = "body center";
|
||
body.innerHTML = `<div class="ph">加载中…</div>`;
|
||
_flushMiniBlobUrls();
|
||
$("mini-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();
|
||
$("mp-meta").textContent = humanSize(blob.size);
|
||
if (cat === "text" || cat === "md") {
|
||
if (blob.size > PREVIEW_TEXT_MAX) {
|
||
_showMiniFallback(`文件过大 (${humanSize(blob.size)}),请下载查看`);
|
||
return;
|
||
}
|
||
const text = await blob.text();
|
||
body.className = "body";
|
||
if (cat === "md") {
|
||
body.innerHTML = `<div class="md-render">${renderMd(text)}</div>`;
|
||
highlightIn(body);
|
||
} else {
|
||
body.innerHTML = "";
|
||
const pre = document.createElement("pre");
|
||
pre.className = "preview-text";
|
||
pre.textContent = text;
|
||
body.appendChild(pre);
|
||
}
|
||
return;
|
||
}
|
||
if (blob.size > PREVIEW_BIN_MAX) {
|
||
_showMiniFallback(`文件过大 (${humanSize(blob.size)}),请下载查看`);
|
||
return;
|
||
}
|
||
body.innerHTML = "";
|
||
if (cat === "image") {
|
||
body.className = "body center";
|
||
const img = document.createElement("img");
|
||
img.className = "preview-img";
|
||
img.src = _trackMiniBlobUrl(blob);
|
||
body.appendChild(img);
|
||
} else if (cat === "video") {
|
||
body.className = "body center";
|
||
const v = document.createElement("video");
|
||
v.className = "preview-video";
|
||
v.src = _trackMiniBlobUrl(blob);
|
||
v.controls = true;
|
||
body.appendChild(v);
|
||
} else if (cat === "pdf") {
|
||
body.className = "body";
|
||
body.innerHTML = `<iframe class="preview-frame" src="${_trackMiniBlobUrl(blob, "application/pdf")}"></iframe>`;
|
||
} else {
|
||
_showMiniFallback("暂不支持小窗预览此格式,请下载查看");
|
||
}
|
||
} catch (e) {
|
||
if (e.status === 401) { closeMiniPreview(); logout(); return; }
|
||
_showMiniFallback("加载失败:" + e.message);
|
||
}
|
||
}
|
||
function _showMiniFallback(msg) {
|
||
const body = $("mp-body");
|
||
body.className = "body center";
|
||
body.innerHTML = "";
|
||
const ph = document.createElement("div");
|
||
ph.className = "ph";
|
||
ph.textContent = msg;
|
||
const dl = document.createElement("button");
|
||
dl.className = "primary";
|
||
dl.textContent = "下载原文件";
|
||
dl.style.marginTop = "12px";
|
||
dl.onclick = () => { if (_mpCurrentRel) downloadFile(_mpCurrentRel); };
|
||
ph.appendChild(document.createElement("br"));
|
||
ph.appendChild(dl);
|
||
body.appendChild(ph);
|
||
}
|
||
function closeMiniPreview() {
|
||
$("mini-preview-modal").classList.remove("show");
|
||
$("mp-body").innerHTML = "";
|
||
_flushMiniBlobUrls();
|
||
_mpCurrentRel = 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();
|
||
});
|
||
$("mp-close").onclick = closeMiniPreview;
|
||
$("mp-download").onclick = () => { if (_mpCurrentRel) downloadFile(_mpCurrentRel); };
|
||
$("mini-preview-modal").addEventListener("click", (e) => {
|
||
if (e.target.id === "mini-preview-modal") closeMiniPreview();
|
||
});
|
||
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; }
|
||
});
|
||
|
||
function uploadTotalBytes(files) {
|
||
return (files || []).reduce((sum, f) => sum + (f.size || 0), 0);
|
||
}
|
||
function uploadFilesLabel(files) {
|
||
if (!files || !files.length) return "";
|
||
return files.length === 1 ? files[0].name : `${files[0].name} 等 ${files.length} 个文件`;
|
||
}
|
||
function formatUploadProgress(files, loaded, total) {
|
||
const denom = total || uploadTotalBytes(files);
|
||
const pct = denom ? Math.min(100, Math.max(0, Math.round((loaded / denom) * 100))) : 0;
|
||
const sizeText = denom ? ` · ${humanSize(Math.min(loaded, denom))}/${humanSize(denom)}` : "";
|
||
return `上传中 ${pct}% · ${uploadFilesLabel(files)}${sizeText}`;
|
||
}
|
||
function setPaneUploadStatus(files, loaded, total) {
|
||
const el = $("file-upload-status");
|
||
const denom = total || uploadTotalBytes(files);
|
||
const pct = denom ? Math.min(100, Math.max(0, Math.round((loaded / denom) * 100))) : 0;
|
||
el.classList.add("show");
|
||
el.innerHTML = `${escapeHtml(formatUploadProgress(files, loaded, total))}<div class="bar"><span style="width:${pct}%"></span></div>`;
|
||
}
|
||
function finishPaneUploadStatus(ok, files) {
|
||
const el = $("file-upload-status");
|
||
el.classList.add("show");
|
||
el.innerHTML = ok
|
||
? `上传完成 · ${escapeHtml(uploadFilesLabel(files))}<div class="bar"><span style="width:100%"></span></div>`
|
||
: `上传失败 · ${escapeHtml(uploadFilesLabel(files))}`;
|
||
setTimeout(() => {
|
||
if (el.textContent.startsWith(ok ? "上传完成" : "上传失败")) {
|
||
el.classList.remove("show");
|
||
el.innerHTML = "";
|
||
}
|
||
}, 3500);
|
||
}
|
||
async function uploadFilesWithPaneStatus(files) {
|
||
if (!files || !files.length) return null;
|
||
setPaneUploadStatus(files, 0, uploadTotalBytes(files));
|
||
const saved = await uploadFiles(files, {
|
||
onProgress: (loaded, total) => setPaneUploadStatus(files, loaded, total),
|
||
});
|
||
finishPaneUploadStatus(!!(saved && saved.length), files);
|
||
return saved;
|
||
}
|
||
|
||
async function uploadFiles(files, opts = {}) {
|
||
if (!files || !files.length) return null;
|
||
const fd = new FormData();
|
||
fd.append("path", state.filesPath || "");
|
||
for (const f of files) fd.append("files", f);
|
||
try {
|
||
const data = await new Promise((resolve, reject) => {
|
||
const xhr = new XMLHttpRequest();
|
||
xhr.open("POST", "/v1/files/upload");
|
||
xhr.setRequestHeader("Authorization", "Bearer " + state.token);
|
||
xhr.upload.onprogress = (ev) => {
|
||
if (opts.onProgress && ev.lengthComputable) opts.onProgress(ev.loaded, ev.total);
|
||
};
|
||
xhr.onerror = () => reject(new Error("网络错误,上传失败"));
|
||
xhr.onload = () => {
|
||
let payload = {};
|
||
try { payload = xhr.responseText ? JSON.parse(xhr.responseText) : {}; }
|
||
catch (_) { payload = {}; }
|
||
if (xhr.status < 200 || xhr.status >= 300) {
|
||
const err = new Error(payload.detail || (xhr.status + " 上传失败"));
|
||
err.status = xhr.status;
|
||
reject(err);
|
||
return;
|
||
}
|
||
resolve(payload);
|
||
};
|
||
if (opts.onProgress) opts.onProgress(0, uploadTotalBytes(files));
|
||
xhr.send(fd);
|
||
});
|
||
await loadFiles();
|
||
return data.saved || [];
|
||
} catch (e) {
|
||
if (e.status === 401) { logout(); return null; }
|
||
alert("上传失败:" + e.message);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function uploadSelected() {
|
||
const inp = $("upload-input");
|
||
const files = Array.from(inp.files || []);
|
||
try {
|
||
await uploadFilesWithPaneStatus(files);
|
||
} finally {
|
||
inp.value = ""; // 允许重新选同名文件
|
||
}
|
||
}
|
||
|
||
// ───── new task ─────
|
||
// wd 二级 input 默认跟随 name;用户一旦手改二级 input → 脱钩;清空二级 input 重置 flag
|
||
let wdManuallyEdited = false;
|
||
$("hd-new").onclick = async () => {
|
||
$("nt-name").value = "";
|
||
$("nt-wd-sel").value = "__new__"; // 默认选 sentinel
|
||
$("nt-wd-new").value = "";
|
||
$("nt-wd-new").style.display = ""; // sentinel 选中态 → 二级 input 可见
|
||
$("nt-desc").value = ""; $("nt-skill").value = "";
|
||
$("nt-err").textContent = "";
|
||
$("nt-wd-hint").textContent = "";
|
||
wdManuallyEdited = false;
|
||
$("new-task-modal").classList.add("show");
|
||
await Promise.all([loadFolderSuggestions(), loadSkillOptions(), loadModels()]);
|
||
$("nt-wd-sel").value = "__new__"; // populateFolderSelects 重渲后再保险一次
|
||
populateModelSelect();
|
||
$("nt-name").focus();
|
||
};
|
||
function populateModelSelect() {
|
||
const sel = $("nt-model");
|
||
const models = state.models || [];
|
||
if (models.length === 0) {
|
||
sel.innerHTML = `<option value="">(默认)</option>`;
|
||
return;
|
||
}
|
||
sel.innerHTML = models.map(m =>
|
||
`<option value="${escapeHtml(m.profile)}" ${m.is_default ? "selected" : ""}>${escapeHtml(m.display_name)}</option>`
|
||
).join("");
|
||
}
|
||
$("nt-cancel").onclick = () => $("new-task-modal").classList.remove("show");
|
||
$("nt-go").onclick = async () => {
|
||
const name = $("nt-name").value.trim();
|
||
const sel = $("nt-wd-sel").value;
|
||
// sentinel:用二级 input 值,空则 fallback name;选已有目录:直接用 value
|
||
const working_dir = sel === "__new__"
|
||
? ($("nt-wd-new").value.trim() || name)
|
||
: sel;
|
||
const desc = $("nt-desc").value.trim();
|
||
const skill = $("nt-skill").value;
|
||
const model_profile = $("nt-model").value;
|
||
$("nt-err").textContent = "";
|
||
if (!name) { $("nt-err").textContent = "任务名为必填项"; return; }
|
||
try {
|
||
const t = await api("POST", "/v1/tasks",
|
||
{ name, working_dir, description: desc, skill, model_profile });
|
||
$("new-task-modal").classList.remove("show");
|
||
await loadTaskList();
|
||
selectTask(t.task_id);
|
||
} catch (e) {
|
||
if (e.status === 401) { logout(); return; }
|
||
$("nt-err").textContent = e.message;
|
||
}
|
||
};
|
||
|
||
// 工作目录:拉数据 + 灌两个 select(顶部 filter-wd 和 modal nt-wd-sel)
|
||
async function loadFolderSuggestions() {
|
||
try {
|
||
const data = await api("GET", "/v1/folders");
|
||
state.folders = data.folders || [];
|
||
} catch (e) {
|
||
state.folders = state.folders || [];
|
||
}
|
||
populateFolderSelects();
|
||
}
|
||
|
||
// 灌 filter-wd + nt-wd-sel options;保留当前选中值
|
||
function populateFolderSelects() {
|
||
const folders = state.folders || [];
|
||
// 顶部 filter:第一项 "(全部目录)" sentinel
|
||
const filterSel = $("filter-wd");
|
||
const filterCur = filterSel.value;
|
||
const filterOpts = ['<option value="">(全部目录)</option>'];
|
||
for (const f of folders) {
|
||
const tag = f.n_tasks ? `${f.n_tasks} 个任务` : `空目录`;
|
||
filterOpts.push(`<option value="${escapeHtml(f.name)}">${escapeHtml(f.name)} — ${escapeHtml(tag)}</option>`);
|
||
}
|
||
filterSel.innerHTML = filterOpts.join("");
|
||
filterSel.value = filterCur; // 重渲后恢复选中
|
||
// modal wd:第一项 "+ 新建(跟随任务名)" sentinel(label 由 updateSentinelLabel 实时刷)
|
||
const wdSel = $("nt-wd-sel");
|
||
const wdCur = wdSel.value || "__new__";
|
||
const wdOpts = [`<option value="__new__">+ 新建(跟随任务名)</option>`];
|
||
for (const f of folders) {
|
||
const tag = f.n_tasks ? `${f.n_tasks} 个任务` : `空目录`;
|
||
wdOpts.push(`<option value="${escapeHtml(f.name)}">${escapeHtml(f.name)} — ${escapeHtml(tag)}</option>`);
|
||
}
|
||
wdSel.innerHTML = wdOpts.join("");
|
||
wdSel.value = wdCur;
|
||
updateSentinelLabel(); // 用最新的 name 刷 sentinel
|
||
}
|
||
|
||
// 智能体类型下拉:skill registry 服务器端静态,首次加载后缓存到 state.skills
|
||
async function loadSkillOptions() {
|
||
const sel = $("nt-skill");
|
||
if (!state.skills) {
|
||
try {
|
||
const data = await api("GET", "/v1/skills");
|
||
state.skills = data.skills || [];
|
||
} catch (e) {
|
||
state.skills = []; // 静默兜底,select 仍保留"(默认)"项
|
||
}
|
||
}
|
||
// 渲染:第一项固定为"默认"(空 value),其后逐 skill 一项
|
||
const opts = ['<option value="">(默认 · 不限定)</option>'];
|
||
for (const s of state.skills) {
|
||
const label = `${s.name}${s.description ? " — " + s.description : ""}`;
|
||
opts.push(`<option value="${escapeHtml(s.name)}" title="${escapeHtml(s.description || "")}">${escapeHtml(label)}</option>`);
|
||
}
|
||
sel.innerHTML = opts.join("");
|
||
sel.value = ""; // hd-new 已清空,这里幂等再保一次
|
||
}
|
||
|
||
// === modal wd select + 二级 input 联动 ===
|
||
// select 选 "__new__" sentinel → 显示二级 input(默认值跟随 name);选已有目录 → 隐藏二级 input
|
||
// wdManuallyEdited:用户改过二级 input 后置 true,name 不再覆盖;清空二级 input 重置 false
|
||
|
||
function updateSentinelLabel() {
|
||
// sentinel 永远是 select 第一项,labels 实时含 name 让用户一眼知会建什么
|
||
const sel = $("nt-wd-sel");
|
||
const opt = sel.options[0];
|
||
if (!opt || opt.value !== "__new__") return;
|
||
const name = $("nt-name").value.trim();
|
||
opt.textContent = name ? `+ 新建「${name}」` : `+ 新建(跟随任务名)`;
|
||
}
|
||
|
||
function updateWdHint() {
|
||
const hint = $("nt-wd-hint");
|
||
const sel = $("nt-wd-sel").value;
|
||
if (sel === "__new__") {
|
||
const v = $("nt-wd-new").value.trim();
|
||
const name = $("nt-name").value.trim();
|
||
const target = v || name;
|
||
if (!target) { hint.textContent = ""; return; }
|
||
// 用户手输的新名恰好命中已有目录 → 提示会复用而非新建
|
||
const collision = (state.folders || []).find(f => f.name === target);
|
||
if (collision) {
|
||
const n = collision.n_tasks || 0;
|
||
hint.innerHTML = `<span style="color:var(--accent);">! 已有同名目录,将复用</span> · ${n} 个任务`;
|
||
} else {
|
||
hint.innerHTML = `<span class="muted">→ 新建目录 ${escapeHtml(target)}</span>`;
|
||
}
|
||
} else {
|
||
const f = (state.folders || []).find(x => x.name === sel);
|
||
const n = f ? (f.n_tasks || 0) : 0;
|
||
hint.innerHTML = `<span style="color:var(--accent);">→ 复用已有目录</span> · ${n} 个任务`;
|
||
}
|
||
}
|
||
|
||
// name 改变 → 更新 sentinel label;若未脱钩且当前是 sentinel,二级 input 跟随 name
|
||
$("nt-name").addEventListener("input", () => {
|
||
updateSentinelLabel();
|
||
if (!wdManuallyEdited && $("nt-wd-sel").value === "__new__") {
|
||
$("nt-wd-new").value = $("nt-name").value;
|
||
}
|
||
updateWdHint();
|
||
});
|
||
|
||
// wd select 切换 → 切显示二级 input + 刷 hint
|
||
$("nt-wd-sel").addEventListener("change", () => {
|
||
const v = $("nt-wd-sel").value;
|
||
if (v === "__new__") {
|
||
$("nt-wd-new").style.display = "";
|
||
if (!wdManuallyEdited) $("nt-wd-new").value = $("nt-name").value;
|
||
} else {
|
||
$("nt-wd-new").style.display = "none";
|
||
}
|
||
updateWdHint();
|
||
});
|
||
|
||
// 二级 input 改变 → 非空视为手动修改;清空重置 flag 但保持空(避免 backspace 想换名时被打断)
|
||
$("nt-wd-new").addEventListener("input", () => {
|
||
wdManuallyEdited = $("nt-wd-new").value.trim() !== "";
|
||
updateWdHint();
|
||
});
|
||
|
||
// ───── embed mode ─────
|
||
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();
|
||
}
|