feat(phase-07): compose attachment/skill priority hints on submit
This commit is contained in:
parent
326c780ab7
commit
27414fc4e1
|
|
@ -75,7 +75,7 @@ Plans:
|
|||
**Plans:** 1 executable plan
|
||||
|
||||
Plans:
|
||||
- [ ] 07-01-PLAN.md — 提交态增强文本组装 + 三入口统一透传 + 显示态/提交态分离回归
|
||||
- [x] 07-01-PLAN.md — 提交态增强文本组装 + 三入口统一透传 + 显示态/提交态分离回归
|
||||
|
||||
---
|
||||
*Next command:* `/gsd-verify-work`
|
||||
|
|
|
|||
|
|
@ -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 用例验证“请求体包含拼接文案,消息区不显示拼接文案”的核心回归场景。
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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>/;
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
}),
|
||||
"请总结",
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue