feat(web): persist and queue chat input
This commit is contained in:
parent
d035cab467
commit
06c47e08cb
|
|
@ -8,6 +8,8 @@
|
|||
|
||||
## Unreleased
|
||||
|
||||
- 对话中尚未发送的文字现在会按对话自动暂存;助手回答或后台进程执行期间也可以继续发送补充信息,消息会显示为待处理并在当前工作结束后依次发送。动作按钮采用单按钮形态:忙碌且输入为空时用于停止,输入文字或加入附件后自动切回发送。切换对话或刷新页面后,草稿和待处理消息仍会保留。
|
||||
|
||||
- Windows Node 的任务、Workspace 和受管运行时现在可以从界面整体迁移到其他本机磁盘;迁移完成前会校验全部文件并保留旧目录,管理员也可以用机器级环境变量统一指定位置。
|
||||
|
||||
- Windows Node 新增 Blender 三维场景创作能力,首批可按长度、直径、斜度、支撑档数和附属部件生成参数化回转窑,并可继续调整工程、预览或导出 GLB。
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
||||
|
||||
最后更新:2026-08-20(Windows Node 数据根目录可安全迁移,未发版)
|
||||
最后更新:2026-08-20(对话草稿暂存与回答中排队发送,未发版)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -20,6 +20,8 @@
|
|||
---
|
||||
## 已完成关键能力
|
||||
|
||||
- **08-20 / Unreleased / 对话草稿自动暂存与回答中排队发送**:未发送文字按用户和对话隔离保存于浏览器本地,切换对话或刷新页面后自动恢复,发送成功后清除;助手回答、停止收尾或后台进程执行期间,消息及附件作为可移除的待处理卡按序保存,当前工作结束后严格串行启动下一轮。composer 对齐 Codex 单按钮形态:忙碌且输入为空时显示“停止”,键入文字或加入附件后同一按钮切回“发送”并走排队语义。新对话在 task 懒创建与首条消息两个请求之间会把草稿迁到真实 task,发送失败仍可找回;输入采用 200ms 合并写入并覆盖润色、语音转写和引用修改入口。JavaScript 语法、队列/草稿专项及既有前端测试通过,无 DB、schema、migration、HTTP API、依赖或运行方式变化。
|
||||
|
||||
- **08-20 / Unreleased / Windows Node 数据根目录自定义与安全迁移**:新增 UI 数据目录选择、打开和整体迁移,机器级 `ZCBOT_WINDOWS_NODE_DATA_DIR` 优先且使 UI 只读,未配置节点继续使用 `%ProgramData%`;迁移拒绝活动任务、非固定盘、嵌套或非空目标及空间不足,停止连接并等待任务/导出收尾后在临时目录复制和逐文件 SHA-256 校验,成功才保存账号级路径并重启,旧目录保留。安装器、runtime、Workspace、诊断和磁盘余量上报统一跟随实际数据根目录;65 项 Windows Node/软件节点专项 unittest、.NET build 与 diff 检查通过,未迁移真实节点数据、未连接生产数据库。
|
||||
|
||||
- **08-20 / Unreleased / Blender 参数化回转窑 adapter**:新增 `blender.scene.author@v1` 与 `rotary_kiln` 声明式场景,可按长度、直径、壳体/衬里厚度、斜度、支撑档数及驱动、燃烧器、罩体、平台和物料参数生成可续作 `.blend` 工程、PNG 预览、场景清单、溯源和可选 GLB;固定启动器以后台、factory startup、禁止自动脚本模式调用 Blender,自身不接收 Python/命令行/路径。已在 Blender 3.6.12 上完成 60 m × 4 m、3.5% 斜度、三档生成及三档到四档 Workspace 续作真机烟测;专项 93 项 unittest、.NET build/publish 和独立 adapter 打包通过,全量 693 项仅 3 个既有数据库集成模块因显式测试库缺少 `users` 表未通过(另跳过 4 项),未连接或迁移生产数据库。
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
NEW_CHAT_DRAFT_ID,
|
||||
chatDraftStorageKey,
|
||||
clearChatDraft,
|
||||
readChatDraft,
|
||||
writeChatDraft,
|
||||
} from "../web/static/js/chat_drafts.js";
|
||||
|
||||
class MemoryStorage {
|
||||
constructor() { this.values = new Map(); }
|
||||
getItem(key) { return this.values.get(key) ?? null; }
|
||||
setItem(key, value) { this.values.set(key, String(value)); }
|
||||
removeItem(key) { this.values.delete(key); }
|
||||
}
|
||||
|
||||
test("chat drafts are isolated by user and conversation", () => {
|
||||
const storage = new MemoryStorage();
|
||||
writeChatDraft(storage, "user-a", "task-1", "任务一草稿", 1);
|
||||
writeChatDraft(storage, "user-a", "task-2", "任务二草稿", 2);
|
||||
writeChatDraft(storage, "user-b", "task-1", "另一用户草稿", 3);
|
||||
|
||||
assert.equal(readChatDraft(storage, "user-a", "task-1"), "任务一草稿");
|
||||
assert.equal(readChatDraft(storage, "user-a", "task-2"), "任务二草稿");
|
||||
assert.equal(readChatDraft(storage, "user-b", "task-1"), "另一用户草稿");
|
||||
assert.notEqual(chatDraftStorageKey("user-a"), chatDraftStorageKey("user-b"));
|
||||
});
|
||||
|
||||
test("new conversation draft persists and blank content clears it", () => {
|
||||
const storage = new MemoryStorage();
|
||||
writeChatDraft(storage, "user-a", NEW_CHAT_DRAFT_ID, "未创建对话", 1);
|
||||
assert.equal(readChatDraft(storage, "user-a", NEW_CHAT_DRAFT_ID), "未创建对话");
|
||||
|
||||
clearChatDraft(storage, "user-a", NEW_CHAT_DRAFT_ID);
|
||||
assert.equal(readChatDraft(storage, "user-a", NEW_CHAT_DRAFT_ID), "");
|
||||
assert.equal(storage.getItem(chatDraftStorageKey("user-a")), null);
|
||||
});
|
||||
|
||||
test("chat integrates draft save, restore, migration, and sent cleanup", () => {
|
||||
const source = readFileSync(new URL("../web/static/js/chat.js", import.meta.url), "utf8");
|
||||
assert.match(source, /export async function selectTask\(tid\) \{\s*persistComposerDraft\(\)/);
|
||||
assert.match(source, /state\.taskId = tid;\s*restoreComposerDraft\(tid\)/);
|
||||
assert.match(source, /persistComposerDraft\(t\.task_id\)/);
|
||||
assert.match(source, /clearChatDraft\(localStorage, state\.userId, taskId\)/);
|
||||
assert.match(source, /addEventListener\("input", \(\) => \{\s*scheduleComposerDraftSave\(\)/);
|
||||
assert.match(source, /addEventListener\("pagehide", \(\) => persistComposerDraft\(\)\)/);
|
||||
});
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
enqueueMessage,
|
||||
readQueuedMessages,
|
||||
removeQueuedMessage,
|
||||
} from "../web/static/js/chat_queue.js";
|
||||
|
||||
class MemoryStorage {
|
||||
constructor() { this.values = new Map(); }
|
||||
getItem(key) { return this.values.get(key) ?? null; }
|
||||
setItem(key, value) { this.values.set(key, String(value)); }
|
||||
removeItem(key) { this.values.delete(key); }
|
||||
}
|
||||
|
||||
test("queued messages are ordered and isolated by user and task", () => {
|
||||
const storage = new MemoryStorage();
|
||||
enqueueMessage(storage, "u1", "t1", { id: "1", content: "第一条" });
|
||||
enqueueMessage(storage, "u1", "t1", { id: "2", content: "第二条" });
|
||||
enqueueMessage(storage, "u1", "t2", { id: "3", content: "另一对话" });
|
||||
enqueueMessage(storage, "u2", "t1", { id: "4", content: "另一用户" });
|
||||
|
||||
assert.deepEqual(readQueuedMessages(storage, "u1", "t1").map(x => x.id), ["1", "2"]);
|
||||
assert.equal(readQueuedMessages(storage, "u1", "t2")[0].content, "另一对话");
|
||||
assert.equal(readQueuedMessages(storage, "u2", "t1")[0].content, "另一用户");
|
||||
});
|
||||
|
||||
test("queued messages can be removed without disturbing later entries", () => {
|
||||
const storage = new MemoryStorage();
|
||||
enqueueMessage(storage, "u1", "t1", { id: "1", content: "第一条" });
|
||||
enqueueMessage(storage, "u1", "t1", { id: "2", content: "第二条" });
|
||||
const remaining = removeQueuedMessage(storage, "u1", "t1", "1");
|
||||
assert.deepEqual(remaining.map(x => x.id), ["2"]);
|
||||
assert.deepEqual(readQueuedMessages(storage, "u1", "t1").map(x => x.id), ["2"]);
|
||||
});
|
||||
|
||||
test("a task queue is bounded", () => {
|
||||
const storage = new MemoryStorage();
|
||||
for (let i = 0; i < 25; i++) {
|
||||
enqueueMessage(storage, "u1", "t1", { id: String(i), content: String(i) });
|
||||
}
|
||||
const items = readQueuedMessages(storage, "u1", "t1");
|
||||
assert.equal(items.length, 20);
|
||||
assert.equal(items[0].id, "5");
|
||||
assert.equal(items.at(-1).id, "24");
|
||||
});
|
||||
|
||||
test("composer uses one Codex-style action button for stop and queued send", () => {
|
||||
const html = readFileSync(new URL("../web/static/dev.html", import.meta.url), "utf8");
|
||||
const chat = readFileSync(new URL("../web/static/js/chat.js", import.meta.url), "utf8");
|
||||
assert.match(html, /id="chat-action"/);
|
||||
assert.doesNotMatch(html, /id="chat-stop"/);
|
||||
assert.match(html, /\.msg\.queued/);
|
||||
assert.match(chat, /card\.className = "msg user queued"/);
|
||||
assert.match(chat, /function composerHasPayload\(\)/);
|
||||
assert.match(chat, /btn\.textContent = "停止"/);
|
||||
assert.match(chat, /if \(composerHasPayload\(\)\)[\s\S]*queueCurrentMessage\(\)/);
|
||||
assert.match(chat, /_actionMode === "loading" \|\| _actionMode === "submitting"[\s\S]*btn\.disabled = true/);
|
||||
assert.match(chat, /setActionMode\("submitting"\)/);
|
||||
assert.match(chat, /if \(state\.taskId === taskId\) \$\("chat-input"\)\.value = ""/);
|
||||
assert.match(chat, /if \(state\.taskId === taskId\) setActionMode\("streaming"\)/);
|
||||
assert.match(chat, /function queueCurrentMessage\(\)/);
|
||||
assert.match(chat, /async function dispatchNextQueuedMessage\(taskId\)/);
|
||||
assert.match(chat, /void dispatchNextQueuedMessage\(ctx\.taskId\)/);
|
||||
assert.match(chat, /removeQueuedMessage\([\s\S]*streamSse\(r\.events_url, run\)/);
|
||||
});
|
||||
|
|
@ -1287,6 +1287,9 @@
|
|||
min-height: 38px; display: flex; align-items: center; gap: 4px; padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--border); background: #fff;
|
||||
}
|
||||
.msg.queued { opacity: .78; border-style: dashed; }
|
||||
.queued-state { display: flex; align-items: center; gap: 8px; margin-top: 6px; font-size: 11px; color: var(--muted); }
|
||||
.queued-remove { padding: 1px 6px; font-size: 11px; }
|
||||
.file-context button {
|
||||
padding: 4px 7px; border: 1px solid transparent; border-radius: 6px; background: transparent;
|
||||
color: var(--muted); font-size: 11px; cursor: pointer; white-space: nowrap;
|
||||
|
|
|
|||
|
|
@ -11,6 +11,17 @@ import { escapeHtml, fmtTime, fmtTokens, fmtTimeAgo, taskUsageTooltip, formatTas
|
|||
import { renderMd, highlightIn, renderMermaidIn } from "./markdown.js";
|
||||
import { mqPhone, setMobileView } from "./layout.js";
|
||||
import { dialogConfirm, dialogPrompt, message } from "./dialog.js";
|
||||
import {
|
||||
NEW_CHAT_DRAFT_ID,
|
||||
clearChatDraft,
|
||||
readChatDraft,
|
||||
writeChatDraft,
|
||||
} from "./chat_drafts.js";
|
||||
import {
|
||||
enqueueMessage,
|
||||
readQueuedMessages,
|
||||
removeQueuedMessage,
|
||||
} from "./chat_queue.js";
|
||||
|
||||
// 微信 logo(simple-icons WeChat path),用于渠道任务徽章;fill 走 currentColor(徽章里为白)
|
||||
const WECHAT_ICON = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.5-6.446 1.45-1.595 3.711-2.55 6.286-2.55.165 0 .33.01.495.027C18.486 4.916 13.929 2.188 8.691 2.188zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.121 2.361-.343a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088a8.067 8.067 0 0 0-.346-.034zm-2.71 3.711c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z"/></svg>`;
|
||||
|
|
@ -423,6 +434,31 @@ function draftWorkingDir() {
|
|||
return (state.draftWorkingDir || "").trim();
|
||||
}
|
||||
|
||||
let _composerDraftSaveTimer = null;
|
||||
let _activeComposerDraftId = null;
|
||||
|
||||
function persistComposerDraft(draftId = _activeComposerDraftId) {
|
||||
clearTimeout(_composerDraftSaveTimer);
|
||||
_composerDraftSaveTimer = null;
|
||||
const input = $("chat-input");
|
||||
if (!input || !draftId) return;
|
||||
writeChatDraft(localStorage, state.userId, draftId, input.value || "");
|
||||
}
|
||||
|
||||
function scheduleComposerDraftSave() {
|
||||
clearTimeout(_composerDraftSaveTimer);
|
||||
_composerDraftSaveTimer = setTimeout(() => persistComposerDraft(), 200);
|
||||
}
|
||||
|
||||
function restoreComposerDraft(draftId = state.taskId || NEW_CHAT_DRAFT_ID) {
|
||||
const input = $("chat-input");
|
||||
if (!input) return;
|
||||
_activeComposerDraftId = draftId;
|
||||
input.value = readChatDraft(localStorage, state.userId, draftId);
|
||||
syncOptimizeBtn();
|
||||
syncComposerAction();
|
||||
}
|
||||
|
||||
function defaultDraftModelProfile() {
|
||||
const models = state.models || [];
|
||||
const preferred = models.find(m => m.is_default) || models[0];
|
||||
|
|
@ -686,6 +722,7 @@ window.addEventListener("resize", positionDraftFolderOptions);
|
|||
$("chat-stream").addEventListener("scroll", positionDraftFolderOptions, { passive: true });
|
||||
|
||||
export async function showNewConversationDraft() {
|
||||
persistComposerDraft();
|
||||
if (state.evtSrc) { state.evtSrc.close(); state.evtSrc = null; }
|
||||
if (state.taskId) { _flushMediaArtifactCache(); clearAttachTray(); }
|
||||
state.taskId = null;
|
||||
|
|
@ -733,7 +770,7 @@ export async function showNewConversationDraft() {
|
|||
$("chat-mic").disabled = false;
|
||||
$("btn-done").disabled = true;
|
||||
$("btn-task-menu").disabled = true;
|
||||
syncOptimizeBtn();
|
||||
restoreComposerDraft(NEW_CHAT_DRAFT_ID);
|
||||
bindDraftControls();
|
||||
syncDraftAction();
|
||||
loadFiles();
|
||||
|
|
@ -758,11 +795,13 @@ export async function showNewConversationDraft() {
|
|||
$("hd-new").onclick = () => showNewConversationDraft();
|
||||
|
||||
export async function selectTask(tid) {
|
||||
persistComposerDraft();
|
||||
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(); clearAttachTray(); }
|
||||
state.taskId = tid;
|
||||
restoreComposerDraft(tid);
|
||||
document.querySelectorAll(".task-row").forEach((el) => {
|
||||
el.classList.toggle("active", el.dataset.tid === tid);
|
||||
});
|
||||
|
|
@ -798,6 +837,7 @@ export async function selectTask(tid) {
|
|||
ensureRunningTaskSubscribed(tid, `/v1/tasks/${tid}/events`, meta);
|
||||
} else {
|
||||
renderLiveRunIfVisible();
|
||||
void dispatchNextQueuedMessage(tid);
|
||||
}
|
||||
// 文件面板自动跳到该 task 的 working_dir(user_root 下一级子目录),
|
||||
// 不强绑定 — 用户可点 crumb 回上层看 user_root 其他目录
|
||||
|
|
@ -1599,7 +1639,9 @@ function loadMessageIntoComposer(text) {
|
|||
input.value = text;
|
||||
input.setSelectionRange(text.length, text.length);
|
||||
}
|
||||
persistComposerDraft();
|
||||
syncOptimizeBtn();
|
||||
syncComposerAction();
|
||||
$("chat-hint").textContent = "已载入旧消息;修改后发送将作为一条新消息追加";
|
||||
}
|
||||
|
||||
|
|
@ -1617,6 +1659,7 @@ function renderMessages(msgs, { stickBottom = true } = {}) {
|
|||
wrap.innerHTML = `<div class="empty">(暂无消息 · 在下方输入开始对话)</div>`;
|
||||
renderPersistedRunTerminal();
|
||||
renderLiveRunIfVisible();
|
||||
renderQueuedMessages(state.taskId);
|
||||
return;
|
||||
}
|
||||
// 还有更早 → 顶部放 sentinel,进视口自动加载(见 _msgScrollObserver)
|
||||
|
|
@ -1837,6 +1880,7 @@ function renderMessages(msgs, { stickBottom = true } = {}) {
|
|||
upgradeMediaArtifacts(wrap);
|
||||
renderPersistedRunTerminal(); // 上次 run error/cancelled 终态 → 末尾补持久卡(所有重渲路径统一走这)
|
||||
renderLiveRunIfVisible();
|
||||
renderQueuedMessages(state.taskId);
|
||||
}
|
||||
|
||||
// 用户附件与助手产物语义不同:每个附件都保留可辨认、可点击的 chip;图片另外在
|
||||
|
|
@ -1852,40 +1896,93 @@ function renderUserAttachmentsHtml(attachments, taskId = "", legacy = false) {
|
|||
}
|
||||
|
||||
// ───── send + SSE ─────
|
||||
// 发送 / 停止 单按钮:idle → 发送(primary 红实心);streaming → 停止(danger 红边);
|
||||
// cancelling 是过渡态 — 用户点过停止后到 SSE 收到 cancelled/done 之间。
|
||||
function setActionMode(mode) {
|
||||
// Codex 式单按钮:忙碌且输入框为空 → 停止;键入文字或加入附件 → 同一按钮
|
||||
// 立即变回发送并加入队列。用户不需要在“发送”和“停止”两个并列按钮间选择。
|
||||
let _actionMode = "loading";
|
||||
|
||||
function composerHasPayload() {
|
||||
return !!((($("chat-input").value || "").trim()) || attachCount());
|
||||
}
|
||||
|
||||
function syncComposerAction() {
|
||||
const btn = $("chat-action");
|
||||
if (!btn) return;
|
||||
btn.classList.remove("primary", "danger");
|
||||
if (mode === "idle") {
|
||||
if (_actionMode === "idle") {
|
||||
btn.textContent = "发送";
|
||||
btn.classList.add("primary");
|
||||
btn.disabled = !!$("chat-input").readOnly;
|
||||
btn.title = "";
|
||||
return;
|
||||
}
|
||||
if (_actionMode === "loading" || _actionMode === "submitting") {
|
||||
btn.textContent = _actionMode === "submitting" ? "发送中…" : "发送";
|
||||
btn.classList.add("primary");
|
||||
btn.disabled = true;
|
||||
btn.title = "";
|
||||
return;
|
||||
}
|
||||
const hasPayload = composerHasPayload();
|
||||
if (hasPayload) {
|
||||
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.title = _actionMode === "bgproc"
|
||||
? "后台进程结束后自动发送"
|
||||
: "当前回答结束后自动发送";
|
||||
} else if (_actionMode === "cancelling") {
|
||||
btn.textContent = "停止中…";
|
||||
btn.classList.add("danger");
|
||||
btn.disabled = true;
|
||||
} else if (mode === "bgproc") {
|
||||
// 后台进程运行期:对话锁定,观感与前台执行一致(发送→停止)。后台化的收益
|
||||
// 定位是"进程扛超时/服务重启",不改变"一个任务同时只做一件事"的对话心智。
|
||||
btn.title = "当前回复正在停止";
|
||||
} else {
|
||||
btn.textContent = "停止";
|
||||
btn.classList.add("danger");
|
||||
btn.disabled = false;
|
||||
btn.title = "终止后台进程(已产生的输出会保留)";
|
||||
} else if (mode === "loading") {
|
||||
// 切 task / 刷新的加载窗口:先锁后放(悲观默认),状态到齐再切真实模式
|
||||
btn.textContent = "发送";
|
||||
btn.classList.add("primary");
|
||||
btn.disabled = true;
|
||||
btn.title = _actionMode === "bgproc"
|
||||
? "终止后台进程(已产生的输出会保留)"
|
||||
: "停止当前流式回复";
|
||||
}
|
||||
}
|
||||
|
||||
function setActionMode(mode) {
|
||||
_actionMode = mode;
|
||||
syncComposerAction();
|
||||
}
|
||||
|
||||
const _messageQueueMemory = new Map();
|
||||
|
||||
function queuedMessages(taskId) {
|
||||
if (!taskId) return [];
|
||||
if (!_messageQueueMemory.has(taskId)) {
|
||||
_messageQueueMemory.set(taskId, readQueuedMessages(localStorage, state.userId, taskId));
|
||||
}
|
||||
return _messageQueueMemory.get(taskId) || [];
|
||||
}
|
||||
|
||||
function renderQueuedMessages(taskId) {
|
||||
if (!taskId || state.taskId !== taskId) return;
|
||||
const wrap = $("chat-stream");
|
||||
wrap.querySelectorAll(".msg.queued").forEach(card => card.remove());
|
||||
const items = queuedMessages(taskId);
|
||||
for (const item of items) {
|
||||
const attachments = Array.isArray(item.attachments) ? item.attachments : [];
|
||||
const card = document.createElement("div");
|
||||
card.className = "msg user queued";
|
||||
card.dataset.queueId = item.id;
|
||||
card.innerHTML = `<div class="role">我 · 待处理</div>`
|
||||
+ (item.content ? `<div class="body">${escapeHtml(item.content)}</div>` : "")
|
||||
+ renderUserAttachmentsHtml(
|
||||
attachments.map(({ rel, kind }) => ({ path: rel, kind })), taskId, true,
|
||||
)
|
||||
+ `<div class="queued-state"><span>当前回答结束后自动发送</span>`
|
||||
+ `<button type="button" class="small queued-remove">移除</button></div>`;
|
||||
wrap.appendChild(card);
|
||||
upgradeMediaArtifacts(card);
|
||||
}
|
||||
if (items.length) wrap.scrollTop = wrap.scrollHeight;
|
||||
}
|
||||
|
||||
// 无直播 run 时的 composer 状态:有本 task 的 running 后台进程 → 锁定(bgproc),
|
||||
// 否则 idle。procs.js 每次拉取后回调,进程结束的那次拉取在这里解锁。
|
||||
let _bgprocLocked = false;
|
||||
|
|
@ -1894,7 +1991,7 @@ export function syncBgprocLock() {
|
|||
if (hasRunningProc(state.taskId)) {
|
||||
setActionMode("bgproc");
|
||||
// hint 只在进入锁定那一刻写一次 —— 5s 轮询重复写会盖掉"已转写"/"润色中"
|
||||
// 等临时提示(润色/语音在锁定期与 streaming 期一样可用,只编辑草稿不发送)
|
||||
// 等临时提示(润色/语音在锁定期与 streaming 期一样可用,也可排队发送)
|
||||
if (!_bgprocLocked) {
|
||||
_bgprocLocked = true;
|
||||
$("chat-hint").textContent = "后台进程运行中,完成后可继续对话…";
|
||||
|
|
@ -1903,6 +2000,7 @@ export function syncBgprocLock() {
|
|||
_bgprocLocked = false;
|
||||
setActionMode("idle");
|
||||
$("chat-hint").textContent = "";
|
||||
void dispatchNextQueuedMessage(state.taskId);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1942,22 +2040,43 @@ export async function openSoftwareJobResults(taskId, outputDir) {
|
|||
}
|
||||
|
||||
function chatAction() {
|
||||
if (isCurrentTaskStreaming()) { cancelCurrentTask(); return; }
|
||||
if (hasRunningProc(state.taskId)) { killTaskProcs(state.taskId); return; }
|
||||
const busy = isCurrentTaskStreaming() || hasRunningProc(state.taskId);
|
||||
if (busy) {
|
||||
if (composerHasPayload()) {
|
||||
queueCurrentMessage();
|
||||
} else if (isCurrentTaskStreaming()) {
|
||||
cancelCurrentTask();
|
||||
} else {
|
||||
killTaskProcs(state.taskId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (queuedMessages(state.taskId).length) {
|
||||
queueCurrentMessage();
|
||||
void dispatchNextQueuedMessage(state.taskId);
|
||||
return;
|
||||
}
|
||||
sendMessage();
|
||||
}
|
||||
|
||||
$("chat-form").addEventListener("submit", (e) => { e.preventDefault(); chatAction(); });
|
||||
$("chat-input").addEventListener("keydown", (e) => {
|
||||
// streaming 期间 Enter 不触发停止 —— 用户可能正在编辑下一条草稿,误触发风险高
|
||||
// busy 期间 Enter 加入待处理队列;空输入时停止只响应按钮点击,避免误停当前任务。
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
// streaming / 后台进程锁定 / 加载态期间 Enter 不发送(与按钮语义一致,防误触)
|
||||
// 只有加载 / POST 在途时禁用;streaming / 后台进程期间 Enter 走排队语义。
|
||||
if ($("chat-action").disabled) return;
|
||||
if (!isCurrentTaskStreaming() && !hasRunningProc(state.taskId)) sendMessage();
|
||||
// 空输入时按钮虽处于“停止”形态,但 Enter 不等同于点击停止,避免误触。
|
||||
if ((isCurrentTaskStreaming() || hasRunningProc(state.taskId)) && !composerHasPayload()) return;
|
||||
chatAction();
|
||||
}
|
||||
});
|
||||
$("chat-input").addEventListener("input", syncOptimizeBtn);
|
||||
$("chat-input").addEventListener("input", () => {
|
||||
scheduleComposerDraftSave();
|
||||
syncOptimizeBtn();
|
||||
syncComposerAction();
|
||||
});
|
||||
window.addEventListener("pagehide", () => persistComposerDraft());
|
||||
// 粘贴 / 拖拽含文件 → 上传到当前目录,chip 累积进 #chat-attach 托盘(与状态文字解耦,
|
||||
// 避免上传进度 / 下一次粘贴把已有 chip 顶掉)。状态反馈仍走 #chat-hint;纯文本粘贴走默认。
|
||||
$("chat-input").addEventListener("paste", (e) => {
|
||||
|
|
@ -2119,6 +2238,7 @@ function clearAttachTray() {
|
|||
if (!tray) return;
|
||||
tray.innerHTML = "";
|
||||
tray.classList.remove("show");
|
||||
syncComposerAction();
|
||||
}
|
||||
|
||||
// 追加 chip,按 rel 去重(同一文件重复粘贴/拖拽只保留一个),并显示托盘。
|
||||
|
|
@ -2135,6 +2255,7 @@ function addAttachChips(saved) {
|
|||
`<span class="paste-chip-wrap" data-rel="${escapeHtml(rel)}" data-kind="${f.attachmentKind === "image" ? "image" : "file"}"><button type="button" class="art-chip paste-chip" data-rel="${escapeHtml(rel)}" title="${escapeHtml(rel)} · 点击预览">${renderArtifactChipContent(name, rel)}</button><button type="button" class="paste-chip-del" data-rel="${escapeHtml(rel)}" title="删除该文件">×</button></span>`);
|
||||
}
|
||||
tray.classList.toggle("show", attachCount() > 0);
|
||||
syncComposerAction();
|
||||
}
|
||||
|
||||
attachTray().addEventListener("click", (e) => {
|
||||
|
|
@ -2164,6 +2285,7 @@ async function deletePastedFile(rel, wrap) {
|
|||
if (attachCount() === 0) {
|
||||
$("chat-hint").innerHTML = `<span class="muted">已删除文件</span>`;
|
||||
}
|
||||
syncComposerAction();
|
||||
} catch (e) {
|
||||
if (btn) btn.disabled = false;
|
||||
if (e.status === 401) { logout(); return; }
|
||||
|
|
@ -2191,6 +2313,7 @@ function applyChannelComposerLock(meta) {
|
|||
if (mic) mic.disabled = !!cfg; // 只读镜像没有发送入口,语音输入同样锁掉
|
||||
const attach = $("chat-attach-btn");
|
||||
if (attach) attach.disabled = !!cfg; // 附件按钮同口径(与拖拽/粘贴的 _composerLocked 一致)
|
||||
syncComposerAction();
|
||||
}
|
||||
|
||||
function setComposerPlaceholder(cfg) {
|
||||
|
|
@ -2259,6 +2382,7 @@ async function optimizePrompt() {
|
|||
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 撤销`;
|
||||
}
|
||||
persistComposerDraft();
|
||||
} catch (e) {
|
||||
if (e.status === 401) { logout(); return; }
|
||||
$("chat-hint").textContent = `润色失败:${e.message}`;
|
||||
|
|
@ -2267,6 +2391,7 @@ async function optimizePrompt() {
|
|||
if (label) label.textContent = oldLabel;
|
||||
btn.setAttribute("aria-label", oldAriaLabel);
|
||||
syncOptimizeBtn();
|
||||
syncComposerAction();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2356,7 +2481,9 @@ function _insertTranscript(text) {
|
|||
ta.focus();
|
||||
const ok = document.execCommand("insertText", false, text);
|
||||
if (!ok) ta.value += text;
|
||||
persistComposerDraft();
|
||||
syncOptimizeBtn();
|
||||
syncComposerAction();
|
||||
$("chat-hint").textContent = "已转写,确认后发送";
|
||||
}
|
||||
|
||||
|
|
@ -2595,6 +2722,18 @@ document.addEventListener("keydown", (e) => {
|
|||
// 视频走原生 <video controls>:点击=播放/暂停,全屏走浏览器自带按钮,不进 modal —
|
||||
// 弹个 modal 反而打断播放,不如交给浏览器。
|
||||
$("chat-stream").addEventListener("click", (e) => {
|
||||
const queuedRemove = e.target.closest && e.target.closest(".queued-remove");
|
||||
if (queuedRemove) {
|
||||
const card = queuedRemove.closest(".msg.queued[data-queue-id]");
|
||||
if (!card || !state.taskId) return;
|
||||
const items = removeQueuedMessage(
|
||||
localStorage, state.userId, state.taskId, card.dataset.queueId,
|
||||
);
|
||||
_messageQueueMemory.set(state.taskId, items);
|
||||
renderQueuedMessages(state.taskId);
|
||||
$("chat-hint").textContent = items.length ? `还有 ${items.length} 条待处理消息` : "已移除待处理消息";
|
||||
return;
|
||||
}
|
||||
const messageAction = e.target.closest && e.target.closest(".msg-action");
|
||||
if (messageAction) {
|
||||
const card = messageAction.closest(".msg[data-idx]");
|
||||
|
|
@ -2710,6 +2849,120 @@ function takePendingAttachments() {
|
|||
return attachments;
|
||||
}
|
||||
|
||||
function appendOutgoingRunCards(taskId, content, attachments) {
|
||||
if (state.taskId !== taskId) return null;
|
||||
const wrap = $("chat-stream");
|
||||
const userCard = document.createElement("div");
|
||||
userCard.className = "msg user";
|
||||
userCard.innerHTML = `<div class="role">我</div>`
|
||||
+ (content ? `<div class="body">${escapeHtml(content)}</div>` : "")
|
||||
+ renderUserAttachmentsHtml(
|
||||
attachments.map(({ rel, kind }) => ({ path: rel, kind })), taskId, true,
|
||||
);
|
||||
wrap.appendChild(userCard);
|
||||
upgradeMediaArtifacts(userCard);
|
||||
|
||||
const asstCard = document.createElement("div");
|
||||
asstCard.className = "msg assistant live-run";
|
||||
asstCard.innerHTML = `<div class="body streaming"></div>`;
|
||||
wrap.appendChild(asstCard);
|
||||
wrap.scrollTop = wrap.scrollHeight;
|
||||
return asstCard;
|
||||
}
|
||||
|
||||
function queueCurrentMessage() {
|
||||
if (!state.taskId || _attachmentUploadsInFlight > 0) return;
|
||||
const content = ($("chat-input").value || "").trim();
|
||||
const pendingCount = attachCount();
|
||||
if (!content && !pendingCount) return;
|
||||
const existing = queuedMessages(state.taskId);
|
||||
if (existing.length >= 20) {
|
||||
$("chat-hint").textContent = "待处理消息已达 20 条,请先移除部分消息";
|
||||
return;
|
||||
}
|
||||
const attachments = takePendingAttachments();
|
||||
const id = (globalThis.crypto && globalThis.crypto.randomUUID && globalThis.crypto.randomUUID())
|
||||
|| `q-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
const entry = {
|
||||
id,
|
||||
content,
|
||||
attachments,
|
||||
imageModel: state.imageModel || "",
|
||||
videoModel: state.videoModel || "",
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
const items = enqueueMessage(localStorage, state.userId, state.taskId, entry);
|
||||
_messageQueueMemory.set(state.taskId, items);
|
||||
$("chat-input").value = "";
|
||||
clearChatDraft(localStorage, state.userId, state.taskId);
|
||||
syncOptimizeBtn();
|
||||
syncComposerAction();
|
||||
renderQueuedMessages(state.taskId);
|
||||
$("chat-hint").textContent = `已排队 · 共 ${items.length} 条待处理消息`;
|
||||
}
|
||||
|
||||
const _queueDispatching = new Set();
|
||||
|
||||
async function dispatchNextQueuedMessage(taskId) {
|
||||
if (!taskId || _queueDispatching.has(taskId) || getLiveRun(taskId) || hasRunningProc(taskId)) return;
|
||||
const item = queuedMessages(taskId)[0];
|
||||
if (!item) return;
|
||||
_queueDispatching.add(taskId);
|
||||
try {
|
||||
const attachments = Array.isArray(item.attachments) ? item.attachments : [];
|
||||
const r = await api("POST", `/v1/tasks/${taskId}/messages`, {
|
||||
content: item.content || "",
|
||||
attachments: attachments.map(({ rel, kind }) => ({ path: rel, kind })),
|
||||
image_model: item.imageModel || "",
|
||||
video_model: item.videoModel || "",
|
||||
});
|
||||
const remaining = removeQueuedMessage(localStorage, state.userId, taskId, item.id);
|
||||
_messageQueueMemory.set(taskId, remaining);
|
||||
const asstCard = appendOutgoingRunCards(taskId, item.content || "", attachments);
|
||||
const run = {
|
||||
taskId,
|
||||
url: r.events_url,
|
||||
acc: "",
|
||||
seenRels: new Set(),
|
||||
terminal: false,
|
||||
card: asstCard,
|
||||
curSeg: null,
|
||||
cancelling: false,
|
||||
workingDir: state.taskId === taskId && state.taskMeta
|
||||
? state.taskMeta.working_dir
|
||||
: ((state.tasksById || {})[taskId] || {}).working_dir || "",
|
||||
autoTitleEligible: !!(item.content || "").trim(),
|
||||
runId: r.run_id || "",
|
||||
progressSteps: [],
|
||||
};
|
||||
if (asstCard) run.curSeg = { el: asstCard.querySelector(".body"), acc: "", pending: false };
|
||||
state.liveRuns.set(taskId, run);
|
||||
state.streaming = true;
|
||||
syncTaskRowRunIndicator(taskId);
|
||||
if (state.taskId === taskId) {
|
||||
renderQueuedMessages(taskId);
|
||||
setActionMode("streaming");
|
||||
$("chat-hint").textContent = remaining.length
|
||||
? `正在处理 · 后面还有 ${remaining.length} 条`
|
||||
: "接收中…";
|
||||
}
|
||||
setRunPhase(run, "llm");
|
||||
streamSse(r.events_url, run);
|
||||
} catch (e) {
|
||||
if (e.status === 401) { logout(); return; }
|
||||
if (state.taskId === taskId) {
|
||||
$("chat-hint").textContent = e.status === 409
|
||||
? "当前任务仍在收尾,待处理消息稍后发送"
|
||||
: `待处理消息发送失败:${e.message}`;
|
||||
}
|
||||
if (e.status === 409 || e.status === 503 || e.name === "TypeError") {
|
||||
setTimeout(() => void dispatchNextQueuedMessage(taskId), 2000);
|
||||
}
|
||||
} finally {
|
||||
_queueDispatching.delete(taskId);
|
||||
}
|
||||
}
|
||||
|
||||
async function createTaskFromDraft() {
|
||||
const workingDir = draftWorkingDir();
|
||||
if (!workingDir) throw new Error("请先选择或新建工作目录");
|
||||
|
|
@ -2719,6 +2972,11 @@ async function createTaskFromDraft() {
|
|||
});
|
||||
state.taskId = t.task_id;
|
||||
state.taskMeta = t;
|
||||
// task 创建与首条消息是两个请求;先把新对话草稿迁到真实 task,确保第二步
|
||||
// 发送失败或此时刷新页面仍能在该对话恢复内容。
|
||||
persistComposerDraft(t.task_id);
|
||||
_activeComposerDraftId = t.task_id;
|
||||
clearChatDraft(localStorage, state.userId, NEW_CHAT_DRAFT_ID);
|
||||
state.filesScope = "task";
|
||||
state.filesPath = workingDir;
|
||||
localStorage.setItem(LS_RECENT_WORKING_DIR, workingDir);
|
||||
|
|
@ -2761,29 +3019,13 @@ async function sendMessage(overrideText) {
|
|||
const pendingAttachments = fromInput ? takePendingAttachments() : [];
|
||||
const attachmentOnly = !content && pendingAttachments.length > 0;
|
||||
if (!content && !pendingAttachments.length) return;
|
||||
setActionMode("cancelling"); // 临时锁住,等 events_url 拿到再切 streaming
|
||||
setActionMode("submitting"); // POST 在途先锁住;拿到 events_url 后才允许继续排队
|
||||
$("chat-hint").textContent = "发送中…";
|
||||
const taskId = state.taskId;
|
||||
const taskWorkingDir = state.taskMeta && state.taskMeta.working_dir;
|
||||
try {
|
||||
closeAllPreviews();
|
||||
// 立刻渲染 user 消息卡(乐观)
|
||||
const wrap = $("chat-stream");
|
||||
const userCard = document.createElement("div");
|
||||
userCard.className = "msg user";
|
||||
userCard.innerHTML = `<div class="role">我</div>`
|
||||
+ (content ? `<div class="body">${escapeHtml(content)}</div>` : "")
|
||||
+ renderUserAttachmentsHtml(
|
||||
pendingAttachments.map(({ rel, kind }) => ({ path: rel, kind })), taskId, true,
|
||||
);
|
||||
wrap.appendChild(userCard);
|
||||
upgradeMediaArtifacts(userCard);
|
||||
|
||||
// assistant 流式占位卡
|
||||
const asstCard = document.createElement("div");
|
||||
asstCard.className = "msg assistant live-run";
|
||||
asstCard.innerHTML = `<div class="body streaming"></div>`;
|
||||
wrap.appendChild(asstCard);
|
||||
wrap.scrollTop = wrap.scrollHeight;
|
||||
const asstCard = appendOutgoingRunCards(taskId, content, pendingAttachments);
|
||||
|
||||
const r = await postMessageWithRetry(taskId, {
|
||||
content,
|
||||
|
|
@ -2794,7 +3036,11 @@ async function sendMessage(overrideText) {
|
|||
image_model: state.imageModel || "",
|
||||
video_model: state.videoModel || "",
|
||||
});
|
||||
if (fromInput) $("chat-input").value = "";
|
||||
if (fromInput) {
|
||||
if (state.taskId === taskId) $("chat-input").value = "";
|
||||
clearChatDraft(localStorage, state.userId, taskId);
|
||||
clearChatDraft(localStorage, state.userId, NEW_CHAT_DRAFT_ID);
|
||||
}
|
||||
syncOptimizeBtn();
|
||||
const run = {
|
||||
taskId,
|
||||
|
|
@ -2805,7 +3051,7 @@ async function sendMessage(overrideText) {
|
|||
card: asstCard,
|
||||
curSeg: null,
|
||||
cancelling: false,
|
||||
workingDir: state.taskMeta && state.taskMeta.working_dir,
|
||||
workingDir: taskWorkingDir,
|
||||
autoTitleEligible: !attachmentOnly,
|
||||
runId: r.run_id || "",
|
||||
progressSteps: [],
|
||||
|
|
@ -2817,7 +3063,7 @@ async function sendMessage(overrideText) {
|
|||
state.liveRuns.set(taskId, run);
|
||||
state.streaming = true;
|
||||
syncTaskRowRunIndicator(taskId);
|
||||
setActionMode("streaming");
|
||||
if (state.taskId === taskId) setActionMode("streaming");
|
||||
streamSse(r.events_url, run);
|
||||
} catch (e) {
|
||||
if (e.status === 401) { logout(); return; }
|
||||
|
|
@ -2825,11 +3071,13 @@ async function sendMessage(overrideText) {
|
|||
const msg = (e.status === 503 || e.name === "TypeError")
|
||||
? "服务更新中,请稍后重发"
|
||||
: e.message;
|
||||
if (state.taskId === taskId) {
|
||||
appendErrorCard(msg);
|
||||
setActionMode("idle");
|
||||
$("chat-hint").textContent = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelCurrentTask() {
|
||||
const run = getLiveRun(state.taskId);
|
||||
|
|
@ -2951,6 +3199,7 @@ async function fetchSse(url, run) {
|
|||
loadFiles(); // 回复结束后右侧文件面板同步刷新(可能有新写入 / 修改的产物)
|
||||
refreshConcurrentWarnings(); // 自己 task 收尾,顺便清/更新 banner(同 wd 邻居可能也变了)
|
||||
}
|
||||
void dispatchNextQueuedMessage(ctx.taskId);
|
||||
}
|
||||
|
||||
async function pollAutoTitle(taskId, attempts = 6) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
// 对话输入草稿的 localStorage 封装。按用户隔离、按 task 保存;特殊 key
|
||||
// "__new__" 表示尚未创建 task 的“新对话”入口。
|
||||
export const NEW_CHAT_DRAFT_ID = "__new__";
|
||||
const STORAGE_PREFIX = "zcbot.chat-drafts.v1.";
|
||||
const MAX_DRAFTS = 50;
|
||||
|
||||
export function chatDraftStorageKey(userId) {
|
||||
return userId ? STORAGE_PREFIX + userId : "";
|
||||
}
|
||||
|
||||
function readAll(storage, userId) {
|
||||
const key = chatDraftStorageKey(userId);
|
||||
if (!key) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(storage.getItem(key) || "{}");
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function readChatDraft(storage, userId, draftId) {
|
||||
const item = readAll(storage, userId)[draftId];
|
||||
return item && typeof item.text === "string" ? item.text : "";
|
||||
}
|
||||
|
||||
export function writeChatDraft(storage, userId, draftId, text, now = Date.now()) {
|
||||
const key = chatDraftStorageKey(userId);
|
||||
if (!key || !draftId) return;
|
||||
const drafts = readAll(storage, userId);
|
||||
if (!text || !text.trim()) {
|
||||
delete drafts[draftId];
|
||||
} else {
|
||||
drafts[draftId] = { text, updatedAt: now };
|
||||
}
|
||||
const entries = Object.entries(drafts)
|
||||
.sort((a, b) => Number(b[1]?.updatedAt || 0) - Number(a[1]?.updatedAt || 0))
|
||||
.slice(0, MAX_DRAFTS);
|
||||
try {
|
||||
if (entries.length) storage.setItem(key, JSON.stringify(Object.fromEntries(entries)));
|
||||
else storage.removeItem(key);
|
||||
} catch (_) {
|
||||
// 隐私模式、WebView 禁用存储或 quota 满时静默跳过持久化,不阻断输入。
|
||||
}
|
||||
}
|
||||
|
||||
export function clearChatDraft(storage, userId, draftId) {
|
||||
writeChatDraft(storage, userId, draftId, "");
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
// 回答进行中的待发送消息。队列保存在当前浏览器,按用户和 task 隔离;
|
||||
// 后端仍保持单活 run,由前端在上一轮收尾后串行派发。
|
||||
const STORAGE_PREFIX = "zcbot.chat-queue.v1.";
|
||||
const MAX_PER_TASK = 20;
|
||||
|
||||
function storageKey(userId) {
|
||||
return userId ? STORAGE_PREFIX + userId : "";
|
||||
}
|
||||
|
||||
function readAll(storage, userId) {
|
||||
const key = storageKey(userId);
|
||||
if (!key) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(storage.getItem(key) || "{}");
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeAll(storage, userId, queues) {
|
||||
const key = storageKey(userId);
|
||||
if (!key) return;
|
||||
try {
|
||||
if (Object.keys(queues).length) storage.setItem(key, JSON.stringify(queues));
|
||||
else storage.removeItem(key);
|
||||
} catch (_) { /* 本地存储不可用时不阻断当前回答 */ }
|
||||
}
|
||||
|
||||
export function readQueuedMessages(storage, userId, taskId) {
|
||||
const items = readAll(storage, userId)[taskId];
|
||||
return Array.isArray(items) ? items.filter(item => item && item.id && typeof item.content === "string") : [];
|
||||
}
|
||||
|
||||
export function enqueueMessage(storage, userId, taskId, entry) {
|
||||
if (!taskId || !entry || !entry.id) return [];
|
||||
const queues = readAll(storage, userId);
|
||||
const items = readQueuedMessages(storage, userId, taskId);
|
||||
items.push(entry);
|
||||
queues[taskId] = items.slice(-MAX_PER_TASK);
|
||||
writeAll(storage, userId, queues);
|
||||
return queues[taskId];
|
||||
}
|
||||
|
||||
export function removeQueuedMessage(storage, userId, taskId, messageId) {
|
||||
const queues = readAll(storage, userId);
|
||||
const items = readQueuedMessages(storage, userId, taskId)
|
||||
.filter(item => item.id !== messageId);
|
||||
if (items.length) queues[taskId] = items;
|
||||
else delete queues[taskId];
|
||||
writeAll(storage, userId, queues);
|
||||
return items;
|
||||
}
|
||||
|
|
@ -47,7 +47,7 @@ export function hasRunningProc(taskId) {
|
|||
return false;
|
||||
}
|
||||
|
||||
// 终止该 task 全部 running 后台进程(composer 主按钮"停止"入口;通常只有 1 个)
|
||||
// 终止该 task 全部 running 后台进程(composer 单一动作按钮的空输入"停止"态;通常只有 1 个)
|
||||
export async function killTaskProcs(taskId) {
|
||||
const running = [..._byId.values()].filter(
|
||||
(p) => p.task_id === taskId && p.status === "running"
|
||||
|
|
|
|||
Loading…
Reference in New Issue