feat(ai写作): 历史记录可继续未完成的会话(从checkpoint续跑)

历史列表里 in_progress/draft_ready/review_ready 记录加绿色▶️「继续未完成」按钮
(点标题亦可);done/error/background 不可继续。多 worker 安全——与4个确认卡同机制,
全基于共享 checkpoint(gateway GET /threads/{id}/state + /history 读,任意 worker)。

链路:continueWriting(sessionId) → closeHistory + 设活跃 sessionId → useStream
fetchStateHistory 拉该 thread checkpoint 进 stream.values → host rehydrate effect
(continueTargetRef 守卫,每 thread 一次)用 buildRehydrateSnapshot 重建已完成阶段;
status=awaiting_* → 显示确认卡等用户操作,否则 submit({command:{}}) 从 checkpoint
续跑当前阶段。重新加回 buildRehydrateSnapshot + rehydrate action。

入口在侧栏 WritingHistoryItem。刷新=重置不受影响(这是显式点击,非自动重连)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
董宏凯andClaude Opus 4.8 committed 2026-06-27 13:12:47 +08:00
1 parent e889cc25cf
commit c11307aa67
3 files changed
+135 -10

No files matched your search

@@ -12,6 +12,7 @@ import {
Newspaper,
PanelLeftClose,
Pencil,
Play,
Plus,
RefreshCw,
Search,
@@ -109,12 +110,17 @@ const WRITING_STATUS_PILL: Record<string, { label: string; className: string; sp
error: { label: "出错", className: "text-rose-500" },
};
/** 可「继续未完成」的状态:有可恢复的 checkpoint(mid-stage 或停在确认点)。done/error 终态、
* background 自有进度弹窗,都不算。 */
const RESUMABLE_STATUSES = new Set(["in_progress", "draft_ready", "review_ready"]);
/** 单条历史记录项 —— 样式参考多智能体会商的 HistoryDropdown 列表项(圆角卡片 + 状态药丸 + 操作按钮)。 */
function WritingHistoryItem({
session,
isCurrent,
onSelect,
onShowProgress,
onContinue,
onDelete,
onRename,
}: {
@@ -122,6 +128,8 @@ function WritingHistoryItem({
isCurrent: boolean;
onSelect: () => void;
onShowProgress?: () => void;
/** 继续这条未完成的会话(从 checkpoint 续跑当前阶段 / 显示确认卡)。 */
onContinue?: () => void;
onDelete: () => void;
onRename: (title: string) => void;
}) {
@@ -129,8 +137,10 @@ function WritingHistoryItem({
const [value, setValue] = useState(session.title);
const inputRef = useRef<HTMLInputElement>(null);
const isBackground = session.status === "background";
// 「挂起中」点击看进度(流程图弹窗),其余打开历史回看。
const open = isBackground && onShowProgress ? onShowProgress : onSelect;
const canContinue = !!onContinue && RESUMABLE_STATUSES.has(session.status);
// 行/标题主点击:未完成→继续续跑;挂起中→看进度弹窗;其余(完成/出错)→只读历史回看。
const primary = isBackground && onShowProgress ? onShowProgress : (canContinue ? onContinue! : onSelect);
const primaryTitle = isBackground ? "查看进度" : canContinue ? "继续未完成" : "打开历史";
const pill = WRITING_STATUS_PILL[session.status] ?? { label: session.status, className: "text-slate-400" };
useEffect(() => {
@@ -168,9 +178,9 @@ function WritingHistoryItem({
) : (
<button
type="button"
onClick={open}
onClick={primary}
className="block w-full text-left"
title={isBackground ? "查看进度" : "打开历史"}
title={primaryTitle}
>
<span className="line-clamp-2 text-[11.5px] font-semibold leading-snug text-slate-800">
{session.title}
@@ -190,10 +200,20 @@ function WritingHistoryItem({
{!renaming && (
<div className="flex shrink-0 items-center gap-0.5">
{canContinue && (
<button
type="button"
onClick={onContinue}
title="继续未完成"
className="rounded p-1 text-emerald-500 transition hover:bg-emerald-50 hover:text-emerald-600"
>
<Play className="size-3.5 fill-current" />
</button>
)}
<button
type="button"
onClick={open}
title={isBackground ? "查看进度" : "打开"}
onClick={isBackground && onShowProgress ? onShowProgress : onSelect}
title={isBackground ? "查看进度" : "只读查看"}
className="rounded p-1 text-slate-500 transition hover:bg-slate-100 hover:text-primary"
>
<Eye className="size-3.5" />
@@ -238,7 +258,7 @@ export function ResearchSidebar({
}) {
const navigate = useNavigate();
const { templates, refresh } = useResearchTemplates();
const { startWriting } = useAIWriting();
const { startWriting, continueWriting } = useAIWriting();
const {
notebooks,
isLoading: notebooksLoading,
@@ -266,7 +286,7 @@ export function ResearchSidebar({
rename: renameSession,
remove: removeSession,
} = useAIWritingSessions();
const { openHistory, historySession } = useAIWritingHistory();
const { openHistory, closeHistory, historySession } = useAIWritingHistory();
// 进入/返回写作配置页时刷新一次历史(本侧栏仅在 idle 态挂载,故等同每次回到表单刷新)。
useEffect(() => {
@@ -276,6 +296,11 @@ export function ResearchSidebar({
const handleSelectHistory = (session: AIWritingSessionSummary) => {
void openHistory(session);
};
// 「继续未完成」:退出只读历史态(若在),把该会话设为活跃并从 checkpoint 续跑。
const handleContinue = (session: AIWritingSessionSummary) => {
closeHistory();
continueWriting(session.id);
};
// 「挂起中」记录:交给右侧面板打开进度流程图弹窗(沿用面板已有的 window 事件解耦模式,
// 与 ai-writing:open-session 一致),本侧栏不直接持有弹窗状态。
const handleShowProgress = (session: AIWritingSessionSummary) => {
@@ -592,6 +617,7 @@ export function ResearchSidebar({
isCurrent={s.id === (historySession?.id ?? null)}
onSelect={() => handleSelectHistory(s)}
onShowProgress={() => handleShowProgress(s)}
onContinue={() => handleContinue(s)}
onDelete={() => removeSession(s.id)}
onRename={(title) => renameSession(s.id, title)}
/>
@@ -26,6 +26,7 @@ import { saveAIWritingTranscript } from '../api/ai-writing-sessions';
import { countArticleWords } from '../utils/wordCount';
import {
INITIAL_STATE,
buildRehydrateSnapshot,
customToProgressEvent,
interruptToEvent,
nodeUpdatesToEvents,
@@ -101,6 +102,9 @@ export type AIWritingApi = {
submitIntervention: (payload: UserInterventionPayload) => Promise<void>;
pauseWriting: () => void;
resumeWriting: () => void;
/** 继续一条未完成的历史会话:加载其 checkpoint 重建已完成阶段,并从断点续跑当前阶段
* (停在确认点则显示确认卡)。多 worker 安全(基于共享 checkpoint,同 4 个确认卡机制)。 */
continueWriting: (sessionId: string) => void;
reset: () => void;
};
@@ -133,12 +137,15 @@ const AIWritingStreamHost = memo(function AIWritingStreamHost({
sessionIdRef,
streamRef,
seenInterruptRef,
continueTargetRef,
}: {
threadId: string | undefined;
dispatchRef: RefObject<Dispatch<Action>>;
sessionIdRef: RefObject<string | null>;
streamRef: MutableRefObject<AIWritingStreamApi | null>;
seenInterruptRef: MutableRefObject<string | null>;
/** 非空 = 本次要"继续未完成的历史会话",等 checkpoint values 到位后 rehydrate + 驱动续跑。 */
continueTargetRef: MutableRefObject<string | null>;
}) {
const eventBatcherRef = useRef<ReturnType<typeof createProgressEventBatcher> | null>(null);
if (!eventBatcherRef.current) {
@@ -238,6 +245,41 @@ const AIWritingStreamHost = memo(function AIWritingStreamHost({
if (ev) emitProgressEvent(ev);
}, [stream.interrupt, emitProgressEvent, seenInterruptRef, sessionIdRef]);
// ── 继续未完成的历史会话:等该 thread 的 checkpoint values 到位后 rehydrate + 驱动续跑 ──
// 仅在显式点「继续」时触发(continueTargetRef 由 continueWriting 置位),非自动重连。
// 每个 thread 只做一次:rehydrate 用 checkpoint 全量重建已完成阶段(素材/大纲/草稿/审核/时间线),
// 然后——停在确认点(awaiting_*)就显示确认卡等用户决策;停在某活跃阶段中途就发空 command:{}
// 让后端从 checkpoint 续跑该阶段(worker 空 command → astream(None))。
const rehydratedForRef = useRef<string | null>(null);
useEffect(() => {
const target = continueTargetRef.current;
if (!target || !threadId || target !== threadId) return;
if (rehydratedForRef.current === threadId) return;
const values = stream.values as AIWritingValues | undefined;
if (!values || !values.status) return; // 等 fetchStateHistory 把 checkpoint 拉到
rehydratedForRef.current = threadId;
continueTargetRef.current = null;
const snapshot = buildRehydrateSnapshot(values, threadId);
dispatchRef.current?.({ type: 'rehydrate', snapshot });
const status = String(snapshot.status ?? '');
if (status.startsWith('awaiting_')) {
// 停在确认点:补一条 await_user 设好 currentPause(与下方既有 interrupt effect 二者其一生效,
// 对齐 seenInterruptRef 防重复派发);不自动驱动,等用户在确认卡上操作。
const intr = stream.interrupt;
if (intr) {
const ev = interruptToEvent(intr.value, threadId);
if (ev) dispatchRef.current?.({ type: 'handle', event: ev });
seenInterruptRef.current = intr.id ?? JSON.stringify(intr.value);
}
} else {
// 停在某活跃阶段中途:从 checkpoint 续跑该阶段。
void streamRef.current?.submit(undefined, {
command: {},
streamMode: [...WRITING_STREAM_MODE],
}).catch(() => { /* 流式错误由 onError 反映 */ });
}
}, [stream.values, stream.interrupt, threadId, continueTargetRef, dispatchRef, seenInterruptRef, streamRef]);
useEffect(() => () => { eventBatcherRef.current?.cancel(); }, []);
return null;
@@ -252,6 +294,8 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
sessionIdRef.current = state.sessionId;
const streamRef = useRef<AIWritingStreamApi | null>(null);
const seenInterruptRef = useRef<string | null>(null);
// 「继续未完成历史」目标:continueWriting 置位,host 在该 thread 的 checkpoint 到位后 rehydrate。
const continueTargetRef = useRef<string | null>(null);
// 刷新页面 = 重置:不持久化、不自动重连。provider 重新挂载即回到 idle 初始态
// (刷新会断开 SSE,后端默认 on_disconnect=cancel 取消该 run,不留孤儿)。
@@ -470,16 +514,30 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
})();
}, [state.request]);
// 继续一条未完成的历史会话:把该会话设为活跃 sessionId → useStream 加载其 checkpoint →
// host 的 rehydrate effect 重建已完成阶段并驱动续跑(停在确认点则显示确认卡)。
// 与「刷新=重置」不冲突:这是显式点击触发,不是自动重连。
const continueWriting = useCallback((sessionId: string) => {
if (!sessionId) return;
void streamRef.current?.stop();
setArtifact(null);
seenInterruptRef.current = null;
continueTargetRef.current = sessionId;
dispatch({ type: 'reset' });
dispatch({ type: 'set_session', sessionId });
}, [setArtifact]);
const reset = useCallback(() => {
void streamRef.current?.stop();
setArtifact(null);
seenInterruptRef.current = null;
continueTargetRef.current = null;
dispatch({ type: 'reset' });
}, [setArtifact]);
const value = useMemo(
() => ({ state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset }),
[state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset],
() => ({ state, startWriting, submitIntervention, pauseWriting, resumeWriting, continueWriting, reset }),
[state, startWriting, submitIntervention, pauseWriting, resumeWriting, continueWriting, reset],
);
return (
@@ -490,6 +548,7 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
sessionIdRef={sessionIdRef}
streamRef={streamRef}
seenInterruptRef={seenInterruptRef}
continueTargetRef={continueTargetRef}
/>
{children}
</AIWritingContext.Provider>
@@ -85,6 +85,8 @@ export type Action =
| { type: 'intervention_submitted'; record: CompletedIntervention; nextStatus: WritingStatus; pendingRevisionContext: AIWritingState['pendingRevisionContext'] }
| { type: 'pause' }
| { type: 'resume_ui' }
// 「继续未完成的历史记录」:用 graph checkpoint 的 values 快照整体重建状态(见 buildRehydrateSnapshot)。
| { type: 'rehydrate'; snapshot: Partial<AIWritingState> }
// 手动「继续」从检查点重跑当前阶段前,清空该阶段的流式缓冲(避免与重跑的新输出粘连)。
| { type: 'clear_streaming' }
| { type: 'reset' }
@@ -134,6 +136,12 @@ export function reducer(prev: AIWritingState, action: Action): AIWritingState {
case 'resume_ui':
return { ...prev, isPaused: false };
case 'rehydrate':
// 继续未完成历史:以 INITIAL_STATE 为底叠加 checkpoint 快照(materials/outline/draft/
// review/status/progressEvents/sessionId 等)。currentPause 由 stream.interrupt 既有 effect
// 或 host 在 rehydrate 时补设。
return { ...INITIAL_STATE, ...action.snapshot, isPaused: false };
case 'clear_streaming':
// 「继续」重跑当前阶段:清掉各阶段流式缓冲,重跑的输出从干净状态累积。
return {
@@ -575,6 +583,38 @@ export function normalizeProgressDict(parsed: Record<string, unknown>, sessionId
return out as unknown as ProgressEvent;
}
/** 继续未完成历史:把 graph checkpoint 的 values 快照重建成前端状态快照(用于 ``rehydrate``)。
*
* ``status`` 后端与前端共用同一套字面量(state.py WritingStatus ↔ types/ai-writing.ts),可直接
* 透传;timeline 由累积的 ``progress_events``(operator.add 全量)重放,素材/大纲/草稿/审核读各自
* 快照字段。pause 点(currentPause)由 ``stream.interrupt`` 的既有 effect / host 另行设置。 */
export function buildRehydrateSnapshot(values: AIWritingValues, sessionId: string): Partial<AIWritingState> {
const pkg = (values.material_package && typeof values.material_package === 'object')
? (values.material_package as Record<string, unknown>) : null;
const materials = pkg && Array.isArray(pkg.materials)
? (pkg.materials as Record<string, unknown>[]).map(normalizeMaterial) : [];
const skillSteps = pkg && Array.isArray(pkg.skill_search_steps ?? pkg.skillSearchSteps)
? ((pkg.skill_search_steps ?? pkg.skillSearchSteps) as Record<string, unknown>[]).map(normalizeSkillStep) : [];
const draft = (values.current_draft && typeof values.current_draft === 'object')
? (values.current_draft as Record<string, unknown>) : null;
const progressEvents = Array.isArray(values.progress_events)
? (values.progress_events as Record<string, unknown>[]).map((e) => normalizeProgressDict(e, sessionId))
: [];
return {
sessionId,
status: (values.status as AIWritingState['status']) || 'researching',
materials,
skillSearchSteps: skillSteps,
outline: normalizeOutline(values.current_outline),
currentDraftMarkdown: String(draft?.full_markdown ?? draft?.fullMarkdown ?? ''),
currentDraftTitle: String(draft?.title ?? ''),
revisionCount: Number(draft?.revision_count ?? draft?.revisionCount ?? 0),
reviewResult: normalizeReviewResult(values.review_result),
progressEvents,
};
}
/** custom 通道事件 → ProgressEvent。事件 ``type`` 字段决定是哪类。 */
export function customToProgressEvent(raw: unknown, sessionId: string): ProgressEvent | null {
if (!raw || typeof raw !== 'object') return null;