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:
commit
e818c24769
262 files changed
+29527
-5236
No files matched your search
@@ -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 () => {
|
||||
|
||||
@@ -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)",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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('深链跳转失败,已进入首页');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user