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

This commit is contained in:
吉鑫升 committed 2026-08-17 15:53:36 +08:00
commit 5bf08c83a6
176 files changed
+33341 -1896

No files matched your search

+54 -14
View File
@@ -46,6 +46,8 @@ import { useAgentSelector, useAgentsPaginated } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks";
import {
isFrontendRagProfile,
isMultiRoundRagProfile,
isSingleRoundRagProfile,
SINGLE_ROUND_RAG_MODELS,
} from "@/core/models/single-round-rag";
@@ -59,6 +61,7 @@ import {
import { useThreadStream } 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";
import { useLayoutMode } from "@/core/system-settings/hooks";
import { useRouteBase } from "@/core/route-base";
import { textOfMessage } from "@/core/threads/utils";
@@ -310,19 +313,38 @@ export default function ChatPage() {
},
});
const singleRoundRag = useSingleRoundRagMessages(threadId);
const multiRoundRag = useMultiRoundRagMessages(threadId);
const singleRoundRagSelected = isSingleRoundRagProfile(settings.context);
const multiRoundRagSelected = isMultiRoundRagProfile(settings.context);
const frontendRagSelected = isFrontendRagProfile(settings.context);
const displayThread = useMemo(() => {
if (singleRoundRag.messages.length === 0 && !singleRoundRag.isLoading) {
if (
singleRoundRag.messages.length === 0 &&
multiRoundRag.messages.length === 0 &&
!singleRoundRag.isLoading &&
!multiRoundRag.isLoading
) {
return thread;
}
const messages = [...thread.messages, ...singleRoundRag.messages];
const messages = [
...thread.messages,
...singleRoundRag.messages,
...multiRoundRag.messages,
];
return {
...thread,
values: { ...thread.values, messages },
messages,
isLoading: thread.isLoading || singleRoundRag.isLoading,
isLoading:
thread.isLoading || singleRoundRag.isLoading || multiRoundRag.isLoading,
} as typeof thread;
}, [singleRoundRag.isLoading, singleRoundRag.messages, thread]);
}, [
multiRoundRag.isLoading,
multiRoundRag.messages,
singleRoundRag.isLoading,
singleRoundRag.messages,
thread,
]);
const writingArtifactPath = useMemo(() => {
const isMarkdown = (path: string | null | undefined) =>
@@ -337,9 +359,9 @@ export default function ChatPage() {
const handleSubmit = useCallback(
(message: PromptInputMessage) => {
if (singleRoundRagSelected) {
if (frontendRagSelected) {
if (message.files && message.files.length > 0) {
toast.error("单轮 RAG 问答暂不支持附件,请移除附件后重试。");
toast.error("RAG 问答暂不支持附件,请移除附件后重试。");
return;
}
setHasStartedConversation(true);
@@ -366,13 +388,23 @@ export default function ChatPage() {
.catch(() => undefined);
}
}
await singleRoundRag.submit({
targetThreadId,
query: message.text,
modelName: settings.context.model_name ?? "OpenAI3",
});
if (multiRoundRagSelected) {
await multiRoundRag.submit({ targetThreadId, query: message.text });
} else {
await singleRoundRag.submit({
targetThreadId,
query: message.text,
modelName: settings.context.model_name ?? "OpenAI3",
});
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "单轮 RAG 问答失败。");
toast.error(
error instanceof Error
? error.message
: multiRoundRagSelected
? "多轮 RAG 问答失败。"
: "单轮 RAG 问答失败。",
);
}
})();
return;
@@ -483,6 +515,9 @@ export default function ChatPage() {
writingArtifactPath,
singleRoundRag,
singleRoundRagSelected,
multiRoundRag,
multiRoundRagSelected,
frontendRagSelected,
routeBase,
setIsNewThread,
setThreadId,
@@ -580,8 +615,12 @@ export default function ChatPage() {
singleRoundRag.stop();
return;
}
if (multiRoundRag.isLoading) {
multiRoundRag.stop();
return;
}
await thread.stop();
}, [singleRoundRag, thread]);
}, [multiRoundRag, singleRoundRag, thread]);
const promptPrefixProp = useMemo(() => {
if (!promptPrefixSettings?.enabled) return undefined;
@@ -782,7 +821,8 @@ export default function ChatPage() {
isUploading ||
agentHandoffPending ||
knowledgeSelectionLoading ||
singleRoundRag.isLoading
singleRoundRag.isLoading ||
multiRoundRag.isLoading
}
onContextChange={(context) =>
setSettings("context", context)
File diff suppressed because it is too large. Load diff
@@ -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>
);
}
+25
View File
@@ -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";
@@ -24,10 +25,12 @@ import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanni
import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtablePage";
import EightBfRoundtablePage from "@/eightbf-roundtable/pages/EightBfRoundtablePage";
import DashboardAgentPage from "@/roundtable-planning/pages/DashboardAgentPage";
import DeepResearchPage from "./DeepResearchPage";
import BusinessChainListPage from "@/roundtable-planning/pages/BusinessChainListPage";
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";
@@ -113,6 +116,7 @@ export default function StrategyRoutes() {
</ChatRuntime>
}
/>
<Route path="qa/deep-research" element={<DeepResearchPage />} />
{/* 大屏绘制智能体独立页面(按 taskId 存取;ChatRuntime 提供 ArtifactsProvider 给沙箱预览) */}
<Route
@@ -309,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 />}
+43 -3
View File
@@ -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';
@@ -58,6 +61,7 @@ import PositionRolesPage from './PositionRolesPage';
import PluginManagementPage from './PluginManagementPage';
import RecommendedQuestionsPage from './RecommendedQuestionsPage';
import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
import DeepResearchPage from './DeepResearchPage';
import StudioNotebooksPage from './StudioNotebooksPage';
import StudioNotebookDetailPage from './StudioNotebookDetailPage';
import RoundtablePlanningPage from '@/roundtable-planning/pages/RoundtablePlanningPage';
@@ -210,9 +214,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 +474,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 />}
@@ -522,6 +552,16 @@ export default function WorkspaceRoutes({
</WorkspaceLayout>
}
/>
<Route
path="deep-research"
element={
<WorkspaceLayout {...layoutProps}>
<ChatRuntime>
<DeepResearchPage />
</ChatRuntime>
</WorkspaceLayout>
}
/>
<Route
path="studio/notebooks"
element={
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,
};
}