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` 稳定提交且具备回归测试。
**Requirements**: ATREF-01, ATREF-02, ATREF-03, ATREF-04
**Depends on:** Phase 5
**Plans:** 3 plans
**Plans:** 4 executable plans + 1 archived revision record
Plans:
- [x] 06-01-PLAN.md — 锁定引用提交契约与软失败链路additional_kwargs.files
- [x] 06-02-PLAN.md — 实现 @ 候选 dropdown、chip 交互与上限控制
- [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`

View File

@ -3,12 +3,12 @@ gsd_state_version: 1.0
milestone: v1.0
milestone_name: milestone
status: Executing Phase 06
last_updated: "2026-04-15T03:07:32.408Z"
last_updated: "2026-04-15T05:06:28.041Z"
progress:
total_phases: 6
completed_phases: 6
total_plans: 10
completed_plans: 10
completed_plans: 12
percent: 100
---
@ -19,7 +19,7 @@ progress:
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.
**Current focus:** Phase 06 — @引用文件与附件
**Current focus:** Phase 06 — phase-06
## Workflow State

View File

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

View File

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