代码提交
This commit is contained in:
1 parent
70da7c5649
commit
178572bc7f
79 files changed
+13456
-798
No files matched your search
@@ -231,7 +231,7 @@ function LegacyDeepResearchPage() {
|
||||
setQuery("");
|
||||
setSelectedId(created.id);
|
||||
// Auto-start the research job.
|
||||
await actions.startJob.mutateAsync(created.id);
|
||||
await actions.startJob.mutateAsync({ sessionId: created.id, entry: "legacy" });
|
||||
toast.success("深度研究已启动");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "启动研究失败");
|
||||
@@ -241,7 +241,7 @@ function LegacyDeepResearchPage() {
|
||||
const handleStart = useCallback(
|
||||
async (sessionId: string) => {
|
||||
try {
|
||||
await actions.startJob.mutateAsync(sessionId);
|
||||
await actions.startJob.mutateAsync({ sessionId, entry: "legacy" });
|
||||
toast.success("深度研究已启动");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "启动研究失败");
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
"use client";
|
||||
|
||||
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||
import { BotIcon, PlusSquare } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
PromptInput,
|
||||
PromptInputProvider,
|
||||
type PromptInputMessage,
|
||||
PromptInputBody,
|
||||
PromptInputFooter,
|
||||
PromptInputSubmit,
|
||||
PromptInputTextarea,
|
||||
} from "@/components/ai-elements/prompt-input";
|
||||
import { useThreadChat } from "@/components/workspace/chats";
|
||||
import { MessageList } from "@/components/workspace/messages";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
||||
import { SENTIMENT_AGENT_ID, isSentimentAgentConfigured } from "@/core/sentiment-agent/config";
|
||||
import { readSentimentAgentProfile } from "@/core/sentiment-agent/profile";
|
||||
import { useSentimentAgentChat } from "@/core/sentiment-agent/use-sentiment-agent-chat";
|
||||
import { SubtasksProvider } from "@/core/tasks/context";
|
||||
import type { AgentThreadState } from "@/core/threads";
|
||||
import { useRouteBase } from "@/core/route-base";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* 舆情分析智能体对话页:一个前端虚拟智能体(不在后端 /api/agents 落库),
|
||||
* 问答直连外部 AG-UI 网关(地址 + Basic Auth 在 runtime-config.js 配置)。
|
||||
* 页面复用普通问答的消息渲染(MessageList),输入框只保留发送按钮;
|
||||
* 会话只存在当前标签页 sessionStorage,不创建后端 thread。
|
||||
*/
|
||||
export default function SentimentAgentChatPage() {
|
||||
// 与 WorkspaceRoutes 的 ChatRuntime 相同的 Provider 组合(这里内联以避免
|
||||
// 页面与路由文件互相导入形成环),使精简输入框仍具备普通问答的一致键盘行为。
|
||||
return (
|
||||
<SubtasksProvider>
|
||||
<ArtifactsProvider>
|
||||
<PromptInputProvider>
|
||||
<SentimentAgentChatPageInner />
|
||||
</PromptInputProvider>
|
||||
</ArtifactsProvider>
|
||||
</SubtasksProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function SentimentAgentChatPageInner() {
|
||||
const router = useRouter();
|
||||
const routeBase = useRouteBase();
|
||||
const chatsBase = `${routeBase}/agents/${SENTIMENT_AGENT_ID}/chats`;
|
||||
const { threadId, isNewThread, setIsNewThread } = useThreadChat();
|
||||
const chat = useSentimentAgentChat(threadId);
|
||||
const [profile, setProfile] = useState(() => readSentimentAgentProfile());
|
||||
const configured = isSentimentAgentConfigured();
|
||||
|
||||
// 编辑弹窗可能挂在管理页 / 本页两处,保存后广播事件,这里即时刷新标题与简介。
|
||||
useEffect(() => {
|
||||
const onChange = () => setProfile(readSentimentAgentProfile());
|
||||
window.addEventListener("sentiment-agent-profile-changed", onChange);
|
||||
return () =>
|
||||
window.removeEventListener("sentiment-agent-profile-changed", onChange);
|
||||
}, []);
|
||||
|
||||
const displayName = desensitize(profile.name);
|
||||
|
||||
// 合成一个 MessageList 需要的 BaseStream:消息与加载态全部来自前端流式
|
||||
// hook(参照 ChatPage 对单轮 RAG displayThread 的合成方式)。
|
||||
const displayThread = useMemo(
|
||||
() =>
|
||||
({
|
||||
messages: chat.messages,
|
||||
values: { messages: chat.messages, artifacts: [] },
|
||||
isLoading: chat.isLoading,
|
||||
isThreadLoading: false,
|
||||
error: chat.error,
|
||||
stop: () => chat.stop(),
|
||||
submit: async () => undefined,
|
||||
joinStream: async () => undefined,
|
||||
}) as unknown as BaseStream<AgentThreadState>,
|
||||
[chat.error, chat.isLoading, chat.messages, chat.stop],
|
||||
);
|
||||
|
||||
const status = chat.error
|
||||
? "error"
|
||||
: chat.isLoading
|
||||
? "streaming"
|
||||
: "ready";
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(message: PromptInputMessage) => {
|
||||
const text = message.text.trim();
|
||||
if (!text || chat.isLoading) return;
|
||||
if (isNewThread) {
|
||||
// 首条消息发出后把 /new 换成本地会话 id,刷新后仍能恢复当前对话。
|
||||
// 这里必须经 HashRouter 导航;直接 history.replaceState 会在 URL 的
|
||||
// hash 内再拼一个 `#`,导致刷新或后续导航命中错误的通用智能体路由。
|
||||
setIsNewThread(false);
|
||||
router.replace(`${chatsBase}/${threadId}`);
|
||||
}
|
||||
void chat.submit({ query: text });
|
||||
},
|
||||
[chat, chatsBase, isNewThread, router, setIsNewThread, threadId],
|
||||
);
|
||||
|
||||
const handleStop = useCallback(() => chat.stop(), [chat]);
|
||||
|
||||
const isLandingState = chat.messages.length === 0;
|
||||
|
||||
const composer = (autoFocus: boolean) => (
|
||||
<div className="w-full">
|
||||
<SentimentComposer
|
||||
status={status}
|
||||
autoFocus={autoFocus}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
{!configured && (
|
||||
<p className="text-destructive mt-2 text-center text-xs">
|
||||
尚未配置舆情分析接口:请在部署目录的 runtime-config.js 里填写
|
||||
VITE_SENTIMENT_AGENT_API_URL 及鉴权用户名 / 密码后刷新页面。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
// isMock=true:会话完全在前端(无后端 thread),借此关掉消息上的
|
||||
// 「删除」按钮 —— 它会去调后端 thread 删除接口,对虚拟会话没有意义。
|
||||
<ThreadContext.Provider value={{ thread: displayThread, isMock: true }}>
|
||||
<div className="workspace-chat-bg flex size-full min-h-0 overflow-hidden">
|
||||
<main className="relative flex size-full min-h-0 min-w-0 flex-col">
|
||||
<header className="bg-card/90 border-border/70 flex h-12 shrink-0 items-center gap-2 border-b px-4 backdrop-blur-md">
|
||||
<div className="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1">
|
||||
<BotIcon className="text-primary h-3.5 w-3.5" />
|
||||
<span
|
||||
className="max-w-[200px] truncate text-xs font-medium"
|
||||
title={displayName}
|
||||
>
|
||||
{displayName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 items-center text-sm font-medium">
|
||||
<span className="text-muted-foreground max-w-[260px] truncate text-xs font-normal">
|
||||
{desensitize(profile.description)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => router.push(`${chatsBase}/new`)}
|
||||
>
|
||||
<PlusSquare /> 新对话
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{isLandingState ? (
|
||||
/* 落地态:名称 + 简介 + 输入框整体居中(普通问答落地页样式)。 */
|
||||
<div className="flex min-h-0 flex-1 flex-col items-center overflow-y-auto px-6 pb-10 pt-16 sm:pt-24">
|
||||
<div className="mb-7 flex flex-col items-center gap-3 text-center">
|
||||
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-full dark:bg-[#022248]">
|
||||
<BotIcon className="text-primary h-7 w-7" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold">{displayName}</h1>
|
||||
<p className="text-muted-foreground max-w-xl text-sm leading-6">
|
||||
{desensitize(profile.description)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-full max-w-(--container-width-md)">
|
||||
{composer(true)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* 对话态:消息列表滚动,输入框停靠在主列底部。 */
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<MessageList
|
||||
className="min-h-0 flex-1"
|
||||
threadId={threadId}
|
||||
thread={displayThread}
|
||||
paddingBottom={24}
|
||||
scrollButtonClassName="bottom-2"
|
||||
onEditResubmit={(text) =>
|
||||
handleSubmit({ text, files: [] })
|
||||
}
|
||||
/>
|
||||
<div className="from-background pointer-events-none shrink-0 bg-gradient-to-t to-transparent px-4 pb-4 pt-2">
|
||||
<div className="pointer-events-auto mx-auto w-full max-w-(--container-width-lg)">
|
||||
{composer(false)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</ThreadContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 精简输入框:只有文本域 + 发送/停止按钮(流式中点击变为停止),
|
||||
* 不带附件、模式、模型选择等普通问答输入框的其余能力。
|
||||
*/
|
||||
function SentimentComposer({
|
||||
status,
|
||||
autoFocus,
|
||||
onSubmit,
|
||||
onStop,
|
||||
}: {
|
||||
status: "error" | "streaming" | "ready";
|
||||
autoFocus?: boolean;
|
||||
onSubmit: (message: PromptInputMessage) => void;
|
||||
onStop: () => void;
|
||||
}) {
|
||||
return (
|
||||
<PromptInput
|
||||
className={cn(
|
||||
"w-full *:data-[slot='input-group']:rounded-3xl",
|
||||
"rounded-3xl border border-border bg-background shadow-sm transition-shadow hover:shadow-md focus-within:border-ring/40 focus-within:shadow-md focus-within:ring-1 focus-within:ring-ring/20 dark:border-slate-700 dark:bg-slate-800",
|
||||
)}
|
||||
onSubmit={(message) => {
|
||||
if (status === "streaming") {
|
||||
onStop();
|
||||
return;
|
||||
}
|
||||
onSubmit(message);
|
||||
}}
|
||||
>
|
||||
<PromptInputBody className="px-2 pt-2">
|
||||
<PromptInputTextarea
|
||||
className="min-h-16 px-2 py-1 text-[15px] leading-7"
|
||||
placeholder="请输入要分析的舆情问题,例如:分析近三个月美伊局势"
|
||||
aria-label="舆情分析问题"
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
</PromptInputBody>
|
||||
<PromptInputFooter className="flex items-center justify-end px-2 pt-1 pb-1.5">
|
||||
<PromptInputSubmit
|
||||
className="rounded-full border-border bg-background shadow-sm hover:bg-muted dark:border-blue-500 dark:bg-blue-500 dark:text-white dark:hover:bg-blue-400 dark:shadow-none"
|
||||
variant="outline"
|
||||
status={status}
|
||||
/>
|
||||
</PromptInputFooter>
|
||||
</PromptInput>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Toaster } from "sonner";
|
||||
|
||||
import { SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { getAccountDisplayName, getStoredAuth } from "@/core/auth";
|
||||
import { isSentimentAgentVisible } from "@/core/sentiment-agent/config";
|
||||
import { RouteBaseProvider } from "@/core/route-base";
|
||||
import GeneralQA from "@/strategy-components/components/chat-qa/GeneralQA";
|
||||
import SentimentAnalyzes from "@/strategy-components/components/task-components/SentimentAnalyzes";
|
||||
@@ -29,6 +30,7 @@ import BusinessChainListPage from "@/roundtable-planning/pages/BusinessChainList
|
||||
import BusinessChainEditorPage from "@/roundtable-planning/pages/BusinessChainEditorPage";
|
||||
import "@/roundtable-planning/styles/roundtable-planning.css";
|
||||
import AgentChatPage from "./AgentChatPage";
|
||||
import SentimentAgentChatPage from "./SentimentAgentChatPage";
|
||||
import { AgentModuleRoutes } from "./AgentModulePage";
|
||||
import { AGENT_MODULES } from "@/components/workspace/agents/agent-module-sidebar";
|
||||
import AgentsPage from "./AgentsPage";
|
||||
@@ -311,6 +313,27 @@ export default function StrategyRoutes() {
|
||||
|
||||
<Route path="agents" element={<AgentsPage />} />
|
||||
<Route path="agents/new" element={<NewAgentPage />} />
|
||||
{/* 舆情分析虚拟智能体:前端直连外部 AG-UI 网关的专用对话页。 */}
|
||||
<Route
|
||||
path="agents/sentiment-analysis/chats"
|
||||
element={
|
||||
isSentimentAgentVisible() ? (
|
||||
<Navigate to="/page/strategy/agents/sentiment-analysis/chats/new" replace />
|
||||
) : (
|
||||
<Navigate to="/page/strategy/agents" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="agents/sentiment-analysis/chats/:thread_id"
|
||||
element={
|
||||
isSentimentAgentVisible() ? (
|
||||
<SentimentAgentChatPage />
|
||||
) : (
|
||||
<Navigate to="/page/strategy/agents" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="agents/:agent_id/chats"
|
||||
element={<Navigate to="/page/strategy/agents" replace />}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { CSSProperties, ReactNode } from 'react';
|
||||
import { Navigate, Route, Routes, useLocation, useParams } from 'react-router-dom';
|
||||
|
||||
import { getStoredAuth } from '@/core/auth';
|
||||
import { isSentimentAgentVisible } from '@/core/sentiment-agent/config';
|
||||
import {
|
||||
resolveChatsIframeEntryRedirect,
|
||||
resolveStandardChatFallback,
|
||||
@@ -21,6 +22,7 @@ import { CommandPalette } from '@/components/workspace/command-palette';
|
||||
import { Toaster } from 'sonner';
|
||||
import { SubtasksProvider } from '@/core/tasks/context';
|
||||
import { ArtifactsProvider } from '@/components/workspace/artifacts';
|
||||
import { DocumentRewritePresentationProvider } from '@/core/document-rewrite/context';
|
||||
|
||||
import { RouteBaseProvider } from '@/core/route-base';
|
||||
|
||||
@@ -29,6 +31,7 @@ import ChatPage from './ChatPage';
|
||||
import IframeChatPage from './IframeChatPage';
|
||||
import AgentsPage from './AgentsPage';
|
||||
import AgentChatPage from './AgentChatPage';
|
||||
import SentimentAgentChatPage from './SentimentAgentChatPage';
|
||||
import NewAgentPage from './NewAgentPage';
|
||||
import AdminAgentsPage from './AdminAgentsPage';
|
||||
import AdminScheduledTasksPage from './AdminScheduledTasksPage';
|
||||
@@ -210,9 +213,11 @@ export function WorkspaceLayout({
|
||||
export function ChatRuntime({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<SubtasksProvider>
|
||||
<ArtifactsProvider>
|
||||
<PromptInputProvider>{children}</PromptInputProvider>
|
||||
</ArtifactsProvider>
|
||||
<DocumentRewritePresentationProvider>
|
||||
<ArtifactsProvider>
|
||||
<PromptInputProvider>{children}</PromptInputProvider>
|
||||
</ArtifactsProvider>
|
||||
</DocumentRewritePresentationProvider>
|
||||
</SubtasksProvider>
|
||||
);
|
||||
}
|
||||
@@ -468,6 +473,30 @@ export default function WorkspaceRoutes({
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
{/* 舆情分析虚拟智能体:静态段优先于 :agent_id 命中,走前端直连外部
|
||||
AG-UI 网关的专用对话页(不在后端 /api/agents 落库)。 */}
|
||||
<Route
|
||||
path="agents/sentiment-analysis/chats"
|
||||
element={
|
||||
isSentimentAgentVisible() ? (
|
||||
<Navigate to="/page/workspace/agents/sentiment-analysis/chats/new" replace />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/agents" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="agents/sentiment-analysis/chats/:thread_id"
|
||||
element={
|
||||
isSentimentAgentVisible() ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<SentimentAgentChatPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/agents" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="agents/:agent_id/chats"
|
||||
element={<Navigate to="/page/workspace/agents" replace />}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,358 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 报告配置卡片(用户协助卡片)。
|
||||
*
|
||||
* 对话式收集完成后弹出:预填原模式下拉(快速/标准/详细/深度/多智能体)与
|
||||
* 原高级配置对话框的全部选项(语言/语气/结构/配图/预算等),由用户确认后
|
||||
* 以 chat 入口启动报告写作 job —— 保留旧输入框全部能力,改造收敛在这一张卡片。
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ChevronRight,
|
||||
ImagePlus,
|
||||
Loader2,
|
||||
MessageSquarePlus,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
type ResearchMode,
|
||||
type ResearchTone,
|
||||
} from "@/strategy-components/api/deep-research";
|
||||
|
||||
const MODE_LABELS: Record<ResearchMode, string> = {
|
||||
basic: "标准",
|
||||
quick: "快速",
|
||||
detailed: "详细",
|
||||
deep: "深度",
|
||||
multi_agent: "多智能体",
|
||||
};
|
||||
|
||||
const MODE_HINTS: Record<ResearchMode, string> = {
|
||||
basic: "完整单遍写作,结构与篇幅均衡",
|
||||
quick: "短报告单遍成文,最快出结果",
|
||||
detailed: "按主题分节综合,篇幅更长",
|
||||
deep: "迭代深挖补充检索后再成文",
|
||||
multi_agent: "分节撰写并附加修订审稿轮",
|
||||
};
|
||||
|
||||
export type AdvancedResearchOptions = {
|
||||
language: "zh-CN" | "en-US";
|
||||
tone: ResearchTone;
|
||||
maxSearchResults: string;
|
||||
maxIterations: string;
|
||||
maxConcurrency: string;
|
||||
maxContextWords: string;
|
||||
maxSubtopics: string;
|
||||
deepBreadth: string;
|
||||
deepDepth: string;
|
||||
maxRevisionLoops: string;
|
||||
maxGeneratedImages: string;
|
||||
thinkingEnabled: boolean;
|
||||
customOutline: string;
|
||||
};
|
||||
|
||||
export const DEFAULT_ADVANCED_OPTIONS: AdvancedResearchOptions = {
|
||||
language: "zh-CN",
|
||||
tone: "objective",
|
||||
maxSearchResults: "8",
|
||||
maxIterations: "3",
|
||||
maxConcurrency: "3",
|
||||
maxContextWords: "24000",
|
||||
maxSubtopics: "8",
|
||||
deepBreadth: "4",
|
||||
deepDepth: "2",
|
||||
maxRevisionLoops: "2",
|
||||
maxGeneratedImages: "2",
|
||||
thinkingEnabled: false,
|
||||
customOutline: "",
|
||||
};
|
||||
|
||||
/** Seed the card's editable options from the frozen session config snapshot. */
|
||||
export function advancedOptionsFromConfig(config?: Record<string, unknown>): AdvancedResearchOptions {
|
||||
const numberish = (key: string, fallback: string): string => {
|
||||
const value = config?.[key];
|
||||
return typeof value === "number" && Number.isFinite(value) ? String(value) : fallback;
|
||||
};
|
||||
return {
|
||||
language: config?.language === "en-US" ? "en-US" : "zh-CN",
|
||||
tone: (typeof config?.tone === "string" ? config.tone : "objective") as ResearchTone,
|
||||
maxSearchResults: numberish("max_search_results_per_query", DEFAULT_ADVANCED_OPTIONS.maxSearchResults),
|
||||
maxIterations: numberish("max_iterations", DEFAULT_ADVANCED_OPTIONS.maxIterations),
|
||||
maxConcurrency: numberish("max_concurrency", DEFAULT_ADVANCED_OPTIONS.maxConcurrency),
|
||||
maxContextWords: numberish("max_context_words", DEFAULT_ADVANCED_OPTIONS.maxContextWords),
|
||||
maxSubtopics: numberish("max_subtopics", DEFAULT_ADVANCED_OPTIONS.maxSubtopics),
|
||||
deepBreadth: numberish("deep_breadth", DEFAULT_ADVANCED_OPTIONS.deepBreadth),
|
||||
deepDepth: numberish("deep_depth", DEFAULT_ADVANCED_OPTIONS.deepDepth),
|
||||
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 : "",
|
||||
};
|
||||
}
|
||||
|
||||
function boundedInteger(value: string, fallback: number, min: number, max: number): number {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) ? Math.max(min, Math.min(max, parsed)) : fallback;
|
||||
}
|
||||
|
||||
/** The card's choices as the job config override sent with entry:"chat". */
|
||||
export function buildReportCardConfig(
|
||||
mode: ResearchMode,
|
||||
generateImages: boolean,
|
||||
options: AdvancedResearchOptions,
|
||||
): Record<string, unknown> {
|
||||
const config: Record<string, unknown> = {
|
||||
mode,
|
||||
generate_images: generateImages,
|
||||
language: options.language,
|
||||
tone: options.tone,
|
||||
thinking_enabled: options.thinkingEnabled,
|
||||
max_search_results_per_query: boundedInteger(options.maxSearchResults, 8, 1, 20),
|
||||
max_iterations: boundedInteger(options.maxIterations, 3, 1, 10),
|
||||
max_concurrency: boundedInteger(options.maxConcurrency, 3, 1, 8),
|
||||
max_context_words: boundedInteger(options.maxContextWords, 24_000, 500, 120_000),
|
||||
};
|
||||
if (mode === "detailed" || mode === "multi_agent") {
|
||||
config.max_subtopics = boundedInteger(options.maxSubtopics, 8, 1, 20);
|
||||
}
|
||||
if (mode === "deep") {
|
||||
config.deep_breadth = boundedInteger(options.deepBreadth, 4, 1, 8);
|
||||
config.deep_depth = boundedInteger(options.deepDepth, 2, 1, 4);
|
||||
}
|
||||
if (mode === "multi_agent") {
|
||||
config.max_revision_loops = boundedInteger(options.maxRevisionLoops, 2, 0, 5);
|
||||
}
|
||||
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);
|
||||
return config;
|
||||
}
|
||||
|
||||
export function ReportConfigCard({
|
||||
availableModes,
|
||||
defaultMode,
|
||||
defaultOptions,
|
||||
defaultGenerateImages,
|
||||
modelLabel,
|
||||
materialCount,
|
||||
imageGenerationAvailable,
|
||||
imageLimit,
|
||||
submitting,
|
||||
onConfirm,
|
||||
onContinueCollecting,
|
||||
}: {
|
||||
availableModes: ResearchMode[];
|
||||
defaultMode: ResearchMode;
|
||||
defaultOptions: AdvancedResearchOptions;
|
||||
defaultGenerateImages: boolean;
|
||||
modelLabel: string;
|
||||
/** Number of unique, harvestable materials visible in the collector thread. */
|
||||
materialCount: number;
|
||||
imageGenerationAvailable: boolean;
|
||||
imageLimit: number;
|
||||
submitting: boolean;
|
||||
onConfirm: (config: Record<string, unknown>) => void;
|
||||
onContinueCollecting: () => void;
|
||||
}) {
|
||||
const [mode, setMode] = useState<ResearchMode>(
|
||||
availableModes.includes(defaultMode) ? defaultMode : (availableModes[0] ?? "basic"),
|
||||
);
|
||||
const [options, setOptions] = useState<AdvancedResearchOptions>(defaultOptions);
|
||||
const [generateImages, setGenerateImages] = useState(defaultGenerateImages);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setOptions(defaultOptions);
|
||||
setGenerateImages(defaultGenerateImages);
|
||||
}, [defaultOptions, defaultGenerateImages]);
|
||||
|
||||
const modes = useMemo(
|
||||
() => (availableModes.length ? availableModes : (["basic", "quick", "detailed", "deep", "multi_agent"] as ResearchMode[])),
|
||||
[availableModes],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl rounded-tl-md border border-primary/25 bg-card p-4 shadow-sm">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div className="mt-0.5 rounded-full bg-primary/10 p-1.5 text-primary">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold">资料收集完成,请确认报告写法</p>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
下方选项沿用原研究配置并可调整;确认后将基于以上对话收集到的资料撰写报告。
|
||||
{modelLabel ? <>写作模型:<span className="font-medium text-foreground">{modelLabel}</span>。</> : null}
|
||||
</p>
|
||||
<div className="mt-2 inline-flex items-center gap-1.5 rounded-md border border-primary/15 bg-primary/[0.06] px-2 py-1 text-[11px] text-primary">
|
||||
<span className="font-medium">已收集 {materialCount} 条资料</span>
|
||||
<span className="text-primary/70">将作为本次报告的写作依据</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-xs font-medium">报告模式</span>
|
||||
<Select value={mode} onValueChange={(value) => setMode(value as ResearchMode)}>
|
||||
<SelectTrigger className="h-9 text-sm"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{modes.map((item) => (
|
||||
<SelectItem key={item} value={item}>{MODE_LABELS[item]}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="mt-1 block text-[11px] text-muted-foreground">{MODE_HINTS[mode]}</span>
|
||||
</label>
|
||||
{imageGenerationAvailable && (
|
||||
<div>
|
||||
<span className="mb-1.5 block text-xs font-medium">报告配图</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={generateImages}
|
||||
onClick={() => setGenerateImages((value) => !value)}
|
||||
className={`inline-flex h-9 items-center gap-1.5 rounded-md border px-3 text-sm transition-colors ${generateImages ? "border-primary/40 bg-primary/10 text-primary" : "border-border text-muted-foreground hover:bg-muted"}`}
|
||||
>
|
||||
<ImagePlus className="h-3.5 w-3.5" />
|
||||
{generateImages ? `生成配图(最多 ${imageLimit} 张)` : "不生成配图"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 overflow-hidden rounded-xl border border-border/70">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdvancedOpen((value) => !value)}
|
||||
className="flex w-full items-center gap-2 px-3 py-2.5 text-left text-xs font-medium hover:bg-muted/40"
|
||||
aria-expanded={advancedOpen}
|
||||
>
|
||||
<SlidersHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
高级配置
|
||||
<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 && (
|
||||
<div className="grid gap-3 border-t border-border/70 p-3 sm:grid-cols-2">
|
||||
<CardSelect
|
||||
label="输出语言"
|
||||
value={options.language}
|
||||
onChange={(value) => update("language", value as AdvancedResearchOptions["language"])}
|
||||
options={[{ value: "zh-CN", label: "中文" }, { value: "en-US", label: "English" }]}
|
||||
/>
|
||||
<CardSelect
|
||||
label="报告语气"
|
||||
value={options.tone}
|
||||
onChange={(value) => update("tone", value as ResearchTone)}
|
||||
options={[
|
||||
{ value: "objective", label: "客观" },
|
||||
{ value: "formal", label: "正式" },
|
||||
{ value: "analytical", label: "分析" },
|
||||
{ value: "persuasive", label: "论证" },
|
||||
]}
|
||||
/>
|
||||
<CardNumber label="单次查询结果" hint="1–20 条" value={options.maxSearchResults} onChange={(v) => update("maxSearchResults", v)} />
|
||||
<CardNumber label="上下文词数" hint="500–120000" value={options.maxContextWords} onChange={(v) => update("maxContextWords", v)} />
|
||||
{(mode === "detailed" || mode === "multi_agent") && (
|
||||
<CardNumber label="最多子主题" hint="1–20 个" value={options.maxSubtopics} onChange={(v) => update("maxSubtopics", v)} />
|
||||
)}
|
||||
{mode === "deep" && (
|
||||
<>
|
||||
<CardNumber label="深挖广度" hint="1–8 路" value={options.deepBreadth} onChange={(v) => update("deepBreadth", v)} />
|
||||
<CardNumber label="深挖层级" hint="1–4 层" value={options.deepDepth} onChange={(v) => update("deepDepth", v)} />
|
||||
</>
|
||||
)}
|
||||
{mode === "multi_agent" && (
|
||||
<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
|
||||
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## 建议"}
|
||||
/>
|
||||
<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
|
||||
type="checkbox"
|
||||
checked={options.thinkingEnabled}
|
||||
onChange={(event) => update("thinkingEnabled", event.target.checked)}
|
||||
className="mt-0.5 accent-primary"
|
||||
/>
|
||||
<span>
|
||||
<span className="block font-medium text-foreground">启用模型深度思考</span>
|
||||
仅在当前模型支持时生效;会增加写作时延与模型消耗。
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap justify-end gap-2">
|
||||
<Button size="sm" variant="outline" onClick={onContinueCollecting} disabled={submitting}>
|
||||
<MessageSquarePlus className="mr-1.5 h-3.5 w-3.5" />
|
||||
继续补充资料
|
||||
</Button>
|
||||
<Button size="sm" disabled={submitting} onClick={() => onConfirm(buildReportCardConfig(mode, generateImages, options))}>
|
||||
{submitting && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
|
||||
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
|
||||
开始撰写报告
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
function update<K extends keyof AdvancedResearchOptions>(key: K, value: AdvancedResearchOptions[K]) {
|
||||
setOptions((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
}
|
||||
|
||||
function CardSelect({ label, value, onChange, options }: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: { value: string; label: string }[];
|
||||
}) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-xs font-medium">{label}</span>
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => <SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function CardNumber({ label, hint, value, onChange }: {
|
||||
label: string;
|
||||
hint: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-xs font-medium">{label}</span>
|
||||
<Input value={value} inputMode="numeric" onChange={(event) => onChange(event.target.value)} className="h-8 text-xs" />
|
||||
<span className="mt-0.5 block text-[10px] text-muted-foreground">{hint}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 深度研究沙箱分栏 —— 对齐主聊天 `ChatBox` / 圆桌 Step2 的 60/40 对话 | 产物预览布局。
|
||||
*
|
||||
* 深度研究没有 LangGraph SDK 线程承载报告(报告由写作 job 的 SSE 流出),这里用
|
||||
* `useReportSandbox` 构造的最小 ThreadContext stub 供 `ArtifactFileDetail` 工作:
|
||||
* 虚拟 `write-file:` URL 直接读 stub tool_call 的 args.content。右侧因此免费获得
|
||||
* 真沙箱的全部能力(Streamdown Markdown 渲染、流式 code 视图自动滚底、写完自动
|
||||
* 切预览 + 脉冲、复制/另存、展示层脱敏)。
|
||||
*/
|
||||
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||
import { FileTextIcon, 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 { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable";
|
||||
import {
|
||||
ArtifactFileDetail,
|
||||
useArtifacts,
|
||||
} from "@/components/workspace/artifacts";
|
||||
import { DocumentRewriteDialog } from "@/components/workspace/artifacts/document-rewrite-dialog";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import type { AgentThreadState } from "@/core/threads";
|
||||
import type { DocumentRewriteStyle } from "@/open-canvas/api/rewrite";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const CLOSE_MODE = { chat: 100, artifacts: 0 };
|
||||
const OPEN_MODE = { chat: 60, artifacts: 40 };
|
||||
|
||||
interface ResearchSandboxPanelProps {
|
||||
/** 左栏:对话收集区(MessageList / 卡片区 / 输入框),由宿主传入。 */
|
||||
children: React.ReactNode;
|
||||
/** stub 线程 id(与 useReportSandbox 的 threadKey 一致)。 */
|
||||
threadId: string;
|
||||
/** useReportSandbox 构造的 stub ai messages(承载 write_file(report.md))。 */
|
||||
stubMessages: unknown[];
|
||||
/** 报告是否正在流式写入 —— stub thread 的 isLoading。 */
|
||||
writing: boolean;
|
||||
/** 右栏是否展开(宿主导航状态;报告首帧到达时由 hook 自动置开)。 */
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/**
|
||||
* 覆盖沙箱「下载」按钮的默认行为(深度研究:弹 Markdown / Word 选择弹窗)。
|
||||
* 不传时沿用 ArtifactFileDetail 内置下载。
|
||||
*/
|
||||
onDownload?: () => void;
|
||||
/** 深度研究报告的全文改写由宿主执行;这里仅提供右侧沙箱入口和配置弹窗。 */
|
||||
onDocumentRewriteStart?: (input: {
|
||||
instruction: string;
|
||||
style: DocumentRewriteStyle;
|
||||
modelName: string | null;
|
||||
}) => void;
|
||||
documentRewriteActive?: boolean;
|
||||
onCancelDocumentRewrite?: () => void;
|
||||
reportCharCount?: number;
|
||||
}
|
||||
|
||||
export function ResearchSandboxPanel({
|
||||
children,
|
||||
threadId,
|
||||
stubMessages,
|
||||
writing,
|
||||
open,
|
||||
onClose,
|
||||
onDownload,
|
||||
onDocumentRewriteStart,
|
||||
documentRewriteActive = false,
|
||||
onCancelDocumentRewrite,
|
||||
reportCharCount = 0,
|
||||
}: ResearchSandboxPanelProps) {
|
||||
const layoutRef = useRef<GroupImperativeHandle>(null);
|
||||
const { selectedArtifact, setOpen, deselect } = useArtifacts();
|
||||
const [rewriteDialogOpen, setRewriteDialogOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!layoutRef.current) return;
|
||||
layoutRef.current.setLayout(open ? OPEN_MODE : CLOSE_MODE);
|
||||
}, [open]);
|
||||
|
||||
const threadStub = useMemo(
|
||||
() =>
|
||||
({
|
||||
messages: stubMessages,
|
||||
isLoading: writing,
|
||||
isThreadLoading: false,
|
||||
values: {
|
||||
title: "",
|
||||
messages: stubMessages,
|
||||
artifacts: [],
|
||||
},
|
||||
}) as unknown as BaseStream<AgentThreadState>,
|
||||
[stubMessages, writing],
|
||||
);
|
||||
|
||||
const previewThreadId = threadId || "";
|
||||
|
||||
const handleArtifactsClose = () => {
|
||||
deselect();
|
||||
setOpen(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ thread: threadStub, isMock: false }}>
|
||||
<ResizablePanelGroup
|
||||
id="deep-research-panels"
|
||||
orientation="horizontal"
|
||||
defaultLayout={CLOSE_MODE}
|
||||
groupRef={layoutRef}
|
||||
className="h-full min-h-0 flex-1"
|
||||
>
|
||||
<ResizablePanel className="relative" defaultSize={100} id="chat">
|
||||
{children}
|
||||
</ResizablePanel>
|
||||
<ResizableHandle
|
||||
id="deep-research-separator"
|
||||
className={cn(
|
||||
"opacity-33 hover:opacity-100",
|
||||
!open && "pointer-events-none opacity-0",
|
||||
)}
|
||||
/>
|
||||
<ResizablePanel
|
||||
className={cn(
|
||||
"bg-[#fffdfa] transition-all duration-300 ease-in-out",
|
||||
!open && "opacity-0",
|
||||
)}
|
||||
id="artifacts"
|
||||
>
|
||||
<div className="relative h-full bg-[#fffdfa] p-4">
|
||||
{selectedArtifact && previewThreadId ? (
|
||||
<>
|
||||
{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}>
|
||||
停止改写
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<ArtifactFileDetail
|
||||
className="size-full"
|
||||
filepath={selectedArtifact}
|
||||
threadId={previewThreadId}
|
||||
onClose={handleArtifactsClose}
|
||||
onDownloadClick={onDownload}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="relative flex size-full justify-center">
|
||||
<div className="absolute top-1 right-1 z-30">
|
||||
<Button size="icon-sm" variant="ghost" onClick={handleArtifactsClose}>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<ConversationEmptyState
|
||||
icon={<FileTextIcon />}
|
||||
title="report.md"
|
||||
description="报告开始撰写后将在此实时预览"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
{onDocumentRewriteStart && (
|
||||
<DocumentRewriteDialog
|
||||
open={rewriteDialogOpen}
|
||||
onOpenChange={setRewriteDialogOpen}
|
||||
fileName="report.md"
|
||||
charCount={reportCharCount}
|
||||
onStart={(input) => {
|
||||
setRewriteDialogOpen(false);
|
||||
onDocumentRewriteStart(input);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ThreadContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 深度研究「真沙箱」报告预览 hook —— 仿圆桌 `useStep3Report` 的 stub 机制。
|
||||
*
|
||||
* 深度研究的报告不是任何 LangGraph 线程里的真实 write_file 工具调用,而是写作
|
||||
* job 通过 SSE(report_delta)流出、由 workbench 在内存里拼装的文本。要让工作区
|
||||
* 的 `ArtifactFileDetail`(真沙箱)能预览它,这里构造一条**stub ai 消息**:携带
|
||||
* 一个 `write_file(report.md)` 的 tool_call,args.content 就是当前报告全文。
|
||||
* `ArtifactFileDetail` 通过虚拟 URL `write-file:...?message_id=...&tool_call_id=...`
|
||||
* 从 ThreadContext 的 stub 消息里直接读取内容(见 `core/artifacts/loader.ts`),
|
||||
* 于是免费获得沙箱的全部既有能力:
|
||||
* - 流式期(stub isLoading=true):code 视图自动滚底;
|
||||
* - isLoading 翻 false:自动切换 Markdown 预览(Streamdown 渲染)+ 脉冲提示;
|
||||
* - 复制 / 另存 / 展示层脱敏等。
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
artifactPreviewKey,
|
||||
useArtifacts,
|
||||
} from "@/components/workspace/artifacts";
|
||||
|
||||
/** 报告在沙箱中的固定虚拟路径(与 runner 实际写入的 artifacts 路径一致)。 */
|
||||
export const REPORT_OUTPUT_PATH = "/mnt/user-data/outputs/report.md";
|
||||
|
||||
export type ReportStubAiMessage = {
|
||||
id: string;
|
||||
type: "ai";
|
||||
content: string;
|
||||
tool_calls: {
|
||||
id: string;
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
}[];
|
||||
};
|
||||
|
||||
interface UseReportSandboxOptions {
|
||||
/** stub 线程标识(建议用 sessionId/jobId 派生,切换会话即切换 stub)。 */
|
||||
threadKey: string;
|
||||
/** 当前报告全文:流式期间为逐帧拼装的文本,完成后为已保存的 reportMarkdown。 */
|
||||
report: string;
|
||||
/** 报告是否正在流式写入 —— 作为 stub thread 的 isLoading。 */
|
||||
writing: boolean;
|
||||
}
|
||||
|
||||
export function useReportSandbox({ threadKey, report, writing }: UseReportSandboxOptions) {
|
||||
const { select, setOpen, openStreamedArtifact } = useArtifacts();
|
||||
const [stubMessage, setStubMessage] = useState<ReportStubAiMessage | null>(null);
|
||||
// 每个 threadKey 只自动展开一次;刷新/切换会话后的重开由用户或 openReport 触发。
|
||||
const autoOpenedRef = useRef(false);
|
||||
|
||||
const messageId = `dr-report-${threadKey}`;
|
||||
const toolCallId = `dr-report-${threadKey}-write`;
|
||||
|
||||
const virtualUrl = useMemo(
|
||||
() =>
|
||||
new URL(
|
||||
`write-file:${REPORT_OUTPUT_PATH}?message_id=${encodeURIComponent(messageId)}&tool_call_id=${encodeURIComponent(toolCallId)}`,
|
||||
).toString(),
|
||||
[messageId, toolCallId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
autoOpenedRef.current = false;
|
||||
setStubMessage(null);
|
||||
}, [threadKey]);
|
||||
|
||||
// 每帧 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;
|
||||
return {
|
||||
id: messageId,
|
||||
type: "ai",
|
||||
content: "",
|
||||
tool_calls: [{ id: toolCallId, name: "write_file", args: { path: REPORT_OUTPUT_PATH, content } }],
|
||||
};
|
||||
});
|
||||
}, [messageId, report, toolCallId]);
|
||||
|
||||
// 报告首帧到达且仍在写入 → 自动展开右栏并选中虚拟 URL。
|
||||
// openStreamedArtifact 尊重用户手动关闭(suppress),不会强行弹回。
|
||||
useEffect(() => {
|
||||
if (!report.trim() || !writing || autoOpenedRef.current) return;
|
||||
autoOpenedRef.current = true;
|
||||
openStreamedArtifact(virtualUrl, artifactPreviewKey(virtualUrl));
|
||||
}, [openStreamedArtifact, report, virtualUrl, writing]);
|
||||
|
||||
/** 手动打开(头部「报告」按钮 / 完成卡「查看报告」):始终选中虚拟 URL。 */
|
||||
const openReport = useCallback(() => {
|
||||
select(virtualUrl, false);
|
||||
setOpen(true);
|
||||
}, [select, setOpen, virtualUrl]);
|
||||
|
||||
return {
|
||||
virtualUrl,
|
||||
stubMessages: useMemo(() => (stubMessage ? [stubMessage] : []), [stubMessage]),
|
||||
openReport,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user