diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingHistoryDropdown.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingHistoryDropdown.tsx index 2b8cac89..84f425b9 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingHistoryDropdown.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingHistoryDropdown.tsx @@ -6,7 +6,8 @@ import { cn } from '@/lib/utils'; import type { AIWritingSessionSummary } from '../../api/ai-writing-sessions'; export const STATUS_LABELS: Record = { - in_progress: { label: '进行中', color: 'text-blue-500' }, + // 刷新=重置后 in_progress 会话已无活进程,显示中性「未完成」而非「进行中」,避免歧义。 + in_progress: { label: '未完成', color: 'text-slate-400' }, // 后台挂起中:后端正自动驱动到定稿(离页/刷新/切号不打断)。 background: { label: '挂起中', color: 'text-sky-500' }, draft_ready: { label: '草稿', color: 'text-amber-500' }, diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx index 8dc166b8..2a87e7d6 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; -import { ChevronsDown, CloudUpload, PanelRightClose, Play, Plus, RotateCcw, Settings2, Square, Trash2 } from 'lucide-react'; +import { ChevronsDown, CloudUpload, PanelRightClose, Pause, Play, Plus, RotateCcw, Settings2, Trash2 } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; @@ -21,26 +21,17 @@ import { adminCleanupAIWritingSessions, getAIWritingSession, suspendAIWritingToBackground, - suspendAIWritingToBackgroundKeepalive, } from '../../api/ai-writing-sessions'; /** - * 暂停 / 继续功能开关(默认 false = 关)。 + * 暂停 / 继续功能开关(现已开启)。 * - * 历史背景:legacy 路径下,为了让用户点"停止"后还能"继续",引入了 per-worker - * 内存 dict(``_event_queues`` / ``_session_pending_pause``)。多 worker 部署下 - * 这些 dict 互相看不见 → SSE 重连失败 / "会话丢失" → 触发了整个 ai-writing-优化.md - * 的迁移计划。 - * - * 当前 LangGraph Server 路径下架构已无 per-worker 状态,但 ``stream.stop()`` 仅 - * 取消客户端 SSE 订阅,**不会真正暂停后端 run**(LLM 继续烧 token);``resume`` - * 也无法重新订阅同一个 run。这是个产品语义没想清楚的功能,先关掉。 - * - * 后期想清楚怎么做"真·暂停"(``client.runs.cancel(run_id, action='interrupt')`` - * 保留 checkpoint)+ "真·继续"(``client.runs.joinStream`` 续上原 run)时, - * 把这个 flag 翻成 true 即可,UI / hook 全套代码都已就位。 + * 真·暂停:``pauseWriting`` → ``stream.stop()`` 取消当前 run(LangGraph 已完成的阶段 + * 已落 checkpoint,不丢)。真·继续:``resumeWriting`` → 带空 ``command:{}`` 重新 submit, + * 后端 worker 据此让 Pregel 从最近 checkpoint 续跑挂起的当前阶段节点(重跑当前未完成阶段, + * 已完成阶段不重来;见 runtime/runs/worker.py)。刷新页面 = 重置(不自动重连)。 */ -const ENABLE_PAUSE_RESUME_UI = false; +const ENABLE_PAUSE_RESUME_UI = true; import { countArticleWords } from '../../utils/wordCount'; import { AIWritingForm } from './AIWritingForm'; import { AIWritingTimeline } from './AIWritingTimeline'; @@ -234,26 +225,10 @@ export function AIWritingPanel({ })(); }, [state.sessionId, reset, refresh, markBackground, clearBackground]); - // 离页 / 刷新 / 关标签页时,若写作仍在进行中则自动转后台(与圆桌「自动挂起」一致)。 - // 用 ref 持有最新的「是否在跑 + 会话 id」,让卸载/卸载前回调读到即时值。 - const suspendRef = useRef<{ running: boolean; sid: string | null }>({ running: false, sid: null }); - useEffect(() => { - suspendRef.current = { running: isRunning, sid: state.sessionId ?? null }; - }, [isRunning, state.sessionId]); - - useEffect(() => { - const onBeforeUnload = () => { - const { running, sid } = suspendRef.current; - if (running && sid) suspendAIWritingToBackgroundKeepalive(sid); - }; - window.addEventListener('beforeunload', onBeforeUnload); - return () => { - window.removeEventListener('beforeunload', onBeforeUnload); - // 组件卸载(路由离开本页 / 切换工作区标签):写作未完成则自动转后台。 - const { running, sid } = suspendRef.current; - if (running && sid) suspendAIWritingToBackgroundKeepalive(sid); - }; - }, []); + // 离页 / 刷新 = 重置(不自动重连、不自动转后台):刷新会断开 SSE,后端默认 + // on_disconnect=cancel 取消该 run,页面重新挂载回到 idle。需要"后台自动跑完" + // (自动确认素材/大纲/审核到定稿、离页不中断)可手动点「后台挂起」。 + // 页内的「暂停/继续」从 checkpoint 续跑当前阶段,不受刷新重置影响(刷新即新开)。 // ── 侧边栏"最近写作"通信:两种路径 ──────────────────────────────────────── // 用 ref 包住 handler,保证事件监听器始终能拿到最新版本 @@ -284,6 +259,16 @@ export function AIWritingPanel({ return () => window.removeEventListener('ai-writing:open-session', onOpenSession); }, []); + // 路径4:左侧研究栏「历史记录」点「挂起中」记录 → 打开进度流程图弹窗(与下拉历史一致)。 + useEffect(() => { + function onShowProgress(e: Event) { + const session = (e as CustomEvent).detail; + if (session) setProgressSession(session); + } + window.addEventListener('ai-writing:show-progress', onShowProgress); + return () => window.removeEventListener('ai-writing:show-progress', onShowProgress); + }, []); + // 路径3:URL ?session=xxx 直接定位到指定会话 useEffect(() => { const sessionId = searchParams.get('session'); @@ -374,8 +359,8 @@ export function AIWritingPanel({ 后台挂起 )} - {/* ⏸️/▶️ "停止 / 继续" 由 ENABLE_PAUSE_RESUME_UI 控制(默认 false 隐藏)。 - 后期把 flag 翻成 true 即可恢复,UI / hook 全套代码都已就位。 */} + {/* ⏸️/▶️ "暂停 / 继续":暂停=stream.stop() 取消当前 run(已完成阶段已落 checkpoint); + 继续=从 checkpoint 重新驱动 graph 续跑(重跑当前阶段,已完成阶段不丢)。 */} {ENABLE_PAUSE_RESUME_UI && isRunning && !state.isPaused && ( )} {ENABLE_PAUSE_RESUME_UI && state.isPaused && ( diff --git a/frontend-web/src/open-canvas/components/ai-writing/ResearchSidebar.tsx b/frontend-web/src/open-canvas/components/ai-writing/ResearchSidebar.tsx index f161a0dd..73627792 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/ResearchSidebar.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/ResearchSidebar.tsx @@ -3,20 +3,26 @@ import { BarChart3, BookOpen, + Eye, FileText, Globe2, + History, LayoutGrid, Loader2, Newspaper, + PanelLeftClose, + Pencil, Plus, + RefreshCw, Search, Settings2, SlidersHorizontal, Sparkles, + Trash2, TrendingUp, } from "lucide-react"; import type { LucideIcon } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { useQueryClient } from "@tanstack/react-query"; @@ -33,9 +39,13 @@ import { useStudioNotebooks } from "@/core/studio/hooks"; import { cn } from "@/lib/utils"; import { useResearchTemplates } from "../../hooks/useResearchTemplates"; +import { useAIWritingSessions } from "../../hooks/useAIWritingSessions"; import type { ResearchTemplate } from "../../api/ai-writing-research-templates"; +import type { AIWritingSessionSummary } from "../../api/ai-writing-sessions"; import { useWritingForm } from "../../contexts/WritingFormContext"; import { useAIWriting } from "../../contexts/AIWritingContext"; +import { useAIWritingHistory } from "../../contexts/AIWritingHistoryContext"; +import { formatRelative } from "./AIWritingHistoryDropdown"; type TemplateVisual = { Icon: LucideIcon; @@ -86,14 +96,145 @@ function getTemplateVisual(tpl: ResearchTemplate, index: number): TemplateVisual return TEMPLATE_VISUALS[index % TEMPLATE_VISUALS.length]; } +// 历史记录状态药丸样式(对齐多智能体会商的历史记录观感)。 +// 注:`in_progress` 不再转圈显示「进行中」——刷新=重置后这些会话已无任何活进程, +// 转圈会误导用户以为还在跑;统一显示为中性、不动的「未完成」。真正还在跑的是 `background` +// (后台挂起,有 AIWritingJobExecutor 驱动),保留「挂起中」转圈。 +const WRITING_STATUS_PILL: Record = { + in_progress: { label: "未完成", className: "text-slate-400" }, + background: { label: "挂起中", className: "text-sky-500", spin: true }, + draft_ready: { label: "草稿", className: "text-amber-500" }, + review_ready: { label: "审核", className: "text-purple-500", dot: true }, + done: { label: "完成", className: "text-emerald-500" }, + error: { label: "出错", className: "text-rose-500" }, +}; + +/** 单条历史记录项 —— 样式参考多智能体会商的 HistoryDropdown 列表项(圆角卡片 + 状态药丸 + 操作按钮)。 */ +function WritingHistoryItem({ + session, + isCurrent, + onSelect, + onShowProgress, + onDelete, + onRename, +}: { + session: AIWritingSessionSummary; + isCurrent: boolean; + onSelect: () => void; + onShowProgress?: () => void; + onDelete: () => void; + onRename: (title: string) => void; +}) { + const [renaming, setRenaming] = useState(false); + const [value, setValue] = useState(session.title); + const inputRef = useRef(null); + const isBackground = session.status === "background"; + // 「挂起中」点击看进度(流程图弹窗),其余打开历史回看。 + const open = isBackground && onShowProgress ? onShowProgress : onSelect; + const pill = WRITING_STATUS_PILL[session.status] ?? { label: session.status, className: "text-slate-400" }; + + useEffect(() => { + if (renaming) inputRef.current?.focus(); + }, [renaming]); + + const commit = () => { + const t = value.trim(); + if (t && t !== session.title) onRename(t); + setRenaming(false); + }; + + return ( +
  • +
    +
    + {renaming ? ( + setValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") commit(); + if (e.key === "Escape") { + setValue(session.title); + setRenaming(false); + } + }} + onBlur={commit} + className="w-full rounded border border-slate-300 px-2 py-1 text-[11.5px] font-semibold text-slate-800 outline-none focus:border-primary" + /> + ) : ( + + )} +
    + + {!renaming && ( +
    + + + +
    + )} +
    +
  • + ); +} + export function ResearchSidebar({ viewingNotebookId, onOpenNotebook, onOpenAllSpaces, + onCollapse, }: { viewingNotebookId?: number | null; onOpenNotebook: (notebookId: number) => void; onOpenAllSpaces: () => void; + /** 收起侧栏(由页面承载收起态)。 */ + onCollapse?: () => void; }) { const navigate = useNavigate(); const { templates, refresh } = useResearchTemplates(); @@ -116,6 +257,31 @@ export function ResearchSidebar({ } = useWritingForm(); const queryClient = useQueryClient(); + // 「历史记录」列表(左下角,样式参考多智能体会商的历史记录):自带一份会话列表, + // 点击打开「历史回看」(openHistory,全局共享上下文,打开后本侧栏随写作进入历史态而隐藏)。 + const { + sessions, + loading: sessionsLoading, + refresh: refreshSessions, + rename: renameSession, + remove: removeSession, + } = useAIWritingSessions(); + const { openHistory, historySession } = useAIWritingHistory(); + + // 进入/返回写作配置页时刷新一次历史(本侧栏仅在 idle 态挂载,故等同每次回到表单刷新)。 + useEffect(() => { + void refreshSessions(); + }, [refreshSessions]); + + const handleSelectHistory = (session: AIWritingSessionSummary) => { + void openHistory(session); + }; + // 「挂起中」记录:交给右侧面板打开进度流程图弹窗(沿用面板已有的 window 事件解耦模式, + // 与 ai-writing:open-session 一致),本侧栏不直接持有弹窗状态。 + const handleShowProgress = (session: AIWritingSessionSummary) => { + window.dispatchEvent(new CustomEvent("ai-writing:show-progress", { detail: session })); + }; + const [templateSearch, setTemplateSearch] = useState(""); const [createOpen, setCreateOpen] = useState(false); const [name, setName] = useState(""); @@ -191,6 +357,20 @@ export function ResearchSidebar({ return (