feat: 深度研究支持独立新报告与原文改写,并完善沙箱、模型管理与全文改写
新报告不再覆盖当前文件卡片,写入时打开沙箱;步骤条仅在改写原文时位于卡片上方。同时补齐改写本地回退、模型管理与会商第三步相关改动。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
11f928bc95
commit
a5a9c952e8
63 files changed
+4113
-929
No files matched your search
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user