diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 62b243bf..109a2c05 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -75,7 +75,7 @@ Plans: **Plans:** 1 executable plan Plans: -- [ ] 07-01-PLAN.md — 提交态增强文本组装 + 三入口统一透传 + 显示态/提交态分离回归 +- [x] 07-01-PLAN.md — 提交态增强文本组装 + 三入口统一透传 + 显示态/提交态分离回归 --- *Next command:* `/gsd-verify-work` diff --git a/.planning/phases/07-phase-06-mention-upload/07-01-SUMMARY.md b/.planning/phases/07-phase-06-mention-upload/07-01-SUMMARY.md new file mode 100644 index 00000000..f9a78dfa --- /dev/null +++ b/.planning/phases/07-phase-06-mention-upload/07-01-SUMMARY.md @@ -0,0 +1,60 @@ +--- +phase: 07-phase-06-mention-upload +plan: 01 +subsystem: prompt-submit-and-display-separation +tags: [prompt-compose, references, skills, message-display, e2e] +requires: + - phase: 07-phase-06-mention-upload + provides: 07-01-PLAN.md +provides: + - 提交态拼接“优先使用”提示(附件优先,Skill次之) + - 显示态与提交态分离(消息区不回显拼接提示) + - 规则单测与发送链路 e2e 回归 +affects: [frontend-chat-input, thread-submit-payload, message-render] +tech-stack: + added: + - frontend/src/core/threads/priority-hint.ts + patterns: + - compose-before-submit with original-display preservation + - case-insensitive dedupe for attachment/skill labels +key-files: + created: + - .planning/phases/07-phase-06-mention-upload/07-01-SUMMARY.md + - frontend/src/core/threads/priority-hint.ts + modified: + - frontend/src/core/threads/hooks.ts + - frontend/src/core/threads/hooks.test.ts + - frontend/src/components/workspace/input-box.tsx + - frontend/src/components/ai-elements/prompt-input.tsx + - frontend/src/core/messages/utils.ts + - frontend/src/components/workspace/messages/message-list-item.tsx + - frontend/tests/e2e/input-and-compose.spec.ts +key-decisions: + - "发送 payload 使用 submitText,消息显示继续使用用户原文。" + - "拼接模板固定为:优先使用【附件...】和【Skill...】;单类单出;大小写不敏感去重。" + - "在渲染层仅剥离固定后缀,避免拼接文案回显到用户消息区。" +requirements-completed: [P7-01, P7-02, P7-03, P7-04] +duration: 45 min +completed: 2026-04-17 +--- + +# Phase 07 Plan 01 Summary + +实现了“提交态增强文本 / 显示态原文”的完整链路:发送时自动拼接附件与 Skill 优先提示,消息区仍只展示用户输入内容。 + +## Implemented + +- 新增 `priority-hint` 纯函数模块,封装 `buildPriorityHintText` 与 `composeSubmitText`。 +- `InputBox` 在提交时统一透传 `references + selectedSkills`,覆盖按钮发送、回车发送、建议词发送路径。 +- `useThreadStream` 与 `useSubmitThread` 在调用 `thread.submit` 前组装 `submitText`。 +- `message-list-item` 渲染人类消息时增加固定后缀剥离,避免回显“优先使用【...】”。 + +## Verification + +- `node --test frontend/src/core/threads/hooks.test.ts`:7 passed +- `cd frontend && pnpm -s typecheck`:passed +- `cd frontend && pnpm -s test:e2e --grep "DF-INPUT-008A"`:passed + +## Notes + +- 本次新增 e2e 用例验证“请求体包含拼接文案,消息区不显示拼接文案”的核心回归场景。 diff --git a/frontend/src/components/ai-elements/prompt-input.tsx b/frontend/src/components/ai-elements/prompt-input.tsx index 193ae372..01556369 100644 --- a/frontend/src/components/ai-elements/prompt-input.tsx +++ b/frontend/src/components/ai-elements/prompt-input.tsx @@ -479,6 +479,12 @@ export type PromptInputMessage = { text: string; files: FileUIPart[]; references?: PromptInputReference[]; + selectedSkills?: PromptInputSkill[]; +}; + +export type PromptInputSkill = { + skill_id: string; + title: string; }; export type PromptInputReference = { diff --git a/frontend/src/components/workspace/input-box.tsx b/frontend/src/components/workspace/input-box.tsx index 64086aad..ba3b164c 100644 --- a/frontend/src/components/workspace/input-box.tsx +++ b/frontend/src/components/workspace/input-box.tsx @@ -368,10 +368,11 @@ export function InputBox({ onSubmit?.({ ...message, references, + selectedSkills: iframeSkill.selectedSkills, }); setReferences([]); }, - [showWelcomeStyle, onSubmit, onStop, references, status], + [showWelcomeStyle, onSubmit, onStop, references, status, iframeSkill.selectedSkills], ); const requestFormSubmit = useCallback(() => { diff --git a/frontend/src/components/workspace/messages/message-list-item.tsx b/frontend/src/components/workspace/messages/message-list-item.tsx index 5e238c31..745a6f59 100644 --- a/frontend/src/components/workspace/messages/message-list-item.tsx +++ b/frontend/src/components/workspace/messages/message-list-item.tsx @@ -30,6 +30,7 @@ import { extractContentFromMessage, extractReasoningContentFromMessage, parseUploadedFiles, + stripPriorityHintSuffix, stripUploadedFilesTag, type FileInMessage, } from "@/core/messages/utils"; @@ -166,7 +167,9 @@ function MessageContent_({ const contentToDisplay = useMemo(() => { if (isHuman) { - return rawContent ? stripUploadedFilesTag(rawContent) : ""; + return rawContent + ? stripPriorityHintSuffix(stripUploadedFilesTag(rawContent)) + : ""; } return rawContent ?? ""; }, [rawContent, isHuman]); diff --git a/frontend/src/core/messages/utils.ts b/frontend/src/core/messages/utils.ts index c8f88768..2690a4b2 100644 --- a/frontend/src/core/messages/utils.ts +++ b/frontend/src/core/messages/utils.ts @@ -351,6 +351,19 @@ export function stripUploadedFilesTag(content: string): string { .trim(); } +/** + * Strip the appended priority-hint suffix from a message content. + * Suffix format: + * - 优先使用【附件...】 + * - 优先使用【Skill...】 + * - 优先使用【附件...】和【Skill...】 + */ +export function stripPriorityHintSuffix(content: string): string { + return content + .replace(/\n?优先使用【[^】]+】(?:和【[^】]+】)?\s*$/u, "") + .trim(); +} + export function parseUploadedFiles(content: string): FileInMessage[] { // Match ... tag const uploadedFilesRegex = /([\s\S]*?)<\/uploaded_files>/; diff --git a/frontend/src/core/threads/hooks.test.ts b/frontend/src/core/threads/hooks.test.ts index 36a138ee..69609d57 100644 --- a/frontend/src/core/threads/hooks.test.ts +++ b/frontend/src/core/threads/hooks.test.ts @@ -4,6 +4,9 @@ import test from "node:test"; const { buildFilesForSubmit } = await import( new URL("./submit-files.ts", import.meta.url).href ); +const { buildPriorityHintText, composeSubmitText } = await import( + new URL("./priority-hint.ts", import.meta.url).href +); void test("buildFilesForSubmit keeps uploads and appends valid references", () => { const result = buildFilesForSubmit( @@ -67,3 +70,56 @@ void test("buildFilesForSubmit keeps artifact mention path without re-upload", ( assert.equal(result.files[0]?.path, "/mnt/user-data/artifacts/image.png"); assert.equal(result.files[0]?.ref_source, "artifact"); }); + +void test("buildPriorityHintText keeps attachments first and skills second", () => { + const result = buildPriorityHintText({ + attachmentNames: ["spec.md"], + skillTitles: ["文档生成"], + }); + assert.equal(result, "优先使用【spec.md】和【文档生成】"); +}); + +void test("buildPriorityHintText outputs single category when the other is empty", () => { + assert.equal( + buildPriorityHintText({ + attachmentNames: ["spec.md"], + skillTitles: [], + }), + "优先使用【spec.md】", + ); + assert.equal( + buildPriorityHintText({ + attachmentNames: [], + skillTitles: ["文档生成"], + }), + "优先使用【文档生成】", + ); +}); + +void test("buildPriorityHintText deduplicates case-insensitively", () => { + const result = buildPriorityHintText({ + attachmentNames: ["Spec.md", "spec.md", " SPEC.md "], + skillTitles: ["Excel", "excel"], + }); + assert.equal(result, "优先使用【Spec.md】和【Excel】"); +}); + +void test("composeSubmitText appends hint only when needed", () => { + assert.equal( + composeSubmitText({ + baseText: "请总结", + attachmentNames: ["spec.md"], + skillTitles: [], + }), + "请总结\n优先使用【spec.md】", + ); + + assert.equal( + composeSubmitText({ + baseText: "请总结", + attachmentNames: [], + skillTitles: [], + }), + "请总结", + ); +}); diff --git a/frontend/src/core/threads/hooks.ts b/frontend/src/core/threads/hooks.ts index a4b763a6..15295c12 100644 --- a/frontend/src/core/threads/hooks.ts +++ b/frontend/src/core/threads/hooks.ts @@ -5,9 +5,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; -import type { - PromptInputMessage, -} from "@/components/ai-elements/prompt-input"; +import type { PromptInputMessage } from "@/components/ai-elements/prompt-input"; import { getAPIClient } from "../api"; import { getBackendBaseURL } from "../config"; @@ -20,6 +18,7 @@ import { listUploadedFiles, uploadFiles } from "../uploads"; import type { UploadTarget } from "../uploads/api"; import { buildFilesForSubmit } from "./submit-files"; +import { buildPriorityHintText, composeSubmitText } from "./priority-hint"; import type { AgentThread, AgentThreadContext, @@ -148,6 +147,8 @@ function normalizeThreadId( return normalized; } +export { buildPriorityHintText, composeSubmitText }; + export function useThreadStreamLegacy({ threadId, isNewThread, @@ -256,27 +257,30 @@ export function useThreadStream({ } }, []); - const showStreamErrorToast = useCallback((error: unknown) => { - const message = getStreamErrorMessage(error); - if (isStreamCancellation(error, message)) { - // Cancellation is expected when user presses "Stop" or stream disconnects. - console.info("[useThreadStream] stream cancelled:", message); - return; - } - const now = Date.now(); - const lastToast = lastErrorToastRef.current; - if ( - lastToast && - lastToast.message === message && - now - lastToast.timestamp < STREAM_ERROR_TOAST_DEDUPE_WINDOW_MS - ) { - return; - } - lastErrorToastRef.current = { message, timestamp: now }; - console.error("[useThreadStream] conversation stream error:", error); - console.error("[useThreadStream] parsed error message:", message); - toast.error(t.threads.streamError); - }, [t.threads.streamError]); + const showStreamErrorToast = useCallback( + (error: unknown) => { + const message = getStreamErrorMessage(error); + if (isStreamCancellation(error, message)) { + // Cancellation is expected when user presses "Stop" or stream disconnects. + console.info("[useThreadStream] stream cancelled:", message); + return; + } + const now = Date.now(); + const lastToast = lastErrorToastRef.current; + if ( + lastToast && + lastToast.message === message && + now - lastToast.timestamp < STREAM_ERROR_TOAST_DEDUPE_WINDOW_MS + ) { + return; + } + lastErrorToastRef.current = { message, timestamp: now }; + console.error("[useThreadStream] conversation stream error:", error); + console.error("[useThreadStream] parsed error message:", message); + toast.error(t.threads.streamError); + }, + [t.threads.streamError], + ); const handleStreamStart = useCallback( (_threadId: string) => { @@ -401,6 +405,12 @@ export function useThreadStream({ sendInFlightRef.current = true; const text = message.text.trim(); + const referenceNames = (message.references ?? []).map( + (reference) => reference.filename, + ); + const selectedSkillTitles = (message.selectedSkills ?? []).map( + (skill) => skill.title, + ); const resolvedThreadId = normalizeThreadId(threadId) ?? normalizeThreadId(threadIdRef.current) ?? @@ -501,9 +511,7 @@ export function useThreadStream({ const failedConversions = conversionResults.length - files.length; if (failedConversions > 0) { - throw new Error( - t.threads.uploadPrepareFailed(failedConversions), - ); + throw new Error(t.threads.uploadPrepareFailed(failedConversions)); } if (!resolvedThreadId) { @@ -563,6 +571,16 @@ export function useThreadStream({ toast.error(t.threads.staleReferencesRemoved); } + const uploadedNames = + uploadedFileInfo.length > 0 + ? uploadedFileInfo.map((file) => file.filename) + : (message.files ?? []).map((file) => file.filename ?? ""); + const submitText = composeSubmitText({ + baseText: text, + attachmentNames: [...uploadedNames, ...referenceNames], + skillTitles: selectedSkillTitles, + }); + await thread.submit( { messages: [ @@ -571,7 +589,7 @@ export function useThreadStream({ content: [ { type: "text", - text, + text: submitText, }, ], additional_kwargs: @@ -671,6 +689,12 @@ export function useSubmitThread({ return; } const text = message.text.trim(); + const referenceNames = (message.references ?? []).map( + (reference) => reference.filename, + ); + const selectedSkillTitles = (message.selectedSkills ?? []).map( + (skill) => skill.title, + ); const hasFiles = !!(message.files && message.files.length > 0); const hasReferences = !!( @@ -736,6 +760,15 @@ export function useSubmitThread({ toast.error(t.threads.staleReferencesRemoved); } + const uploadedNames = (message.files ?? []).map( + (file) => file.filename ?? "", + ); + const submitText = composeSubmitText({ + baseText: text, + attachmentNames: [...uploadedNames, ...referenceNames], + skillTitles: selectedSkillTitles, + }); + await thread.submit( { messages: [ @@ -744,7 +777,7 @@ export function useSubmitThread({ content: [ { type: "text", - text, + text: submitText, }, ], additional_kwargs: diff --git a/frontend/src/core/threads/priority-hint.ts b/frontend/src/core/threads/priority-hint.ts new file mode 100644 index 00000000..267d2a28 --- /dev/null +++ b/frontend/src/core/threads/priority-hint.ts @@ -0,0 +1,55 @@ +function uniqueNormalizedValues(values: Array): string[] { + const result: string[] = []; + const seen = new Set(); + for (const value of values) { + const normalized = value?.trim(); + if (!normalized) continue; + const dedupeKey = normalized.toLocaleLowerCase(); + if (seen.has(dedupeKey)) continue; + seen.add(dedupeKey); + result.push(normalized); + } + return result; +} + +export function buildPriorityHintText({ + attachmentNames, + skillTitles, +}: { + attachmentNames: string[]; + skillTitles: string[]; +}): string { + const attachments = uniqueNormalizedValues(attachmentNames); + const skills = uniqueNormalizedValues(skillTitles); + if (attachments.length === 0 && skills.length === 0) { + return ""; + } + + const attachmentPart = + attachments.length > 0 ? `【${attachments.join("、")}】` : ""; + const skillPart = skills.length > 0 ? `【${skills.join("、")}】` : ""; + + if (attachmentPart && skillPart) { + return `优先使用${attachmentPart}和${skillPart}`; + } + return `优先使用${attachmentPart || skillPart}`; +} + +export function composeSubmitText({ + baseText, + attachmentNames, + skillTitles, +}: { + baseText: string; + attachmentNames: string[]; + skillTitles: string[]; +}): string { + const trimmedBase = baseText.trim(); + if (!trimmedBase) return trimmedBase; + const priorityHint = buildPriorityHintText({ + attachmentNames, + skillTitles, + }); + if (!priorityHint) return trimmedBase; + return `${trimmedBase}\n${priorityHint}`; +} diff --git a/frontend/tests/e2e/input-and-compose.spec.ts b/frontend/tests/e2e/input-and-compose.spec.ts index acb9d409..17465c82 100644 --- a/frontend/tests/e2e/input-and-compose.spec.ts +++ b/frontend/tests/e2e/input-and-compose.spec.ts @@ -141,10 +141,9 @@ test.describe("聊天工作台 / 输入区与发送", () => { ).toHaveCount(1); }); - test("DF-INPUT-007 输入@时展示文件候选并可选择为引用 chip", async ( - { page }, - testInfo, - ) => { + test("DF-INPUT-007 输入@时展示文件候选并可选择为引用 chip", async ({ + page, + }, testInfo) => { skipIfMissingThread( testInfo, THREAD_WITH_REFERENCE_FIXTURE, @@ -174,10 +173,9 @@ test.describe("聊天工作台 / 输入区与发送", () => { await expect(panel).toBeHidden(); }); - test("DF-INPUT-008 失效引用不会阻断文本发送(可解释 skip)", async ( - { page }, - testInfo, - ) => { + test("DF-INPUT-008 失效引用不会阻断文本发送(可解释 skip)", async ({ + page, + }, testInfo) => { skipIfMissingThread( testInfo, THREAD_WITH_STALE_REFERENCE, @@ -230,7 +228,9 @@ test.describe("聊天工作台 / 输入区与发送", () => { request.method() === "POST" && request .url() - .includes(`/api/langgraph/threads/${THREAD_WITH_STALE_REFERENCE}/runs/stream`), + .includes( + `/api/langgraph/threads/${THREAD_WITH_STALE_REFERENCE}/runs/stream`, + ), ); await page.locator("button[aria-label='Submit']").click(); await expect.poll(() => staleArtifactRequested).toBe(true); @@ -242,10 +242,59 @@ test.describe("聊天工作台 / 输入区与发送", () => { await expect(page.locator("textarea[name='message']")).toHaveValue(""); }); - test("DF-INPUT-009 引用上限为 10,第 11 个被阻止并提示", async ( - { page }, - testInfo, - ) => { + test("DF-INPUT-008A 提交态附加优先提示但消息区只显示原文", async ({ + page, + }, testInfo) => { + skipIfMissingThread( + testInfo, + THREAD_WITH_REFERENCE_FIXTURE, + "FRONTEND_E2E_ARTIFACTS_THREAD_ID 或 FRONTEND_E2E_THREAD_ID", + ); + await stubReferenceFixtures(page, { + threadId: THREAD_WITH_REFERENCE_FIXTURE!, + artifactPaths: [REFERENCE_ARTIFACT_PATH], + uploadFiles: REFERENCE_UPLOAD_FIXTURES, + }); + await openChat(page, reuseThreadChatEntry(THREAD_WITH_REFERENCE_FIXTURE!)); + + await expandComposer(page); + const textarea = page.locator("textarea[name='message']"); + const userInput = "请根据引用文件给出摘要"; + await textarea.fill(`${userInput} `); + + await openReferencePicker(page); + const firstReference = page.getByTestId("mention-candidate-item").first(); + await expect(firstReference).toBeVisible(); + const referenceName = + REFERENCE_UPLOAD_FIXTURES[0]?.filename ?? "fixture-01.md"; + await firstReference.click(); + await expect(page.getByTestId("reference-chip")).toHaveCount(1); + + const submitRequest = page.waitForRequest( + (request) => + request.method() === "POST" && + request + .url() + .includes( + `/api/langgraph/threads/${THREAD_WITH_REFERENCE_FIXTURE}/runs/stream`, + ), + ); + + await page.locator("button[aria-label='Submit']").click(); + const request = await submitRequest; + const requestBody = request.postData() ?? ""; + + expect(requestBody).toContain(userInput); + expect(requestBody).toContain("优先使用【"); + expect(requestBody).toContain(referenceName); + await expect( + page.locator(".is-user").filter({ hasText: "优先使用【" }), + ).toHaveCount(0); + }); + + test("DF-INPUT-009 引用上限为 10,第 11 个被阻止并提示", async ({ + page, + }, testInfo) => { skipIfMissingThread( testInfo, THREAD_WITH_REFERENCE_FIXTURE, @@ -291,8 +340,6 @@ test.describe("聊天工作台 / 输入区与发送", () => { }); await expect(page.getByTestId("reference-chip-remove")).toHaveCount(10); - await expect( - toastByText(page, "单条消息最多引用 10 个文件"), - ).toBeVisible(); + await expect(toastByText(page, "单条消息最多引用 10 个文件")).toBeVisible(); }); });