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:
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;
|
||||
|
||||
Reference in new issue
Block a user