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` 稳定提交且具备回归测试。
|
**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`
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue