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) => {
// 视频走原生