feat: 深度研究支持独立新报告与原文改写,并完善沙箱、模型管理与全文改写

新报告不再覆盖当前文件卡片,写入时打开沙箱;步骤条仅在改写原文时位于卡片上方。同时补齐改写本地回退、模型管理与会商第三步相关改动。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-08-21 21:46:04 +08:00
1 parent 11f928bc95
commit a5a9c952e8
63 files changed
+4113 -929

No files matched your search

+183 -68
View File
@@ -1,9 +1,28 @@
import {
DndContext,
DragOverlay,
KeyboardSensor,
PointerSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
arrayMove,
rectSortingStrategy,
sortableKeyboardCoordinates,
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
BotIcon,
ChevronDownIcon,
CircleAlertIcon,
CpuIcon,
EyeIcon,
GripVerticalIcon,
KeyRoundIcon,
LoaderCircleIcon,
PlusIcon,
@@ -31,6 +50,7 @@ import {
type ModelSavePayload,
useDeleteManagedModel,
useManagedModels,
useReorderManagedModels,
useSaveManagedModel,
useTestManagedModel,
} from "@/core/model-management";
@@ -170,6 +190,103 @@ function ModelCapabilityBadge({
);
}
function SortableModelCard({
model,
isDefault,
dragEnabled,
onConfigure,
onDelete,
}: {
model: ManagedModel;
isDefault: boolean;
dragEnabled: boolean;
onConfigure: () => void;
onDelete: () => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: model.name,
disabled: !dragEnabled,
});
return (
<article
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.45 : 1,
}}
className={`rounded-lg border border-border bg-background p-4 transition-[border-color,box-shadow] ${
dragEnabled ? "hover:border-primary/35 hover:shadow-sm" : ""
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-2">
<button
type="button"
className={`mt-0.5 shrink-0 rounded p-1 text-muted-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
dragEnabled
? "cursor-grab touch-none hover:bg-muted hover:text-foreground active:cursor-grabbing"
: "cursor-not-allowed opacity-40"
}`}
title={dragEnabled ? "拖动调整顺序" : "请清空搜索和筛选后再调整顺序"}
aria-label={`${model.display_name || model.name}:${dragEnabled ? "拖动调整顺序" : "当前不可调整顺序"}`}
{...(dragEnabled ? attributes : {})}
{...(dragEnabled ? listeners : {})}
>
<GripVerticalIcon className="size-4" aria-hidden />
</button>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h2 className="truncate font-semibold">{model.display_name || model.name}</h2>
{isDefault && <Tag theme="primary" variant="light" size="small">默认模型</Tag>}
<Tag theme="success" variant="light" size="small">config.yaml</Tag>
</div>
<p className="mt-1 truncate font-mono text-xs text-muted-foreground">{model.name}</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<TButton theme="default" variant="outline" size="small" onClick={onConfigure}>
配置
</TButton>
<TButton
theme="danger"
variant="text"
size="small"
aria-label={`删除模型 ${model.display_name || model.name}`}
onClick={onDelete}
>
<Trash2Icon className="mr-1 size-3.5" />删除
</TButton>
</div>
</div>
{model.description ? (
<p className="mt-3 line-clamp-2 min-h-10 text-sm leading-5 text-muted-foreground">{model.description}</p>
) : (
<p className="mt-3 min-h-10 text-sm leading-5 text-muted-foreground">未填写描述</p>
)}
<div className="mt-3 grid gap-2 border-t pt-3 text-xs text-muted-foreground sm:grid-cols-2">
<span className="truncate" title={model.use}>驱动:{model.use}</span>
<span className="truncate" title={model.model}>模型:{model.model}</span>
</div>
<div className="mt-3 flex flex-wrap items-center gap-1.5">
{model.api_key_configured && (
<ModelCapabilityBadge icon={KeyRoundIcon} label="已配置密钥" className="bg-muted text-foreground" />
)}
{model.supports_thinking && (
<ModelCapabilityBadge icon={SparklesIcon} label="思考" className="bg-primary/10 text-primary" />
)}
{model.supports_vision && (
<ModelCapabilityBadge icon={EyeIcon} label="视觉" className="bg-amber-500/10 text-amber-700 dark:text-amber-300" />
)}
{model.supports_reasoning_effort && (
<ModelCapabilityBadge label="推理档位" className="bg-muted text-muted-foreground" />
)}
</div>
</article>
);
}
function showGlobalMessage(
theme: "success" | "error" | "warning",
content: string,
@@ -274,15 +391,25 @@ export default function ModelManagementPage() {
const [filter, setFilter] = useState<"all" | "thinking" | "vision">("all");
const [editor, setEditor] = useState<ManagedModel | null | "new">(null);
const [deleteTarget, setDeleteTarget] = useState<ManagedModel | null>(null);
const [pendingOrder, setPendingOrder] = useState<string[] | null>(null);
const [activeDragId, setActiveDragId] = useState<string | null>(null);
const remove = useDeleteManagedModel();
const reorder = useReorderManagedModels();
const sourceModels = data?.models ?? [];
const orderedModels = useMemo(() => {
if (!pendingOrder || pendingOrder.length !== sourceModels.length) return sourceModels;
const byName = new Map(sourceModels.map((model) => [model.name, model]));
const reordered = pendingOrder.map((name) => byName.get(name)).filter((model): model is ManagedModel => Boolean(model));
return reordered.length === sourceModels.length ? reordered : sourceModels;
}, [pendingOrder, sourceModels]);
const models = useMemo(() => {
const keyword = search.trim().toLowerCase();
return (data?.models ?? []).filter((model) => {
return orderedModels.filter((model) => {
const matchesFilter = filter === "all" || (filter === "thinking" ? model.supports_thinking : model.supports_vision);
const matchesSearch = !keyword || [model.name, model.display_name, model.model, model.use].filter(Boolean).join(" ").toLowerCase().includes(keyword);
return matchesFilter && matchesSearch;
});
}, [data?.models, filter, search]);
}, [filter, orderedModels, search]);
const stats = useMemo(() => ({ total: data?.models.length ?? 0, thinking: data?.models.filter((item) => item.supports_thinking).length ?? 0, vision: data?.models.filter((item) => item.supports_vision).length ?? 0 }), [data?.models]);
const handleDelete = async () => {
if (!deleteTarget)
@@ -297,6 +424,28 @@ export default function ModelManagementPage() {
showGlobalMessage("error", error instanceof Error ? error.message : "删除模型失败,请稍后重试。");
}
};
const dragEnabled = filter === "all" && !search.trim() && !reorder.isPending;
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const handleDragEnd = async ({ active, over }: DragEndEvent) => {
setActiveDragId(null);
if (!dragEnabled || !over || active.id === over.id) return;
const oldIndex = orderedModels.findIndex((model) => model.name === active.id);
const newIndex = orderedModels.findIndex((model) => model.name === over.id);
if (oldIndex < 0 || newIndex < 0) return;
const nextOrder = arrayMove(orderedModels.map((model) => model.name), oldIndex, newIndex);
setPendingOrder(nextOrder);
try {
await reorder.mutateAsync(nextOrder);
showGlobalMessage("success", `模型顺序已更新,“${nextOrder[0]}”现在是默认模型。`);
} catch (error) {
setPendingOrder(null);
showGlobalMessage("error", error instanceof Error ? error.message : "调整模型顺序失败,请稍后重试。");
}
};
return (
<WorkspaceContainer>
<WorkspaceHeader />
@@ -375,73 +524,39 @@ export default function ModelManagementPage() {
</div>
)}
{!isLoading && !error && models.length > 0 && (
<div className="grid gap-3 p-4 lg:grid-cols-2">
{models.map((model) => (
<article key={model.name} className="rounded-lg border border-border bg-background p-4 transition-colors hover:border-primary/35">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h2 className="truncate font-semibold">{model.display_name || model.name}</h2>
<Tag theme="success" variant="light" size="small">config.yaml</Tag>
</div>
<p className="mt-1 truncate font-mono text-xs text-muted-foreground">{model.name}</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<TButton theme="default" variant="outline" size="small" onClick={() => setEditor(model)}>
配置
</TButton>
<TButton
theme="danger"
variant="text"
size="small"
aria-label={"删除模型 " + (model.display_name || model.name)}
onClick={() => setDeleteTarget(model)}
>
<Trash2Icon className="mr-1 size-3.5" />删除
</TButton>
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={({ active }) => setActiveDragId(String(active.id))}
onDragCancel={() => setActiveDragId(null)}
onDragEnd={(event) => void handleDragEnd(event)}
>
<div className="border-b bg-muted/20 px-4 py-2.5 text-xs text-muted-foreground">
拖动卡片左侧手柄即可调整顺序;排在第一位的模型会作为默认模型。
{!dragEnabled && " 请清空搜索和筛选条件后再调整顺序。"}
</div>
<SortableContext items={models.map((model) => model.name)} strategy={rectSortingStrategy}>
<div className="grid gap-3 p-4 lg:grid-cols-2">
{models.map((model) => (
<SortableModelCard
key={model.name}
model={model}
isDefault={model.name === orderedModels[0]?.name}
dragEnabled={dragEnabled}
onConfigure={() => setEditor(model)}
onDelete={() => setDeleteTarget(model)}
/>
))}
</div>
</SortableContext>
<DragOverlay>
{activeDragId ? (
<div className="rounded-lg border border-primary/30 bg-background px-4 py-3 text-sm font-medium shadow-lg">
{orderedModels.find((model) => model.name === activeDragId)?.display_name || activeDragId}
</div>
{model.description ? (
<p className="mt-3 line-clamp-2 min-h-10 text-sm leading-5 text-muted-foreground">{model.description}</p>
) : (
<p className="mt-3 min-h-10 text-sm leading-5 text-muted-foreground">未填写描述</p>
)}
<div className="mt-3 grid gap-2 border-t pt-3 text-xs text-muted-foreground sm:grid-cols-2">
<span className="truncate" title={model.use}>驱动:{model.use}</span>
<span className="truncate" title={model.model}>模型:{model.model}</span>
</div>
<div className="mt-3 flex flex-wrap items-center gap-1.5">
{model.api_key_configured && (
<ModelCapabilityBadge
icon={KeyRoundIcon}
label="已配置密钥"
className="bg-muted text-foreground"
/>
)}
{model.supports_thinking && (
<ModelCapabilityBadge
icon={SparklesIcon}
label="思考"
className="bg-primary/10 text-primary"
/>
)}
{model.supports_vision && (
<ModelCapabilityBadge
icon={EyeIcon}
label="视觉"
className="bg-amber-500/10 text-amber-700 dark:text-amber-300"
/>
)}
{model.supports_reasoning_effort && (
<ModelCapabilityBadge
label="推理档位"
className="bg-muted text-muted-foreground"
/>
)}
</div>
</article>
))}
</div>
) : null}
</DragOverlay>
</DndContext>
)}
</section>
@@ -1,7 +1,7 @@
"use client";
import { AlertCircle,CheckCircle2, Loader2Icon, ShieldIcon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState, type FormEvent } from "react";
import { toast } from "sonner";
import { useTheme } from "@/contexts/ThemeContext";
@@ -86,6 +86,11 @@ export default function PrivateSquareSettingsPage({
}
};
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
void handleSave();
};
const handleClearPassword = async () => {
if (!window.confirm("确认清除私密广场密码?清除后任何人都将无法访问私密广场。")) {
return;
@@ -123,7 +128,18 @@ export default function PrivateSquareSettingsPage({
加载失败:{error.message}
</div>
) : (
<div className="flex flex-col gap-5 rounded-xl border bg-white px-5 py-5 dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
<form
className="flex flex-col gap-5 rounded-xl border bg-white px-5 py-5 dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white"
onSubmit={handleSubmit}
>
<input
className="sr-only"
type="text"
name="private-square-admin"
autoComplete="username"
tabIndex={-1}
aria-hidden="true"
/>
<div className="flex items-center justify-between gap-4">
<div>
<div className="text-sm font-medium">启用私密广场</div>
@@ -135,7 +151,7 @@ export default function PrivateSquareSettingsPage({
</div>
<div className="flex flex-col gap-1.5">
<p className="text-sm font-medium">
<label className="text-sm font-medium" htmlFor="private-square-password">
访问密码
{data?.password_set ? (
<span className="text-muted-foreground ml-2 text-xs font-normal">
@@ -146,25 +162,31 @@ export default function PrivateSquareSettingsPage({
(尚未设置)
</span>
)}
</p>
</label>
<Input
id="private-square-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={
data?.password_set ? "留空表示不修改密码" : "请输入访问密码"
}
autoComplete="new-password"
/>
</div>
<div className="flex flex-col gap-1.5">
<p className="text-sm font-medium">确认密码</p>
<label className="text-sm font-medium" htmlFor="private-square-confirm-password">
确认密码
</label>
<Input
id="private-square-confirm-password"
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
placeholder="再次输入新密码"
disabled={!password}
autoComplete="new-password"
/>
</div>
@@ -179,14 +201,14 @@ export default function PrivateSquareSettingsPage({
<ShieldIcon className="mr-1.5 size-4" />
清除密码
</Button>
<Button onClick={handleSave} disabled={update.isPending}>
<Button type="submit" disabled={update.isPending}>
{update.isPending && (
<Loader2Icon className="mr-1.5 size-4 animate-spin" />
)}
保存
</Button>
</div>
</div>
</form>
)}
</div>
);
File diff suppressed because it is too large. Load diff
@@ -195,8 +195,12 @@ function matchingReportStructureId(
return structures.find((structure) => structure.content.trim() === normalized)?.id ?? null;
}
/** Shared by the initial settings dialog and the write-confirmation card. */
export function ReportOutlineEditor({
/**
* The concise structure picker is deliberately usable in the confirmation
* card. Users should not have to open advanced settings merely to choose how
* their report is organised.
*/
export function ReportStructureSelect({
id,
value,
onChange,
@@ -228,9 +232,9 @@ export function ReportOutlineEditor({
};
return (
<div className="space-y-2">
<label htmlFor={`${id}-template`} className="block text-xs font-medium">
报告结构模板
<div>
<label htmlFor={`${id}-template`} className="mb-1.5 block text-xs font-medium">
报告结构
</label>
<Select value={templateValue} onValueChange={selectTemplate}>
<SelectTrigger id={`${id}-template`} className="h-9 text-sm" aria-label="报告结构模板">
@@ -251,22 +255,46 @@ export function ReportOutlineEditor({
<SelectItem value={CUSTOM_REPORT_STRUCTURE_VALUE}>自定义结构</SelectItem>
</SelectContent>
</Select>
<p className="mt-1 text-[11px] leading-4 text-muted-foreground">
{selectedStructureId
? "已选工作流结构;可在高级配置中继续编辑章节内容。"
: "当前使用自定义结构;可在高级配置中编辑章节内容。"}
</p>
</div>
);
}
/** Shared by the initial settings dialog and the write-confirmation card. */
export function ReportOutlineEditor({
id,
value,
onChange,
showStructureSelect = true,
}: {
id: string;
value: string;
onChange: (value: string) => void;
/** The confirmation card already exposes its picker in the primary form. */
showStructureSelect?: boolean;
}) {
return (
<div className="space-y-2">
{showStructureSelect && (
<ReportStructureSelect id={id} value={value} onChange={onChange} />
)}
<label htmlFor={id} className="block pt-1 text-xs font-medium">
报告结构(可编辑)
</label>
<Textarea
id={id}
value={value}
onChange={(event) => {
setCustomSelected(true);
onChange(event.target.value);
}}
onChange={(event) => onChange(event.target.value)}
maxLength={4000}
className="min-h-28 resize-y text-xs"
placeholder={"例如:\n# 引言\n# 一、问题与事实\n## 1.1 范围与依据"}
/>
<p className="text-[10px] leading-4 text-muted-foreground">
选择工作流结构会替换下方内容;选择“自定义”会清空后从头编辑。支持 Markdown 标题或编号列表。
支持 Markdown 标题或编号列表。选择“自定义结构”会清空后从头编辑。
</p>
</div>
);
@@ -430,7 +458,7 @@ export function ReportConfigCard({
</div>
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<label className="block">
<span className="mb-1.5 block text-xs font-medium">报告模式</span>
<Select value={mode} onValueChange={(value) => setMode(value as ResearchMode)}>
@@ -443,6 +471,11 @@ export function ReportConfigCard({
</Select>
<span className="mt-1 block text-[11px] text-muted-foreground">{MODE_HINTS[mode]}</span>
</label>
<ReportStructureSelect
id="report-card-structure"
value={options.customOutline}
onChange={(value) => update("customOutline", value)}
/>
{imageGenerationAvailable && (
<div>
<span className="mb-1.5 block text-xs font-medium">报告配图</span>
@@ -468,7 +501,7 @@ export function ReportConfigCard({
>
<SlidersHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
高级配置
<span className="font-normal text-muted-foreground">语言、语气、自定义结构等</span>
<span className="font-normal text-muted-foreground">语言、语气、检索预算与结构细调</span>
<ChevronRight className={`ml-auto h-3.5 w-3.5 text-muted-foreground transition-transform ${advancedOpen ? "rotate-90" : ""}`} />
</button>
{advancedOpen && (
@@ -509,6 +542,7 @@ export function ReportConfigCard({
id="report-card-outline"
value={options.customOutline}
onChange={(value) => update("customOutline", value)}
showStructureSelect={false}
/>
</div>
<div className="flex justify-end sm:col-span-2">
@@ -57,9 +57,20 @@ interface ResearchSandboxPanelProps {
instruction: string;
style: DocumentRewriteStyle;
modelName: string | null;
reportOutline: string;
/** Regeneration forks the report session before it starts the rewrite. */
createNewReport?: boolean;
}) => void;
documentRewriteActive?: boolean;
/** Distinguishes a new report generation from an in-place full rewrite. */
documentRewriteOperation?: "rewrite" | "generate" | null;
onCancelDocumentRewrite?: () => void;
/** A completed-report card can request opening the same safe rewrite dialog. */
documentRewriteDialogRequest?: number;
/** Controls whether a requested dialog creates another report or rewrites this one. */
documentRewriteDialogPurpose?: "rewrite" | "regenerate";
/** Outline selected from the research workflow, used to preselect a saved structure. */
initialDocumentRewriteOutline?: string;
reportCharCount?: number;
}
@@ -73,12 +84,18 @@ export function ResearchSandboxPanel({
onDownload,
onDocumentRewriteStart,
documentRewriteActive = false,
documentRewriteOperation = null,
onCancelDocumentRewrite,
documentRewriteDialogRequest = 0,
documentRewriteDialogPurpose = "rewrite",
initialDocumentRewriteOutline,
reportCharCount = 0,
}: ResearchSandboxPanelProps) {
const layoutRef = useRef<GroupImperativeHandle>(null);
const { selectedArtifact, setOpen, deselect } = useArtifacts();
const [rewriteDialogOpen, setRewriteDialogOpen] = useState(false);
const [rewriteDialogPurpose, setRewriteDialogPurpose] = useState<"rewrite" | "regenerate">("rewrite");
const generatingNewReport = documentRewriteOperation === "generate";
useEffect(() => {
if (!layoutRef.current) return;
@@ -108,6 +125,13 @@ export function ResearchSandboxPanel({
onClose();
};
useEffect(() => {
if (documentRewriteDialogRequest > 0) {
setRewriteDialogPurpose(documentRewriteDialogPurpose);
setRewriteDialogOpen(true);
}
}, [documentRewriteDialogPurpose, documentRewriteDialogRequest]);
return (
<ThreadContext.Provider value={{ thread: threadStub, isMock: false }}>
<ResizablePanelGroup
@@ -135,12 +159,12 @@ export function ResearchSandboxPanel({
id="artifacts"
>
<div className="relative h-full bg-[#fffdfa] p-4">
{selectedArtifact && previewThreadId ? (
{selectedArtifact && previewThreadId && stubMessages.length > 0 ? (
<>
{documentRewriteActive && onCancelDocumentRewrite && (
<div className="absolute right-36 bottom-7 z-30">
<Button size="sm" variant="outline" onClick={onCancelDocumentRewrite}>
停止改写
停止{generatingNewReport ? "生成" : "改写"}
</Button>
</div>
)}
@@ -155,14 +179,22 @@ export function ResearchSandboxPanel({
onDocumentRewriteStart ? (
<ArtifactAction
icon={documentRewriteActive ? Loader2Icon : SparklesIcon}
label={documentRewriteActive ? "正在全文改写" : "AI 全文改写"}
label={documentRewriteActive
? generatingNewReport
? "正在生成新报告"
: "正在全文改写"
: "AI 全文改写"}
tooltip={
documentRewriteActive
? "正在生成临时草稿,原报告尚未被覆盖"
? generatingNewReport
? "正在生成独立的新报告,当前报告不会被覆盖"
: "正在生成临时草稿,原报告尚未被覆盖"
: "使用 AI 重写完整 report.md"
}
disabled={documentRewriteActive}
onClick={() => setRewriteDialogOpen(true)}
onClick={() => {
setRewriteDialogPurpose("rewrite");
setRewriteDialogOpen(true);
}}
/>
) : null
}
@@ -178,7 +210,11 @@ export function ResearchSandboxPanel({
<ConversationEmptyState
icon={<FileTextIcon />}
title="report.md"
description="报告开始撰写后将在此实时预览"
description={
documentRewriteActive && generatingNewReport
? "正在生成新报告,正文将在模型首个字符输出后显示"
: "报告开始撰写后将在此实时预览"
}
/>
</div>
)}
@@ -191,9 +227,30 @@ export function ResearchSandboxPanel({
onOpenChange={setRewriteDialogOpen}
fileName="report.md"
charCount={reportCharCount}
initialReportOutline={initialDocumentRewriteOutline}
operationLabel={rewriteDialogPurpose === "regenerate" ? "确认新报告生成要求" : undefined}
operationDescription={
rewriteDialogPurpose === "regenerate"
? "请确认新报告的结构、资料范围和生成要求;确认后会创建一篇独立的新报告,当前报告不会被覆盖。"
: undefined
}
submitLabel={rewriteDialogPurpose === "regenerate" ? "确认并生成新报告" : undefined}
operation={rewriteDialogPurpose === "regenerate" ? "generate" : "rewrite"}
onRewriteOriginal={rewriteDialogPurpose === "regenerate"
? (input) => {
setRewriteDialogOpen(false);
onDocumentRewriteStart({
...input,
createNewReport: false,
});
}
: undefined}
onStart={(input) => {
setRewriteDialogOpen(false);
onDocumentRewriteStart(input);
onDocumentRewriteStart({
...input,
createNewReport: rewriteDialogPurpose === "regenerate",
});
}}
/>
)}
@@ -532,11 +532,27 @@ export function WorkflowConfigButton({
disabled?: boolean;
}) {
const [open, setOpen] = useState(false);
const structuresQuery = useReportStructures();
const selectedWorkflowTitle = useMemo(() => {
const content = appliedContent?.trim();
if (!content) return null;
return structuresQuery.data?.find((structure) => structure.content.trim() === content)?.title ?? null;
}, [appliedContent, structuresQuery.data]);
const buttonLabel = selectedWorkflowTitle || "工作流配置";
return (
<>
<Button variant={variant} size={size} className={className} disabled={disabled} onClick={() => setOpen(true)}>
<Button
aria-label={selectedWorkflowTitle ? `已选择工作流:${selectedWorkflowTitle}` : "工作流配置"}
className={className}
disabled={disabled}
onClick={() => setOpen(true)}
size={size}
title={selectedWorkflowTitle ? `已选择工作流:${selectedWorkflowTitle}` : "工作流配置"}
variant={variant}
>
<Workflow className="mr-1.5 h-3.5 w-3.5" />
工作流配置
<span className="max-w-40 truncate">{buttonLabel}</span>
</Button>
<WorkflowConfigDialog
open={open}
@@ -43,13 +43,28 @@ interface UseReportSandboxOptions {
report: string;
/** 报告是否正在流式写入 —— 作为 stub thread 的 isLoading。 */
writing: boolean;
/** First visible Markdown has arrived; the host can expand the right panel then. */
onFirstReportContent?: () => void;
/**
* A structural regeneration writes a sibling file in the background. Leave
* the currently displayed report in the sandbox until the user opens that
* new card; do not steal the panel for the incoming stream.
*/
autoOpen?: boolean;
}
export function useReportSandbox({ threadKey, report, writing }: UseReportSandboxOptions) {
export function useReportSandbox({
threadKey,
report,
writing,
onFirstReportContent,
autoOpen = true,
}: UseReportSandboxOptions) {
const { select, setOpen, openStreamedArtifact } = useArtifacts();
const [stubMessage, setStubMessage] = useState<ReportStubAiMessage | null>(null);
// 每个 threadKey 只自动展开一次;刷新/切换会话后的重开由用户或 openReport 触发。
// Each writing pass opens only once, once it has actual Markdown content.
const autoOpenedRef = useRef(false);
const wasWritingRef = useRef(writing);
const messageId = `dr-report-${threadKey}`;
const toolCallId = `dr-report-${threadKey}-write`;
@@ -67,6 +82,11 @@ export function useReportSandbox({ threadKey, report, writing }: UseReportSandbo
setStubMessage(null);
}, [threadKey]);
useEffect(() => {
if (writing && !wasWritingRef.current) autoOpenedRef.current = false;
wasWritingRef.current = writing;
}, [writing]);
// 每帧 upsert stub(带防抹空:新帧为空而旧内容非空时保留旧内容)。
useEffect(() => {
if (!report) return;
@@ -91,10 +111,11 @@ export function useReportSandbox({ threadKey, report, writing }: UseReportSandbo
// 报告首帧到达且仍在写入 → 自动展开右栏并选中虚拟 URL。
// openStreamedArtifact 尊重用户手动关闭(suppress),不会强行弹回。
useEffect(() => {
if (!report.trim() || !writing || autoOpenedRef.current) return;
if (!autoOpen || !report.trim() || !writing || autoOpenedRef.current) return;
autoOpenedRef.current = true;
onFirstReportContent?.();
openStreamedArtifact(virtualUrl, artifactPreviewKey(virtualUrl));
}, [openStreamedArtifact, report, virtualUrl, writing]);
}, [autoOpen, onFirstReportContent, openStreamedArtifact, report, virtualUrl, writing]);
/** 手动打开(头部「报告」按钮 / 完成卡「查看报告」):始终选中虚拟 URL。 */
const openReport = useCallback(() => {
@@ -102,9 +123,18 @@ export function useReportSandbox({ threadKey, report, writing }: UseReportSandbo
setOpen(true);
}, [select, setOpen, virtualUrl]);
// Effects clear the previous message after a thread switch. Do not wait
// for that post-render effect before changing what is rendered: a report
// regeneration must show an empty new file immediately, never one frame of
// the previous report while the model is still preparing its first token.
const currentStubMessage = stubMessage?.id === messageId ? stubMessage : null;
return {
virtualUrl,
stubMessages: useMemo(() => (stubMessage ? [stubMessage] : []), [stubMessage]),
stubMessages: useMemo(
() => (currentStubMessage ? [currentStubMessage] : []),
[currentStubMessage],
),
openReport,
};
}