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();
});
});