feat(web): persist and queue chat input

This commit is contained in:
caoqianming 2026-08-20 15:12:33 +08:00
parent d035cab467
commit 06c47e08cb
9 changed files with 531 additions and 55 deletions

View File

@ -8,6 +8,8 @@
## Unreleased
- 对话中尚未发送的文字现在会按对话自动暂存;助手回答或后台进程执行期间也可以继续发送补充信息,消息会显示为待处理并在当前工作结束后依次发送。动作按钮采用单按钮形态:忙碌且输入为空时用于停止,输入文字或加入附件后自动切回发送。切换对话或刷新页面后,草稿和待处理消息仍会保留。
- Windows Node 的任务、Workspace 和受管运行时现在可以从界面整体迁移到其他本机磁盘;迁移完成前会校验全部文件并保留旧目录,管理员也可以用机器级环境变量统一指定位置。
- Windows Node 新增 Blender 三维场景创作能力,首批可按长度、直径、斜度、支撑档数和附属部件生成参数化回转窑,并可继续调整工程、预览或导出 GLB。

View File

@ -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 项),未连接或迁移生产数据库。

View File

@ -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\(\)\)/);
});

View File

@ -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\)/);
});

View File

@ -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;

View File

@ -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,9 +3071,11 @@ async function sendMessage(overrideText) {
const msg = (e.status === 503 || e.name === "TypeError")
? "服务更新中,请稍后重发"
: e.message;
appendErrorCard(msg);
setActionMode("idle");
$("chat-hint").textContent = "";
if (state.taskId === taskId) {
appendErrorCard(msg);
setActionMode("idle");
$("chat-hint").textContent = "";
}
}
}
@ -2951,6 +3199,7 @@ async function fetchSse(url, run) {
loadFiles(); // 回复结束后右侧文件面板同步刷新(可能有新写入 / 修改的产物)
refreshConcurrentWarnings(); // 自己 task 收尾,顺便清/更新 banner(同 wd 邻居可能也变了)
}
void dispatchNextQueuedMessage(ctx.taskId);
}
async function pollAutoTitle(taskId, attempts = 6) {

View File

@ -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, "");
}

View File

@ -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;
}

View File

@ -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"