From 06c47e08cb9495dde90a5b62fb93cf1be02f229c Mon Sep 17 00:00:00 2001 From: caoqianming Date: Thu, 20 Aug 2026 15:12:33 +0800 Subject: [PATCH] feat(web): persist and queue chat input --- CHANGELOG.md | 2 + PROGRESS.md | 4 +- tests/frontend_chat_drafts.test.mjs | 50 ++++ tests/frontend_chat_queue.test.mjs | 68 ++++++ web/static/dev.html | 3 + web/static/js/chat.js | 355 +++++++++++++++++++++++----- web/static/js/chat_drafts.js | 49 ++++ web/static/js/chat_queue.js | 53 +++++ web/static/js/procs.js | 2 +- 9 files changed, 531 insertions(+), 55 deletions(-) create mode 100644 tests/frontend_chat_drafts.test.mjs create mode 100644 tests/frontend_chat_queue.test.mjs create mode 100644 web/static/js/chat_drafts.js create mode 100644 web/static/js/chat_queue.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c8b5f1..a2a3ec0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ## Unreleased +- 对话中尚未发送的文字现在会按对话自动暂存;助手回答或后台进程执行期间也可以继续发送补充信息,消息会显示为待处理并在当前工作结束后依次发送。动作按钮采用单按钮形态:忙碌且输入为空时用于停止,输入文字或加入附件后自动切回发送。切换对话或刷新页面后,草稿和待处理消息仍会保留。 + - Windows Node 的任务、Workspace 和受管运行时现在可以从界面整体迁移到其他本机磁盘;迁移完成前会校验全部文件并保留旧目录,管理员也可以用机器级环境变量统一指定位置。 - Windows Node 新增 Blender 三维场景创作能力,首批可按长度、直径、斜度、支撑档数和附属部件生成参数化回转窑,并可继续调整工程、预览或导出 GLB。 diff --git a/PROGRESS.md b/PROGRESS.md index 491ecfa..cdff146 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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 项),未连接或迁移生产数据库。 diff --git a/tests/frontend_chat_drafts.test.mjs b/tests/frontend_chat_drafts.test.mjs new file mode 100644 index 0000000..87abb23 --- /dev/null +++ b/tests/frontend_chat_drafts.test.mjs @@ -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\(\)\)/); +}); diff --git a/tests/frontend_chat_queue.test.mjs b/tests/frontend_chat_queue.test.mjs new file mode 100644 index 0000000..1923081 --- /dev/null +++ b/tests/frontend_chat_queue.test.mjs @@ -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\)/); +}); diff --git a/web/static/dev.html b/web/static/dev.html index bf16aba..09f6800 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -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; diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 11686dd..a058e9f 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -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 = ``; @@ -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 = `
(暂无消息 · 在下方输入开始对话)
`; 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 = `
我 · 待处理
` + + (item.content ? `
${escapeHtml(item.content)}
` : "") + + renderUserAttachmentsHtml( + attachments.map(({ rel, kind }) => ({ path: rel, kind })), taskId, true, + ) + + `
当前回答结束后自动发送` + + `
`; + 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) { ``); } 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 = `已删除文件`; } + 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) => { // 视频走原生