From a8b84b1d6f8c7e33ba29c0e1cb46a93628ae2b2f Mon Sep 17 00:00:00 2001 From: caoqianming Date: Mon, 3 Aug 2026 09:57:21 +0800 Subject: [PATCH] feat(web): allow attachments to start conversations --- core/task_title.py | 7 ++ tests/test_static_vendor.py | 3 + tests/test_task_title.py | 11 +++- web/routers/messages.py | 7 +- web/static/js/chat.js | 126 +++++++++++++++++++++++++++--------- 5 files changed, 123 insertions(+), 31 deletions(-) diff --git a/core/task_title.py b/core/task_title.py index 26179a5..cbf4064 100644 --- a/core/task_title.py +++ b/core/task_title.py @@ -33,6 +33,13 @@ _TITLE_PROMPT = """请给下面这条新对话消息生成一个简短、具体 """ _PREFIX_RE = re.compile(r"^(?:标题|对话标题)\s*[::]\s*", re.I) +_ATTACHMENT_LINE_RE = re.compile(r"^\[用户上传的(?:参考图|文件)\]\s+\S.*$") + + +def is_attachment_only_message(content: str) -> bool: + """消息是否只包含前端注入的附件路径标记。""" + lines = [line.strip() for line in (content or "").splitlines() if line.strip()] + return bool(lines) and all(_ATTACHMENT_LINE_RE.fullmatch(line) for line in lines) def clean_generated_title(raw: str, user_message: str) -> str: diff --git a/tests/test_static_vendor.py b/tests/test_static_vendor.py index fedd031..a32998e 100644 --- a/tests/test_static_vendor.py +++ b/tests/test_static_vendor.py @@ -62,6 +62,9 @@ class StaticVendorTests(unittest.TestCase): self.assertIn(".msg.assistant", css) self.assertIn(".composer-tool .composer-label", css) self.assertIn('aria-label="添加附件"', html) + self.assertIn("await ensureTaskFromDraft();", frontend) + self.assertIn('"[用户上传的文件]"', frontend) + self.assertNotIn("发送第一条消息创建对话后即可添加附件", frontend) self.assertIn('aria-label="语音输入"', html) self.assertIn('aria-label="润色提示词"', html) self.assertIn("hasVisibleAssistantContent", frontend) diff --git a/tests/test_task_title.py b/tests/test_task_title.py index 1358e8a..0ed38b3 100644 --- a/tests/test_task_title.py +++ b/tests/test_task_title.py @@ -3,7 +3,7 @@ from __future__ import annotations import unittest -from core.task_title import clean_generated_title +from core.task_title import clean_generated_title, is_attachment_only_message class TaskTitleTests(unittest.TestCase): @@ -23,6 +23,15 @@ class TaskTitleTests(unittest.TestCase): title = clean_generated_title("这是一段非常非常长而且不适合作为对话标题的模型输出内容", "") self.assertLessEqual(len(title), 24) + def test_attachment_only_message_is_deferred_for_auto_title(self): + self.assertTrue(is_attachment_only_message( + "[用户上传的参考图] 项目/显微照片.png\n[用户上传的文件] 项目/数据表.xlsx" + )) + self.assertFalse(is_attachment_only_message( + "请分析这些数据\n\n[用户上传的文件] 项目/数据表.xlsx" + )) + self.assertFalse(is_attachment_only_message("")) + if __name__ == "__main__": unittest.main() diff --git a/web/routers/messages.py b/web/routers/messages.py index 3ea6404..a896c9a 100644 --- a/web/routers/messages.py +++ b/web/routers/messages.py @@ -272,8 +272,13 @@ def register_message_routes(app, *, require_user) -> None: ) title_profile = claim.metadata["title_profile"] should_auto_title = claim.metadata["should_auto_title"] + if should_auto_title: + from core.task_title import is_attachment_only_message + should_auto_title = not is_attachment_only_message(content) # 快速入口只在首条消息时 pending=true。辅助标题与主 run 并行,不阻塞 - # TTFT/SSE;generate_task_title 内再次查闸并用条件 UPDATE 防人工改名竞态。 + # TTFT/SSE;纯附件消息保留 pending,等下一条自然语言再命名,避免标题暴露 + # `[用户上传的文件]` 等内部标记。generate_task_title 内再次查闸并用条件 UPDATE + # 防人工改名竞态。 if should_auto_title: from core.task_title import generate_task_title_safe title_task = asyncio.create_task(asyncio.to_thread( diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 0ce7700..4b252f5 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -415,6 +415,7 @@ let _draftFolderActiveIndex = -1; function syncDraftAction() { const ready = !!draftWorkingDir(); setActionMode(ready ? "idle" : "loading"); + $("chat-attach-btn").disabled = !ready; $("chat-hint").textContent = ""; const note = $("new-chat-dir-note"); if (note) { @@ -633,7 +634,7 @@ export async function showNewConversationDraft() { $("chat-input").readOnly = false; $("chat-input").classList.remove("readonly-locked"); setComposerPlaceholder(null); - $("chat-attach-btn").disabled = true; // task_dir 尚不存在,首发建 task 后开放 + $("chat-attach-btn").disabled = true; // 选定目录后开放;选中文件时再懒创建 task $("chat-mic").disabled = false; $("btn-done").disabled = true; $("btn-task-menu").disabled = true; @@ -1794,26 +1795,84 @@ $("chat-attach-btn").addEventListener("click", (e) => { ]); }); -// 上传一批文件并把结果 chip 追加进托盘。状态写 #chat-hint(进度 / 已上传),不碰 chip。 +const IMAGE_ATTACHMENT_EXTENSIONS = new Set([ + ".avif", ".bmp", ".gif", ".heic", ".heif", ".jpeg", ".jpg", + ".png", ".svg", ".tif", ".tiff", ".webp", +]); + +function attachmentKind(file, saved) { + if (file && String(file.type || "").toLowerCase().startsWith("image/")) return "image"; + const name = String((saved && saved.name) || (file && file.name) || "").toLowerCase(); + const dot = name.lastIndexOf("."); + return dot >= 0 && IMAGE_ATTACHMENT_EXTENSIONS.has(name.slice(dot)) ? "image" : "file"; +} + +let _draftTaskCreatePromise = null; +let _attachmentUploadsInFlight = 0; + +async function ensureTaskFromDraft() { + if (state.taskId) return state.taskMeta; + if (!_draftTaskCreatePromise) { + _draftTaskCreatePromise = createTaskFromDraft().finally(() => { + _draftTaskCreatePromise = null; + }); + } + return _draftTaskCreatePromise; +} + +function restoreActionAfterAttachmentUpload() { + if (_attachmentUploadsInFlight > 0) return; + if (!state.taskId) { + const ready = !!draftWorkingDir(); + setActionMode(ready ? "idle" : "loading"); + $("chat-attach-btn").disabled = !ready; + return; + } + const run = getLiveRun(state.taskId); + if (run) setActionMode(run.cancelling ? "cancelling" : "streaming"); + else if (hasRunningProc(state.taskId)) setActionMode("bgproc"); + else setActionMode("idle"); +} + +// 上传一批文件并把结果 chip 追加进托盘。草稿态以“首次有效操作”懒创建 task: +// 文件选择器取消不会留空记录,真正选中文件后才复用后端 task 创建校验并上传。 +// 上传在途锁住发送,避免用户消息先于文件落盘而漏带附件。 async function uploadAttachFiles(files) { if (!files || !files.length) return; const hint = $("chat-hint"); - if (!state.taskId) { - hint.textContent = "发送第一条消息创建对话后即可添加附件"; + if (!state.taskId && !draftWorkingDir()) { + hint.textContent = "请先选择文件保存位置"; return; } - hint.textContent = files.length === 1 ? `上传中:${files[0].name}…` : `上传中:${files.length} 个文件…`; - const saved = await uploadFiles(files, { - onProgress: (loaded, total) => { - hint.textContent = formatUploadProgress(files, loaded, total); - }, - }); - if (saved && saved.length) { - addAttachChips(saved); - const n = attachCount(); - hint.innerHTML = `已添加 ${saved.length} 个文件,共 ${n} 个待发送 可在右侧文件处查看`; + _attachmentUploadsInFlight++; + setActionMode("loading"); + try { + if (!state.taskId) { + hint.textContent = "正在创建对话…"; + await ensureTaskFromDraft(); + } + hint.textContent = files.length === 1 ? `上传中:${files[0].name}…` : `上传中:${files.length} 个文件…`; + const saved = await uploadFiles(files, { + onProgress: (loaded, total) => { + hint.textContent = formatUploadProgress(files, loaded, total); + }, + }); + if (saved && saved.length) { + addAttachChips(saved.map((item, i) => ({ + ...item, + attachmentKind: attachmentKind(files[i], item), + }))); + const n = attachCount(); + hint.innerHTML = `已添加 ${saved.length} 个文件,共 ${n} 个待发送 可在右侧文件处查看`; + } else { + hint.textContent = ""; + } + } catch (e) { + hint.textContent = `创建失败:${e.message}`; + } finally { + _attachmentUploadsInFlight = Math.max(0, _attachmentUploadsInFlight - 1); + restoreActionAfterAttachmentUpload(); } - // 失败时 uploadFiles 内部已 alert;hint 保留"上传中…"文字也无碍(下次发送会覆盖) } function attachTray() { return $("chat-attach"); } @@ -1842,7 +1901,7 @@ function addAttachChips(saved) { existing.add(rel); const name = f.name || (rel.split("/").pop() || rel); tray.insertAdjacentHTML("beforeend", - ``); + ``); } tray.classList.toggle("show", attachCount() > 0); } @@ -2392,16 +2451,18 @@ async function postMessageWithRetry(taskId, body) { // overrideText:点 ask_user 选项时传入选项 label,直接作为用户消息发出(不读输入框、 // 不清空输入框 —— 用户可能正在输入框打讨论草稿)。无参数则走输入框(正常发送)。 -// 收集 chat-hint 里的粘贴附件路径(粘贴图片已上传到 task_dir,chip 带 data-rel)。 -// 返回路径数组并清掉 chip —— 这些路径要随消息正文发给模型,否则模型不知道用户贴了哪张图 -// (改图 / 看图都靠它定位)。只在「从输入框发送」时取,ask_user 选项点击(overrideText)不带附件。 -function takePastedRels() { +// 收集托盘里的待发送附件并清掉 chip。路径和类型要随消息正文发给模型,否则模型 +// 不知道用户上传了什么、也无法选择看图/读文档等正确工具。只在「从输入框发送」时取, +// ask_user 选项点击(overrideText)不带附件。 +function takePendingAttachments() { const wraps = attachWraps(); - const rels = wraps.map((w) => w.dataset.rel).filter(Boolean); + const attachments = wraps + .map((w) => ({ rel: w.dataset.rel, kind: w.dataset.kind === "image" ? "image" : "file" })) + .filter((item) => !!item.rel); wraps.forEach((w) => w.remove()); const tray = attachTray(); if (tray) tray.classList.remove("show"); - return rels; + return attachments; } async function createTaskFromDraft() { @@ -2425,6 +2486,10 @@ async function createTaskFromDraft() { async function sendMessage(overrideText) { if (isCurrentTaskStreaming()) return; + if (_attachmentUploadsInFlight > 0) { + $("chat-hint").textContent = "附件仍在上传,请稍候…"; + return; + } const fromInput = typeof overrideText !== "string"; let content = (fromInput ? $("chat-input").value : overrideText).trim(); if (!content && !state.taskId) return; // 草稿态尚不能上传附件,空文本无需建 task @@ -2433,7 +2498,7 @@ async function sendMessage(overrideText) { setActionMode("loading"); $("chat-hint").textContent = "正在创建对话…"; try { - await createTaskFromDraft(); + await ensureTaskFromDraft(); } catch (e) { syncDraftAction(); $("chat-hint").textContent = `创建失败:${e.message}`; @@ -2446,11 +2511,13 @@ async function sendMessage(overrideText) { $("chat-hint").textContent = `${_chCfg.label}对话请在${_chCfg.label}里进行 — web 端为只读镜像`; return; } - // 粘贴附件路径注入正文:用户贴图后发的消息往往是「按这张改 / 看看这张图」, - // 模型只有拿到路径才能传给 seedream(reference_images)/ 未来的 look_at_image。 - const pastedRels = fromInput ? takePastedRels() : []; - if (pastedRels.length) { - const lines = pastedRels.map((r) => `[用户上传的参考图] ${r}`).join("\n"); + // 附件路径按类型注入正文:图片走视觉/改图工具,其他文件走文档读取、音频转写等工具。 + const pendingAttachments = fromInput ? takePendingAttachments() : []; + const attachmentOnly = !content && pendingAttachments.length > 0; + if (pendingAttachments.length) { + const lines = pendingAttachments.map(({ rel, kind }) => + `${kind === "image" ? "[用户上传的参考图]" : "[用户上传的文件]"} ${rel}` + ).join("\n"); content = content ? `${content}\n\n${lines}` : lines; } if (!content) return; @@ -2489,6 +2556,7 @@ async function sendMessage(overrideText) { curSeg: null, cancelling: false, workingDir: state.taskMeta && state.taskMeta.working_dir, + autoTitleEligible: !attachmentOnly, progressSteps: cloneProgressSteps(state.taskProgressByTask.get(taskId)), }; // 预建的空占位 .body 即首个文字段(首字到达前显示「思考中」) @@ -2620,7 +2688,7 @@ async function fetchSse(url, run) { if (state.taskId === ctx.taskId) { state.taskMeta = meta; renderChatMeta(); - if (meta.auto_title_pending) pollAutoTitle(ctx.taskId); + if (meta.auto_title_pending && ctx.autoTitleEligible !== false) pollAutoTitle(ctx.taskId); } } catch (e) { /* meta 刷新失败不致命:保留旧值,不打断收尾 */ } await loadMessages();