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:** 1 executable plan
|
||||||
|
|
||||||
Plans:
|
Plans:
|
||||||
- [ ] 07-01-PLAN.md — 提交态增强文本组装 + 三入口统一透传 + 显示态/提交态分离回归
|
- [x] 07-01-PLAN.md — 提交态增强文本组装 + 三入口统一透传 + 显示态/提交态分离回归
|
||||||
|
|
||||||
---
|
---
|
||||||
*Next command:* `/gsd-verify-work`
|
*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;
|
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 = {
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
|
|
||||||
|
|
@ -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>/;
|
||||||
|
|
|
||||||
|
|
@ -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: [],
|
||||||
|
}),
|
||||||
|
"请总结",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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);
|
).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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue