feat(input): align phase-06 reference contract and state docs
This commit is contained in:
parent
345359ab8f
commit
bf0278e586
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue