feat(input): align phase-06 reference contract and state docs

This commit is contained in:
肖应宇 2026-04-15 13:21:35 +08:00
parent 345359ab8f
commit bf0278e586
4 changed files with 118 additions and 33 deletions

View File

@ -58,12 +58,14 @@
**Goal:** 在当前线程聊天输入框中实现 `@` 文件引用artifacts + uploads并通过 `additional_kwargs.files` 稳定提交且具备回归测试。 **Goal:** 在当前线程聊天输入框中实现 `@` 文件引用artifacts + uploads并通过 `additional_kwargs.files` 稳定提交且具备回归测试。
**Requirements**: ATREF-01, ATREF-02, ATREF-03, ATREF-04 **Requirements**: ATREF-01, ATREF-02, ATREF-03, ATREF-04
**Depends on:** Phase 5 **Depends on:** Phase 5
**Plans:** 3 plans **Plans:** 4 executable plans + 1 archived revision record
Plans: Plans:
- [x] 06-01-PLAN.md — 锁定引用提交契约与软失败链路additional_kwargs.files - [x] 06-01-PLAN.md — 锁定引用提交契约与软失败链路additional_kwargs.files
- [x] 06-02-PLAN.md — 实现 @ 候选 dropdown、chip 交互与上限控制 - [x] 06-02-PLAN.md — 实现 @ 候选 dropdown、chip 交互与上限控制
- [x] 06-03-PLAN.md — 补齐自动化验证并产出 style/logic/tests/docs 提交分组计划 - [x] 06-03-PLAN.md — 补齐自动化验证并产出 style/logic/tests/docs 提交分组计划
- [x] 06-04-ARCHIVED.md — 修订归档:原 gap-closure 计划与锁定决策 D-08上限 10冲突保留追踪但不再执行
- [ ] 06-05-PLAN.md — 关闭 verification 缺口:恢复 10 个上限/类型去歧义,并稳定 DF-INPUT-008/009 回归
--- ---
*Next command:* `/gsd-verify-work` *Next command:* `/gsd-verify-work`

View File

@ -3,12 +3,12 @@ gsd_state_version: 1.0
milestone: v1.0 milestone: v1.0
milestone_name: milestone milestone_name: milestone
status: Executing Phase 06 status: Executing Phase 06
last_updated: "2026-04-15T03:07:32.408Z" last_updated: "2026-04-15T05:06:28.041Z"
progress: progress:
total_phases: 6 total_phases: 6
completed_phases: 6 completed_phases: 6
total_plans: 10 total_plans: 10
completed_plans: 10 completed_plans: 12
percent: 100 percent: 100
--- ---
@ -19,7 +19,7 @@ progress:
See: .planning/PROJECT.md (updated 2026-04-07) See: .planning/PROJECT.md (updated 2026-04-07)
**Core value:** Keep the frontend visually familiar while preserving and hardening new-system behavior end to end. **Core value:** Keep the frontend visually familiar while preserving and hardening new-system behavior end to end.
**Current focus:** Phase 06 — @引用文件与附件 **Current focus:** Phase 06 — phase-06
## Workflow State ## Workflow State

View File

@ -57,9 +57,13 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup, DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Tag } from "@/components/ui/tag"; import { Tag } from "@/components/ui/tag";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
@ -89,7 +93,12 @@ import { Tooltip } from "./tooltip";
import { useThread } from "./messages/context"; import { useThread } from "./messages/context";
import type { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime"; import type { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime";
const MAX_REFERENCES_PER_MESSAGE = 6; const MAX_REFERENCES_PER_MESSAGE = 10;
const REFERENCE_SOURCE_LABELS = {
artifact: "生成文件",
upload: "上传附件",
} as const;
type MentionCandidate = { type MentionCandidate = {
key: string; key: string;
@ -98,6 +107,7 @@ type MentionCandidate = {
pathTail: string; pathTail: string;
ref_source: "artifact" | "upload"; ref_source: "artifact" | "upload";
ref_kind: "mention"; ref_kind: "mention";
typeLabel: string;
}; };
function getPathTail(path: string | undefined): string { function getPathTail(path: string | undefined): string {
@ -172,6 +182,7 @@ export function InputBox({
const promptRootRef = useRef<HTMLDivElement | null>(null); const promptRootRef = useRef<HTMLDivElement | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null); const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const mentionTriggerRef = useRef<HTMLButtonElement | null>(null);
const [followups, setFollowups] = useState<string[]>([]); const [followups, setFollowups] = useState<string[]>([]);
const [followupsHidden, setFollowupsHidden] = useState(false); const [followupsHidden, setFollowupsHidden] = useState(false);
const [followupsLoading, setFollowupsLoading] = useState(false); const [followupsLoading, setFollowupsLoading] = useState(false);
@ -240,6 +251,7 @@ export function InputBox({
pathTail: getPathTail(path), pathTail: getPathTail(path),
ref_source: "artifact" as const, ref_source: "artifact" as const,
ref_kind: "mention" as const, ref_kind: "mention" as const,
typeLabel: REFERENCE_SOURCE_LABELS.artifact,
}; };
}); });
@ -251,6 +263,7 @@ export function InputBox({
pathTail: getPathTail(file.virtual_path), pathTail: getPathTail(file.virtual_path),
ref_source: "upload" as const, ref_source: "upload" as const,
ref_kind: "mention" as const, ref_kind: "mention" as const,
typeLabel: REFERENCE_SOURCE_LABELS.upload,
})) ?? []; })) ?? [];
const deduped = new Map<string, MentionCandidate>(); const deduped = new Map<string, MentionCandidate>();
@ -266,7 +279,9 @@ export function InputBox({
return mentionCandidates; return mentionCandidates;
} }
return mentionCandidates.filter((candidate) => return mentionCandidates.filter((candidate) =>
`${candidate.filename} ${candidate.pathTail}`.toLowerCase().includes(query), `${candidate.filename} ${candidate.typeLabel} ${candidate.pathTail}`
.toLowerCase()
.includes(query),
); );
}, [mentionCandidates, mentionQuery]); }, [mentionCandidates, mentionQuery]);
const handleModelSelect = useCallback( const handleModelSelect = useCallback(
@ -331,7 +346,7 @@ export function InputBox({
return prev; return prev;
} }
if (prev.length >= MAX_REFERENCES_PER_MESSAGE) { if (prev.length >= MAX_REFERENCES_PER_MESSAGE) {
toast.error("单条消息最多引用 6 个文件"); toast.error("单条消息最多引用 10 个文件");
return prev; return prev;
} }
return prev.concat({ return prev.concat({
@ -467,6 +482,12 @@ export function InputBox({
setTimeout(() => requestFormSubmit(), 0); setTimeout(() => requestFormSubmit(), 0);
}, [pendingSuggestion, requestFormSubmit, textInput]); }, [pendingSuggestion, requestFormSubmit, textInput]);
useEffect(() => {
if (mentionOpen) {
mentionTriggerRef.current?.focus();
}
}, [mentionOpen]);
useEffect(() => { useEffect(() => {
/* /*
// 暂时禁用 suggestions 接口(/api/threads/{thread_id}/suggestions // 暂时禁用 suggestions 接口(/api/threads/{thread_id}/suggestions
@ -544,9 +565,13 @@ export function InputBox({
data-testid="reference-inline-preview" data-testid="reference-inline-preview"
> >
{references.map((reference) => { {references.map((reference) => {
const label = reference.path const typeLabel = REFERENCE_SOURCE_LABELS[reference.ref_source];
? `${reference.filename} · ${getPathTail(reference.path)}` const labelParts = [
: reference.filename; reference.filename,
typeLabel,
reference.path ? getPathTail(reference.path) : "",
].filter(Boolean);
const label = labelParts.join(" · ");
const referenceUrl = const referenceUrl =
threadId && reference.path threadId && reference.path
? urlOfArtifact({ ? urlOfArtifact({
@ -561,6 +586,7 @@ export function InputBox({
<div <div
key={`${reference.ref_source}:${reference.path ?? reference.filename}`} key={`${reference.ref_source}:${reference.path ?? reference.filename}`}
className="bg-background flex h-12 max-w-[280px] items-center gap-2 rounded-lg border px-2" className="bg-background flex h-12 max-w-[280px] items-center gap-2 rounded-lg border px-2"
data-testid="reference-chip"
> >
{isImageReference && referenceUrl ? ( {isImageReference && referenceUrl ? (
<img <img
@ -573,12 +599,25 @@ export function InputBox({
<PaperclipIcon className="size-4" /> <PaperclipIcon className="size-4" />
</div> </div>
)} )}
<span className="truncate text-xs" title={label}> <div className="min-w-0 flex-1">
{label} <span
</span> className="block truncate text-xs font-medium"
title={reference.filename}
>
{reference.filename}
</span>
<span
className="text-muted-foreground block truncate text-[11px]"
title={`${typeLabel}${reference.path ? ` · ${getPathTail(reference.path)}` : ""}`}
>
{typeLabel}
{reference.path ? ` · ${getPathTail(reference.path)}` : ""}
</span>
</div>
<button <button
aria-label="移除引用" aria-label={`移除引用 ${reference.filename} ${typeLabel}`}
className="text-muted-foreground hover:text-foreground rounded-full" className="text-muted-foreground hover:text-foreground rounded-full"
data-testid="reference-chip-remove"
onClick={() => onClick={() =>
setReferences((prev) => setReferences((prev) =>
prev.filter( prev.filter(
@ -614,33 +653,77 @@ export function InputBox({
onChange={handleTextareaChange} onChange={handleTextareaChange}
onKeyDown={handleTextareaKeyDown} onKeyDown={handleTextareaKeyDown}
/> />
{mentionOpen && filteredMentionCandidates.length > 0 && ( <DropdownMenu
<div modal={false}
className="bg-popover absolute right-2 bottom-full left-2 z-30 mb-1 rounded-lg border p-1 shadow-md" open={mentionOpen && filteredMentionCandidates.length > 0}
onOpenChange={(open) => {
setMentionOpen(open);
if (!open) {
setMentionRange(null);
}
}}
>
<DropdownMenuTrigger asChild>
<button
ref={mentionTriggerRef}
type="button"
className="pointer-events-none absolute right-2 bottom-2 h-0 w-0 opacity-0"
aria-hidden="true"
tabIndex={-1}
/>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
side="top"
sideOffset={8}
className="w-[min(32rem,var(--radix-dropdown-menu-trigger-width)+28rem)] p-2"
data-testid="mention-candidate-panel" data-testid="mention-candidate-panel"
onCloseAutoFocus={(event) => {
event.preventDefault();
textareaRef.current?.focus();
}}
> >
{filteredMentionCandidates.slice(0, 20).map((candidate, index) => { <DropdownMenuLabel className="px-2 py-1 text-xs text-muted-foreground">
const detail = candidate.pathTail || candidate.ref_source;
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
{filteredMentionCandidates.slice(0, 20).map((candidate, index) => {
const detail = [candidate.typeLabel, candidate.pathTail]
.filter(Boolean)
.join(" · ");
return ( return (
<button <DropdownMenuItem
key={candidate.key} key={candidate.key}
type="button"
className={cn( className={cn(
"hover:bg-accent flex w-full items-center justify-between rounded-md px-2 py-1.5 text-left text-sm", "flex items-center justify-between gap-3 rounded-md px-2 py-2 text-left",
index === activeMentionIndex && "bg-accent", index === activeMentionIndex && "bg-accent",
)} )}
data-active={index === activeMentionIndex ? "true" : "false"}
data-candidate-key={candidate.key}
data-testid="mention-candidate-item"
aria-label={`${candidate.filename} ${candidate.typeLabel}${candidate.pathTail ? ` ${candidate.pathTail}` : ""}`}
onFocus={() => setActiveMentionIndex(index)}
onMouseDown={(event) => event.preventDefault()} onMouseDown={(event) => event.preventDefault()}
onClick={() => selectMentionCandidate(candidate)} onSelect={(event) => {
event.preventDefault();
selectMentionCandidate(candidate);
}}
> >
<span className="truncate">{candidate.filename}</span> <div className="min-w-0 flex-1">
<span className="text-muted-foreground ml-4 shrink-0 text-xs"> <span className="block truncate text-sm font-medium">
{detail} {candidate.filename}
</span> </span>
</button> <span className="text-muted-foreground block truncate text-xs">
{detail}
</span>
</div>
</DropdownMenuItem>
); );
})} })}
</div> </DropdownMenuGroup>
)} </DropdownMenuContent>
</DropdownMenu>
</PromptInputBody> </PromptInputBody>
{!effectiveIsFocused && ( {!effectiveIsFocused && (
<div <div

View File

@ -593,7 +593,7 @@ export function useThreadStream({
normalizedReferences, normalizedReferences,
); );
if (staleCount > 0) { if (staleCount > 0) {
toast.error("部分引用已失效,已自动移除"); toast.error("部分引用文件已失效,已自动移除并继续发送。");
} }
await thread.submit( await thread.submit(
@ -757,7 +757,7 @@ export function useSubmitThread({
normalizedReferences, normalizedReferences,
); );
if (staleCount > 0) { if (staleCount > 0) {
toast.error("部分引用已失效,已自动移除"); toast.error("部分引用文件已失效,已自动移除并继续发送。");
} }
await thread.submit( await thread.submit(