Files
deerflow-code/frontend-web/src/open-canvas/contexts/AIWritingContext.tsx
T

687 lines
28 KiB
TypeScript

import {
createContext,
memo,
useCallback,
useContext,
useEffect,
useMemo,
useReducer,
useRef,
type Dispatch,
type MutableRefObject,
type ReactNode,
type RefObject,
} from 'react';
import { useStream } from '@langchain/langgraph-sdk/react';
import { getAPIClient } from '@/core/api';
import {
getStreamErrorMessage,
isThreadNotAccessibleError,
notifyThreadAccessChanged,
} from '@/core/threads/thread-access-error';
import { useCanvasGraphContext } from '../contexts/CanvasGraphContext';
import { saveAIWritingTranscript } from '../api/ai-writing-sessions';
import { countArticleWords } from '../utils/wordCount';
import {
INITIAL_STATE,
buildAwaitUserEventFromCheckpointPause,
buildCompletedInterventionFromCheckpointPause,
buildRehydrateSnapshot,
customToProgressEvent,
interruptToEvent,
nodeUpdatesToEvents,
reducer,
type Action,
type AIWritingValues,
} from '../hooks/useAIWritingStream';
const WRITING_STREAM_MODE = ['values', 'updates', 'custom', 'messages-tuple'] as const;
import type {
AIWritingRequest,
AIWritingState,
CompletedIntervention,
ProgressEvent,
UserInterventionPayload,
WritingStatus,
} from '../types/ai-writing';
/** 高频 chunk 类型:合并到同一帧再 dispatch,避免同步嵌套 setState 触发 Maximum update depth。 */
const BATCHED_CHUNK_TYPES = new Set<ProgressEvent['type']>([
'draft_chunk',
'outline_chunk',
'intent_chunk',
'keyword_chunk',
'skill_select_chunk',
'review_chunk',
]);
function createProgressEventBatcher(dispatchRef: RefObject<Dispatch<Action>>) {
const pending: ProgressEvent[] = [];
let rafId = 0;
const flush = () => {
rafId = 0;
if (pending.length === 0) return;
const events = pending.splice(0, pending.length);
if (events.length === 1) {
dispatchRef.current?.({ type: 'handle', event: events[0] });
} else {
dispatchRef.current?.({ type: 'handle_batch', events });
}
};
return {
push(event: ProgressEvent) {
pending.push(event);
if (!rafId) rafId = requestAnimationFrame(flush);
},
pushImmediate(event: ProgressEvent) {
// 关键:先把缓冲里的 chunk 同步 flush,再派发立即事件,保证事件顺序。
// 否则 *_ready / await_user / step_* 会插队到尚在 rAF 缓冲里的 *_chunk 之前——
// ready 先清空流式文本 / 切状态,随后残留 chunk 又被回灌,导致:
// 意图/大纲/审核的流式视图清不掉(残留 JSON 尾巴)、切不回结果卡、右侧 markdown 切不回编辑器。
if (rafId) cancelAnimationFrame(rafId);
flush();
dispatchRef.current?.({ type: 'handle', event });
},
cancel() {
if (rafId) cancelAnimationFrame(rafId);
rafId = 0;
pending.length = 0;
},
};
}
type AIWritingStreamApi = Pick<
ReturnType<typeof useStream<AIWritingValues>>,
'submit' | 'stop' | 'interrupt'
>;
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' ? value as Record<string, unknown> : {};
}
function pausePointFromStatus(status: string): string | null {
return status.startsWith('awaiting_') ? status.slice('awaiting_'.length) : null;
}
function defaultResumePayloadForPause(
pausePoint: string | null | undefined,
interruptValue: unknown,
values: AIWritingValues,
): Record<string, unknown> {
const intr = asRecord(interruptValue);
const valueRecord = asRecord(values);
if (pausePoint === 'material_confirm') {
return { action: 'confirm' };
}
if (pausePoint === 'outline_confirm') {
return { action: 'confirm' };
}
if (pausePoint === 'section_help') {
const blocked = Array.isArray(intr.blocked_sections)
? intr.blocked_sections
: (Array.isArray(valueRecord.blocked_sections) ? valueRecord.blocked_sections : []);
const decisions = blocked
.map((b) => asRecord(b))
.map((b) => String(b.section_title ?? b.sectionTitle ?? ''))
.filter(Boolean)
.map((sectionTitle) => ({ sectionTitle, decision: 'loose' }));
return { action: 'section_help', sectionDecisions: decisions };
}
if (pausePoint === 'draft_confirm') {
const review = asRecord(valueRecord.review_result);
const hasReview = Object.keys(review).length > 0;
const revisionCount = Number(valueRecord.revision_count ?? 0);
const maxRevisions = Number(valueRecord.max_revisions ?? 3);
if (!hasReview) return { action: 'to_editor' };
if (review.verdict === 'pass') return { action: 'finalize' };
if (revisionCount >= maxRevisions) return { action: 'finalize' };
return { action: 'to_editor' };
}
if (pausePoint === 'review_confirm') {
return { action: 'accept_review' };
}
return { action: 'confirm' };
}
function nextStatusAfterResumePayload(
pausePoint: string | null | undefined,
payload: Record<string, unknown>,
): WritingStatus {
const action = String(payload.action ?? 'confirm');
if (pausePoint === 'material_confirm') {
return (action === 're_search' || action === 'enable_skill_search')
? 'researching'
: 'writing_outline';
}
if (pausePoint === 'outline_confirm') {
return action === 're_outline' ? 'writing_outline' : 'writing_draft';
}
if (pausePoint === 'draft_confirm') {
if (action === 'finalize') return 'done';
if (action === 'to_editor') return 'reviewing';
return 'revising';
}
if (pausePoint === 'review_confirm') {
return action === 'force_finalize' ? 'done' : 'revising';
}
if (pausePoint === 'section_help') {
const decisions = Array.isArray(payload.sectionDecisions) ? payload.sectionDecisions : [];
return decisions.some((d) => asRecord(d).decision === 'supplement')
? 'researching'
: 'writing_draft';
}
return 'researching';
}
export type AIWritingApi = {
state: AIWritingState;
startWriting: (request: AIWritingRequest) => Promise<void>;
submitIntervention: (payload: UserInterventionPayload) => Promise<void>;
pauseWriting: () => void;
resumeWriting: () => void;
/** Restore an unfinished history session: rehydrate completed stages from checkpoint,
* auto-advance completed awaiting_* stages, and re-run the active unfinished stage. */
continueWriting: (sessionId: string) => void;
reset: () => void;
};
const AIWritingContext = createContext<AIWritingApi | null>(null);
/** 可选读取:不在 Provider 内时返回 null(如 Canvas 布局右侧 artifact 面板)。 */
export function useAIWritingOptional(): AIWritingApi | null {
return useContext(AIWritingContext);
}
/** 在 ``AIWritingProvider`` 内读取写作状态与操作 API。 */
export function useAIWriting(): AIWritingApi {
const ctx = useContext(AIWritingContext);
if (!ctx) {
throw new Error('useAIWriting must be used within AIWritingProvider');
}
return ctx;
}
/**
* 承载 ``useStream`` 的独立组件(render null)。
*
* 用 ``memo`` 且仅随 ``threadId`` 重渲染,这样 ``draft_chunk`` 等高频 custom 事件
* 更新 reducer 时不会重跑 ``useStream``,避免 onCustomEvent → dispatch → 重渲染
* → useStream 重入 的更新风暴(Maximum update depth exceeded)。
*/
const AIWritingStreamHost = memo(function AIWritingStreamHost({
threadId,
dispatchRef,
sessionIdRef,
streamRef,
seenInterruptRef,
continueTargetRef,
}: {
threadId: string | undefined;
dispatchRef: RefObject<Dispatch<Action>>;
sessionIdRef: RefObject<string | null>;
streamRef: MutableRefObject<AIWritingStreamApi | null>;
seenInterruptRef: MutableRefObject<string | null>;
/** Non-empty while an explicit history restore is waiting for checkpoint values. */
continueTargetRef: MutableRefObject<string | null>;
}) {
const eventBatcherRef = useRef<ReturnType<typeof createProgressEventBatcher> | null>(null);
if (!eventBatcherRef.current) {
eventBatcherRef.current = createProgressEventBatcher(dispatchRef);
}
const emitProgressEvent = useCallback((event: ProgressEvent) => {
if (BATCHED_CHUNK_TYPES.has(event.type)) {
eventBatcherRef.current!.push(event);
} else {
eventBatcherRef.current!.pushImmediate(event);
}
}, []);
const stream = useStream<AIWritingValues>({
client: getAPIClient(),
assistantId: 'ai_writing',
threadId,
// 刷新页面 = 重置:不在挂载时自动重连已有 run(避免刷新后接回旧会话)。
reconnectOnMount: false,
onCreated(meta) {
dispatchRef.current?.({ type: 'set_session', sessionId: meta.thread_id });
},
onCustomEvent(event: unknown) {
if (typeof window !== 'undefined' && (window as { __AI_WRITING_DEBUG__?: boolean }).__AI_WRITING_DEBUG__) {
const t = (event as { type?: string } | null)?.type;
// eslint-disable-next-line no-console
console.debug(`[ai-writing:custom] +${Math.round(performance.now())}ms type=${t ?? '?'}`, event);
}
const pe = customToProgressEvent(event, sessionIdRef.current ?? '');
if (pe) emitProgressEvent(pe);
},
onUpdateEvent(data: Record<string, unknown> | undefined) {
if (!data) return;
if (typeof window !== 'undefined' && (window as { __AI_WRITING_DEBUG__?: boolean }).__AI_WRITING_DEBUG__) {
// eslint-disable-next-line no-console
console.debug(`[ai-writing:updates] +${Math.round(performance.now())}ms nodes=${Object.keys(data).join(',')}`);
}
if (Array.isArray((data as Record<string, unknown>).__interrupt__)) {
const interrupts = (data as { __interrupt__: unknown[] }).__interrupt__;
for (const intr of interrupts) {
const value = (intr && typeof intr === 'object' && 'value' in intr)
? (intr as { value: unknown }).value : intr;
const ev = interruptToEvent(value, sessionIdRef.current ?? '');
if (ev) emitProgressEvent(ev);
}
return;
}
for (const nodeOutput of Object.values(data)) {
const events = nodeUpdatesToEvents(nodeOutput, sessionIdRef.current ?? '');
for (const ev of events) emitProgressEvent(ev);
}
},
onError(err: unknown) {
eventBatcherRef.current?.cancel();
const message = getStreamErrorMessage(err);
// A "thread not accessible/not found" 404 almost always means the writing
// thread belongs to a previous login session — the user switched accounts
// (or this tab outlived a re-login), so the current account legitimately
// can't access the old thread. Instead of dumping the raw HTTP 404 into
// the panel, re-frame it around the current identity and discard the
// stale component-state session without reloading the browser page.
if (isThreadNotAccessibleError(message)) {
notifyThreadAccessChanged(() => {
continueTargetRef.current = null;
dispatchRef.current?.({ type: 'reset' });
});
return;
}
dispatchRef.current?.({ type: 'set_error', message });
},
onFinish(finishState) {
eventBatcherRef.current?.cancel();
const finalStatus = (finishState.values as AIWritingValues | undefined)?.status;
if (finalStatus === 'done' || finalStatus === 'error') {
dispatchRef.current?.({
type: 'handle',
event: {
type: finalStatus === 'error' ? 'error' : 'done',
sessionId: finishState.values?.sessionId as string ?? '',
message: finalStatus === 'error' ? '写作出错' : '写作完成',
},
});
}
},
});
streamRef.current = stream;
useEffect(() => {
const intr = stream.interrupt;
if (!intr) {
seenInterruptRef.current = null;
return;
}
const key = intr.id ?? JSON.stringify(intr.value);
if (seenInterruptRef.current === key) return;
if (continueTargetRef.current && continueTargetRef.current === threadId) {
seenInterruptRef.current = key;
return;
}
seenInterruptRef.current = key;
const ev = interruptToEvent(intr.value, sessionIdRef.current ?? '');
if (ev) emitProgressEvent(ev);
}, [stream.interrupt, emitProgressEvent, seenInterruptRef, sessionIdRef, continueTargetRef, threadId]);
// History restore is explicit: after the history detail "restore" button is clicked,
// rehydrate from checkpoint, auto-advance completed awaiting_* stages with default
// decisions, and re-run the currently active unfinished stage with an empty command.
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 ?? '');
const checkpointModelName = typeof values.model_name === 'string' ? values.model_name : '';
const submitFromCheckpoint = (command: Record<string, unknown>) => {
void streamRef.current?.submit(undefined, {
command,
streamMode: [...WRITING_STREAM_MODE],
...(checkpointModelName ? { config: { configurable: { model_name: checkpointModelName } } } : {}),
}).catch(() => { /* 流式错误由 onError 反映 */ });
};
if (status.startsWith('awaiting_')) {
const intr = stream.interrupt;
if (intr) seenInterruptRef.current = intr.id ?? JSON.stringify(intr.value);
const pausePoint = (asRecord(intr?.value).pause_point as string | undefined) ?? pausePointFromStatus(status);
const resumePayload = defaultResumePayloadForPause(pausePoint, intr?.value, values);
const pauseEvent = buildAwaitUserEventFromCheckpointPause(values, threadId, pausePoint, intr?.value);
if (pauseEvent) {
dispatchRef.current?.({ type: 'handle', event: pauseEvent });
}
const record = buildCompletedInterventionFromCheckpointPause(values, pausePoint, resumePayload.action);
if (record) {
dispatchRef.current?.({
type: 'intervention_submitted',
record,
nextStatus: nextStatusAfterResumePayload(pausePoint, resumePayload),
pendingRevisionContext: null,
});
}
submitFromCheckpoint({ resume: resumePayload });
} else {
submitFromCheckpoint({});
}
}, [stream.values, stream.interrupt, threadId, continueTargetRef, dispatchRef, seenInterruptRef, streamRef]);
useEffect(() => () => { eventBatcherRef.current?.cancel(); }, []);
return null;
}, (prev, next) => prev.threadId === next.threadId);
export function AIWritingProvider({ children }: { children: ReactNode }) {
const { setArtifact } = useCanvasGraphContext();
const [state, dispatch] = useReducer(reducer, INITIAL_STATE);
const dispatchRef = useRef(dispatch);
dispatchRef.current = dispatch;
const sessionIdRef = useRef<string | null>(state.sessionId);
sessionIdRef.current = state.sessionId;
const streamRef = useRef<AIWritingStreamApi | null>(null);
const seenInterruptRef = useRef<string | null>(null);
// Explicit history restore target; the host rehydrates once this thread's checkpoint arrives.
const continueTargetRef = useRef<string | null>(null);
// 刷新页面 = 重置:不持久化、不自动重连。provider 重新挂载即回到 idle 初始态
// (刷新会断开 SSE,后端默认 on_disconnect=cancel 取消该 run,不留孤儿)。
const threadId = state.sessionId ?? undefined;
const resetAfterThreadAccessError = useCallback(() => {
setArtifact(null);
seenInterruptRef.current = null;
continueTargetRef.current = null;
dispatch({ type: 'reset' });
}, [setArtifact]);
useEffect(() => {
const s = state.status;
const savable = s === 'done' || s === 'error' || s.startsWith('awaiting_');
if (!state.sessionId || !savable || state.progressEvents.length === 0) return;
saveAIWritingTranscript(state.sessionId, {
progressEvents: state.progressEvents,
completedInterventions: state.completedInterventions,
}).catch(() => { });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.status, state.sessionId]);
const startWriting = useCallback(async (request: AIWritingRequest) => {
if (state.status !== 'idle' && state.status !== 'done' && state.status !== 'error') return;
dispatch({ type: 'start', request });
const stream = streamRef.current;
if (!stream) return;
try {
// 默认是 general(通用检索),但用户可以在开写前或素材确认后手动切换检索方式。
await stream.submit(
{
user_intent: request.userIntent,
// 用户自带大纲原文(留空则后端走自动规划)
user_outline_raw: request.userOutline?.trim() || '',
skip_intent_parser: request.skipIntentRecognition ?? false,
writing_mode_type: request.writingModeType === 'imitate' ? 'imitate' : 'normal',
sample_text: request.sampleText?.trim() || '',
sample_filename: request.sampleFilename?.trim() || '',
sample_style_profile: null,
imitate_strength: request.imitateStrength ?? 'medium',
preserve_sample_structure: request.preserveSampleStructure ?? true,
article_type: request.articleType,
// 新(列表外)文章类型的一句话特征,仅本次写作临时使用;命中已有类型时为空。
article_type_detail: request.articleTypeDetail?.trim() || '',
target_audience: request.targetAudience,
word_count_target: request.wordCountTarget,
keyword_count: Math.max(1, Math.min(10, request.keywordCount)),
writing_mode: request.writingMode === 'strict' ? 'strict' : 'loose',
// 是否加参考文献(正文 [n] + 文末「参考文献」列表);缺省 false 不改其它入口行为。
add_references: request.addReferences ?? false,
material_source: request.materialSource,
// 不收集素材,直接用通用知识写作:researcher 节点据此短路跳过检索
skip_research: request.skipResearch ?? false,
notebook_search_space_id: request.noteSearchSpaceId ?? null,
notebook_source_ids: request.noteSourceIds ?? null,
revision_count: 0,
max_revisions: 3,
status: request.writingModeType === 'imitate' ? 'analyzing_sample' : 'researching',
drafts: [],
progress_events: [],
material_package: null,
current_outline: null,
current_draft: null,
review_result: null,
last_user_intervention: null,
} as Partial<AIWritingValues>,
{
config: { configurable: { model_name: request.modelName ?? '' } } as Record<string, unknown>,
streamMode: [...WRITING_STREAM_MODE],
},
);
} catch (err) {
const message = getStreamErrorMessage(err);
if (isThreadNotAccessibleError(message)) {
notifyThreadAccessChanged(resetAfterThreadAccessError);
return;
}
dispatch({ type: 'set_error', message });
}
}, [state.status, resetAfterThreadAccessError]);
const submitIntervention = useCallback(async (payload: UserInterventionPayload) => {
if (!state.sessionId || !state.currentPause) return;
const stream = streamRef.current;
if (!stream) return;
const pausePoint = state.currentPause.pausePoint;
const record: CompletedIntervention = {
pausePoint: pausePoint as CompletedIntervention['pausePoint'],
action: payload.action,
};
// 捕获用户在该干预点输入的自然语言要求,供时间线以「我的要求」独立样式回显:
// 素材确认 → 补充检索内容(userQuery) / 大纲确认 → 修改意见(outlineFeedback) /
// 草稿确认 + 编辑意见处理 → 修改·审核意见(userRevisionNotes)
const userInput = (
payload.userQuery ??
payload.outlineFeedback ??
payload.userRevisionNotes ??
''
).trim();
if (userInput) record.userInput = userInput;
if (pausePoint === 'material_confirm') record.materials = state.materials;
else if (pausePoint === 'outline_confirm') record.outline = state.outline ?? undefined;
else if (pausePoint === 'draft_confirm') {
record.draftTitle = state.currentDraftTitle;
record.draftWordCount = countArticleWords(state.currentDraftMarkdown);
record.reviewResult = state.reviewResult ?? undefined;
} else if (pausePoint === 'review_confirm') {
record.reviewResult = state.reviewResult ?? undefined;
}
let pendingRevisionContext: AIWritingState['pendingRevisionContext'] = null;
const isApplyingReview =
state.reviewResult != null &&
(
(pausePoint === 'review_confirm' && payload.action === 'accept_review') ||
payload.selectedIssueIndices !== undefined
);
if (isApplyingReview && state.reviewResult) {
const allIssues = state.reviewResult.revisionNotes;
const selectedSet = payload.selectedIssueIndices !== undefined
? new Set(payload.selectedIssueIndices)
: new Set(allIssues.map((_, i) => i));
pendingRevisionContext = {
submittedIssues: allIssues.filter((_, i) => selectedSet.has(i)),
skippedIssues: allIssues.filter((_, i) => !selectedSet.has(i)),
oldWordCount: countArticleWords(state.currentDraftMarkdown),
};
}
let nextStatus: WritingStatus = state.status;
if (pausePoint === 'material_confirm') {
nextStatus = (payload.action === 're_search' || payload.action === 'enable_skill_search')
? 'researching' : 'writing_outline';
} else if (pausePoint === 'outline_confirm') {
nextStatus = payload.action === 're_outline' ? 'writing_outline' : 'writing_draft';
} else if (pausePoint === 'draft_confirm') {
if (payload.action === 'finalize') nextStatus = 'done';
else if (payload.action === 'to_editor') nextStatus = 'reviewing';
else nextStatus = 'revising';
} else if (pausePoint === 'review_confirm') {
nextStatus = payload.action === 'force_finalize' ? 'done' : 'revising';
} else if (pausePoint === 'section_help') {
nextStatus = payload.sectionDecisions?.some((d) => d.decision === 'supplement')
? 'researching' : 'writing_draft';
}
const resumePayload: Record<string, unknown> = { action: payload.action };
if (payload.approvedMaterialIds !== undefined) resumePayload.approvedMaterialIds = payload.approvedMaterialIds;
if (payload.extraKeywords !== undefined) resumePayload.extraKeywords = payload.extraKeywords;
if (payload.userQuery !== undefined) resumePayload.userQuery = payload.userQuery;
if (payload.materialSource !== undefined) resumePayload.materialSource = payload.materialSource;
if (payload.editedOutline !== undefined) resumePayload.editedOutline = payload.editedOutline;
if (payload.outlineFeedback !== undefined) resumePayload.outlineFeedback = payload.outlineFeedback;
if (payload.userRevisionNotes !== undefined) resumePayload.userRevisionNotes = payload.userRevisionNotes;
if (payload.overrideVerdict !== undefined) resumePayload.overrideVerdict = payload.overrideVerdict;
if (payload.sectionDecisions !== undefined) {
resumePayload.sectionDecisions = payload.sectionDecisions.map((d) => ({
sectionTitle: d.sectionTitle, decision: d.decision,
}));
}
const intr = stream.interrupt;
if (intr) {
seenInterruptRef.current = intr.id ?? JSON.stringify(intr.value);
}
dispatch({ type: 'intervention_submitted', record, nextStatus, pendingRevisionContext, materialSource: payload.materialSource });
try {
// resume 是新 invocation,其 config 默认不带最初的 model_name;而写作/审核节点
// 在 resume 后才运行,若首轮 intent 未把模型存进 state(历史会话 / 捕获为空),
// 后端就只能回退默认模型。这里把表单选定的模型随每次 resume 一并回传,
// 让后端(get_writing_model / resolve_agent_model_name 的 configurable 兜底)
// 始终用上用户真正选的模型。
const resumeModelName = state.request?.modelName;
await stream.submit(undefined, {
command: { resume: resumePayload },
streamMode: [...WRITING_STREAM_MODE],
...(resumeModelName ? { config: { configurable: { model_name: resumeModelName } } } : {}),
});
} catch (err) {
const message = getStreamErrorMessage(err);
if (isThreadNotAccessibleError(message)) {
notifyThreadAccessChanged(resetAfterThreadAccessError);
return;
}
dispatch({ type: 'set_error', message });
}
}, [state.sessionId, state.currentPause, state.materials, state.outline,
state.currentDraftTitle, state.currentDraftMarkdown, state.reviewResult,
state.status, resetAfterThreadAccessError]);
const pauseWriting = useCallback(() => {
// 真停:取消当前 run(graph 已完成的阶段已落 checkpoint)。继续时从该 checkpoint 续跑。
void streamRef.current?.stop();
dispatch({ type: 'pause' });
}, []);
// 「继续」:清掉当前阶段流式缓冲后,从最近的 checkpoint 恢复 graph 续跑
// (会重跑当前未完成的阶段,已完成阶段不丢)。同时用于断网后 run 已死的兜底恢复。
//
// **关键**:必须带一个 `command`(哪怕是空对象),后端 worker 才会把它翻成
// `langgraph.types.Command` 让 Pregel **从最近 checkpoint 恢复**(与 4 个干预卡续跑同机制);
// 不带 command 时 worker 按 graph_input **起新一轮**=从图入口 intent 重跑(就是之前
// "暂停后继续又从识别意图开始" 的根因,见 runtime/runs/worker.py 注释)。空 Command
// 没有 interrupt 可消费 → Pregel 直接续跑 checkpoint 里挂起的当前阶段节点。
const resumeWriting = useCallback(() => {
const stream = streamRef.current;
if (!stream) {
dispatch({ type: 'resume_ui' });
return;
}
dispatch({ type: 'clear_streaming' });
const resumeModelName = state.request?.modelName;
void (async () => {
try {
await stream.submit(undefined, {
command: {},
streamMode: [...WRITING_STREAM_MODE],
...(resumeModelName ? { config: { configurable: { model_name: resumeModelName } } } : {}),
});
} catch (err) {
const message = getStreamErrorMessage(err);
if (isThreadNotAccessibleError(message)) {
notifyThreadAccessChanged(resetAfterThreadAccessError);
return;
}
dispatch({ type: 'set_error', message });
}
})();
}, [state.request, resetAfterThreadAccessError]);
// Restore an unfinished history session: make it active, load its checkpoint, then let
// the host skip completed awaiting_* stages or re-run the current unfinished stage.
// 与「刷新=重置」不冲突:这是显式点击触发,不是自动重连。
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, continueWriting, reset }),
[state, startWriting, submitIntervention, pauseWriting, resumeWriting, continueWriting, reset],
);
return (
<AIWritingContext.Provider value={value}>
<AIWritingStreamHost
threadId={threadId}
dispatchRef={dispatchRef}
sessionIdRef={sessionIdRef}
streamRef={streamRef}
seenInterruptRef={seenInterruptRef}
continueTargetRef={continueTargetRef}
/>
{children}
</AIWritingContext.Provider>
);
}