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:
- [ ] 07-01-PLAN.md — 提交态增强文本组装 + 三入口统一透传 + 显示态/提交态分离回归
- [x] 07-01-PLAN.md — 提交态增强文本组装 + 三入口统一透传 + 显示态/提交态分离回归
---
*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;
files: FileUIPart[];
references?: PromptInputReference[];
selectedSkills?: PromptInputSkill[];
};
export type PromptInputSkill = {
skill_id: string;
title: string;
};
export type PromptInputReference = {

View File

@ -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(() => {

View File

@ -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]);

View File

@ -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 <uploaded_files>...</uploaded_files> tag
const uploadedFilesRegex = /<uploaded_files>([\s\S]*?)<\/uploaded_files>/;

View File

@ -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: [],
}),
"请总结",
);
});

View File

@ -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,7 +257,8 @@ export function useThreadStream({
}
}, []);
const showStreamErrorToast = useCallback((error: unknown) => {
const showStreamErrorToast = useCallback(
(error: unknown) => {
const message = getStreamErrorMessage(error);
if (isStreamCancellation(error, message)) {
// Cancellation is expected when user presses "Stop" or stream disconnects.
@ -276,7 +278,9 @@ export function useThreadStream({
console.error("[useThreadStream] conversation stream error:", error);
console.error("[useThreadStream] parsed error message:", message);
toast.error(t.threads.streamError);
}, [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:

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