feat(web): allow attachments to start conversations
This commit is contained in:
parent
b0e0182b73
commit
a8b84b1d6f
|
|
@ -33,6 +33,13 @@ _TITLE_PROMPT = """请给下面这条新对话消息生成一个简短、具体
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_PREFIX_RE = re.compile(r"^(?:标题|对话标题)\s*[::]\s*", re.I)
|
_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:
|
def clean_generated_title(raw: str, user_message: str) -> str:
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,9 @@ class StaticVendorTests(unittest.TestCase):
|
||||||
self.assertIn(".msg.assistant", css)
|
self.assertIn(".msg.assistant", css)
|
||||||
self.assertIn(".composer-tool .composer-label", css)
|
self.assertIn(".composer-tool .composer-label", css)
|
||||||
self.assertIn('aria-label="添加附件"', html)
|
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('aria-label="润色提示词"', html)
|
self.assertIn('aria-label="润色提示词"', html)
|
||||||
self.assertIn("hasVisibleAssistantContent", frontend)
|
self.assertIn("hasVisibleAssistantContent", frontend)
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import unittest
|
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):
|
class TaskTitleTests(unittest.TestCase):
|
||||||
|
|
@ -23,6 +23,15 @@ class TaskTitleTests(unittest.TestCase):
|
||||||
title = clean_generated_title("这是一段非常非常长而且不适合作为对话标题的模型输出内容", "")
|
title = clean_generated_title("这是一段非常非常长而且不适合作为对话标题的模型输出内容", "")
|
||||||
self.assertLessEqual(len(title), 24)
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
|
|
@ -272,8 +272,13 @@ def register_message_routes(app, *, require_user) -> None:
|
||||||
)
|
)
|
||||||
title_profile = claim.metadata["title_profile"]
|
title_profile = claim.metadata["title_profile"]
|
||||||
should_auto_title = claim.metadata["should_auto_title"]
|
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 并行,不阻塞
|
# 快速入口只在首条消息时 pending=true。辅助标题与主 run 并行,不阻塞
|
||||||
# TTFT/SSE;generate_task_title 内再次查闸并用条件 UPDATE 防人工改名竞态。
|
# TTFT/SSE;纯附件消息保留 pending,等下一条自然语言再命名,避免标题暴露
|
||||||
|
# `[用户上传的文件]` 等内部标记。generate_task_title 内再次查闸并用条件 UPDATE
|
||||||
|
# 防人工改名竞态。
|
||||||
if should_auto_title:
|
if should_auto_title:
|
||||||
from core.task_title import generate_task_title_safe
|
from core.task_title import generate_task_title_safe
|
||||||
title_task = asyncio.create_task(asyncio.to_thread(
|
title_task = asyncio.create_task(asyncio.to_thread(
|
||||||
|
|
|
||||||
|
|
@ -415,6 +415,7 @@ let _draftFolderActiveIndex = -1;
|
||||||
function syncDraftAction() {
|
function syncDraftAction() {
|
||||||
const ready = !!draftWorkingDir();
|
const ready = !!draftWorkingDir();
|
||||||
setActionMode(ready ? "idle" : "loading");
|
setActionMode(ready ? "idle" : "loading");
|
||||||
|
$("chat-attach-btn").disabled = !ready;
|
||||||
$("chat-hint").textContent = "";
|
$("chat-hint").textContent = "";
|
||||||
const note = $("new-chat-dir-note");
|
const note = $("new-chat-dir-note");
|
||||||
if (note) {
|
if (note) {
|
||||||
|
|
@ -633,7 +634,7 @@ export async function showNewConversationDraft() {
|
||||||
$("chat-input").readOnly = false;
|
$("chat-input").readOnly = false;
|
||||||
$("chat-input").classList.remove("readonly-locked");
|
$("chat-input").classList.remove("readonly-locked");
|
||||||
setComposerPlaceholder(null);
|
setComposerPlaceholder(null);
|
||||||
$("chat-attach-btn").disabled = true; // task_dir 尚不存在,首发建 task 后开放
|
$("chat-attach-btn").disabled = true; // 选定目录后开放;选中文件时再懒创建 task
|
||||||
$("chat-mic").disabled = false;
|
$("chat-mic").disabled = false;
|
||||||
$("btn-done").disabled = true;
|
$("btn-done").disabled = true;
|
||||||
$("btn-task-menu").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) {
|
async function uploadAttachFiles(files) {
|
||||||
if (!files || !files.length) return;
|
if (!files || !files.length) return;
|
||||||
const hint = $("chat-hint");
|
const hint = $("chat-hint");
|
||||||
if (!state.taskId) {
|
if (!state.taskId && !draftWorkingDir()) {
|
||||||
hint.textContent = "发送第一条消息创建对话后即可添加附件";
|
hint.textContent = "请先选择文件保存位置";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
hint.textContent = files.length === 1 ? `上传中:${files[0].name}…` : `上传中:${files.length} 个文件…`;
|
_attachmentUploadsInFlight++;
|
||||||
const saved = await uploadFiles(files, {
|
setActionMode("loading");
|
||||||
onProgress: (loaded, total) => {
|
try {
|
||||||
hint.textContent = formatUploadProgress(files, loaded, total);
|
if (!state.taskId) {
|
||||||
},
|
hint.textContent = "正在创建对话…";
|
||||||
});
|
await ensureTaskFromDraft();
|
||||||
if (saved && saved.length) {
|
}
|
||||||
addAttachChips(saved);
|
hint.textContent = files.length === 1 ? `上传中:${files[0].name}…` : `上传中:${files.length} 个文件…`;
|
||||||
const n = attachCount();
|
const saved = await uploadFiles(files, {
|
||||||
hint.innerHTML = `已添加 ${saved.length} 个文件,共 ${n} 个待发送 <span class="muted">可在右侧文件处查看</span>`;
|
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} 个待发送 <span class="muted">可在右侧文件处查看</span>`;
|
||||||
|
} 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"); }
|
function attachTray() { return $("chat-attach"); }
|
||||||
|
|
@ -1842,7 +1901,7 @@ function addAttachChips(saved) {
|
||||||
existing.add(rel);
|
existing.add(rel);
|
||||||
const name = f.name || (rel.split("/").pop() || rel);
|
const name = f.name || (rel.split("/").pop() || rel);
|
||||||
tray.insertAdjacentHTML("beforeend",
|
tray.insertAdjacentHTML("beforeend",
|
||||||
`<span class="paste-chip-wrap" data-rel="${escapeHtml(rel)}"><button type="button" class="art-chip paste-chip" data-rel="${escapeHtml(rel)}" title="${escapeHtml(rel)} · 点击预览">${escapeHtml(name)}</button><button type="button" class="paste-chip-del" data-rel="${escapeHtml(rel)}" title="删除该文件">×</button></span>`);
|
`<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)} · 点击预览">${escapeHtml(name)}</button><button type="button" class="paste-chip-del" data-rel="${escapeHtml(rel)}" title="删除该文件">×</button></span>`);
|
||||||
}
|
}
|
||||||
tray.classList.toggle("show", attachCount() > 0);
|
tray.classList.toggle("show", attachCount() > 0);
|
||||||
}
|
}
|
||||||
|
|
@ -2392,16 +2451,18 @@ async function postMessageWithRetry(taskId, body) {
|
||||||
|
|
||||||
// overrideText:点 ask_user 选项时传入选项 label,直接作为用户消息发出(不读输入框、
|
// overrideText:点 ask_user 选项时传入选项 label,直接作为用户消息发出(不读输入框、
|
||||||
// 不清空输入框 —— 用户可能正在输入框打讨论草稿)。无参数则走输入框(正常发送)。
|
// 不清空输入框 —— 用户可能正在输入框打讨论草稿)。无参数则走输入框(正常发送)。
|
||||||
// 收集 chat-hint 里的粘贴附件路径(粘贴图片已上传到 task_dir,chip 带 data-rel)。
|
// 收集托盘里的待发送附件并清掉 chip。路径和类型要随消息正文发给模型,否则模型
|
||||||
// 返回路径数组并清掉 chip —— 这些路径要随消息正文发给模型,否则模型不知道用户贴了哪张图
|
// 不知道用户上传了什么、也无法选择看图/读文档等正确工具。只在「从输入框发送」时取,
|
||||||
// (改图 / 看图都靠它定位)。只在「从输入框发送」时取,ask_user 选项点击(overrideText)不带附件。
|
// ask_user 选项点击(overrideText)不带附件。
|
||||||
function takePastedRels() {
|
function takePendingAttachments() {
|
||||||
const wraps = attachWraps();
|
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());
|
wraps.forEach((w) => w.remove());
|
||||||
const tray = attachTray();
|
const tray = attachTray();
|
||||||
if (tray) tray.classList.remove("show");
|
if (tray) tray.classList.remove("show");
|
||||||
return rels;
|
return attachments;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createTaskFromDraft() {
|
async function createTaskFromDraft() {
|
||||||
|
|
@ -2425,6 +2486,10 @@ async function createTaskFromDraft() {
|
||||||
|
|
||||||
async function sendMessage(overrideText) {
|
async function sendMessage(overrideText) {
|
||||||
if (isCurrentTaskStreaming()) return;
|
if (isCurrentTaskStreaming()) return;
|
||||||
|
if (_attachmentUploadsInFlight > 0) {
|
||||||
|
$("chat-hint").textContent = "附件仍在上传,请稍候…";
|
||||||
|
return;
|
||||||
|
}
|
||||||
const fromInput = typeof overrideText !== "string";
|
const fromInput = typeof overrideText !== "string";
|
||||||
let content = (fromInput ? $("chat-input").value : overrideText).trim();
|
let content = (fromInput ? $("chat-input").value : overrideText).trim();
|
||||||
if (!content && !state.taskId) return; // 草稿态尚不能上传附件,空文本无需建 task
|
if (!content && !state.taskId) return; // 草稿态尚不能上传附件,空文本无需建 task
|
||||||
|
|
@ -2433,7 +2498,7 @@ async function sendMessage(overrideText) {
|
||||||
setActionMode("loading");
|
setActionMode("loading");
|
||||||
$("chat-hint").textContent = "正在创建对话…";
|
$("chat-hint").textContent = "正在创建对话…";
|
||||||
try {
|
try {
|
||||||
await createTaskFromDraft();
|
await ensureTaskFromDraft();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
syncDraftAction();
|
syncDraftAction();
|
||||||
$("chat-hint").textContent = `创建失败:${e.message}`;
|
$("chat-hint").textContent = `创建失败:${e.message}`;
|
||||||
|
|
@ -2446,11 +2511,13 @@ async function sendMessage(overrideText) {
|
||||||
$("chat-hint").textContent = `${_chCfg.label}对话请在${_chCfg.label}里进行 — web 端为只读镜像`;
|
$("chat-hint").textContent = `${_chCfg.label}对话请在${_chCfg.label}里进行 — web 端为只读镜像`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 粘贴附件路径注入正文:用户贴图后发的消息往往是「按这张改 / 看看这张图」,
|
// 附件路径按类型注入正文:图片走视觉/改图工具,其他文件走文档读取、音频转写等工具。
|
||||||
// 模型只有拿到路径才能传给 seedream(reference_images)/ 未来的 look_at_image。
|
const pendingAttachments = fromInput ? takePendingAttachments() : [];
|
||||||
const pastedRels = fromInput ? takePastedRels() : [];
|
const attachmentOnly = !content && pendingAttachments.length > 0;
|
||||||
if (pastedRels.length) {
|
if (pendingAttachments.length) {
|
||||||
const lines = pastedRels.map((r) => `[用户上传的参考图] ${r}`).join("\n");
|
const lines = pendingAttachments.map(({ rel, kind }) =>
|
||||||
|
`${kind === "image" ? "[用户上传的参考图]" : "[用户上传的文件]"} ${rel}`
|
||||||
|
).join("\n");
|
||||||
content = content ? `${content}\n\n${lines}` : lines;
|
content = content ? `${content}\n\n${lines}` : lines;
|
||||||
}
|
}
|
||||||
if (!content) return;
|
if (!content) return;
|
||||||
|
|
@ -2489,6 +2556,7 @@ async function sendMessage(overrideText) {
|
||||||
curSeg: null,
|
curSeg: null,
|
||||||
cancelling: false,
|
cancelling: false,
|
||||||
workingDir: state.taskMeta && state.taskMeta.working_dir,
|
workingDir: state.taskMeta && state.taskMeta.working_dir,
|
||||||
|
autoTitleEligible: !attachmentOnly,
|
||||||
progressSteps: cloneProgressSteps(state.taskProgressByTask.get(taskId)),
|
progressSteps: cloneProgressSteps(state.taskProgressByTask.get(taskId)),
|
||||||
};
|
};
|
||||||
// 预建的空占位 .body 即首个文字段(首字到达前显示「思考中」)
|
// 预建的空占位 .body 即首个文字段(首字到达前显示「思考中」)
|
||||||
|
|
@ -2620,7 +2688,7 @@ async function fetchSse(url, run) {
|
||||||
if (state.taskId === ctx.taskId) {
|
if (state.taskId === ctx.taskId) {
|
||||||
state.taskMeta = meta;
|
state.taskMeta = meta;
|
||||||
renderChatMeta();
|
renderChatMeta();
|
||||||
if (meta.auto_title_pending) pollAutoTitle(ctx.taskId);
|
if (meta.auto_title_pending && ctx.autoTitleEligible !== false) pollAutoTitle(ctx.taskId);
|
||||||
}
|
}
|
||||||
} catch (e) { /* meta 刷新失败不致命:保留旧值,不打断收尾 */ }
|
} catch (e) { /* meta 刷新失败不致命:保留旧值,不打断收尾 */ }
|
||||||
await loadMessages();
|
await loadMessages();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue