687 lines
28 KiB
TypeScript
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>
|
|
);
|
|
}
|