feat(phase-07): compose attachment/skill priority hints on submit

This commit is contained in:
肖应宇 2026-04-17 11:00:12 +08:00
parent 326c780ab7
commit 27414fc4e1
10 changed files with 322 additions and 48 deletions

View File

@ -75,7 +75,7 @@ Plans:
**Plans:** 1 executable plan **Plans:** 1 executable plan
Plans: Plans:
- [ ] 07-01-PLAN.md — 提交态增强文本组装 + 三入口统一透传 + 显示态/提交态分离回归 - [x] 07-01-PLAN.md — 提交态增强文本组装 + 三入口统一透传 + 显示态/提交态分离回归
--- ---
*Next command:* `/gsd-verify-work` *Next command:* `/gsd-verify-work`

View File

@ -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 用例验证“请求体包含拼接文案,消息区不显示拼接文案”的核心回归场景。

View File

@ -479,6 +479,12 @@ export type PromptInputMessage = {
text: string; text: string;
files: FileUIPart[]; files: FileUIPart[];
references?: PromptInputReference[]; references?: PromptInputReference[];
selectedSkills?: PromptInputSkill[];
};
export type PromptInputSkill = {
skill_id: string;
title: string;
}; };
export type PromptInputReference = { export type PromptInputReference = {

View File

@ -368,10 +368,11 @@ export function InputBox({
onSubmit?.({ onSubmit?.({
...message, ...message,
references, references,
selectedSkills: iframeSkill.selectedSkills,
}); });
setReferences([]); setReferences([]);
}, },
[showWelcomeStyle, onSubmit, onStop, references, status], [showWelcomeStyle, onSubmit, onStop, references, status, iframeSkill.selectedSkills],
); );
const requestFormSubmit = useCallback(() => { const requestFormSubmit = useCallback(() => {

View File

@ -30,6 +30,7 @@ import {
extractContentFromMessage, extractContentFromMessage,
extractReasoningContentFromMessage, extractReasoningContentFromMessage,
parseUploadedFiles, parseUploadedFiles,
stripPriorityHintSuffix,
stripUploadedFilesTag, stripUploadedFilesTag,
type FileInMessage, type FileInMessage,
} from "@/core/messages/utils"; } from "@/core/messages/utils";
@ -166,7 +167,9 @@ function MessageContent_({
const contentToDisplay = useMemo(() => { const contentToDisplay = useMemo(() => {
if (isHuman) { if (isHuman) {
return rawContent ? stripUploadedFilesTag(rawContent) : ""; return rawContent
? stripPriorityHintSuffix(stripUploadedFilesTag(rawContent))
: "";
} }
return rawContent ?? ""; return rawContent ?? "";
}, [rawContent, isHuman]); }, [rawContent, isHuman]);

View File

@ -351,6 +351,19 @@ export function stripUploadedFilesTag(content: string): string {
.trim(); .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[] { export function parseUploadedFiles(content: string): FileInMessage[] {
// Match <uploaded_files>...</uploaded_files> tag // Match <uploaded_files>...</uploaded_files> tag
const uploadedFilesRegex = /<uploaded_files>([\s\S]*?)<\/uploaded_files>/; const uploadedFilesRegex = /<uploaded_files>([\s\S]*?)<\/uploaded_files>/;

View File

@ -4,6 +4,9 @@ import test from "node:test";
const { buildFilesForSubmit } = await import( const { buildFilesForSubmit } = await import(
new URL("./submit-files.ts", import.meta.url).href 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", () => { void test("buildFilesForSubmit keeps uploads and appends valid references", () => {
const result = buildFilesForSubmit( 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]?.path, "/mnt/user-data/artifacts/image.png");
assert.equal(result.files[0]?.ref_source, "artifact"); 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: [],
}),
"请总结",
);
});

View File

@ -5,9 +5,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import type { import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
PromptInputMessage,
} from "@/components/ai-elements/prompt-input";
import { getAPIClient } from "../api"; import { getAPIClient } from "../api";
import { getBackendBaseURL } from "../config"; import { getBackendBaseURL } from "../config";
@ -20,6 +18,7 @@ import { listUploadedFiles, uploadFiles } from "../uploads";
import type { UploadTarget } from "../uploads/api"; import type { UploadTarget } from "../uploads/api";
import { buildFilesForSubmit } from "./submit-files"; import { buildFilesForSubmit } from "./submit-files";
import { buildPriorityHintText, composeSubmitText } from "./priority-hint";
import type { import type {
AgentThread, AgentThread,
AgentThreadContext, AgentThreadContext,
@ -148,6 +147,8 @@ function normalizeThreadId(
return normalized; return normalized;
} }
export { buildPriorityHintText, composeSubmitText };
export function useThreadStreamLegacy({ export function useThreadStreamLegacy({
threadId, threadId,
isNewThread, isNewThread,
@ -256,27 +257,30 @@ export function useThreadStream({
} }
}, []); }, []);
const showStreamErrorToast = useCallback((error: unknown) => { const showStreamErrorToast = useCallback(
const message = getStreamErrorMessage(error); (error: unknown) => {
if (isStreamCancellation(error, message)) { const message = getStreamErrorMessage(error);
// Cancellation is expected when user presses "Stop" or stream disconnects. if (isStreamCancellation(error, message)) {
console.info("[useThreadStream] stream cancelled:", message); // Cancellation is expected when user presses "Stop" or stream disconnects.
return; console.info("[useThreadStream] stream cancelled:", message);
} return;
const now = Date.now(); }
const lastToast = lastErrorToastRef.current; const now = Date.now();
if ( const lastToast = lastErrorToastRef.current;
lastToast && if (
lastToast.message === message && lastToast &&
now - lastToast.timestamp < STREAM_ERROR_TOAST_DEDUPE_WINDOW_MS lastToast.message === message &&
) { now - lastToast.timestamp < STREAM_ERROR_TOAST_DEDUPE_WINDOW_MS
return; ) {
} return;
lastErrorToastRef.current = { message, timestamp: now }; }
console.error("[useThreadStream] conversation stream error:", error); lastErrorToastRef.current = { message, timestamp: now };
console.error("[useThreadStream] parsed error message:", message); console.error("[useThreadStream] conversation stream error:", error);
toast.error(t.threads.streamError); console.error("[useThreadStream] parsed error message:", message);
}, [t.threads.streamError]); toast.error(t.threads.streamError);
},
[t.threads.streamError],
);
const handleStreamStart = useCallback( const handleStreamStart = useCallback(
(_threadId: string) => { (_threadId: string) => {
@ -401,6 +405,12 @@ export function useThreadStream({
sendInFlightRef.current = true; sendInFlightRef.current = true;
const text = message.text.trim(); const text = message.text.trim();
const referenceNames = (message.references ?? []).map(
(reference) => reference.filename,
);
const selectedSkillTitles = (message.selectedSkills ?? []).map(
(skill) => skill.title,
);
const resolvedThreadId = const resolvedThreadId =
normalizeThreadId(threadId) ?? normalizeThreadId(threadId) ??
normalizeThreadId(threadIdRef.current) ?? normalizeThreadId(threadIdRef.current) ??
@ -501,9 +511,7 @@ export function useThreadStream({
const failedConversions = conversionResults.length - files.length; const failedConversions = conversionResults.length - files.length;
if (failedConversions > 0) { if (failedConversions > 0) {
throw new Error( throw new Error(t.threads.uploadPrepareFailed(failedConversions));
t.threads.uploadPrepareFailed(failedConversions),
);
} }
if (!resolvedThreadId) { if (!resolvedThreadId) {
@ -563,6 +571,16 @@ export function useThreadStream({
toast.error(t.threads.staleReferencesRemoved); 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( await thread.submit(
{ {
messages: [ messages: [
@ -571,7 +589,7 @@ export function useThreadStream({
content: [ content: [
{ {
type: "text", type: "text",
text, text: submitText,
}, },
], ],
additional_kwargs: additional_kwargs:
@ -671,6 +689,12 @@ export function useSubmitThread({
return; return;
} }
const text = message.text.trim(); 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 hasFiles = !!(message.files && message.files.length > 0);
const hasReferences = !!( const hasReferences = !!(
@ -736,6 +760,15 @@ export function useSubmitThread({
toast.error(t.threads.staleReferencesRemoved); 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( await thread.submit(
{ {
messages: [ messages: [
@ -744,7 +777,7 @@ export function useSubmitThread({
content: [ content: [
{ {
type: "text", type: "text",
text, text: submitText,
}, },
], ],
additional_kwargs: additional_kwargs:

View File

@ -0,0 +1,55 @@
function uniqueNormalizedValues(values: Array<string | undefined>): string[] {
const result: string[] = [];
const seen = new Set<string>();
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}`;
}

View File

@ -141,10 +141,9 @@ test.describe("聊天工作台 / 输入区与发送", () => {
).toHaveCount(1); ).toHaveCount(1);
}); });
test("DF-INPUT-007 输入@时展示文件候选并可选择为引用 chip", async ( test("DF-INPUT-007 输入@时展示文件候选并可选择为引用 chip", async ({
{ page }, page,
testInfo, }, testInfo) => {
) => {
skipIfMissingThread( skipIfMissingThread(
testInfo, testInfo,
THREAD_WITH_REFERENCE_FIXTURE, THREAD_WITH_REFERENCE_FIXTURE,
@ -174,10 +173,9 @@ test.describe("聊天工作台 / 输入区与发送", () => {
await expect(panel).toBeHidden(); await expect(panel).toBeHidden();
}); });
test("DF-INPUT-008 失效引用不会阻断文本发送(可解释 skip", async ( test("DF-INPUT-008 失效引用不会阻断文本发送(可解释 skip", async ({
{ page }, page,
testInfo, }, testInfo) => {
) => {
skipIfMissingThread( skipIfMissingThread(
testInfo, testInfo,
THREAD_WITH_STALE_REFERENCE, THREAD_WITH_STALE_REFERENCE,
@ -230,7 +228,9 @@ test.describe("聊天工作台 / 输入区与发送", () => {
request.method() === "POST" && request.method() === "POST" &&
request request
.url() .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 page.locator("button[aria-label='Submit']").click();
await expect.poll(() => staleArtifactRequested).toBe(true); await expect.poll(() => staleArtifactRequested).toBe(true);
@ -242,10 +242,59 @@ test.describe("聊天工作台 / 输入区与发送", () => {
await expect(page.locator("textarea[name='message']")).toHaveValue(""); await expect(page.locator("textarea[name='message']")).toHaveValue("");
}); });
test("DF-INPUT-009 引用上限为 10第 11 个被阻止并提示", async ( test("DF-INPUT-008A 提交态附加优先提示但消息区只显示原文", async ({
{ page }, page,
testInfo, }, 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( skipIfMissingThread(
testInfo, testInfo,
THREAD_WITH_REFERENCE_FIXTURE, THREAD_WITH_REFERENCE_FIXTURE,
@ -291,8 +340,6 @@ test.describe("聊天工作台 / 输入区与发送", () => {
}); });
await expect(page.getByTestId("reference-chip-remove")).toHaveCount(10); await expect(page.getByTestId("reference-chip-remove")).toHaveCount(10);
await expect( await expect(toastByText(page, "单条消息最多引用 10 个文件")).toBeVisible();
toastByText(page, "单条消息最多引用 10 个文件"),
).toBeVisible();
}); });
}); });