Merge remote-tracking branch 'origin/master' into ji

# Conflicts:
#	.gitignore
#	frontend-web/src/components/workspace/artifacts/context.tsx
#	frontend-web/src/components/workspace/messages/message-group.tsx
#	offline-backend-20260512/backend/CLAUDE.md
#	offline-backend-20260512/backend/README.md
#	offline-backend-20260512/backend/app/gateway/app.py
#	offline-backend-20260512/backend/app/gateway/deps.py
#	offline-backend-20260512/backend/app/gateway/routers/llmwiki.py
This commit is contained in:
吉鑫升 committed 2026-08-24 14:21:19 +08:00
commit e818c24769
262 files changed
+29527 -5236

No files matched your search

+5 -4
View File
@@ -31,7 +31,7 @@ import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-dep
import { useModels } from "@/core/models/hooks";
import { useNotification } from "@/core/notification/hooks";
import { saveLastAgentId, useThreadSettings } from "@/core/settings";
import { stopThreadRun, useThreadStream } from "@/core/threads/hooks";
import { stopActiveThreadBeforeClarificationReply, stopThreadRun, useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils";
import { OptType, writeYLog } from "@/core/log/y-log";
import {
@@ -542,11 +542,12 @@ export default function AgentChatPage({
}, [taskAutoStart, taskStartText, isNewThread, handleSubmit]);
const handleClarificationSubmit = useCallback(
(answer: string) => {
async (answer: string) => {
setHasStartedConversation(true);
void sendMessage(threadId, { text: answer, files: [] }, { agent_id });
await stopActiveThreadBeforeClarificationReply(threadId, thread);
await sendMessage(threadId, { text: answer, files: [] }, { agent_id });
},
[sendMessage, threadId, agent_id],
[sendMessage, thread, threadId, agent_id],
);
const handleStop = useCallback(async () => {
+7 -6
View File
@@ -58,7 +58,7 @@ import {
useKnowledgeRagEnabled,
useThreadSettings,
} from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks";
import { useThreadStream, stopActiveThreadBeforeClarificationReply } from "@/core/threads/hooks";
import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit";
import { useSingleRoundRagMessages } from "@/core/threads/use-single-round-rag";
import { useMultiRoundRagMessages } from "@/core/threads/use-multi-round-rag";
@@ -525,11 +525,12 @@ export default function ChatPage() {
);
const handleClarificationSubmit = useCallback(
(answer: string) => {
async (answer: string) => {
setHasStartedConversation(true);
void sendMessage(threadId, { text: answer, files: [] });
await stopActiveThreadBeforeClarificationReply(threadId, thread);
await sendMessage(threadId, { text: answer, files: [] });
},
[sendMessage, threadId],
[sendMessage, thread, threadId],
);
useEffect(() => {
@@ -773,9 +774,9 @@ export default function ChatPage() {
!compactLanding &&
"-translate-y-[calc(50vh-96px)]",
compactLanding
? "max-w-(--container-width-xl)"
? "max-w-[calc(var(--container-width-xl)+150px)]"
: isLandingState
? "max-w-(--container-width-md)"
? "max-w-[calc(var(--container-width-md)+150px)]"
: "max-w-(--container-width-lg)",
)}
>
+5 -4
View File
@@ -9,7 +9,7 @@ import { setStoredAuth } from "@/core/auth";
import { resolveEmbedChatParams } from "@/core/embed/params";
import { registerEmbedSession } from "@/core/embed/status-api";
import { useThreadSettings } from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks";
import { stopActiveThreadBeforeClarificationReply, useThreadStream } from "@/core/threads/hooks";
import { uuid } from "@/core/utils/uuid";
import { ChatBox } from "@/components/workspace/chats";
import {
@@ -110,10 +110,11 @@ export default function EmbedChatPage() {
}, [authReady, threadId, registerSessionForThread]);
const handleClarificationSubmit = useCallback(
(answer: string) => {
void sendMessage(threadId, { text: answer, files: [] });
async (answer: string) => {
await stopActiveThreadBeforeClarificationReply(threadId, thread);
await sendMessage(threadId, { text: answer, files: [] });
},
[sendMessage, threadId],
[sendMessage, thread, threadId],
);
useEffect(() => {
@@ -0,0 +1,734 @@
import { type FormEvent, useEffect, useMemo, useRef, useState } from "react";
import {
AlertCircle,
BookOpenCheck,
CheckCircle2,
ChevronRight,
ClipboardList,
Database,
Download,
ExternalLink,
FileSearch,
FileText,
Loader2,
Play,
Plus,
Search,
Send,
ShieldCheck,
Square,
Sparkles,
WandSparkles,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { ModelNameSelect } from "@/components/workspace/model-name-select";
import { DownloadWordButton } from "@/components/workspace/messages/download-word-button";
import { DocumentRewriteDiffDialog } from "@/components/workspace/messages/document-rewrite-diff-dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { openKnowledgeSource } from "@/core/llmwiki/source-links";
import MarkdownRenderer from "@/strategy-components/MarkdownRenderer";
import {
useLlmWikiKnowledgeBases,
useLlmWikiRuntime,
} from "@/strategy-components/api/llmwiki";
import {
type EnterpriseResearchEvidence,
type EnterpriseResearchTask,
type EnterpriseResearchTaskStatus,
type EnterpriseResearchReportEvent,
type EnterpriseResearchReportJob,
collectEnterpriseResearchTask,
cancelEnterpriseResearchReportJob,
createEnterpriseResearchAiPlan,
createEnterpriseResearchTask,
createEnterpriseResearchPlanDraft,
createEnterpriseResearchReportJob,
listEnterpriseResearchTasks,
listEnterpriseResearchReportJobs,
restoreEnterpriseResearchReportVersion,
streamEnterpriseResearchReportJob,
updateEnterpriseResearchPlan,
} from "@/strategy-components/api/enterprise-research";
type TemplateId = "topic" | "event" | "policy";
type TaskStatus = EnterpriseResearchTaskStatus;
type ResearchTemplate = {
id: TemplateId;
name: string;
description: string;
stages: string[];
buildQueries: (subject: string, focus: string) => string[];
};
const TEMPLATES: ResearchTemplate[] = [
{
id: "topic",
name: "通用议题研究",
description: "先梳理事实与背景,再归纳关键分歧、影响与待验证问题。",
stages: ["界定研究问题", "检索内部证据", "归纳观点与分歧", "形成研究任务包"],
buildQueries: (subject, focus) => [subject, `${subject} 背景 关键事实`, focus ? `${subject} ${focus}` : `${subject} 影响 风险 机会`],
},
{
id: "event",
name: "事件影响评估",
description: "适用于突发事件、舆情事件或专项行动的影响研判。",
stages: ["还原事件时间线", "识别相关主体", "评估影响传导", "输出风险与跟踪项"],
buildQueries: (subject, focus) => [
`${subject} 时间线 经过`,
`${subject} 相关主体 公开表态`,
focus ? `${subject} ${focus} 影响 风险` : `${subject} 影响 风险 应对`,
],
},
{
id: "policy",
name: "政策与行业研究",
description: "适用于政策、产业、市场等需比较变化与传导机制的主题。",
stages: ["明确政策或产业边界", "对比历史与现状", "拆解传导机制", "沉淀研判与建议"],
buildQueries: (subject, focus) => [
`${subject} 政策 现状`,
`${subject} 发展趋势 数据`,
focus ? `${subject} ${focus} 行业影响` : `${subject} 影响 挑战 机遇`,
],
},
];
const STATUS_META: Record<TaskStatus, { label: string; className: string }> = {
draft: { label: "草稿", className: "bg-muted text-muted-foreground" },
collecting: { label: "检索中", className: "bg-amber-100 text-amber-800 dark:bg-amber-950/60 dark:text-amber-200" },
ready: { label: "证据已就绪", className: "bg-emerald-100 text-emerald-800 dark:bg-emerald-950/60 dark:text-emerald-200" },
partial: { label: "部分完成", className: "bg-orange-100 text-orange-800 dark:bg-orange-950/60 dark:text-orange-200" },
failed: { label: "检索失败", className: "bg-red-100 text-red-800 dark:bg-red-950/60 dark:text-red-200" },
};
function formatTime(value?: string | null) {
if (!value) return "刚刚";
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return "刚刚";
return new Intl.DateTimeFormat("zh-CN", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" }).format(parsed);
}
function getTemplate(id: TemplateId) {
return TEMPLATES.find((item) => item.id === id) ?? TEMPLATES[0];
}
function StatusBadge({ status }: { status: TaskStatus }) {
const meta = STATUS_META[status];
return <Badge className={cn("border-0", meta.className)}>{meta.label}</Badge>;
}
export default function EnterpriseResearchPage() {
const runtime = useLlmWikiRuntime();
const knowledgeBases = useLlmWikiKnowledgeBases("all", runtime.data?.provider === "weknora");
const [tasks, setTasks] = useState<EnterpriseResearchTask[]>([]);
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
const [subject, setSubject] = useState("");
const [focus, setFocus] = useState("");
const [templateId, setTemplateId] = useState<TemplateId>("topic");
const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState<string[]>([]);
const [selectedSourceId, setSelectedSourceId] = useState<string | null>(null);
const [reportJob, setReportJob] = useState<EnterpriseResearchReportJob | null>(null);
const [reportJobs, setReportJobs] = useState<EnterpriseResearchReportJob[]>([]);
const [reportDraft, setReportDraft] = useState("");
const [jobDraft, setJobDraft] = useState("");
const [reasoning, setReasoning] = useState("");
const [isStartingReport, setIsStartingReport] = useState(false);
const reportActionRef = useRef<EnterpriseResearchReportJob["action"]>("initial");
useEffect(() => {
let alive = true;
void listEnterpriseResearchTasks()
.then((stored) => {
if (!alive) return;
setTasks(stored);
setActiveTaskId(stored[0]?.id ?? null);
})
.catch(() => {
if (alive) toast.error("研究任务读取失败,请稍后重试");
});
return () => { alive = false; };
}, []);
const activeTask = useMemo(
() => tasks.find((task) => task.id === activeTaskId) ?? null,
[activeTaskId, tasks],
);
const template = getTemplate(templateId);
const selectedSource = activeTask?.sources.find((source) => source.chunk_id === selectedSourceId) ?? activeTask?.sources[0] ?? null;
const isWeKnoraReady = runtime.data?.provider === "weknora";
const consumeReportStream = (taskId: string, jobId: string, signal?: AbortSignal) => streamEnterpriseResearchReportJob(taskId, jobId, {
signal,
onEvent: (event: EnterpriseResearchReportEvent) => {
if (event.type === "snapshot") {
setReportJob(event.job);
reportActionRef.current = event.job.action;
if (event.job.action === "followup") setJobDraft(event.job.report_markdown || "");
else setReportDraft(event.job.report_markdown || "");
return;
}
if (event.type === "report_delta") {
if (reportActionRef.current === "followup") setJobDraft((current) => current + event.delta);
else setReportDraft((current) => current + event.delta);
return;
}
if (event.type === "reasoning_delta") {
setReasoning((current) => (current + event.delta).slice(-6_000));
return;
}
if (event.type === "phase") {
setReportJob((current) => current ? { ...current, status: event.status, phase: event.phase } : current);
return;
}
if (event.type === "completed" || event.type === "failed" || event.type === "cancelled") {
if (!event.job) return;
setReportJob(event.job);
setReportJobs((current) => [event.job!, ...current.filter((item) => item.id !== event.job!.id)]);
if (event.job.action === "followup") {
setJobDraft(event.job.report_markdown || "");
} else if (event.type === "completed") {
setReportDraft(event.job.report_markdown || "");
setTasks((current) => current.map((task) => task.id === taskId ? {
...task,
report_markdown: event.job?.report_markdown ?? task.report_markdown,
report_summary: event.job?.report_summary ?? task.report_summary,
report_model_name: event.job?.model_name ?? task.report_model_name,
active_report_job_id: null,
} : task));
} else {
// A failed or cancelled rewrite may contain a persisted temporary
// draft. Never expose it as the committed report in local state.
setReportDraft(event.job.original_report || tasks.find((task) => task.id === taskId)?.report_markdown || "");
setTasks((current) => current.map((task) => task.id === taskId ? { ...task, active_report_job_id: null } : task));
}
if (event.type === "completed") toast.success("企业研究报告已完成");
if (event.type === "failed") toast.error(event.job.error_message || "报告生成失败");
}
},
}).catch((error: unknown) => {
if ((error as { name?: string } | null)?.name !== "AbortError") {
toast.error(error instanceof Error ? error.message : "报告流连接中断");
}
});
useEffect(() => {
if (!activeTaskId) {
setReportJob(null);
setReportJobs([]);
setReportDraft("");
setJobDraft("");
setReasoning("");
return;
}
const controller = new AbortController();
let alive = true;
setReportJob(null);
setReportJobs([]);
setReportDraft("");
setJobDraft("");
setReasoning("");
setIsStartingReport(false);
void listEnterpriseResearchReportJobs(activeTaskId)
.then((jobs) => {
if (!alive) return;
const latest = jobs[0] ?? null;
setReportJobs(jobs);
setReportJob(latest);
const latestReport = jobs.find((job) => job.status === "completed" && job.action !== "followup");
if (latestReport?.report_markdown) setReportDraft(latestReport.report_markdown);
const latestFollowup = jobs.find((job) => job.status === "completed" && job.action === "followup");
if (latestFollowup?.report_markdown) setJobDraft(latestFollowup.report_markdown);
if (latest && (latest.status === "queued" || latest.status === "running")) {
reportActionRef.current = latest.action;
void consumeReportStream(activeTaskId, latest.id, controller.signal);
}
})
.catch(() => {
if (alive) toast.error("报告任务读取失败,请稍后重试");
});
return () => {
alive = false;
controller.abort();
};
// The stream is intentionally scoped to the selected task only. It must not
// restart whenever token deltas update the task list.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTaskId]);
const resetComposer = () => {
setActiveTaskId(null);
setSubject("");
setFocus("");
setTemplateId("topic");
setSelectedKnowledgeBaseIds([]);
setSelectedSourceId(null);
setReportJob(null);
setReportJobs([]);
setReportDraft("");
setJobDraft("");
setReasoning("");
};
const toggleKnowledgeBase = (id: string, checked: boolean) => {
setSelectedKnowledgeBaseIds((current) => checked ? [...new Set([...current, id])] : current.filter((item) => item !== id));
};
const startResearch = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const normalizedSubject = subject.trim();
if (!normalizedSubject) {
toast.error("请先填写研究主题");
return;
}
if (!isWeKnoraReady) {
toast.error("知识服务尚未启用,请先在系统中配置 WeKnora");
return;
}
const selectedTemplate = getTemplate(templateId);
const queries = [...new Set(selectedTemplate.buildQueries(normalizedSubject, focus.trim()).map((item) => item.trim()).filter(Boolean))];
try {
const created = await createEnterpriseResearchTask({
subject: normalizedSubject,
focus: focus.trim(),
template_id: templateId,
knowledge_base_ids: selectedKnowledgeBaseIds,
queries,
});
const collecting: EnterpriseResearchTask = { ...created, status: "collecting" };
setTasks((current) => [collecting, ...current.filter((item) => item.id !== collecting.id)].slice(0, 20));
setActiveTaskId(created.id);
setSelectedSourceId(null);
const completed = await collectEnterpriseResearchTask(created.id);
setTasks((current) => current.map((item) => item.id === completed.id ? completed : item));
if (completed.sources[0]) setSelectedSourceId(completed.sources[0].chunk_id);
if (completed.status === "failed") toast.error("未检索到可用证据");
else toast.success(`已完成证据收集:${completed.sources.length} 条资料`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "创建研究任务失败,请稍后重试");
}
};
const createPlanDraft = async (taskId: string) => {
const updated = await createEnterpriseResearchPlanDraft(taskId);
setTasks((current) => current.map((item) => item.id === updated.id ? updated : item));
return updated;
};
const createAiPlan = async (taskId: string, input: { model_name?: string | null; instruction?: string }) => {
const updated = await createEnterpriseResearchAiPlan(taskId, input);
setTasks((current) => current.map((item) => item.id === updated.id ? updated : item));
return updated;
};
const savePlan = async (taskId: string, input: { plan_markdown: string; selected_source_ids: string[] }) => {
const updated = await updateEnterpriseResearchPlan(taskId, input);
setTasks((current) => current.map((item) => item.id === updated.id ? updated : item));
return updated;
};
const startReport = async (
taskId: string,
input: Parameters<typeof createEnterpriseResearchReportJob>[1] = {},
) => {
if (isStartingReport) return;
setIsStartingReport(true);
try {
const job = await createEnterpriseResearchReportJob(taskId, input);
reportActionRef.current = job.action;
setReportJob(job);
setReportJobs((current) => [job, ...current.filter((item) => item.id !== job.id)]);
if (job.action === "followup") setJobDraft(job.report_markdown || "");
else setReportDraft(job.report_markdown || "");
setReasoning("");
void consumeReportStream(taskId, job.id);
} catch (error) {
toast.error(error instanceof Error ? error.message : "创建报告写作任务失败");
} finally {
setIsStartingReport(false);
}
};
const cancelReport = async (taskId: string, jobId: string) => {
const cancelled = await cancelEnterpriseResearchReportJob(taskId, jobId);
setReportJob(cancelled);
setReportJobs((current) => [cancelled, ...current.filter((item) => item.id !== cancelled.id)]);
if (cancelled.action === "followup") setJobDraft("");
else setReportDraft(tasks.find((task) => task.id === taskId)?.report_markdown || "");
setTasks((current) => current.map((task) => task.id === taskId ? { ...task, active_report_job_id: null } : task));
toast.success("已取消报告任务,已保存的正式报告未受影响");
};
const restoreReport = async (taskId: string, jobId: string) => {
const updated = await restoreEnterpriseResearchReportVersion(taskId, jobId);
const jobs = await listEnterpriseResearchReportJobs(taskId);
setTasks((current) => current.map((task) => task.id === updated.id ? updated : task));
setReportJobs(jobs);
setReportJob(jobs[0] ?? null);
setReportDraft(updated.report_markdown || "");
toast.success("已恢复改写前版本");
};
return (
<div className="flex h-full min-h-0 flex-col bg-background">
<header className="flex shrink-0 items-center justify-between border-b bg-card px-5 py-3.5 sm:px-7">
<div className="flex min-w-0 items-center gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<FileSearch className="size-5" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h1 className="truncate text-base font-semibold sm:text-lg">企业深度研究</h1>
<Badge variant="outline" className="text-muted-foreground">独立工作台</Badge>
</div>
<p className="hidden text-xs text-muted-foreground sm:block">面向内部知识库的研究任务、证据编排与可追溯交付</p>
</div>
</div>
<Button size="sm" onClick={resetComposer}>
<Plus className="mr-1.5 size-4" />
新建任务
</Button>
</header>
<div className="grid min-h-0 flex-1 grid-cols-1 overflow-hidden xl:grid-cols-[272px_minmax(440px,0.9fr)_minmax(420px,1.1fr)]">
<aside className="min-h-0 border-b bg-muted/20 xl:border-r xl:border-b-0">
<div className="flex items-center justify-between px-5 py-4">
<div>
<p className="text-sm font-semibold">研究任务</p>
<p className="mt-0.5 text-xs text-muted-foreground">服务端保留最近 20 条</p>
</div>
<Badge variant="secondary">{tasks.length}</Badge>
</div>
<ScrollArea className="h-[220px] xl:h-[calc(100%-76px)]">
<div className="space-y-1 px-3 pb-4">
{tasks.length ? tasks.map((task) => (
<button
key={task.id}
type="button"
onClick={() => { setActiveTaskId(task.id); setSelectedSourceId(task.sources[0]?.chunk_id ?? null); }}
className={cn(
"w-full rounded-lg border px-3 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
activeTaskId === task.id ? "border-primary/35 bg-primary/8" : "border-transparent hover:bg-accent",
)}
>
<div className="flex items-start justify-between gap-2">
<span className="line-clamp-2 text-sm font-medium leading-5">{task.subject}</span>
<StatusBadge status={task.status} />
</div>
<div className="mt-2 flex items-center gap-2 text-xs text-muted-foreground">
<span>{task.sources.length} 条资料</span><span>·</span><span>{formatTime(task.updated_at || task.created_at)}</span>
</div>
</button>
)) : (
<div className="rounded-lg border border-dashed p-4 text-center text-sm text-muted-foreground">尚无任务。创建第一个研究任务开始。</div>
)}
</div>
</ScrollArea>
</aside>
<main className="min-h-0 overflow-y-auto border-b p-5 xl:border-r xl:border-b-0 xl:p-6">
{!activeTaskId ? (
<form className="mx-auto max-w-2xl space-y-5" onSubmit={startResearch}>
<section>
<Badge variant="secondary" className="mb-3"><Sparkles className="size-3" />阶段一:证据编排</Badge>
<h2 className="text-xl font-semibold tracking-tight">建立一个可追溯的研究任务</h2>
<p className="mt-2 max-w-xl text-sm leading-6 text-muted-foreground">页面独立于现有“深度研究”功能。任务、检索词与证据快照保存在 DeerFlow 后端;当前版本只使用已授权的 WeKnora 知识库,不会修改任何知识库或既有报告。</p>
</section>
<Card>
<CardHeader className="gap-1">
<CardTitle className="text-base">研究对象与范围</CardTitle>
<CardDescription>用一句话明确需要研究的对象;补充范围可帮助生成更聚焦的检索方向。</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<label className="block space-y-2 text-sm font-medium" htmlFor="enterprise-research-subject">
研究主题 <span className="text-destructive">*</span>
<Input id="enterprise-research-subject" value={subject} onChange={(event) => setSubject(event.target.value)} placeholder="例如:新能源汽车产业链的供应风险" maxLength={180} />
</label>
<label className="block space-y-2 text-sm font-medium" htmlFor="enterprise-research-focus">
重点范围 <span className="font-normal text-muted-foreground">(可选)</span>
<Textarea id="enterprise-research-focus" value={focus} onChange={(event) => setFocus(event.target.value)} placeholder="例如:聚焦 2025 年以来的海外供应、关键原材料与对国内产能的影响" className="min-h-22 resize-y" maxLength={800} />
</label>
</CardContent>
</Card>
<Card>
<CardHeader className="gap-1">
<CardTitle className="text-base">选择研究模板</CardTitle>
<CardDescription>模板只决定问题拆解和证据编排方式,不会替换你的原始问题。</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-3">
{TEMPLATES.map((item) => (
<button
type="button"
key={item.id}
onClick={() => setTemplateId(item.id)}
className={cn(
"rounded-lg border p-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
templateId === item.id ? "border-primary bg-primary/5" : "hover:bg-accent",
)}
>
<span className="block text-sm font-semibold">{item.name}</span>
<span className="mt-1.5 block text-xs leading-5 text-muted-foreground">{item.description}</span>
</button>
))}
</CardContent>
</Card>
<Card>
<CardHeader className="gap-1">
<CardTitle className="flex items-center gap-2 text-base"><Database className="size-4 text-primary" />证据范围</CardTitle>
<CardDescription>{isWeKnoraReady ? "只显示当前账号已授权的知识库。未选择时会检索全部可见知识库。" : "知识服务未启用,无法开始内部证据检索。"}</CardDescription>
</CardHeader>
<CardContent>
{knowledgeBases.isLoading ? <div className="flex items-center gap-2 text-sm text-muted-foreground"><Loader2 className="size-4 animate-spin" />正在读取知识库…</div> : knowledgeBases.isError ? <div className="flex items-center gap-2 text-sm text-destructive"><AlertCircle className="size-4" />知识库读取失败</div> : (
<div className="space-y-2">
{knowledgeBases.data?.length ? knowledgeBases.data.map((base) => {
const checked = selectedKnowledgeBaseIds.includes(base.id);
return <label key={base.id} className="flex cursor-pointer items-start gap-3 rounded-md border p-3 hover:bg-accent/60">
<Checkbox checked={checked} onCheckedChange={(next) => toggleKnowledgeBase(base.id, next)} aria-label={`选择知识库 ${base.name}`} />
<span className="min-w-0 flex-1"><span className="block truncate text-sm font-medium">{base.name}</span><span className="mt-0.5 block line-clamp-1 text-xs text-muted-foreground">{base.description || `${base.knowledge_count} 份资料`}</span></span>
<span className="text-xs text-muted-foreground">{base.chunk_count} 段</span>
</label>;
}) : <p className="rounded-md border border-dashed p-3 text-sm text-muted-foreground">当前账号没有可用知识库,可先前往“知识库管理”添加或发布资料。</p>}
</div>
)}
</CardContent>
</Card>
<div className="flex flex-col gap-3 rounded-lg border bg-muted/30 p-4 sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm"><span className="font-medium">即将检索 {template.stages.length - 1} 个研究方向</span><p className="mt-1 text-xs text-muted-foreground">会生成主题、背景与重点范围三组可见检索词。</p></div>
<Button type="submit" disabled={!isWeKnoraReady || knowledgeBases.isLoading}><Play className="mr-1.5 size-4" />开始证据编排</Button>
</div>
</form>
) : activeTask ? (
<TaskOverview task={activeTask} reportJob={reportJob} isStartingReport={isStartingReport} onNew={resetComposer} onCreatePlanDraft={createPlanDraft} onCreateAiPlan={createAiPlan} onSavePlan={savePlan} />
) : null}
</main>
<section className="min-h-0 overflow-y-auto bg-muted/10 p-5 xl:p-6">
<EvidencePane task={activeTask} source={selectedSource} reportJob={reportJob} reportJobs={reportJobs} reportDraft={reportDraft} jobDraft={jobDraft} reasoning={reasoning} isStartingReport={isStartingReport} onStartReport={startReport} onCancelReport={cancelReport} onRestoreReport={restoreReport} onSourceSelect={setSelectedSourceId} />
</section>
</div>
</div>
);
}
function TaskOverview({
task,
reportJob,
isStartingReport,
onNew,
onCreatePlanDraft,
onCreateAiPlan,
onSavePlan,
}: {
task: EnterpriseResearchTask;
reportJob: EnterpriseResearchReportJob | null;
isStartingReport: boolean;
onNew: () => void;
onCreatePlanDraft: (taskId: string) => Promise<EnterpriseResearchTask>;
onCreateAiPlan: (taskId: string, input: { model_name?: string | null; instruction?: string }) => Promise<EnterpriseResearchTask>;
onSavePlan: (taskId: string, input: { plan_markdown: string; selected_source_ids: string[] }) => Promise<EnterpriseResearchTask>;
}) {
const template = getTemplate(task.template_id as TemplateId);
const [planMarkdown, setPlanMarkdown] = useState(task.plan_markdown ?? "");
const [selectedSourceIds, setSelectedSourceIds] = useState<string[]>(task.selected_source_ids ?? []);
const [isCreatingPlan, setIsCreatingPlan] = useState(false);
const [isSavingPlan, setIsSavingPlan] = useState(false);
const [isCreatingAiPlan, setIsCreatingAiPlan] = useState(false);
const [planModel, setPlanModel] = useState<string | null>(null);
const [planInstruction, setPlanInstruction] = useState("");
const canPlan = task.status === "ready" || task.status === "partial";
const isWriting = isStartingReport || reportJob?.status === "queued" || reportJob?.status === "running";
useEffect(() => {
setPlanMarkdown(task.plan_markdown ?? "");
setSelectedSourceIds(task.selected_source_ids ?? []);
}, [task.id, task.plan_markdown, task.selected_source_ids]);
const toggleSource = (sourceId: string, checked: boolean) => {
setSelectedSourceIds((current) => checked ? [...new Set([...current, sourceId])] : current.filter((item) => item !== sourceId));
};
const handleCreatePlan = async () => {
setIsCreatingPlan(true);
try {
const updated = await onCreatePlanDraft(task.id);
toast.success(`已根据 ${updated.selected_source_ids.length} 条资料生成可编辑研究计划`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "生成研究计划失败");
} finally {
setIsCreatingPlan(false);
}
};
const handleSavePlan = async () => {
setIsSavingPlan(true);
try {
const updated = await onSavePlan(task.id, { plan_markdown: planMarkdown, selected_source_ids: selectedSourceIds });
toast.success(`研究计划已保存,已选 ${updated.selected_source_ids.length} 条资料`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存研究计划失败");
} finally {
setIsSavingPlan(false);
}
};
const handleCreateAiPlan = async () => {
setIsCreatingAiPlan(true);
try {
const updated = await onCreateAiPlan(task.id, { model_name: planModel, instruction: planInstruction.trim() });
setPlanMarkdown(updated.plan_markdown ?? "");
setSelectedSourceIds(updated.selected_source_ids ?? []);
toast.success("AI 研究计划已生成,可继续人工修改后保存");
} catch (error) {
toast.error(error instanceof Error ? error.message : "AI 规划失败");
} finally {
setIsCreatingAiPlan(false);
}
};
return <div className="mx-auto max-w-2xl space-y-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div><Badge variant="secondary" className="mb-3"><ClipboardList className="size-3" />研究任务</Badge><h2 className="text-xl font-semibold tracking-tight">{task.subject}</h2>{task.focus ? <p className="mt-2 max-w-xl text-sm leading-6 text-muted-foreground">{task.focus}</p> : null}</div>
<div className="flex items-center gap-2"><StatusBadge status={task.status} /><Button size="sm" variant="outline" onClick={onNew}><Plus className="mr-1 size-4" />新建</Button></div>
</div>
<Card>
<CardHeader className="gap-1"><CardTitle className="text-base">研究路径</CardTitle><CardDescription>{template.name} · 每一步保留用户可核验的检索词和证据来源。</CardDescription></CardHeader>
<CardContent><ol className="space-y-4">{template.stages.map((stage, index) => {
const complete = task.status !== "collecting" && (index < 2 || task.sources.length > 0);
const current = task.status === "collecting" && index === 1;
return <li key={stage} className="flex gap-3"><span className={cn("flex size-7 shrink-0 items-center justify-center rounded-full border text-xs font-semibold", complete ? "border-emerald-300 bg-emerald-50 text-emerald-700" : current ? "border-primary bg-primary/10 text-primary" : "text-muted-foreground")}>{complete ? <CheckCircle2 className="size-4" /> : index + 1}</span><div className="min-w-0 pt-0.5"><p className="text-sm font-medium">{stage}</p><p className="mt-1 text-xs leading-5 text-muted-foreground">{index === 0 ? "已固定研究主题、范围和知识库权限。" : index === 1 ? task.status === "collecting" ? "正在调用 WeKnora 检索并去重证据。" : `已执行 ${task.queries.length} 个检索方向。` : index === 2 ? `${task.sources.length} 条去重后的资料可在右侧逐条核验。` : "研究计划会固定选用资料、章节结构和写作约束。"}</p></div></li>;
})}</ol></CardContent>
</Card>
<Card>
<CardHeader className="gap-1"><CardTitle className="text-base">已执行检索词</CardTitle><CardDescription>检索词由模板和你的研究范围生成,结果仅来自当前账号可见的 WeKnora 知识库。</CardDescription></CardHeader>
<CardContent><div className="flex flex-wrap gap-2">{task.queries.map((query) => <Badge key={query} variant="outline" className="max-w-full whitespace-normal py-1 text-left"><Search className="size-3" />{query}</Badge>)}</div>{task.warning ? <div className="mt-4 flex gap-2 rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100"><AlertCircle className="mt-0.5 size-4 shrink-0" />{task.warning}</div> : null}</CardContent>
</Card>
{canPlan ? <Card>
<CardHeader className="gap-1"><CardTitle className="text-base">研究计划与选用资料</CardTitle><CardDescription>计划和资料选择会保存到当前任务;后续报告只能使用这里确认过的证据。</CardDescription></CardHeader>
<CardContent className="space-y-4">
{!task.plan_markdown ? <div className="rounded-lg border border-dashed bg-muted/30 p-4"><p className="text-sm font-medium">证据已收集,可生成一份可编辑的研究计划</p><p className="mt-1 text-xs leading-5 text-muted-foreground">初版会按当前研究模板组织目标、核心问题、章节和写作约束;生成报告前仍可修改。</p><Button className="mt-3" size="sm" onClick={handleCreatePlan} disabled={isCreatingPlan}><Sparkles className="mr-1.5 size-4" />{isCreatingPlan ? "正在生成…" : "生成研究计划"}</Button></div> : <>
<div className="space-y-2"><div className="flex items-center justify-between gap-3"><label htmlFor={`enterprise-plan-${task.id}`} className="text-sm font-medium">可编辑研究计划</label><span className="text-xs text-muted-foreground">{planMarkdown.length} / 16,000</span></div><Textarea id={`enterprise-plan-${task.id}`} value={planMarkdown} onChange={(event) => setPlanMarkdown(event.target.value)} className="min-h-80 resize-y font-mono text-xs leading-6" maxLength={16_000} /></div>
<div className="space-y-2"><div className="flex items-center justify-between gap-3"><p className="text-sm font-medium">选用资料</p><span className="text-xs text-muted-foreground">已选 {selectedSourceIds.length} / {task.sources.length}</span></div><ScrollArea className="h-44 rounded-md border"><div className="space-y-1 p-2">{task.sources.map((source, index) => { const checked = selectedSourceIds.includes(source.chunk_id); return <label key={source.chunk_id || `${source.knowledge_id}-${index}`} className="flex cursor-pointer items-start gap-2 rounded-md px-2 py-2 hover:bg-muted"><Checkbox checked={checked} onCheckedChange={(next) => toggleSource(source.chunk_id, next === true)} aria-label={`选用资料 ${source.title || source.filename || index + 1}`} /><span className="min-w-0 text-xs leading-5"><span className="block truncate font-medium">{source.title || source.filename || "未命名资料"}</span><span className="block truncate text-muted-foreground">{source.knowledge_base_name || "知识库"}</span></span></label>; })}</div></ScrollArea></div>
<div className="rounded-lg border bg-muted/20 p-3"><div className="flex flex-wrap items-center gap-2"><ModelNameSelect value={planModel} onChange={setPlanModel} disabled={isCreatingAiPlan || isWriting} triggerClassName="w-40" /><Input value={planInstruction} onChange={(event) => setPlanInstruction(event.target.value)} placeholder="AI 规划补充要求(可选)" className="h-8 min-w-48 flex-1 text-xs" maxLength={2_000} /><Button size="sm" variant="outline" onClick={handleCreateAiPlan} disabled={isCreatingAiPlan || isWriting}><WandSparkles className="mr-1.5 size-4" />{isCreatingAiPlan ? "正在规划…" : "AI 优化计划"}</Button></div></div>
<div className="flex justify-end"><Button size="sm" onClick={handleSavePlan} disabled={isSavingPlan || !planMarkdown.trim() || isWriting}>{isSavingPlan ? "正在保存…" : "保存研究计划"}</Button></div>
{reportJob ? <div className="rounded-md border bg-muted/30 p-3 text-xs text-muted-foreground"><span className="font-medium text-foreground">报告任务:</span>{reportJob.status === "completed" ? "已完成" : reportJob.status === "failed" ? `失败:${reportJob.error_message || "请稍后重试"}` : `正在${reportJob.phase === "understanding" ? "理解研究计划" : reportJob.phase === "reviewing" ? "校验报告" : "流式写入 Markdown"}`};仅使用本任务已保存的计划与已选资料快照。</div> : null}
</>}
</CardContent>
</Card> : null}
</div>;
}
function EvidencePane({
task, source, reportJob, reportJobs, reportDraft, jobDraft, reasoning, isStartingReport,
onStartReport, onCancelReport, onRestoreReport, onSourceSelect,
}: {
task: EnterpriseResearchTask | null;
source: EnterpriseResearchEvidence | null;
reportJob: EnterpriseResearchReportJob | null;
reportJobs: EnterpriseResearchReportJob[];
reportDraft: string;
jobDraft: string;
reasoning: string;
isStartingReport: boolean;
onStartReport: (taskId: string, input?: Parameters<typeof createEnterpriseResearchReportJob>[1]) => Promise<void>;
onCancelReport: (taskId: string, jobId: string) => Promise<void>;
onRestoreReport: (taskId: string, jobId: string) => Promise<void>;
onSourceSelect: (id: string | null) => void;
}) {
const [modelName, setModelName] = useState<string | null>(null);
const [style, setStyle] = useState<"formal" | "analytical" | "concise">("formal");
const [targetLength, setTargetLength] = useState("5000");
const [rewriteInstruction, setRewriteInstruction] = useState("优化结构、逻辑与表达,减少重复内容,并保留事实与引用");
const [followup, setFollowup] = useState("");
const [diffJob, setDiffJob] = useState<EnterpriseResearchReportJob | null>(null);
if (!task) return <div className="flex h-full min-h-72 flex-col items-center justify-center rounded-xl border border-dashed p-8 text-center"><div className="flex size-11 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground"><BookOpenCheck className="size-5" /></div><h2 className="mt-4 text-sm font-semibold">等待研究任务</h2><p className="mt-1 max-w-xs text-sm leading-6 text-muted-foreground">提交主题后,这里会展示证据、研究报告和可追溯引用。</p></div>;
const isCollecting = task.status === "collecting";
const isWriting = isStartingReport || reportJob?.status === "queued" || reportJob?.status === "running";
const currentReport = isWriting && reportJob?.action !== "followup"
? reportDraft
: reportDraft || task.report_markdown || "";
const latestRewrite = reportJobs.find((job) => job.action === "rewrite" && job.status === "completed" && job.original_report);
const latestCommittedJob = reportJobs.find((job) => job.action !== "followup" && job.status === "completed");
const completedFollowups = reportJobs.filter((job) => job.action === "followup" && job.status === "completed");
const selectedSources = task.selected_source_ids.map((id) => task.sources.find((item) => item.chunk_id === id)).filter(Boolean) as EnterpriseResearchEvidence[];
const citationIndex = new Map(selectedSources.map((item, index) => [item.chunk_id, index + 1]));
const renderedReport = currentReport.replace(/\[资料:([^\]]+)\]/g, (match, id: string) => {
const index = citationIndex.get(id.trim());
return index ? `[[${index}]]` : match;
});
const parsedTarget = Number(targetLength);
const downloadMarkdown = () => {
const blob = new Blob([currentReport], { type: "text/markdown;charset=utf-8" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `${task.subject.replace(/[\\/:*?"<>|]/g, "-") || "企业研究报告"}.md`;
anchor.click();
URL.revokeObjectURL(url);
};
const start = (action: "initial" | "rewrite" | "followup", instruction = "") => onStartReport(task.id, {
model_name: modelName,
action,
instruction,
style,
target_length: Number.isFinite(parsedTarget) && parsedTarget >= 800 ? parsedTarget : null,
});
const phaseOrder = ["understanding", "writing", "reviewing", "completed"];
const phaseIndex = reportJob ? phaseOrder.indexOf(reportJob.phase) : -1;
return <div className="space-y-4">
{task.plan_markdown && !currentReport && !isWriting ? <Card>
<CardHeader className="gap-1"><CardTitle className="text-sm">报告写作配置</CardTitle><CardDescription>写作只使用已保存计划和 {task.selected_source_ids.length} 条已选资料。</CardDescription></CardHeader>
<CardContent className="space-y-3">
<div className="grid gap-2 sm:grid-cols-3"><ModelNameSelect value={modelName} onChange={setModelName} triggerClassName="w-full" /><Select value={style} onValueChange={(value) => setStyle(value as typeof style)}><SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="formal">正式报告</SelectItem><SelectItem value="analytical">分析研判</SelectItem><SelectItem value="concise">精炼摘要</SelectItem></SelectContent></Select><Input type="number" min={800} max={30000} value={targetLength} onChange={(event) => setTargetLength(event.target.value)} className="h-8 text-xs" aria-label="报告目标字数" /></div>
<Button className="w-full" onClick={() => void start("initial")}><Play className="mr-1.5 size-4" />开始流式撰写报告</Button>
</CardContent>
</Card> : null}
{isWriting && reportJob ? <Card>
<CardHeader className="gap-1"><div className="flex items-center justify-between gap-3"><CardTitle className="flex items-center gap-2 text-sm"><Loader2 className="size-4 animate-spin text-primary" />{reportJob.action === "followup" ? "正在回答报告追问" : reportJob.action === "rewrite" ? "正在全文改写" : "正在撰写研究报告"}</CardTitle><Button size="sm" variant="outline" onClick={() => void onCancelReport(task.id, reportJob.id)}><Square className="mr-1.5 size-3.5" />停止</Button></div><CardDescription>真实报告只在任务成功完成后提交;取消或失败不会覆盖已保存版本。</CardDescription></CardHeader>
<CardContent><ol className="grid gap-2 sm:grid-cols-4">{["理解要求", "流式写入", "校验报告", "保存版本"].map((label, index) => <li key={label} className={cn("rounded-md border px-3 py-2 text-xs", index < phaseIndex ? "border-emerald-200 bg-emerald-50 text-emerald-800" : index === phaseIndex ? "border-primary/40 bg-primary/5 text-primary" : "text-muted-foreground")}><span className="font-medium">{index + 1}. {label}</span></li>)}</ol>{reasoning ? <details className="mt-3 rounded-md border px-3 py-2 text-xs text-muted-foreground"><summary className="cursor-pointer">模型思考过程(不会写入报告)</summary><pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap font-sans leading-5">{reasoning}</pre></details> : null}</CardContent>
</Card> : null}
{currentReport ? <Card className="overflow-hidden">
<CardHeader className="border-b py-4"><div className="flex flex-wrap items-start justify-between gap-3"><div><CardTitle className="flex items-center gap-2 text-sm"><FileText className="size-4 text-primary" />{task.subject}</CardTitle><CardDescription className="mt-1">{latestCommittedJob?.report_summary || task.report_summary || `${currentReport.length} 字`}</CardDescription></div><div className="flex items-center gap-1"><Button size="icon-sm" variant="ghost" onClick={downloadMarkdown} aria-label="下载 Markdown" title="下载 Markdown"><Download className="size-4" /></Button><DownloadWordButton markdown={currentReport} fileName={task.subject} title={task.subject} aria-label="下载 Word" />{latestRewrite ? <Button size="sm" variant="outline" onClick={() => setDiffJob(latestRewrite)}>查看改写对比</Button> : null}</div></div></CardHeader>
<CardContent className="p-0"><ScrollArea className="h-[430px]"><article className="p-5"><MarkdownRenderer content={renderedReport} onCitationClick={(index) => onSourceSelect(selectedSources[index - 1]?.chunk_id ?? null)} /></article></ScrollArea></CardContent>
</Card> : null}
{currentReport && !isWriting ? <Card>
<CardHeader className="gap-1"><CardTitle className="text-sm">继续完善报告</CardTitle><CardDescription>全文改写会保存原版本;追问只生成回答,不会覆盖报告。</CardDescription></CardHeader>
<CardContent className="space-y-3"><div className="grid gap-2 sm:grid-cols-[160px_120px_1fr]"><ModelNameSelect value={modelName} onChange={setModelName} triggerClassName="w-full" /><Select value={style} onValueChange={(value) => setStyle(value as typeof style)}><SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="formal">正式报告</SelectItem><SelectItem value="analytical">分析研判</SelectItem><SelectItem value="concise">精炼表达</SelectItem></SelectContent></Select><Input type="number" min={800} max={30000} value={targetLength} onChange={(event) => setTargetLength(event.target.value)} className="h-8 text-xs" aria-label="改写目标字数" /></div><Textarea value={rewriteInstruction} onChange={(event) => setRewriteInstruction(event.target.value)} className="min-h-20 text-sm" maxLength={4_000} /><div className="flex justify-end gap-2">{latestRewrite ? <Button variant="outline" onClick={() => void onRestoreReport(task.id, latestRewrite.id)}>恢复最近改写前版本</Button> : null}<Button onClick={() => void start("rewrite", rewriteInstruction)}><WandSparkles className="mr-1.5 size-4" />全文改写</Button></div><div className="flex gap-2 border-t pt-3"><Input value={followup} onChange={(event) => setFollowup(event.target.value)} placeholder="基于当前报告和已选资料继续追问" maxLength={4_000} onKeyDown={(event) => { if (event.key === "Enter" && !event.shiftKey && followup.trim()) { event.preventDefault(); void start("followup", followup.trim()); setFollowup(""); } }} /><Button size="icon" disabled={!followup.trim()} onClick={() => { void start("followup", followup.trim()); setFollowup(""); }} aria-label="发送报告追问"><Send className="size-4" /></Button></div></CardContent>
</Card> : null}
{(jobDraft || completedFollowups.length) ? <Card><CardHeader className="gap-1"><CardTitle className="text-sm">报告追问</CardTitle><CardDescription>回答基于当前报告和已选资料,不会自动发起新检索。</CardDescription></CardHeader><CardContent className="space-y-4">{jobDraft ? <div className="rounded-md border bg-muted/20 p-4"><MarkdownRenderer content={jobDraft} /></div> : null}{completedFollowups.slice(0, 3).map((job) => job.id === reportJob?.id && jobDraft ? null : <div key={job.id} className="border-t pt-4"><p className="mb-2 text-xs font-medium text-muted-foreground">{job.instruction}</p><MarkdownRenderer content={job.report_markdown} /></div>)}</CardContent></Card> : null}
<div className="flex items-start justify-between gap-3"><div><p className="text-sm font-semibold">证据工作台</p><p className="mt-1 text-xs text-muted-foreground">{isCollecting ? "正在收集内部证据…" : `已收集 ${task.sources.length} 条去重资料;报告引用可点击定位`}</p></div>{isCollecting ? <Loader2 className="size-4 animate-spin text-primary" /> : <ShieldCheck className="size-5 text-emerald-600" />}</div>
{isCollecting ? <div className="rounded-xl border bg-card p-5 text-sm text-muted-foreground"><div className="flex items-center gap-2"><Loader2 className="size-4 animate-spin" />正在执行 {task.queries.length} 个检索方向</div></div> : task.sources.length ? <div className="grid gap-4 2xl:grid-cols-[minmax(220px,0.8fr)_minmax(260px,1.2fr)]"><div className="rounded-xl border bg-card"><div className="border-b px-4 py-3 text-xs font-medium text-muted-foreground">资料列表</div><ScrollArea className="h-80"><div className="p-2">{task.sources.map((item, index) => <button key={item.chunk_id || `${item.knowledge_id}-${index}`} type="button" onClick={() => onSourceSelect(item.chunk_id)} className={cn("w-full rounded-lg p-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", source?.chunk_id === item.chunk_id ? "bg-primary/10" : "hover:bg-muted")}><p className="line-clamp-2 text-sm font-medium leading-5">{index + 1}. {item.title || item.filename || "未命名资料"}</p><p className="mt-1 truncate text-xs text-muted-foreground">{item.knowledge_base_name || "知识库"}</p></button>)}</div></ScrollArea></div><article className="rounded-xl border bg-card p-5"><p className="text-sm font-semibold">{source?.title || source?.filename || "资料预览"}</p><p className="mt-1 text-xs text-muted-foreground">{source?.knowledge_base_name || "知识库"} · 段落 {typeof source?.chunk_index === "number" ? source.chunk_index + 1 : "-"}</p><p className="mt-5 whitespace-pre-wrap text-sm leading-7 text-foreground/90">{source?.content || "请选择一条资料查看内容。"}</p>{source?.url ? <button type="button" className="mt-5 inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline" onClick={() => { if (!openKnowledgeSource(source)) toast.error("暂时无法定位该资料原文"); }}><ExternalLink className="size-4" />定位知识库原文</button> : null}</article></div> : <div className="rounded-xl border border-dashed bg-card p-6 text-center"><FileText className="mx-auto size-6 text-muted-foreground" /><h2 className="mt-3 text-sm font-semibold">未发现可用资料</h2></div>}
<DocumentRewriteDiffDialog
open={Boolean(diffJob)}
onOpenChange={(open) => { if (!open) setDiffJob(null); }}
fileName={`${task.subject}.md`}
original={diffJob?.original_report || ""}
rewritten={diffJob?.report_markdown || ""}
/>
</div>;
}
+5 -4
View File
@@ -40,7 +40,7 @@ import {
useThreadSettings,
} from "@/core/settings";
import { usePluginManagementSettings } from "@/core/system-settings/hooks";
import { useThreadStream } from "@/core/threads/hooks";
import { stopActiveThreadBeforeClarificationReply, useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env";
import { cn } from "@/lib/utils";
@@ -336,11 +336,12 @@ export default function IframeChatPage() {
);
const handleClarificationSubmit = useCallback(
(answer: string) => {
async (answer: string) => {
setHasStartedConversation(true);
void sendMessage(threadId, { text: answer, files: [] });
await stopActiveThreadBeforeClarificationReply(threadId, thread);
await sendMessage(threadId, { text: answer, files: [] });
},
[sendMessage, threadId],
[sendMessage, thread, threadId],
);
const handleAgentChange = useCallback((agentId: string | null) => {
+42 -17
View File
@@ -18,11 +18,13 @@ import {
readExternalLoginParams,
resolveThemeFromQuery,
} from '@/core/auth/external-login';
import { toast } from 'sonner';
import {
fetchCopTaskDetail,
persistCopTaskDetail,
resolveDeeplinkTaskId,
resolveTaskDeeplinkTarget,
stubCopTaskDetail,
} from '@/core/auth/task-deeplink';
import { resolveUsernameByOnlineTicket } from '@/core/auth/wujie-ticket-login';
import { isWujieEnv, useWujieProps } from '@/core/embed/wujie';
@@ -61,6 +63,17 @@ function maskLoginToken(value: string): string {
return `${value.slice(0, 6)}...${value.slice(-4)}(${value.length})`;
}
/**
* Hash 路由下深链参数可能写在 ``#/login?...``,也可能写在 hash 前的
* ``?taskId=&goPath=#/login``。两边都读,避免宿主把 query 放在外层 URL 时丢参落到首页。
*/
function readLoginQuery(searchParams: URLSearchParams, key: string): string {
const fromHash = searchParams.get(key)?.trim() ?? '';
if (fromHash) return fromHash;
if (typeof window === 'undefined') return '';
return new URLSearchParams(window.location.search).get(key)?.trim() ?? '';
}
/** 读取已落地的 userInfo(脱敏 access token 后)供登录后排查。 */
function summarizeStoredUserInfo(): Record<string, unknown> | null {
try {
@@ -206,8 +219,8 @@ export default function LoginPage() {
const effectiveGatePassword = configuredRouteLoginPassword || gatePassword;
const usesConfiguredRouteLoginPassword = Boolean(configuredRouteLoginPassword);
// 任务深链:外部系统带 ?taskId=&goPath= 时,登录后拉任务详情并直送对应问答/会商页
const taskId = searchParams.get('taskId')?.trim() ?? '';
const goPath = searchParams.get('goPath')?.trim() ?? '';
const taskId = readLoginQuery(searchParams, 'taskId');
const goPath = readLoginQuery(searchParams, 'goPath');
// 通用「登录后跳指定页」:?redirect=page/strategy/dashboard-agent(值可不带前导斜杠,
// 也可自带 query,如 page/strategy/dashboard-agent?taskId=1580——记得整体 URL 编码)。
// 优先级最高:显式指定即按它跳,先于 taskId/goPath 深链与默认首页。出于安全只接受**站内
@@ -447,26 +460,38 @@ export default function LoginPage() {
return;
}
// 任务深链:带 taskId + goPath 时拉任务详情并直送对应问答/会商页;
// 任一环节失败(无地址/拉取失败/未配置智能体/goPath 无法识别)都回退默认首页。
// 任务深链:带 taskId + goPath 时直送对应问答/会商页。
// 任务详情拉取失败不再阻断跳转(本地/外网 consumer 常不可达),用仅含 taskId 的 stub 继续。
// 真正无法解析(goPath 无法识别 / 单智能体未配置业务映射)才回退默认首页。
if (taskId && goPath) {
try {
// xdfx 深链 ?taskId= 传入的是「行动id」,先换成真正的「任务id」;其它业务原样。
const realTaskId = await resolveDeeplinkTaskId(goPath, taskId);
// xdfx 深链 ?taskId= 传入的是「行动id」,先换成真正的「任务id」;解析失败回退原始 id。
const resolvedTaskId = await resolveDeeplinkTaskId(goPath, taskId);
const realTaskId = resolvedTaskId || taskId;
if (cancelled) return;
const detail = await fetchCopTaskDetail(realTaskId);
let detail = await fetchCopTaskDetail(realTaskId);
if (cancelled) return;
if (detail) {
persistCopTaskDetail(detail);
const target = await resolveTaskDeeplinkTarget(goPath, detail, realTaskId, taskId);
if (cancelled) return;
if (target) {
navigate(target.path, { replace: true, state: target.state });
return;
}
if (!detail) {
console.warn('[login] cop-task-detail missing, continue with stub', {
goPath,
taskId,
realTaskId,
});
detail = stubCopTaskDetail(realTaskId);
}
} catch {
/* 深链失败不阻断登录,落默认首页 */
persistCopTaskDetail(detail);
const target = await resolveTaskDeeplinkTarget(goPath, detail, realTaskId, taskId);
if (cancelled) return;
if (target) {
console.info('[login] deeplink →', target.path, { goPath, realTaskId });
navigate(target.path, { replace: true, state: target.state });
return;
}
console.warn('[login] deeplink target unresolved', { goPath, realTaskId });
toast.warning('深链未配置对应智能体或 goPath 无法识别,已进入首页');
} catch (error) {
console.warn('[login] deeplink failed, fallback home', error);
toast.warning('深链跳转失败,已进入首页');
}
}
+22 -13
View File
@@ -78,9 +78,11 @@ import {
type MenuOverride,
type MergedMenuNode,
} from "@/core/page-layout/menu-overrides";
import type {
CompactMenuOverride,
CompactMenuPlacement,
import {
COMPACT_MENU_TYPE_LABELS,
getRuntimeCompactMenuType,
type CompactMenuOverride,
type CompactMenuPlacement,
} from "@/core/page-layout/compact-menu-overrides";
import { sidebarMenuItems, type MenuItem } from "@/core/page-layout/sidebar-menu";
import { injectLightApps } from "@/core/page-layout/use-sidebar-menu";
@@ -506,7 +508,7 @@ export default function MenuManagementPage() {
() => [
{
id: "workspace-compact-nav",
label: "简介模式菜单",
label: "简洁模式菜单",
icon: Settings,
children: WORKSPACE_COMPACT_NAV_ITEMS,
},
@@ -775,7 +777,7 @@ export default function MenuManagementPage() {
mode === "compact" ? "bg-red-600 text-white shadow-sm" : "text-slate-500 hover:text-slate-800"
}`}
>
简介模式
简洁模式
</button>
</div>
);
@@ -975,8 +977,11 @@ export default function MenuManagementPage() {
}
function CompactMenuManagementPanel({ baseTree }: { baseTree: MenuItem[] }) {
const { data: overrides = [], isLoading } = useCompactMenuOverrides();
const { save } = useCompactMenuOverrideActions();
const runtimeType = getRuntimeCompactMenuType();
// 不再提供 A/B 切换:固定编辑当前网页(runtime-config.js VITE_COMPACT_MENU_TYPE)使用的那套。
const menuType = runtimeType;
const { data: overrides = [], isLoading } = useCompactMenuOverrides(menuType);
const { save } = useCompactMenuOverrideActions(menuType);
const initialRows = useMemo(() => compactRowsFromTree(baseTree, overrides), [baseTree, overrides]);
const [rows, setRows] = useState<CompactRow[]>(initialRows);
const [loadedKey, setLoadedKey] = useState("");
@@ -988,8 +993,9 @@ function CompactMenuManagementPanel({ baseTree }: { baseTree: MenuItem[] }) {
(top.children ?? []).map((child) => [top.id, child.id, child.label]),
),
overrides,
menuType,
}),
[baseTree, overrides],
[baseTree, overrides, menuType],
);
useEffect(() => {
if (sourceKey !== loadedKey) {
@@ -1031,14 +1037,14 @@ function CompactMenuManagementPanel({ baseTree }: { baseTree: MenuItem[] }) {
const handleSave = async () => {
try {
await save.mutateAsync(currentOverrides);
toast.success("简介菜单配置已保存");
toast.success(`简洁菜单配置已保存(${COMPACT_MENU_TYPE_LABELS[menuType]})`);
} catch (err) {
toast.error(err instanceof Error ? err.message : "保存失败");
}
};
const handleReset = () => {
if (!confirm("确定要重置简介模式菜单为默认布局吗?保存后将清除简介模式自定义配置。")) return;
if (!confirm(`确定要重置${COMPACT_MENU_TYPE_LABELS[menuType]}的简洁模式菜单为默认布局吗?保存后将清除该类型的自定义配置。`)) return;
setRows(compactRowsFromTree(baseTree, []));
};
@@ -1053,10 +1059,10 @@ function CompactMenuManagementPanel({ baseTree }: { baseTree: MenuItem[] }) {
<div>
<span className="mb-3 inline-flex select-none items-center gap-1.5 rounded-full border border-red-100 bg-red-50 px-3 py-1 text-xs font-semibold text-red-600">
<Sparkles className="size-3" />
仅简介模式生效
仅简洁模式生效
</span>
<h1 className="flex items-center gap-3 text-2xl font-bold tracking-tight text-slate-800 md:text-3xl">
简介菜单管理
简洁菜单管理
<span className="rounded-lg border border-slate-200/60 bg-slate-100 px-2.5 py-0.5 font-mono text-xs font-medium text-slate-500">
{rows.length} 个项目
</span>
@@ -1084,7 +1090,10 @@ function CompactMenuManagementPanel({ baseTree }: { baseTree: MenuItem[] }) {
</div>
<p className="flex max-w-2xl items-start gap-2.5 rounded-2xl border border-slate-200/50 bg-slate-50/70 p-3.5 text-sm leading-relaxed text-slate-600">
<Info className="mt-0.5 size-4 shrink-0 text-slate-400" />
<span>这里配置的是简介模式工作区左侧菜单:可改显示名称、调整顺序,或迁移到「设置和更多」下拉中;全量模式菜单不受影响。</span>
<span>
这里配置的是简洁模式工作区左侧菜单,保存后仅对当前网页使用的
{COMPACT_MENU_TYPE_LABELS[runtimeType]}生效(两套部署各改各的);全量模式菜单不受影响。
</span>
</p>
</div>
</div>
+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>
+15 -3
View File
@@ -53,7 +53,7 @@ import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks";
import { isAgentSkillCandidate } from "@/core/skills";
import { useSkills } from "@/core/skills/hooks";
import { useThreadStream } from "@/core/threads/hooks";
import { stopActiveThreadBeforeClarificationReply, useThreadStream } from "@/core/threads/hooks";
import { uuid } from "@/core/utils/uuid";
import { cn } from "@/lib/utils";
@@ -335,13 +335,25 @@ export default function NewAgentPage() {
// Chat is purely a discussion channel. The agent record is only
// created when the user explicitly clicks Save (top-right). If the
// model proposes a config via setup_agent, our approval handler
// prefills the form locally 鈥?it does NOT persist anything.
// prefills the form locally — it does NOT persist anything.
await ensureThreadExists();
await sendMessage(threadId, { text: trimmed, files: [] });
},
[ensureThreadExists, sendMessage, thread.isLoading, threadId],
);
const handleClarificationSubmit = useCallback(
async (text: string) => {
const trimmed = text.trim();
if (!trimmed) return;
// 协助卡提交不能因 isLoading 直接丢弃——弱模型出卡后可能仍在流式。
await stopActiveThreadBeforeClarificationReply(threadId, thread);
await ensureThreadExists();
await sendMessage(threadId, { text: trimmed, files: [] });
},
[ensureThreadExists, sendMessage, thread, threadId],
);
const handleApproveSetupAgent = useCallback(
async (payload: SetupAgentApprovalPayload) => {
// Approving the model's setup_agent proposal **does not** persist
@@ -425,7 +437,7 @@ export default function NewAgentPage() {
className="size-full"
threadId={threadId}
thread={thread}
onClarificationSubmit={handleChatSubmit}
onClarificationSubmit={handleClarificationSubmit}
onSetupAgentApprove={handleApproveSetupAgent}
onSetupAgentReject={handleRejectSetupAgent}
/>
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { useAgentSelector } from "@/core/agents";
import { createClientId } from "@/lib/client-id";
import {
type DomainAgentMapping,
normalizeDomainInput,
@@ -20,10 +21,7 @@ import {
function makeRow(): DomainAgentMapping {
return {
id:
typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
id: createClientId(),
domain: "",
agentId: "",
enabled: true,
@@ -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>
);
+11
View File
@@ -26,6 +26,8 @@ import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtab
import EightBfRoundtablePage from "@/eightbf-roundtable/pages/EightBfRoundtablePage";
import DashboardAgentPage from "@/roundtable-planning/pages/DashboardAgentPage";
import DeepResearchPage from "./DeepResearchPage";
import ReportStructureCreatePage from "./deep-research/ReportStructureCreatePage";
import EnterpriseResearchPage from "./EnterpriseResearchPage";
import BusinessChainListPage from "@/roundtable-planning/pages/BusinessChainListPage";
import BusinessChainEditorPage from "@/roundtable-planning/pages/BusinessChainEditorPage";
import "@/roundtable-planning/styles/roundtable-planning.css";
@@ -118,6 +120,15 @@ export default function StrategyRoutes() {
}
/>
<Route path="qa/deep-research" element={<DeepResearchPage />} />
<Route
path="qa/deep-research/structures/new"
element={
<ChatRuntime>
<ReportStructureCreatePage />
</ChatRuntime>
}
/>
<Route path="qa/enterprise-research" element={<EnterpriseResearchPage />} />
{/* 大屏绘制智能体独立页面(按 taskId 存取;ChatRuntime 提供 ArtifactsProvider 给沙箱预览) */}
<Route
@@ -25,6 +25,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { useNotebookChat, useNotebookSources, useNotebookThreads, useStudioNotebooks } from "@/core/studio/hooks";
import { stopActiveThreadBeforeClarificationReply } from "@/core/threads/hooks";
import { fetchDocumentByChunk } from "@/core/studio/api";
import type { DocumentWithChunks } from "@/core/studio/api";
import type { SourceItem } from "@/core/studio/types";
@@ -381,12 +382,13 @@ export default function StudioNotebookDetailPage({
// Clarification 卡片的「发送」按钮回调。MessageList 内部把用户选中的选项 +
// 自由文本合并成一段 answer 传过来,这里走和普通发送同一条 send() 链路,
// 把当前选中的来源也一并带上,保持上下文一致。
// 弱模型出卡后可能仍在流式:先 stop 再发,不能因 isStreaming 直接丢弃。
const handleClarificationSubmit = useCallback(
async (answer: string) => {
if (isStreaming) return;
await send(answer, selectedSourceIds);
await stopActiveThreadBeforeClarificationReply(chatThreadId, thread);
await send(answer, selectedSourceIds, { fromClarification: true });
},
[send, selectedSourceIds, isStreaming],
[send, selectedSourceIds, chatThreadId, thread],
);
const handleBackToList = () => {
+36 -2
View File
@@ -173,7 +173,7 @@ export default function TaskButtonsPage() {
<div>
<div className="text-foreground text-base font-semibold">按钮管理</div>
<div className="text-muted-foreground text-xs">
配置任务工作区底部「快捷跳转」栏的按钮(名称 / 跳转方式 / 地址 / 启停 / 排序)
配置任务工作区底部「快捷跳转」栏的按钮(名称 / 跳转方式 / 地址 / 启停 / 排序)。「宿主路由」用于 iframe 套用时让对方系统跳转。
</div>
</div>
</div>
@@ -265,13 +265,14 @@ export default function TaskButtonsPage() {
value={r.linkType}
onValueChange={(v) => update(r.id, { linkType: v as TaskButton["linkType"] })}
>
<SelectTrigger className="h-8 w-36">
<SelectTrigger className="h-8 w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="url">外部链接</SelectItem>
<SelectItem value="business">切换业务</SelectItem>
<SelectItem value="purpose">目的树弹框</SelectItem>
<SelectItem value="host">宿主路由</SelectItem>
</SelectContent>
</Select>
</div>
@@ -282,6 +283,39 @@ export default function TaskButtonsPage() {
<span className="text-muted-foreground text-xs">
点击弹出当前任务的「任务目的」表格,无需配置目标。
</span>
) : r.linkType === "host" ? (
<>
<Input
value={r.target}
onChange={(e) => update(r.id, { target: e.target.value })}
placeholder="/task/…(宿主系统路由)"
className="h-8 flex-1 min-w-[16rem]"
title="宿主系统路由,点击后通过 postMessage 让父页面跳转"
/>
<label className="text-muted-foreground ml-2 flex shrink-0 items-center gap-1.5 text-xs">
<Switch
checked={r.appendTaskId}
onCheckedChange={(v) => update(r.id, { appendTaskId: v })}
/>
追加任务id
</label>
{r.appendTaskId && (
<div className="flex shrink-0 items-center gap-1 text-xs">
<span className="text-muted-foreground">queryParams.</span>
<Input
value={r.idParam}
onChange={(e) => update(r.id, { idParam: e.target.value })}
placeholder="id"
className="h-8 w-24"
title="发给宿主的参数名(默认 id)"
/>
<span className="text-muted-foreground">= 任务id(数字)</span>
</div>
)}
<span className="text-muted-foreground w-full text-[11px]">
iframe 嵌入时向父页面发送 HOST_NAVIGATE,由宿主系统跳转;独立打开本系统时无效。
</span>
</>
) : r.linkType === "url" ? (
<>
<Input
@@ -64,6 +64,8 @@ import PluginManagementPage from './PluginManagementPage';
import RecommendedQuestionsPage from './RecommendedQuestionsPage';
import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
import DeepResearchPage from './DeepResearchPage';
import ReportStructureCreatePage from './deep-research/ReportStructureCreatePage';
import EnterpriseResearchPage from './EnterpriseResearchPage';
import StudioNotebooksPage from './StudioNotebooksPage';
import StudioNotebookDetailPage from './StudioNotebookDetailPage';
import RoundtablePlanningPage from '@/roundtable-planning/pages/RoundtablePlanningPage';
@@ -564,6 +566,24 @@ export default function WorkspaceRoutes({
</WorkspaceLayout>
}
/>
<Route
path="deep-research/structures/new"
element={
<WorkspaceLayout {...layoutProps}>
<ChatRuntime>
<ReportStructureCreatePage />
</ChatRuntime>
</WorkspaceLayout>
}
/>
<Route
path="enterprise-research"
element={
<WorkspaceLayout {...layoutProps}>
<EnterpriseResearchPage />
</WorkspaceLayout>
}
/>
<Route
path="studio/notebooks"
element={
File diff suppressed because it is too large. Load diff
@@ -32,6 +32,16 @@ import {
type ResearchMode,
type ResearchTone,
} from "@/strategy-components/api/deep-research";
import {
DEFAULT_REPORT_STRUCTURE_MODE,
normalizeReportStructureMode,
normalizeRetrievalDirections,
normalizeRetrievalSkills,
REPORT_STRUCTURE_MODE_OPTIONS,
type ReportStructureMode,
useReportStructures,
} from "@/strategy-components/api/report-structures";
import { WorkflowConfigButton } from "./WorkflowConfigDialog";
const MODE_LABELS: Record<ResearchMode, string> = {
basic: "标准",
@@ -63,8 +73,317 @@ export type AdvancedResearchOptions = {
maxGeneratedImages: string;
thinkingEnabled: boolean;
customOutline: string;
structureMode: ReportStructureMode;
retrievalDirections: string[];
retrievalSkills: string[];
};
/**
* A deliberately domain-neutral structure which gives lightweight models a
* dependable writing scaffold without forcing market-analysis terminology on
* every research topic. Users can edit or replace it in the advanced dialog.
*/
export const DEFAULT_REPORT_OUTLINE = `# 报告标题
# 引言
用 1–2 段说明研究对象、提出本议题的背景、研究范围及资料边界;简要交代全文的分析路径,不单独拆分小节。
# 一、研究对象与事实基础
## 1.1 概念、范围与背景
界定研究对象、关键概念、时间和空间范围;说明读者理解问题所需的背景。
## 1.2 已有事实与证据
按时间或逻辑顺序梳理已知事实、数据、案例或材料发现;优先使用可核验的来源。
## 1.3 当前状态与主要特征
归纳研究对象目前呈现的状态、关键变化、共性与差异;没有数据支撑时明确说明。
# 二、问题形成与作用机制
## 2.1 直接原因与触发条件
分析近期事件、条件变化或关键节点如何推动问题出现或演变。
## 2.2 深层影响因素
从制度、技术、资源、行为、环境或历史条件中选择适用维度,分析长期影响。
## 2.3 因果关系与相互作用
说明不同因素如何共同作用;区分来源已证实的事实、分析判断和仍待验证的推断。
# 三、影响、问题与不同观点
## 3.1 对研究对象的直接影响
从结果、效率、质量、风险、成本、体验或其他适用维度,分析当前影响。
## 3.2 对相关主体与外部环境的影响
说明影响如何传导到相关群体、组织、地区、系统或后续事项;不适用的维度可省略。
## 3.3 主要争议与解释差异
并列材料支持的不同观点、解释或结论,并说明各自的证据基础与局限。
# 四、比较、趋势与不确定性
## 4.1 可比案例或横向比较
在材料允许时选择历史案例、不同地区、不同方案或不同阶段进行比较,说明异同与启示。
## 4.2 发展趋势与关键变量
识别后续可能变化、决定性变量、观察指标和潜在转折点;不把预测写成既成事实。
## 4.3 风险情景与不确定性
说明不同情景的触发条件、可能影响和证据不足之处;对没有依据的推测明确标注。
# 五、结论、建议与后续关注
## 5.1 核心结论
用编号或分点方式收束全文;每条结论都应能回溯到正文中的事实和分析。
## 5.2 可行建议或应对方向
仅在研究材料支持时提出建议;说明适用对象、前提条件、优先级和预期作用。
## 5.3 资料局限与后续关注
列出证据缺口、数据口径差异、尚不能下结论的问题,以及值得持续跟踪的事项。
# 参考资料
仅列出本报告实际使用的来源,并与正文引用保持一致。
> 写作要求:引言保持简洁;每个二级小节按材料充分程度写 2–5 段完整分析,每段围绕一个明确观点展开,采用“事实/证据 → 分析 → 影响或小结”的顺序。章节可根据研究主题合并、增删或调整,但不要为了套模板而编造不适用的主体、数据、案例或引用。`;
/**
* A causal, chapter-to-chapter alternative for topics where the report should
* read as one continuous argument rather than a set of parallel dimensions.
* Each chapter explicitly takes the previous chapter's conclusion as input.
*/
export const PROGRESSIVE_REPORT_OUTLINE = `# 报告标题
# 引言
用 1–2 段说明研究对象、核心问题、研究范围和资料边界;简要说明本文将按“事实基线 → 成因机制 → 影响判断 → 应对方向 → 结论”的递进逻辑展开。
# 一、事实基线:问题是什么
## 1.1 概念界定与研究范围
明确关键概念、时间/对象边界和判断口径,避免后续讨论混用概念。
## 1.2 关键事实、数据与证据
按时间或逻辑顺序列出已经发生的事实、可核验数据、案例和来源。
## 1.3 基线判断与待解问题
基于前两节归纳当前状态、主要矛盾和需要解释的核心问题。
> 本章产出:后续分析必须以本章已经确认的事实和问题为基础,不得在后文补写没有来源支撑的新事实。
# 二、形成机制:为什么会这样
## 2.1 直接原因与触发条件
从第一章识别的问题出发,分析近期事件、条件变化或关键节点如何导致当前结果。
## 2.2 深层条件与关键约束
结合第一章的证据,说明制度、技术、资源、行为、环境或历史条件等长期影响。
## 2.3 因果链条与关键变量
把前两节串成清晰的“条件 → 机制 → 结果”链条,标出决定性变量及仍待验证的环节。
> 本章产出:第三章只能沿用本章已说明的机制评估影响,不能把相关性直接写成因果关系。
# 三、结果与影响:会带来什么
## 3.1 直接结果与短期影响
依据第二章的因果链条,分析对研究对象最直接、最先出现的影响。
## 3.2 传导效应与中长期影响
继续说明影响如何传导至相关主体、外部环境或后续事项;不适用的维度可省略。
## 3.3 权衡、分歧与边界
比较不同影响之间的取舍、材料支持的不同观点,以及结论适用的边界。
> 本章产出:第四章的趋势判断、情景和建议应以本章已评估的影响、风险和权衡为依据。
# 四、趋势与应对:接下来怎么办
## 4.1 未来走势与观察指标
从第三章的影响出发,识别可能变化、关键观察指标和潜在转折点;不把预测写成既成事实。
## 4.2 可选方案与适用条件
针对第三章发现的问题和风险,提出可行的应对方向、前提条件与优先级。
## 4.3 风险情景与验证重点
说明不同情景的触发条件、可能后果,以及下一步需要补充验证的数据或材料。
> 本章产出:第五章只综合前四章已经建立的事实、机制、影响和建议,不新增独立论点。
# 五、综合结论与后续关注
## 5.1 核心结论
按“事实基线 → 原因机制 → 主要影响 → 应对方向”逐项收束,每条结论都可回溯到前文证据。
## 5.2 局限与后续研究
列出资料缺口、数据口径差异、尚不能下结论的问题和后续跟踪清单。
# 参考资料
仅列出本报告实际使用的来源,并与正文引用保持一致。
> 写作要求:除引言外,每个二级小节写 2–5 段完整分析。后章必须明确承接前章的结论;若材料无法支持递进关系,应说明资料限制而非编造衔接。`;
const CUSTOM_REPORT_STRUCTURE_VALUE = "__custom_report_structure__";
const REPORT_STRUCTURE_VALUE_PREFIX = "structure:";
function matchingReportStructureId(
outline: string,
structures: Array<{ id: string; content: string }>,
): string | null {
const normalized = outline.trim();
if (!normalized) return null;
return structures.find((structure) => structure.content.trim() === normalized)?.id ?? null;
}
/**
* 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,
onStructureModeChange,
onRetrievalDirectionsChange,
onRetrievalSkillsChange,
}: {
id: string;
value: string;
onChange: (value: string) => void;
onStructureModeChange?: (mode: ReportStructureMode) => void;
onRetrievalDirectionsChange?: (directions: string[]) => void;
onRetrievalSkillsChange?: (skills: string[]) => void;
}) {
// 与「工作流配置」共用报告结构库;创建/编辑/启停后 React Query 的失效会
// 自动刷新本下拉,避免维护一份会逐渐过时的前端硬编码模板。
const structuresQuery = useReportStructures({ status: "enabled" });
const structures = structuresQuery.data ?? [];
const selectedStructureId = matchingReportStructureId(value, structures);
const templateValue = selectedStructureId
? `${REPORT_STRUCTURE_VALUE_PREFIX}${selectedStructureId}`
: CUSTOM_REPORT_STRUCTURE_VALUE;
const selectTemplate = (next: string) => {
if (next === CUSTOM_REPORT_STRUCTURE_VALUE) {
// “自定义” is an intentional blank starting point. Saved structures are
// always available as one-click scaffolds whenever the user wants them.
onChange("");
onStructureModeChange?.(DEFAULT_REPORT_STRUCTURE_MODE);
onRetrievalDirectionsChange?.([]);
onRetrievalSkillsChange?.([]);
return;
}
if (!next.startsWith(REPORT_STRUCTURE_VALUE_PREFIX)) return;
const structureId = next.slice(REPORT_STRUCTURE_VALUE_PREFIX.length);
const structure = structures.find((item) => item.id === structureId);
if (structure) {
onChange(structure.content);
onStructureModeChange?.(structure.structureMode);
onRetrievalDirectionsChange?.(structure.retrievalDirections);
onRetrievalSkillsChange?.(structure.retrievalSkills);
}
};
return (
<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="报告结构模板">
<SelectValue />
</SelectTrigger>
<SelectContent>
{structuresQuery.isLoading && (
<SelectItem value="__report_structures_loading__" disabled>正在加载工作流结构…</SelectItem>
)}
{structuresQuery.isError && (
<SelectItem value="__report_structures_error__" disabled>报告结构库加载失败</SelectItem>
)}
{structures.map((structure) => (
<SelectItem key={structure.id} value={`${REPORT_STRUCTURE_VALUE_PREFIX}${structure.id}`}>
{structure.title}
</SelectItem>
))}
<SelectItem value={CUSTOM_REPORT_STRUCTURE_VALUE}>自定义结构</SelectItem>
</SelectContent>
</Select>
<p className="mt-1 text-[11px] leading-4 text-muted-foreground">
{selectedStructureId
? "已选工作流结构;可在高级配置中继续编辑章节内容。"
: "当前使用自定义结构;可在高级配置中编辑章节内容。"}
</p>
</div>
);
}
export function StructureModeSelect({
id,
value,
onChange,
}: {
id?: string;
value: ReportStructureMode;
onChange: (value: ReportStructureMode) => void;
}) {
const selected = REPORT_STRUCTURE_MODE_OPTIONS.find((option) => option.value === value)
?? REPORT_STRUCTURE_MODE_OPTIONS[1];
return (
<div>
<label htmlFor={id} className="mb-1.5 block text-xs font-medium">
结构约束
</label>
<Select value={value} onValueChange={(next) => onChange(normalizeReportStructureMode(next))}>
<SelectTrigger id={id} className="h-9 text-sm" aria-label="结构约束">
<SelectValue />
</SelectTrigger>
<SelectContent>
{REPORT_STRUCTURE_MODE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-[11px] leading-4 text-muted-foreground">
{selected.description}
</p>
</div>
);
}
/** Shared by the initial settings dialog and the write-confirmation card. */
export function ReportOutlineEditor({
id,
value,
onChange,
structureMode,
onStructureModeChange,
onRetrievalDirectionsChange,
onRetrievalSkillsChange,
showStructureSelect = true,
}: {
id: string;
value: string;
onChange: (value: string) => void;
structureMode?: ReportStructureMode;
onStructureModeChange?: (mode: ReportStructureMode) => void;
onRetrievalDirectionsChange?: (directions: string[]) => void;
onRetrievalSkillsChange?: (skills: string[]) => void;
/** The confirmation card already exposes its picker in the primary form. */
showStructureSelect?: boolean;
}) {
return (
<div className="space-y-2">
{showStructureSelect && (
<div className="grid gap-3 sm:grid-cols-2">
<ReportStructureSelect
id={id}
value={value}
onChange={onChange}
onStructureModeChange={onStructureModeChange}
onRetrievalDirectionsChange={onRetrievalDirectionsChange}
onRetrievalSkillsChange={onRetrievalSkillsChange}
/>
{onStructureModeChange && (
<StructureModeSelect
id={`${id}-mode`}
value={structureMode ?? DEFAULT_REPORT_STRUCTURE_MODE}
onChange={onStructureModeChange}
/>
)}
</div>
)}
<label htmlFor={id} className="block pt-1 text-xs font-medium">
报告结构(可编辑)
</label>
<Textarea
id={id}
value={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 标题或编号列表。选择“自定义结构”会清空后从头编辑。
</p>
</div>
);
}
export const DEFAULT_ADVANCED_OPTIONS: AdvancedResearchOptions = {
language: "zh-CN",
tone: "objective",
@@ -78,7 +397,10 @@ export const DEFAULT_ADVANCED_OPTIONS: AdvancedResearchOptions = {
maxRevisionLoops: "2",
maxGeneratedImages: "2",
thinkingEnabled: false,
customOutline: "",
customOutline: DEFAULT_REPORT_OUTLINE,
structureMode: DEFAULT_REPORT_STRUCTURE_MODE,
retrievalDirections: [],
retrievalSkills: [],
};
/** Seed the card's editable options from the frozen session config snapshot. */
@@ -100,7 +422,12 @@ export function advancedOptionsFromConfig(config?: Record<string, unknown>): Adv
maxRevisionLoops: numberish("max_revision_loops", DEFAULT_ADVANCED_OPTIONS.maxRevisionLoops),
maxGeneratedImages: numberish("max_generated_images", DEFAULT_ADVANCED_OPTIONS.maxGeneratedImages),
thinkingEnabled: config?.thinking_enabled === true,
customOutline: typeof config?.custom_outline === "string" ? config.custom_outline : "",
customOutline: typeof config?.custom_outline === "string" && config.custom_outline.trim()
? config.custom_outline
: DEFAULT_REPORT_OUTLINE,
structureMode: normalizeReportStructureMode(config?.structure_mode),
retrievalDirections: normalizeRetrievalDirections(config?.retrieval_directions),
retrievalSkills: normalizeRetrievalSkills(config?.retrieval_skills),
};
}
@@ -138,6 +465,9 @@ export function buildReportCardConfig(
}
if (generateImages) config.max_generated_images = boundedInteger(options.maxGeneratedImages, 2, 1, 4);
if (options.customOutline.trim()) config.custom_outline = options.customOutline.trim().slice(0, 4_000);
config.structure_mode = normalizeReportStructureMode(options.structureMode);
config.retrieval_directions = normalizeRetrievalDirections(options.retrievalDirections);
config.retrieval_skills = normalizeRetrievalSkills(options.retrievalSkills);
return config;
}
@@ -145,6 +475,15 @@ export function ReportConfigCard({
availableModes,
defaultMode,
defaultOptions,
/**
* A report structure chosen from the collector's workflow configuration
* while this card is open. It supersedes only the outline, leaving choices
* already made in this card intact.
*/
outlineOverride,
structureModeOverride,
retrievalDirectionsOverride,
retrievalSkillsOverride,
defaultGenerateImages,
modelLabel,
materialCount,
@@ -157,6 +496,10 @@ export function ReportConfigCard({
availableModes: ResearchMode[];
defaultMode: ResearchMode;
defaultOptions: AdvancedResearchOptions;
outlineOverride?: string;
structureModeOverride?: ReportStructureMode;
retrievalDirectionsOverride?: string[];
retrievalSkillsOverride?: string[];
defaultGenerateImages: boolean;
modelLabel: string;
/** Number of unique, harvestable materials visible in the collector thread. */
@@ -170,14 +513,47 @@ export function ReportConfigCard({
const [mode, setMode] = useState<ResearchMode>(
availableModes.includes(defaultMode) ? defaultMode : (availableModes[0] ?? "basic"),
);
const [options, setOptions] = useState<AdvancedResearchOptions>(defaultOptions);
const [options, setOptions] = useState<AdvancedResearchOptions>(() => ({
...defaultOptions,
customOutline: outlineOverride ?? defaultOptions.customOutline,
structureMode: structureModeOverride ?? defaultOptions.structureMode,
retrievalDirections: retrievalDirectionsOverride ?? defaultOptions.retrievalDirections,
retrievalSkills: retrievalSkillsOverride ?? defaultOptions.retrievalSkills,
}));
const [generateImages, setGenerateImages] = useState(defaultGenerateImages);
const [advancedOpen, setAdvancedOpen] = useState(false);
// The collector composer and this confirmation card are both reachable while
// a session is awaiting report writing. Selecting a structure in the former
// must be reflected here, but should not reset the card's mode, image switch,
// or any other in-progress edits.
useEffect(() => {
setOptions(defaultOptions);
setGenerateImages(defaultGenerateImages);
}, [defaultOptions, defaultGenerateImages]);
if (
outlineOverride === undefined
&& structureModeOverride === undefined
&& retrievalDirectionsOverride === undefined
&& retrievalSkillsOverride === undefined
) return;
setOptions((current) => {
const nextOutline = outlineOverride ?? current.customOutline;
const nextMode = structureModeOverride ?? current.structureMode;
const nextDirections = retrievalDirectionsOverride ?? current.retrievalDirections;
const nextSkills = retrievalSkillsOverride ?? current.retrievalSkills;
if (
current.customOutline === nextOutline
&& current.structureMode === nextMode
&& current.retrievalDirections.join("\n") === nextDirections.join("\n")
&& current.retrievalSkills.join("\n") === nextSkills.join("\n")
) return current;
return {
...current,
customOutline: nextOutline,
structureMode: nextMode,
retrievalDirections: nextDirections,
retrievalSkills: nextSkills,
};
});
}, [outlineOverride, structureModeOverride, retrievalDirectionsOverride, retrievalSkillsOverride]);
const modes = useMemo(
() => (availableModes.length ? availableModes : (["basic", "quick", "detailed", "deep", "multi_agent"] as ResearchMode[])),
@@ -203,7 +579,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)}>
@@ -216,6 +592,19 @@ 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)}
onStructureModeChange={(mode) => update("structureMode", mode)}
onRetrievalDirectionsChange={(directions) => update("retrievalDirections", directions)}
onRetrievalSkillsChange={(skills) => update("retrievalSkills", skills)}
/>
<StructureModeSelect
id="report-card-structure-mode"
value={options.structureMode}
onChange={(mode) => update("structureMode", mode)}
/>
{imageGenerationAvailable && (
<div>
<span className="mb-1.5 block text-xs font-medium">报告配图</span>
@@ -241,7 +630,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 && (
@@ -278,15 +667,27 @@ export function ReportConfigCard({
<CardNumber label="修订轮数" hint="0–5 轮" value={options.maxRevisionLoops} onChange={(v) => update("maxRevisionLoops", v)} />
)}
<div className="sm:col-span-2">
<span className="mb-1.5 block text-xs font-medium">自定义报告结构</span>
<Textarea
<ReportOutlineEditor
id="report-card-outline"
value={options.customOutline}
onChange={(event) => update("customOutline", event.target.value)}
maxLength={4000}
className="min-h-20 resize-y text-xs"
placeholder={"例如:\n# 执行摘要\n## 市场与背景\n## 机会与风险\n## 建议"}
onChange={(value) => update("customOutline", value)}
structureMode={options.structureMode}
onStructureModeChange={(mode) => update("structureMode", mode)}
onRetrievalDirectionsChange={(directions) => update("retrievalDirections", directions)}
onRetrievalSkillsChange={(skills) => update("retrievalSkills", skills)}
showStructureSelect={false}
/>
</div>
<div className="flex justify-end sm:col-span-2">
<WorkflowConfigButton
appliedContent={options.customOutline}
onApply={(value, mode, directions, skills) => {
update("customOutline", value);
update("structureMode", mode);
update("retrievalDirections", directions);
update("retrievalSkills", skills);
}}
/>
<p className="mt-1 text-[10px] text-muted-foreground">支持 Markdown 标题或编号列表,作为报告写作结构要求。</p>
</div>
<label className="flex cursor-pointer items-start gap-2 rounded-lg border border-border/70 bg-background/60 p-2.5 text-[11px] leading-4 sm:col-span-2">
<input
@@ -0,0 +1,124 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { ArrowLeft, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import type { SetupReportStructureApprovalPayload } from "@/components/workspace/messages";
import {
normalizeReportStructureMode,
normalizeRetrievalDirections,
normalizeRetrievalSkills,
useReportStructureMutations,
} from "@/strategy-components/api/report-structures";
import {
EMPTY_REPORT_STRUCTURE_FORM,
ReportStructureFormFields,
type ReportStructureFormValue,
} from "./ReportStructureFormFields";
import { ReportStructureSetupChat } from "./ReportStructureSetupChat";
/**
* 深度研究「智能新增报告结构」页。
*
* 左对话(几轮问清需求 → 确认卡回填)+ 右表单(可纯手工填写)。保存后回到来源页。
*/
export default function ReportStructureCreatePage() {
const navigate = useNavigate();
const location = useLocation();
const mutations = useReportStructureMutations();
const [form, setForm] = useState<ReportStructureFormValue>(EMPTY_REPORT_STRUCTURE_FORM);
const [saving, setSaving] = useState(false);
const backPath = useMemo(() => {
const path = location.pathname.replace(/\/structures\/new\/?$/, "");
return path || "/page/workspace/deep-research";
}, [location.pathname]);
const applyProposal = useCallback((payload: SetupReportStructureApprovalPayload) => {
setForm((current) => ({
title: payload.title?.trim() || current.title,
name: payload.name?.trim() || current.name,
content: payload.content?.trim() || current.content,
structureMode: payload.structure_mode
? normalizeReportStructureMode(payload.structure_mode)
: current.structureMode,
retrievalDirections:
payload.retrieval_directions && payload.retrieval_directions.length > 0
? normalizeRetrievalDirections(payload.retrieval_directions)
: current.retrievalDirections,
retrievalSkills:
payload.retrieval_skills && payload.retrieval_skills.length > 0
? normalizeRetrievalSkills(payload.retrieval_skills)
: current.retrievalSkills,
}));
}, []);
const handleSave = async () => {
if (!form.title.trim() || !form.content.trim()) {
toast.error("请填写标题和结构内容");
return;
}
setSaving(true);
try {
await mutations.create.mutateAsync({
title: form.title.trim(),
name: form.name.trim(),
content: form.content,
structureMode: form.structureMode,
retrievalDirections: form.retrievalDirections,
retrievalSkills: form.retrievalSkills,
});
toast.success(`已保存报告结构「${form.title.trim()}」`);
navigate(backPath, { replace: true, state: { openWorkflowConfig: true } });
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
setSaving(false);
}
};
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-background">
<header className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-5 py-3">
<div className="flex min-w-0 items-center gap-2">
<Button variant="ghost" size="icon-sm" onClick={() => navigate(backPath)} aria-label="返回">
<ArrowLeft className="size-4" />
</Button>
<div className="min-w-0">
<h1 className="text-base font-semibold">智能新增报告结构</h1>
<p className="text-xs text-muted-foreground">
左侧用问答描述需求,出现确认卡后点「应用到表单」;也可直接在右侧手工填写。
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button variant="outline" size="sm" onClick={() => navigate(backPath)} disabled={saving}>
取消
</Button>
<Button size="sm" onClick={() => void handleSave()} disabled={saving}>
{saving && <Loader2 className="mr-1.5 size-4 animate-spin" />}
保存
</Button>
</div>
</header>
<div className="flex min-h-0 flex-1">
<div className="min-w-0 flex-[7] border-r border-border">
<ReportStructureSetupChat onApprove={applyProposal} />
</div>
<aside className="flex min-w-0 flex-[3] flex-col overflow-hidden">
<div className="shrink-0 border-b border-border px-5 py-3">
<h2 className="text-sm font-semibold">报告结构配置</h2>
<p className="text-xs text-muted-foreground">对话确认后自动填充,也可随时改。</p>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-5">
<ReportStructureFormFields value={form} onChange={setForm} />
</div>
</aside>
</div>
</div>
);
}
@@ -0,0 +1,232 @@
"use client";
import { useMemo, useState } from "react";
import { X } from "lucide-react";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { useSkills } from "@/core/skills/hooks";
import { desensitize } from "@/lib/desensitize";
import {
type ReportStructureInput,
type ReportStructureMode,
MAX_RETRIEVAL_DIRECTIONS,
MAX_RETRIEVAL_DIRECTION_CHARS,
MAX_RETRIEVAL_SKILLS,
REPORT_STRUCTURE_MODE_OPTIONS,
normalizeReportStructureMode,
normalizeRetrievalDirections,
normalizeRetrievalSkills,
} from "@/strategy-components/api/report-structures";
export type ReportStructureFormValue = ReportStructureInput;
export const EMPTY_REPORT_STRUCTURE_FORM: ReportStructureFormValue = {
title: "",
name: "",
content: "",
structureMode: "adaptive",
retrievalDirections: [],
retrievalSkills: [],
};
function skillChipLabel(name: string, nameZh?: string | null) {
const zh = nameZh?.trim();
return zh || desensitize(name);
}
function RetrievalSkillPicker({
value,
onChange,
}: {
value: string[];
onChange: (next: string[]) => void;
}) {
const { skills, isLoading } = useSkills();
const selected = normalizeRetrievalSkills(value);
const byName = useMemo(() => new Map(skills.map((skill) => [skill.name, skill])), [skills]);
const remaining = useMemo(
() => skills.filter((skill) => skill.enabled !== false && !selected.includes(skill.name)),
[skills, selected],
);
const atCap = selected.length >= MAX_RETRIEVAL_SKILLS;
return (
<div>
<span className="mb-1.5 block text-sm font-medium">检索来源</span>
<div className="flex flex-wrap gap-1.5">
{selected.map((name) => (
<span
key={name}
className="inline-flex max-w-full items-center gap-1 rounded-full border border-border bg-muted/50 px-2 py-0.5 text-xs"
>
<span className="truncate">{skillChipLabel(name, byName.get(name)?.name_zh)}</span>
<button
type="button"
aria-label={`移除检索技能 ${name}`}
className="rounded-full p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground"
onClick={() => onChange(selected.filter((item) => item !== name))}
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
<Select
key={selected.join("|")}
onValueChange={(name) => {
if (!name || atCap) return;
onChange(normalizeRetrievalSkills([...selected, name]));
}}
disabled={atCap || remaining.length === 0}
>
<SelectTrigger aria-label="添加检索技能" className="mt-2">
<SelectValue placeholder={isLoading ? "正在加载技能…" : atCap ? "已达上限" : "选择技能添加到检索来源"} />
</SelectTrigger>
<SelectContent>
{remaining.map((skill) => (
<SelectItem key={skill.name} value={skill.name}>
{skillChipLabel(skill.name, skill.name_zh)}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="mt-1 block text-[11px] text-muted-foreground">
选填。指定后,搜集素材会调用这些技能检索;不填则仍由收集员使用默认联网/知识库工具。
</span>
</div>
);
}
export function ReportStructureFormFields({
value,
onChange,
}: {
value: ReportStructureFormValue;
onChange: (next: ReportStructureFormValue) => void;
}) {
const [directionDraft, setDirectionDraft] = useState("");
const set = <K extends keyof ReportStructureFormValue>(key: K, next: ReportStructureFormValue[K]) =>
onChange({ ...value, [key]: next });
const commitDirectionDraft = () => {
const next = normalizeRetrievalDirections([...value.retrievalDirections, directionDraft]);
set("retrievalDirections", next);
setDirectionDraft("");
};
return (
<div className="space-y-3">
<label className="block">
<span className="mb-1.5 block text-sm font-medium">标题</span>
<Input
value={value.title}
onChange={(event) => set("title", event.target.value)}
maxLength={256}
placeholder="例如:行业研究通用结构"
/>
</label>
<label className="block">
<span className="mb-1.5 block text-sm font-medium">描述</span>
<Input
value={value.name}
onChange={(event) => set("name", event.target.value)}
maxLength={256}
placeholder="可选,一句话描述这套结构的适用场景"
/>
</label>
<label className="block">
<span className="mb-1.5 block text-sm font-medium">结构约束</span>
<Select
value={value.structureMode}
onValueChange={(next) => set("structureMode", normalizeReportStructureMode(next) as ReportStructureMode)}
>
<SelectTrigger aria-label="结构约束">
<SelectValue />
</SelectTrigger>
<SelectContent>
{REPORT_STRUCTURE_MODE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="mt-1 block text-[11px] text-muted-foreground">
{REPORT_STRUCTURE_MODE_OPTIONS.find((option) => option.value === value.structureMode)?.description}
</span>
</label>
<div>
<span className="mb-1.5 block text-sm font-medium">检索方向</span>
<div className="flex flex-wrap gap-1.5">
{value.retrievalDirections.map((item) => (
<span
key={item}
className="inline-flex max-w-full items-center gap-1 rounded-full border border-border bg-muted/50 px-2 py-0.5 text-xs"
>
<span className="truncate">{item}</span>
<button
type="button"
aria-label={`移除检索方向 ${item}`}
className="rounded-full p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground"
onClick={() =>
set(
"retrievalDirections",
value.retrievalDirections.filter((entry) => entry !== item),
)
}
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
<Input
value={directionDraft}
onChange={(event) => setDirectionDraft(event.target.value)}
maxLength={MAX_RETRIEVAL_DIRECTION_CHARS}
placeholder="输入后按回车添加,可写多个;用作拆词方向,不是直接搜索的词"
className="mt-2"
disabled={value.retrievalDirections.length >= MAX_RETRIEVAL_DIRECTIONS}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === ",") {
event.preventDefault();
commitDirectionDraft();
}
}}
onBlur={() => {
if (directionDraft.trim()) commitDirectionDraft();
}}
/>
<span className="mt-1 block text-[11px] text-muted-foreground">
选填。指定后,收集员会按这些方向拆分具体检索词,而不是把方向名称直接拿去搜;不填则仍由收集员自行拆词。
</span>
</div>
<RetrievalSkillPicker
value={value.retrievalSkills}
onChange={(next) => set("retrievalSkills", next)}
/>
<label className="block">
<span className="mb-1.5 block text-sm font-medium">结构内容</span>
<Textarea
value={value.content}
onChange={(event) => set("content", event.target.value)}
maxLength={20000}
placeholder={"报告结构正文,支持 Markdown 标题,例如:\n# 引言\n# 一、事实与背景\n## 1.1 研究范围"}
className="min-h-[260px] resize-y font-mono text-xs"
/>
<span className="mt-1 block text-[11px] text-muted-foreground">
选中卡片时,该内容会填充到高级配置的「报告结构」。
</span>
</label>
</div>
);
}
@@ -0,0 +1,238 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Sparkles, Zap } from "lucide-react";
import { toast } from "sonner";
import {
PromptInput,
PromptInputFooter,
PromptInputSubmit,
PromptInputTextarea,
} from "@/components/ai-elements/prompt-input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { ArtifactsProvider } from "@/components/workspace/artifacts";
import {
MessageList,
type SetupReportStructureApprovalPayload,
} from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { extractContentFromMessage, extractReasoningContentFromMessage, hasToolCalls } from "@/core/messages/utils";
import { getAPIClient } from "@/core/api";
import { useModels } from "@/core/models/hooks";
import { useSkills } from "@/core/skills/hooks";
import { stopActiveThreadBeforeClarificationReply, useThreadStream } from "@/core/threads/hooks";
import { uuid } from "@/core/utils/uuid";
import { cn } from "@/lib/utils";
import { ReportStructureWelcome } from "./ReportStructureWelcome";
/**
* 报告结构智能新增的左侧对话(对标 WritingSetupChat / 新建智能体)。
*
* context 带 ``is_report_structure_setup``,后端只挂 setup_report_structure + ask_clarification。
* 模型提交后被拦截成确认卡;点「应用到表单」回填右侧,不落库。
*/
export function ReportStructureSetupChat({
onApprove,
}: {
onApprove: (payload: SetupReportStructureApprovalPayload) => void;
}) {
const threadId = useMemo(() => uuid(), []);
const [threadPrepared, setThreadPrepared] = useState(false);
const [chatModel, setChatModel] = useState("");
const [quickMode, setQuickMode] = useState(false);
const { models, isLoading: modelsLoading } = useModels();
const { skills } = useSkills();
useEffect(() => {
if (models.length === 0) return;
setChatModel((prev) => (prev && models.some((m) => m.name === prev) ? prev : models[0]!.name));
}, [models]);
const validRetrievalSkills = useMemo(
() =>
skills
.filter((skill) => skill.enabled !== false)
.slice(0, 40)
.map((skill) => ({ name: skill.name, name_zh: skill.name_zh ?? "" })),
[skills],
);
const [thread, sendMessage] = useThreadStream({
threadId,
context: {
mode: "flash",
is_report_structure_setup: true,
writing_setup_quick: quickMode,
valid_retrieval_skills: validRetrievalSkills,
model_name: chatModel || undefined,
},
});
const ensureThreadExists = useCallback(async () => {
if (threadPrepared) return;
await getAPIClient().threads.create({
threadId,
metadata: { thread_type: "report_structure_setup", system: true },
});
setThreadPrepared(true);
}, [threadId, threadPrepared]);
const handleChatSubmit = useCallback(
async (text: string) => {
if (thread.isLoading) {
void thread.stop();
return;
}
const trimmed = text.trim();
if (!trimmed) return;
await ensureThreadExists();
await sendMessage(threadId, { text: trimmed, files: [] });
},
[thread, ensureThreadExists, sendMessage, threadId],
);
const handleClarificationSubmit = useCallback(
async (text: string) => {
const trimmed = text.trim();
if (!trimmed) return;
await stopActiveThreadBeforeClarificationReply(threadId, thread);
await ensureThreadExists();
await sendMessage(threadId, { text: trimmed, files: [] });
},
[thread, ensureThreadExists, sendMessage, threadId],
);
const submitStatus = thread.error ? "error" : thread.isLoading ? "streaming" : "ready";
const handleApprove = useCallback(
async (payload: SetupReportStructureApprovalPayload) => {
onApprove(payload);
toast.success("已应用到表单,可继续编辑后保存");
},
[onApprove],
);
const handleReject = useCallback(async () => {
await handleChatSubmit("这份配置先不应用,请继续帮我调整。");
}, [handleChatSubmit]);
const showWelcome = thread.messages.length === 0 && !thread.isLoading;
const showThinkingPlaceholder = useMemo(() => {
if (!thread.isLoading) return false;
const last = thread.messages[thread.messages.length - 1];
if (!last || last.type !== "ai") return true;
const hasOutput =
!!extractContentFromMessage(last).trim() ||
!!extractReasoningContentFromMessage(last) ||
hasToolCalls(last);
return !hasOutput;
}, [thread.isLoading, thread.messages]);
return (
<ThreadContext.Provider value={{ thread }}>
<ArtifactsProvider>
<div className="flex size-full flex-col bg-background dark:bg-transparent">
{!showWelcome && (
<header className="flex shrink-0 items-center gap-2 border-b px-4 py-3">
<Sparkles className="size-4 text-primary" />
<h2 className="text-sm font-semibold">结构助手</h2>
<span className="text-muted-foreground text-xs">描述你想要的报告结构,我来帮你填好右侧表单</span>
</header>
)}
<div className="min-h-0 flex-1">
{showWelcome ? (
<ReportStructureWelcome />
) : (
<MessageList
className="size-full"
threadId={threadId}
thread={thread}
onClarificationSubmit={handleClarificationSubmit}
onSetupReportStructureApprove={handleApprove}
onSetupReportStructureReject={handleReject}
/>
)}
</div>
{showThinkingPlaceholder && (
<div className="shrink-0 border-t bg-background px-4 py-2 dark:bg-transparent">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="inline-block size-1.5 animate-pulse rounded-full bg-blue-500" />
正在整理…
</div>
</div>
)}
<div className="bg-background dark:bg-transparent shrink-0 border-t px-4 py-4">
<div className="space-y-3 rounded-2xl border border-[#ece5e4] bg-card p-3 shadow-sm dark:border-[#1B477E] dark:bg-[#022248]/40">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
<Sparkles className="size-3.5 shrink-0 text-primary" />
<span className="shrink-0">当前对话模型</span>
{modelsLoading || models.length === 0 ? (
<div className="border-input bg-background text-muted-foreground flex h-7 min-w-[9rem] items-center rounded-md border px-2 text-xs shadow-xs dark:border-[#1B477E] dark:bg-[#022248]">
{modelsLoading ? "加载中…" : "暂无模型"}
</div>
) : (
<Select value={chatModel} onValueChange={setChatModel} disabled={thread.isLoading}>
<SelectTrigger
className={cn(
"border-input bg-background h-7 min-w-[9rem] max-w-[14rem] px-2 text-xs shadow-xs outline-none",
"dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF]",
)}
>
<SelectValue placeholder="选择模型" />
</SelectTrigger>
<SelectContent>
{models.map((model) => (
<SelectItem key={model.name} value={model.name}>
{model.display_name || model.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<label
className={cn(
"flex shrink-0 cursor-pointer select-none items-center gap-1.5 rounded-md border px-2 py-1 text-xs transition",
quickMode
? "border-primary/40 bg-primary/5 text-primary dark:border-[#1B477E] dark:bg-[#022248]"
: "border-input bg-background text-muted-foreground dark:border-[#1B477E] dark:bg-[#022248]",
)}
title="开启后跳过逐项澄清,给出主题即直接生成大纲"
>
<Zap className={cn("size-3.5 shrink-0", quickMode && "fill-current")} />
<span className="shrink-0">快捷模式</span>
<Switch
checked={quickMode}
onCheckedChange={setQuickMode}
disabled={thread.isLoading}
className="ml-0.5 scale-90"
/>
</label>
</div>
<PromptInput onSubmit={({ text }) => void handleChatSubmit(text)}>
<PromptInputTextarea placeholder="例如:帮我建一套市场分析报告结构,固定大纲,检索方向是各品牌汽车销量" />
<PromptInputFooter className="justify-end">
<PromptInputSubmit status={submitStatus} size="icon-sm" />
</PromptInputFooter>
</PromptInput>
</div>
</div>
</div>
</ArtifactsProvider>
</ThreadContext.Provider>
);
}
@@ -0,0 +1,68 @@
import { Compass, ListTree, Sparkles } from "lucide-react";
/**
* 报告结构智能新增页左栏欢迎屏(对标 AI 写作 WritingWelcome)。
* 仅在助手对话还没有任何消息时显示。
*/
const STEPS = [
{
n: "1",
title: "对话描述需求",
desc: "用一句话说你想要什么报告结构,例如「市场分析固定大纲,检索方向是各品牌销量」。",
icon: Sparkles,
iconClass: "bg-indigo-50 text-indigo-600 dark:bg-indigo-500/15 dark:text-indigo-300",
},
{
n: "2",
title: "确认大纲卡片",
desc: "助手会先给出 Markdown 大纲,确认后出现「应用到表单」卡片,一点即可填到右侧。",
icon: ListTree,
iconClass: "bg-amber-50 text-amber-600 dark:bg-amber-500/15 dark:text-amber-300",
},
{
n: "3",
title: "保存入库",
desc: "右侧表单可再改标题、约束、检索方向。点保存后回到工作流配置列表。",
icon: Compass,
iconClass: "bg-emerald-50 text-emerald-600 dark:bg-emerald-500/15 dark:text-emerald-300",
},
];
export function ReportStructureWelcome() {
return (
<div className="size-full overflow-y-auto">
<div className="mx-auto flex min-h-full w-full max-w-3xl flex-col px-6 py-7">
<div className="flex flex-1 flex-col justify-center gap-6">
<div>
<h2 className="text-2xl font-bold leading-snug tracking-tight text-slate-800 dark:text-white">
智能新增报告结构
</h2>
<p className="mt-2 max-w-xl text-sm leading-relaxed text-slate-500 dark:text-white/60">
左侧用对话把结构聊清楚,确认后填到右侧表单;也可以
<strong className="font-semibold text-slate-700 dark:text-white/80">直接在右侧手工填写</strong>
,再点保存。
</p>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{STEPS.map((step) => (
<div
key={step.n}
className="flex flex-col gap-2 rounded-xl border border-slate-100/80 bg-slate-50/50 p-4 transition-colors hover:bg-slate-50 dark:border-white/5 dark:bg-white/[0.02] dark:hover:bg-white/5"
>
<div className={`flex size-8 items-center justify-center rounded-lg ${step.iconClass}`}>
<step.icon className="size-4" />
</div>
<h4 className="text-sm font-semibold text-slate-800 dark:text-white">
{step.n}. {step.title}
</h4>
<p className="text-xs leading-relaxed text-slate-500 dark:text-white/55">{step.desc}</p>
</div>
))}
</div>
</div>
</div>
</div>
);
}
@@ -10,11 +10,12 @@
* 切预览 + 脉冲、复制/另存、展示层脱敏)。
*/
import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { FileTextIcon, XIcon } from "lucide-react";
import { FileTextIcon, Loader2Icon, SparklesIcon, XIcon } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import type { GroupImperativeHandle } from "react-resizable-panels";
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
import { ArtifactAction } from "@/components/ai-elements/artifact";
import { Button } from "@/components/ui/button";
import {
ResizableHandle,
@@ -29,6 +30,7 @@ import { DocumentRewriteDialog } from "@/components/workspace/artifacts/document
import { ThreadContext } from "@/components/workspace/messages/context";
import type { AgentThreadState } from "@/core/threads";
import type { DocumentRewriteStyle } from "@/open-canvas/api/rewrite";
import type { ReportStructureMode } from "@/strategy-components/api/report-structures";
import { cn } from "@/lib/utils";
const CLOSE_MODE = { chat: 100, artifacts: 0 };
@@ -56,9 +58,21 @@ interface ResearchSandboxPanelProps {
instruction: string;
style: DocumentRewriteStyle;
modelName: string | null;
reportOutline: string;
structureMode?: ReportStructureMode;
/** 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;
}
@@ -72,12 +86,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;
@@ -107,6 +127,13 @@ export function ResearchSandboxPanel({
onClose();
};
useEffect(() => {
if (documentRewriteDialogRequest > 0) {
setRewriteDialogPurpose(documentRewriteDialogPurpose);
setRewriteDialogOpen(true);
}
}, [documentRewriteDialogPurpose, documentRewriteDialogRequest]);
return (
<ThreadContext.Provider value={{ thread: threadStub, isMock: false }}>
<ResizablePanelGroup
@@ -134,24 +161,12 @@ export function ResearchSandboxPanel({
id="artifacts"
>
<div className="relative h-full bg-[#fffdfa] p-4">
{selectedArtifact && previewThreadId ? (
{selectedArtifact && previewThreadId && stubMessages.length > 0 ? (
<>
{onDocumentRewriteStart && (
<div className="absolute top-7 right-36 z-30">
<Button
size="sm"
variant="outline"
disabled={documentRewriteActive}
onClick={() => setRewriteDialogOpen(true)}
>
{documentRewriteActive ? "正在改写" : "AI 全文改写"}
</Button>
</div>
)}
{documentRewriteActive && onCancelDocumentRewrite && (
<div className="absolute right-36 bottom-7 z-30">
<Button size="sm" variant="outline" onClick={onCancelDocumentRewrite}>
停止改写
停止{generatingNewReport ? "生成" : "改写"}
</Button>
</div>
)}
@@ -159,8 +174,32 @@ export function ResearchSandboxPanel({
className="size-full"
filepath={selectedArtifact}
threadId={previewThreadId}
researchSessionId={previewThreadId}
onClose={handleArtifactsClose}
onDownloadClick={onDownload}
toolbarActions={
onDocumentRewriteStart ? (
<ArtifactAction
icon={documentRewriteActive ? Loader2Icon : SparklesIcon}
label={documentRewriteActive
? generatingNewReport
? "正在生成新报告"
: "正在全文改写"
: "AI 全文改写"}
tooltip={
documentRewriteActive
? generatingNewReport
? "正在生成独立的新报告,当前报告不会被覆盖"
: "正在生成临时草稿,原报告尚未被覆盖"
: "使用 AI 重写完整 report.md"
}
onClick={() => {
setRewriteDialogPurpose("rewrite");
setRewriteDialogOpen(true);
}}
/>
) : null
}
/>
</>
) : (
@@ -173,7 +212,11 @@ export function ResearchSandboxPanel({
<ConversationEmptyState
icon={<FileTextIcon />}
title="report.md"
description="报告开始撰写后将在此实时预览"
description={
documentRewriteActive && generatingNewReport
? "正在生成新报告,正文将在模型首个字符输出后显示"
: "报告开始撰写后将在此实时预览"
}
/>
</div>
)}
@@ -186,9 +229,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",
});
}}
/>
)}
File diff suppressed because it is too large. Load diff
@@ -20,6 +20,7 @@ import {
artifactPreviewKey,
useArtifacts,
} from "@/components/workspace/artifacts";
import { stripInlineThinkTags } from "@/core/streamdown/inline-think";
/** 报告在沙箱中的固定虚拟路径(与 runner 实际写入的 artifacts 路径一致)。 */
export const REPORT_OUTPUT_PATH = "/mnt/user-data/outputs/report.md";
@@ -42,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`;
@@ -66,13 +82,23 @@ 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;
setStubMessage((prev) => {
const prevArgs = prev?.tool_calls?.[0]?.args as { content?: unknown } | undefined;
const prevContent = typeof prevArgs?.content === "string" ? prevArgs.content : "";
const content = report.trim() ? report : prevContent;
// Backstop the report sandbox against providers that expose reasoning
// through raw `<think>` tags in ordinary text chunks. The backend
// filters them at the source; this also protects historical/replayed
// streams generated by an older service.
const visibleReport = stripInlineThinkTags(report);
const content = visibleReport.trim() ? visibleReport : prevContent;
return {
id: messageId,
type: "ai",
@@ -85,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(() => {
@@ -96,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,
};
}