- {/* 元信息 */}
{session.draft_title ?? session.title}
@@ -134,39 +431,15 @@ export function AIWritingHistoryView({ session }: AIWritingHistoryViewProps) {
- {hasTranscript ? (
- /* 完整对话时间线回显 */
+ {timelineEvents.length > 0 ? (
) : (
- /* 旧会话无时间线快照时,回退到精简阶段列表 */
-
- {interventions.length > 0 ? (
-
-
写作阶段
-
- {interventions.map((rec, i) => (
-
- ))}
-
-
- ) : (
-
无阶段记录
- )}
-
- {session.status === 'done' && (
-
-
- 写作完成
-
- )}
+
+ 暂无阶段记录
)}
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..684d341d 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,18 @@ 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;
+const RESUMABLE_HISTORY_STATUSES = new Set(['in_progress', 'draft_ready', 'review_ready']);
import { countArticleWords } from '../../utils/wordCount';
import { AIWritingForm } from './AIWritingForm';
import { AIWritingTimeline } from './AIWritingTimeline';
@@ -89,7 +81,7 @@ export function AIWritingPanel({
} = {}) {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
- const { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset } = useAIWriting();
+ const { state, startWriting, submitIntervention, pauseWriting, resumeWriting, continueWriting, reset } = useAIWriting();
const { historySession, isHistoryMode, openHistory, closeHistory } = useAIWritingHistory();
const writingForm = useWritingFormOptional();
const { sessions, loading: sessionsLoading, refresh, rename, remove, markBackground, clearBackground } = useAIWritingSessions();
@@ -207,6 +199,15 @@ export function AIWritingPanel({
reset();
}, [closeHistory, reset]);
+ const canRestoreHistory =
+ isHistoryMode && !!historySession && RESUMABLE_HISTORY_STATUSES.has(historySession.status);
+
+ const handleRestoreHistory = useCallback(() => {
+ if (!historySession || !RESUMABLE_HISTORY_STATUSES.has(historySession.status)) return;
+ closeHistory();
+ continueWriting(historySession.id);
+ }, [historySession, closeHistory, continueWriting]);
+
// ── 后台挂起:把当前写作转后台,后端自动驱动到定稿(离页/刷新/切号不打断) ──
// 与「多智能体会商」一致:点击后**立刻**回到初始页面,挂起请求 fire-and-forget;
// 历史下拉靠乐观标记马上显示「挂起中」,不等后端确认。
@@ -234,26 +235,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 +269,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');
@@ -322,19 +317,33 @@ export function AIWritingPanel({
)}
{/* 头部 */}
-
+ {canRestoreHistory && (
+
)}
- onClick={() => { if (!isIdle && !isHistoryMode && state.request) setReqDialogOpen(true); }}
- title={isHistoryMode ? (historySession?.draft_title ?? historySession?.title) : undefined}
- >
- {isHistoryMode
- ? (historySession?.draft_title ?? historySession?.title ?? '写作历史')
- : 'AI 智能写作'}
-
+
{ if (!isIdle && !isHistoryMode && state.request) setReqDialogOpen(true); }}
+ title={isHistoryMode ? (historySession?.draft_title ?? historySession?.title) : undefined}
+ >
+ {isHistoryMode
+ ? (historySession?.draft_title ?? historySession?.title ?? '写作历史')
+ : 'AI 智能写作'}
+
+
{/* ⚠️ 临时调试:admin 一键清理 —— 阶段 7 删冗余时移除 */}
-
- {record.reviewResult && (
- <>
-
-
-
- >
- )}
+
+ {record.reviewResult && }
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..ba6b7586 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,146 @@ 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";
+ // Row/title click only opens the history view; restore starts from the detail header.
+ const primary = isBackground && onShowProgress ? onShowProgress : onSelect;
+ const primaryTitle = isBackground ? "查看进度" : "打开历史";
+ 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"
+ />
+ ) : (
+
+
+ {session.title}
+
+
+
+ {pill.spin && }
+ {pill.dot && }
+ {pill.label}
+
+ ·
+ {formatRelative(session.updated_at)}
+
+
+ )}
+
+
+ {!renaming && (
+
+
+
+
+
{
+ setValue(session.title);
+ setRenaming(true);
+ }}
+ title="重命名"
+ className="rounded p-1 text-slate-500 transition hover:bg-slate-100 hover:text-primary"
+ >
+
+
+
+
+
+
+ )}
+
+
+ );
+}
+
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 +258,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 +358,20 @@ export function ResearchSidebar({
return (