660 lines
27 KiB
TypeScript
660 lines
27 KiB
TypeScript
/**
|
||
* AI 写作入口页(左右双栏:聊天面板 + 草稿预览)。
|
||
*
|
||
* ──────────────────────────────────────────────────────────────────────────
|
||
* Docker 内网部署后「会话丢失 / transcript 报 500 / resume 报 400」排查指南
|
||
* ──────────────────────────────────────────────────────────────────────────
|
||
*
|
||
* 后端涉及接口(前缀 /api/ai-writing):
|
||
* POST /start 启动写作流程
|
||
* GET /{session_id}/stream SSE 进度流
|
||
* POST /{session_id}/resume 提交用户干预 → 继续流程
|
||
* PUT /sessions/{session_id}/transcript 保存对话时间线
|
||
*
|
||
* ── 1) 确认 alembic 迁移已跑过(修 transcript 500)─────────────────────
|
||
* transcript 列在 MySQL 上原是 TEXT(64 KB 上限),长时间线会溢出抛 500。
|
||
* 迁移 20260519_02 把它改成 LONGTEXT。容器启动会自动跑,验证方法:
|
||
*
|
||
* docker exec <gateway> sh -c "cd /app/backend \
|
||
* && PYTHONPATH=. uv run alembic current"
|
||
* docker exec <mysql> mysql -uroot -p<pwd> deerflow -e \
|
||
* "SHOW COLUMNS FROM ai_writing_sessions LIKE 'transcript';"
|
||
*
|
||
* 列类型应是 longtext。若仍是 text,手动执行:
|
||
* docker exec <gateway> sh -c "cd /app/backend \
|
||
* && PYTHONPATH=. uv run alembic upgrade head"
|
||
*
|
||
* ── 2) 确认 .deer-flow 数据卷已挂出(修「容器没重启也丢会话」)──────
|
||
* AI 写作 graph 的 checkpointer 已从 MemorySaver 换成 AsyncSqliteSaver,
|
||
* 文件落到 .deer-flow/data/checkpoints.db。docker-compose 必须把该目录
|
||
* 挂出来,否则容器重启数据照样丢:
|
||
*
|
||
* services:
|
||
* gateway:
|
||
* volumes:
|
||
* - ./data:/app/.deer-flow # 关键:挂出 .deer-flow
|
||
*
|
||
* ── 3) 确认 worker 数 = 1(关键嫌疑:多 worker 内存隔离)────────────
|
||
* _event_queues / _session_pending_pause 都是模块级 dict,每个 worker
|
||
* 一份。多 worker 下,/start 命中 worker A、/resume 命中 worker B,B
|
||
* 的 dict 没有这个 session → 404/「会话丢失」。
|
||
*
|
||
* docker exec <gateway> ps -ef | grep -E 'uvicorn|gunicorn'
|
||
*
|
||
* 若 > 1:要么把启动参数改成 --workers 1,要么在 nginx 加 sticky
|
||
* session(ip_hash / cookie),要么把队列搬 Redis。
|
||
*
|
||
* ── 4) 排查节点异常(嫌疑:graph 节点抛错触发清理分支)─────────────
|
||
* _stream_graph_segment 的 except 分支会清掉 _event_queues[session_id],
|
||
* 用户下一次 resume 就 404。日志里搜:
|
||
*
|
||
* docker logs <gateway> 2>&1 | grep -E \
|
||
* 'ai_writing graph segment failed|ai_writing.*error' | tail -50
|
||
*
|
||
* ── 5) 排查反向代理掐 SSE 长连接(嫌疑:proxy_read_timeout 太短)───
|
||
* SSE 默认连接挂半个小时以上,nginx 默认 60s 会断。检查内网反代:
|
||
*
|
||
* grep -rE 'proxy_read_timeout|sse|keepalive' /etc/nginx/
|
||
*
|
||
* 需在 SSE 路由上加:proxy_read_timeout 3600s; proxy_buffering off;
|
||
*
|
||
* ── 定期清理(每天 03:00 跑)────────────────────────────────────────
|
||
* config.yaml -> ai_writing.cleanup 段(retention_days: 7)。联动清理
|
||
* MySQL 业务表 + SQLite checkpointer 里的 thread 状态。手动验证:
|
||
*
|
||
* docker exec <mysql> mysql -uroot -p<pwd> deerflow -e \
|
||
* "SELECT COUNT(*) FROM ai_writing_sessions \
|
||
* WHERE updated_at < NOW() - INTERVAL 7 DAY;"
|
||
*
|
||
* ── 不想等凌晨 3 点?手动触发一次清理(admin only)──────────────────
|
||
* POST /api/ai-writing/admin/cleanup
|
||
* Body(全部可选,留空走 config 默认):
|
||
* { "retention_days": 7, "only_finished": false, "batch_size": 500 }
|
||
* 返回:
|
||
* { "deleted_db": N, "deleted_checkpoints": N,
|
||
* "elapsed_seconds": X, "params": {...} }
|
||
*
|
||
* 例:清掉所有 1 小时前的会话(测试用):
|
||
* curl -X POST http://<gateway>/api/ai-writing/admin/cleanup \
|
||
* -H 'Content-Type: application/json' \
|
||
* -H 'Cookie: <admin session cookie>' \
|
||
* -d '{"retention_days": 0}'
|
||
* ──────────────────────────────────────────────────────────────────────────
|
||
*/
|
||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||
import {
|
||
ArrowLeft,
|
||
History,
|
||
Loader2,
|
||
PanelLeftClose,
|
||
PanelLeftOpen,
|
||
PanelRightOpen,
|
||
} from 'lucide-react';
|
||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||
import { cn } from '@/lib/utils';
|
||
import type { GroupImperativeHandle } from 'react-resizable-panels';
|
||
import {
|
||
Navigate,
|
||
Route,
|
||
Routes,
|
||
useLocation,
|
||
useNavigate,
|
||
useParams,
|
||
} from 'react-router-dom';
|
||
|
||
import { CanvasThreadProvider } from '../contexts/CanvasThreadContext';
|
||
import { CanvasGraphProvider } from '../contexts/CanvasGraphContext';
|
||
import { CanvasUIProvider } from '../contexts/CanvasUIContext';
|
||
import { AIWritingPanel } from '../components/ai-writing/AIWritingPanel';
|
||
import { AIWritingDraftPanel } from '../components/ai-writing/AIWritingDraftPanel';
|
||
import { WritingSetupChat } from '../components/ai-writing/WritingSetupChat';
|
||
import { ResearchSidebar } from '../components/ai-writing/ResearchSidebar';
|
||
import StudioNotebookDetailPage from '@/pages/StudioNotebookDetailPage';
|
||
import StudioNotebooksPage from '@/pages/StudioNotebooksPage';
|
||
import AgentChatPage from '@/pages/AgentChatPage';
|
||
import { ChatRuntime } from '@/pages/WorkspaceRoutes';
|
||
import {
|
||
LayoutModeSwitcher,
|
||
ThemeSwitcher,
|
||
} from '@/components/workspace/appearance-controls';
|
||
import {
|
||
AgentModuleSidebar,
|
||
useAgentModuleSidebarData,
|
||
} from '@/components/workspace/agents/agent-module-sidebar';
|
||
import { AgentModuleSettingsDialog } from '@/components/workspace/agents/agent-module-settings-dialog';
|
||
import { getStoredAuth } from '@/core/auth';
|
||
import { RouteBaseProvider } from '@/core/route-base';
|
||
|
||
/** 右侧内嵌视图:笔记本详情 / 所有空间列表 / null(写作) */
|
||
type RightView = { kind: 'notebook'; id: number } | { kind: 'allSpaces' } | null;
|
||
import { AIWritingProvider, useAIWriting } from '../contexts/AIWritingContext';
|
||
import { AIWritingHistoryProvider, useAIWritingHistory } from '../contexts/AIWritingHistoryContext';
|
||
import { WritingFormProvider, useWritingForm } from '../contexts/WritingFormContext';
|
||
import {
|
||
ResizableHandle,
|
||
ResizablePanel,
|
||
ResizablePanelGroup,
|
||
} from '@/components/ui/resizable';
|
||
|
||
// Panel IDs used as keys in the v4 Layout map
|
||
const WRITING_ID = 'aw-writing';
|
||
const DRAFT_ID = 'aw-draft';
|
||
const AI_WRITING_AGENT_MODULE_KEY = 'ai-writing';
|
||
const AI_WRITING_AGENT_MODULE_TITLE = 'AI 写作智能体';
|
||
const AI_WRITING_LAST_AGENT_KEY = `agent-module:${AI_WRITING_AGENT_MODULE_KEY}:last-agent`;
|
||
const SHOW_NOTEBOOK_SPACE_ENTRIES = false;
|
||
|
||
// Match the original canvas page proportions (chat=35, artifact=65)
|
||
const DEFAULT_LAYOUT = { [WRITING_ID]: 35, [DRAFT_ID]: 65 };
|
||
|
||
function AIWritingCompactAppearanceActions() {
|
||
return (
|
||
<div className="flex items-center gap-1">
|
||
<ThemeSwitcher tone="content" />
|
||
<LayoutModeSwitcher tone="content" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function useAIWritingRouteBase() {
|
||
const { pathname } = useLocation();
|
||
return useMemo(() => {
|
||
const marker = '/ai-writing';
|
||
const index = pathname.indexOf(marker);
|
||
return index >= 0
|
||
? pathname.slice(0, index + marker.length)
|
||
: '/page/workspace/ai-writing';
|
||
}, [pathname]);
|
||
}
|
||
|
||
function useAIWritingAgentRouteState(base: string) {
|
||
const { pathname } = useLocation();
|
||
return useMemo(() => {
|
||
const prefix = `${base}/agents/`;
|
||
if (!pathname.startsWith(prefix)) {
|
||
return { active: false, agentId: undefined, threadId: undefined };
|
||
}
|
||
const parts = pathname.slice(prefix.length).split('/');
|
||
return {
|
||
active: true,
|
||
agentId: parts[0] ? decodeURIComponent(parts[0]) : undefined,
|
||
threadId: parts[2] ? decodeURIComponent(parts[2]) : undefined,
|
||
};
|
||
}, [base, pathname]);
|
||
}
|
||
|
||
function AIWritingAgentDefaultRedirect() {
|
||
const { groups, isLoading } = useAgentModuleSidebarData(AI_WRITING_AGENT_MODULE_KEY);
|
||
const agents = groups.flatMap((group) => group.agents);
|
||
const lastAgentId = (() => {
|
||
try {
|
||
return sessionStorage.getItem(AI_WRITING_LAST_AGENT_KEY);
|
||
} catch {
|
||
return null;
|
||
}
|
||
})();
|
||
const targetAgent = agents.find((agent) => agent.id === lastAgentId) ?? agents[0];
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<div className="flex size-full items-center justify-center">
|
||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!targetAgent) {
|
||
return (
|
||
<div className="flex size-full items-center justify-center px-6 text-center text-sm text-muted-foreground">
|
||
暂无可用智能体,请先在模块设置中配置或创建智能体。
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return <Navigate to={`agents/${targetAgent.id}/chats/new`} replace />;
|
||
}
|
||
|
||
function AIWritingAgentChatRoute({
|
||
compactAppearanceActions,
|
||
}: {
|
||
compactAppearanceActions?: ReactNode;
|
||
}) {
|
||
const { agent_id } = useParams<{ agent_id: string }>();
|
||
return (
|
||
<ChatRuntime>
|
||
<AgentChatPage
|
||
key={agent_id}
|
||
compactAppearanceActions={compactAppearanceActions}
|
||
/>
|
||
</ChatRuntime>
|
||
);
|
||
}
|
||
|
||
function AIWritingAgentRoutes({
|
||
base,
|
||
compactAppearanceActions,
|
||
}: {
|
||
base: string;
|
||
compactAppearanceActions?: ReactNode;
|
||
}) {
|
||
return (
|
||
<RouteBaseProvider value={base}>
|
||
<Routes>
|
||
<Route index element={<AIWritingAgentDefaultRedirect />} />
|
||
<Route
|
||
path="agents/:agent_id/chats/:thread_id"
|
||
element={
|
||
<AIWritingAgentChatRoute
|
||
compactAppearanceActions={compactAppearanceActions}
|
||
/>
|
||
}
|
||
/>
|
||
</Routes>
|
||
</RouteBaseProvider>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 写作配置表单面板(写作助手回填的目标),抽出复用:idle 时在右栏,开始写作后在左栏。
|
||
*/
|
||
interface NotebookProps {
|
||
initialNotebookId?: number;
|
||
initialDocumentIds?: number[];
|
||
studioToken?: string;
|
||
}
|
||
|
||
function WritingFormPanel({
|
||
hideAdvancedFields,
|
||
initialNotebookId,
|
||
initialDocumentIds,
|
||
studioToken,
|
||
headerActionsBeforeCollapse,
|
||
}: {
|
||
hideAdvancedFields: boolean;
|
||
headerActionsBeforeCollapse?: ReactNode;
|
||
} & NotebookProps) {
|
||
return (
|
||
// 与原布局保持相同的 flex-1 min-h-0 包裹,确保内部 h-full 正确解析
|
||
<div className="flex-1 min-h-0 overflow-hidden">
|
||
<AIWritingPanel
|
||
hideAdvancedFields={hideAdvancedFields}
|
||
initialNotebookId={initialNotebookId}
|
||
initialDocumentIds={initialDocumentIds}
|
||
studioToken={studioToken}
|
||
headerActionsBeforeCollapse={headerActionsBeforeCollapse}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 根据写作状态切换两套左右布局:
|
||
* - idle(初始问答阶段):左=「写作助手 Q&A」,右=「写作配置选项」表单;
|
||
* - 开始写作后:回到原布局——左=写作配置/时间线面板,右=草稿预览。
|
||
*/
|
||
function AIWritingLayout({
|
||
hideAdvancedFields,
|
||
initialNotebookId,
|
||
initialDocumentIds,
|
||
studioToken,
|
||
compactAppearanceActions,
|
||
}: {
|
||
hideAdvancedFields: boolean;
|
||
compactAppearanceActions?: ReactNode;
|
||
} & NotebookProps) {
|
||
const layoutRef = useRef<GroupImperativeHandle>(null);
|
||
const { state } = useAIWriting();
|
||
const { isHistoryMode } = useAIWritingHistory();
|
||
const { formExpanded, setFormExpanded } = useWritingForm();
|
||
|
||
// 初始问答阶段:无历史查看;查看历史时与「正在写作」相同左右分栏(左时间线、右草稿)。
|
||
//
|
||
// 用稳定的 flex 结构(而非按 formExpanded 在两套容器间切换):左侧问答区永远是第一个
|
||
// 子节点、位置不变,避免收起/展开时 WritingSetupChat 被重新挂载(它在内部 useMemo 生成
|
||
// 独立 threadId,一旦重挂会换新线程、丢失对话)。右侧整块「写作配置表单」可收起为一条
|
||
// 竖向展开栏;问答侧点「应用到表单」(applyProposal) 会自动把 formExpanded 置 true 展开。
|
||
if (state.status === 'idle' && !isHistoryMode) {
|
||
return (
|
||
<div className="relative flex h-full">
|
||
{/* 左侧:写作助手 Q&A —— 恒为第一个子节点,位置稳定不重挂 */}
|
||
<div className="min-h-0 flex-1 overflow-hidden">
|
||
<WritingSetupChat />
|
||
</div>
|
||
|
||
{/* 右侧:写作配置表单整块。容器持久存在(表单始终挂载、保留状态),宽度随 formExpanded
|
||
平滑过渡——收起时宽度归零、整块滑出,右侧完全让给问答区。 */}
|
||
<div
|
||
className={cn(
|
||
'flex min-h-0 shrink-0 flex-col overflow-hidden transition-[width,min-width] duration-300 ease-in-out',
|
||
formExpanded ? 'w-[36%] min-w-[340px] max-w-[600px] border-l' : 'w-0 min-w-0',
|
||
)}
|
||
>
|
||
<WritingFormPanel
|
||
hideAdvancedFields={hideAdvancedFields}
|
||
initialNotebookId={initialNotebookId}
|
||
initialDocumentIds={initialDocumentIds}
|
||
studioToken={studioToken}
|
||
headerActionsBeforeCollapse={compactAppearanceActions}
|
||
/>
|
||
</div>
|
||
|
||
{/* 收起态:右上角浮动展开图标按钮(与展开态头部的收起按钮位置呼应) */}
|
||
{!formExpanded && (
|
||
<div className="absolute right-3 top-2 z-10 flex items-center gap-1">
|
||
{compactAppearanceActions}
|
||
<button
|
||
type="button"
|
||
onClick={() => setFormExpanded(true)}
|
||
title="展开写作配置"
|
||
className="flex h-7 w-7 items-center justify-center rounded-md border bg-background text-muted-foreground shadow-sm transition-colors hover:bg-muted hover:text-foreground"
|
||
>
|
||
<PanelRightOpen className="size-4" />
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<ResizablePanelGroup
|
||
orientation="horizontal"
|
||
groupRef={layoutRef}
|
||
defaultLayout={DEFAULT_LAYOUT}
|
||
className="h-full"
|
||
>
|
||
{/* 左侧:AI 写作面板,与原画布页左侧等宽 */}
|
||
<ResizablePanel
|
||
id={WRITING_ID}
|
||
defaultSize={35}
|
||
minSize={20}
|
||
className="overflow-hidden flex flex-col"
|
||
>
|
||
<WritingFormPanel hideAdvancedFields={hideAdvancedFields} />
|
||
</ResizablePanel>
|
||
|
||
{/* 隐形拖拽热区只向右(草稿区)延伸、可见抓手也微微右移,二者都不再盖住左侧时间线的
|
||
滚动条——否则鼠标想抓滚动条会误触左右 resize、导致滑不动。右侧是草稿区留白(px-10),
|
||
盖住无副作用。 */}
|
||
<ResizableHandle
|
||
withHandle
|
||
className="after:left-0 after:w-3 after:translate-x-0 [&>div]:translate-x-1.5"
|
||
/>
|
||
|
||
{/* 右侧:草稿预览面板 */}
|
||
<ResizablePanel
|
||
id={DRAFT_ID}
|
||
defaultSize={65}
|
||
minSize={40}
|
||
className="overflow-hidden"
|
||
>
|
||
<AIWritingDraftPanel />
|
||
</ResizablePanel>
|
||
</ResizablePanelGroup>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 写作页主体(侧栏 + 内容区)。放在 Provider 内部,以便读 useAIWriting 状态:
|
||
* 进入写作(status !== idle 或查看历史)后自动收起左侧研究模板栏,让写作区更宽。
|
||
*/
|
||
function AIWritingBody({
|
||
hideAdvancedFields,
|
||
initialNotebookId,
|
||
initialDocumentIds,
|
||
studioToken,
|
||
compactChrome = false,
|
||
}: {
|
||
hideAdvancedFields: boolean;
|
||
compactChrome?: boolean;
|
||
} & NotebookProps) {
|
||
const { state } = useAIWriting();
|
||
const { isHistoryMode } = useAIWritingHistory();
|
||
const location = useLocation();
|
||
const navigate = useNavigate();
|
||
const routeBase = useAIWritingRouteBase();
|
||
const agentRoute = useAIWritingAgentRouteState(routeBase);
|
||
const isAdmin = getStoredAuth()?.system_role === 'admin';
|
||
const externalNotebookMode =
|
||
studioToken != null || initialNotebookId != null || initialDocumentIds != null;
|
||
|
||
// 需求 0616 #5:左栏点「笔记本 / 所有空间」→ 在右侧内嵌打开对应页,替换右侧内容
|
||
// (写作布局不卸载,仅 display:none 隐藏,避免 WritingSetupChat 重挂丢线程)。
|
||
const [rightView, setRightView] = useState<RightView>(null);
|
||
const overlayActive = rightView != null;
|
||
const viewingNotebookId = rightView?.kind === 'notebook' ? rightView.id : null;
|
||
|
||
// 侧栏「收起」态:由页面承载,ResearchSidebar 顶部的收起按钮触发,收起后显示一条细窄展开条。
|
||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||
const [sidebarTab, setSidebarTab] = useState<'research' | 'agents'>('agents');
|
||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||
|
||
useEffect(() => {
|
||
if (agentRoute.active) {
|
||
setSidebarTab('agents');
|
||
}
|
||
}, [agentRoute.active]);
|
||
|
||
const agentsActive = !externalNotebookMode && sidebarTab === 'agents';
|
||
|
||
// 研究模板侧栏在「写作配置(idle)」与「历史回看」两态都展示(点历史记录后保留侧栏、
|
||
// 历史内容显示到右侧区域);写作进行中收起;嵌入态(studioToken)始终不展示。
|
||
const showSidebar =
|
||
!externalNotebookMode && (state.status === 'idle' || isHistoryMode || agentsActive);
|
||
const compactAppearanceActions = compactChrome ? (
|
||
<AIWritingCompactAppearanceActions />
|
||
) : null;
|
||
|
||
return (
|
||
<div className="flex-1 min-h-0 overflow-hidden flex">
|
||
{/* 左栏:课题研究 / 智能体。课题研究支持收起为细窄展开条。 */}
|
||
{showSidebar && (
|
||
<div
|
||
className={cn(
|
||
'relative h-full shrink-0 overflow-hidden border-r border-slate-100 bg-white text-slate-700 shadow-[1px_0_18px_rgba(15,23,42,0.03)] transition-[width] duration-300 ease-in-out dark:border-[#2b8ffe]/45 dark:bg-[#032650]/80 dark:text-[#c5cfe0] dark:shadow-none dark:backdrop-blur-md',
|
||
sidebarCollapsed ? 'w-11' : 'w-[320px]',
|
||
)}
|
||
>
|
||
<div
|
||
className={cn(
|
||
'absolute inset-0 flex flex-col items-center gap-2 py-3 transition-opacity duration-200 ease-out',
|
||
sidebarCollapsed ? 'opacity-100' : 'pointer-events-none opacity-0',
|
||
)}
|
||
>
|
||
<button
|
||
type="button"
|
||
onClick={() => setSidebarCollapsed(false)}
|
||
title="展开侧栏"
|
||
aria-label="展开侧栏"
|
||
className="rounded-md p-1.5 text-slate-400 transition-colors hover:bg-slate-50 hover:text-slate-700 dark:text-[#8aa0c4] dark:hover:bg-white/10 dark:hover:text-[#e8edf5]"
|
||
>
|
||
<PanelLeftOpen className="size-4" />
|
||
</button>
|
||
<History className="size-4 text-slate-300 dark:text-[#6e88b0]" />
|
||
</div>
|
||
<aside
|
||
className={cn(
|
||
'absolute inset-y-0 left-0 flex h-full w-[320px] flex-col overflow-hidden transition-all duration-300 ease-in-out',
|
||
sidebarCollapsed ? 'pointer-events-none -translate-x-4 opacity-0' : 'translate-x-0 opacity-100',
|
||
)}
|
||
>
|
||
<div className="flex shrink-0 items-center gap-2 border-b border-slate-100 px-3 py-2 dark:border-[#2b8ffe]/20">
|
||
<div className="grid flex-1 grid-cols-2 rounded-lg bg-slate-100 p-1 dark:bg-white/10">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setSidebarTab('agents');
|
||
setRightView(null);
|
||
if (!agentRoute.active) navigate(routeBase);
|
||
}}
|
||
className={cn(
|
||
'rounded-md px-2 py-1.5 text-xs font-semibold transition-colors',
|
||
sidebarTab === 'agents'
|
||
? 'bg-white text-slate-900 shadow-sm dark:bg-[#122347]/90 dark:text-[#e8edf5] dark:shadow-none'
|
||
: 'text-slate-500 hover:text-slate-800 dark:text-[#8aa0c4] dark:hover:bg-white/10 dark:hover:text-[#e8edf5]',
|
||
)}
|
||
>
|
||
课题研究
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setSidebarTab('research');
|
||
setRightView(null);
|
||
if (location.pathname !== routeBase) navigate(routeBase);
|
||
}}
|
||
className={cn(
|
||
'rounded-md px-2 py-1.5 text-xs font-semibold transition-colors',
|
||
sidebarTab === 'research'
|
||
? 'bg-white text-slate-900 shadow-sm dark:bg-[#122347]/90 dark:text-[#e8edf5] dark:shadow-none'
|
||
: 'text-slate-500 hover:text-slate-800 dark:text-[#8aa0c4] dark:hover:bg-white/10 dark:hover:text-[#e8edf5]',
|
||
)}
|
||
>
|
||
ai写作
|
||
</button>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => setSidebarCollapsed(true)}
|
||
title="收起侧栏"
|
||
aria-label="收起侧栏"
|
||
className="rounded-md p-1 text-slate-400 transition-colors hover:bg-slate-50 hover:text-slate-700 dark:text-[#8aa0c4] dark:hover:bg-white/10 dark:hover:text-[#e8edf5]"
|
||
>
|
||
<PanelLeftClose className="size-4" />
|
||
</button>
|
||
</div>
|
||
<div className="min-h-0 flex-1 overflow-hidden">
|
||
{sidebarTab === 'research' ? (
|
||
<ResearchSidebar
|
||
viewingNotebookId={viewingNotebookId}
|
||
onOpenNotebook={(id) => setRightView({ kind: 'notebook', id })}
|
||
onOpenAllSpaces={() => setRightView({ kind: 'allSpaces' })}
|
||
/>
|
||
) : (
|
||
<AgentModuleSidebar
|
||
moduleKey={AI_WRITING_AGENT_MODULE_KEY}
|
||
activeAgentId={agentRoute.agentId}
|
||
activeThreadId={agentRoute.threadId}
|
||
routeBase={routeBase}
|
||
headerTitle={AI_WRITING_AGENT_MODULE_TITLE}
|
||
className="w-full border-r-0"
|
||
onOpenSettings={isAdmin ? () => setSettingsOpen(true) : undefined}
|
||
/>
|
||
)}
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
)}
|
||
{/* 右侧:写作布局常驻(仅 display:none 隐藏,避免 WritingSetupChat 重挂丢线程),
|
||
查看笔记本 / 所有空间时在其上叠加内嵌页。 */}
|
||
<div className="relative flex-1 min-h-0 overflow-hidden">
|
||
<div className={cn('absolute inset-0', (overlayActive || agentsActive) && 'hidden')}>
|
||
<AIWritingLayout
|
||
hideAdvancedFields={hideAdvancedFields}
|
||
initialNotebookId={initialNotebookId}
|
||
initialDocumentIds={initialDocumentIds}
|
||
studioToken={studioToken}
|
||
compactAppearanceActions={compactAppearanceActions}
|
||
/>
|
||
</div>
|
||
{overlayActive && !agentsActive && (
|
||
<div className="absolute inset-0 flex flex-col bg-background">
|
||
<div className="flex shrink-0 items-center gap-1 border-b border-border px-4 py-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setRightView(null)}
|
||
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||
>
|
||
<ArrowLeft className="size-4" />
|
||
返回写作
|
||
</button>
|
||
{SHOW_NOTEBOOK_SPACE_ENTRIES && rightView.kind === 'notebook' && (
|
||
<button
|
||
type="button"
|
||
onClick={() => setRightView({ kind: 'allSpaces' })}
|
||
className="ml-1 rounded-md px-2 py-1 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||
>
|
||
所有空间
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div className="min-h-0 flex-1 overflow-hidden">
|
||
{rightView.kind === 'notebook' ? (
|
||
// 笔记本详情页内部用 ChatBox(useArtifacts) → 需 ChatRuntime 提供 Provider。
|
||
<ChatRuntime>
|
||
<StudioNotebookDetailPage key={rightView.id} notebookId={rightView.id} />
|
||
</ChatRuntime>
|
||
) : (
|
||
<div className="h-full overflow-y-auto">
|
||
<StudioNotebooksPage
|
||
onOpenNotebook={(id) => setRightView({ kind: 'notebook', id })}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{agentsActive && (
|
||
<div className="workspace-chat-bg absolute inset-0">
|
||
<AIWritingAgentRoutes
|
||
base={routeBase}
|
||
compactAppearanceActions={compactAppearanceActions}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{isAdmin && (
|
||
<AgentModuleSettingsDialog
|
||
open={settingsOpen}
|
||
onOpenChange={setSettingsOpen}
|
||
moduleKey={AI_WRITING_AGENT_MODULE_KEY}
|
||
moduleTitle={AI_WRITING_AGENT_MODULE_TITLE}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function AIWritingPage({
|
||
hideAdvancedFields = false,
|
||
initialNotebookId,
|
||
initialDocumentIds,
|
||
studioToken,
|
||
compactChrome = false,
|
||
}: {
|
||
hideAdvancedFields?: boolean;
|
||
initialNotebookId?: number;
|
||
initialDocumentIds?: number[];
|
||
studioToken?: string;
|
||
compactChrome?: boolean;
|
||
} = {}) {
|
||
return (
|
||
<CanvasThreadProvider>
|
||
<CanvasGraphProvider>
|
||
<CanvasUIProvider>
|
||
<AIWritingHistoryProvider>
|
||
<AIWritingProvider>
|
||
<WritingFormProvider
|
||
initialNotebookId={initialNotebookId}
|
||
initialDocumentIds={initialDocumentIds}
|
||
>
|
||
<TooltipProvider delayDuration={300}>
|
||
<div className="h-full flex flex-col overflow-hidden bg-background canvas-writing-bg">
|
||
<AIWritingBody
|
||
hideAdvancedFields={hideAdvancedFields}
|
||
initialNotebookId={initialNotebookId}
|
||
initialDocumentIds={initialDocumentIds}
|
||
studioToken={studioToken}
|
||
compactChrome={compactChrome}
|
||
/>
|
||
</div>
|
||
</TooltipProvider>
|
||
</WritingFormProvider>
|
||
</AIWritingProvider>
|
||
</AIWritingHistoryProvider>
|
||
</CanvasUIProvider>
|
||
</CanvasGraphProvider>
|
||
</CanvasThreadProvider>
|
||
);
|
||
}
|