暂停继续

This commit is contained in:
董宏凯 committed 2026-05-17 21:19:12 +08:00
1 parent b768e0affb
commit c19417a706
4 files changed
+97 -17

No files matched your search

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ChevronsDown, Plus, RotateCcw, Settings2, Square } from 'lucide-react';
import { ChevronsDown, Play, Plus, RotateCcw, Settings2, Square } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
@@ -38,7 +38,7 @@ const STATUS_LABEL: Record<string, string> = {
export function AIWritingPanel() {
const navigate = useNavigate();
const { setArtifact } = useCanvasGraphContext();
const { state, startWriting, submitIntervention, stopWriting, reset } = useAIWriting();
const { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset } = useAIWriting();
const { sessions, loading: sessionsLoading, refresh, rename, remove } = useAIWritingSessions();
// History view state — null means normal writing mode
@@ -48,7 +48,7 @@ export function AIWritingPanel() {
const isDone = state.status === 'done';
const isError = state.status === 'error';
const isRunning = !isIdle && !isDone && !isError;
const showSkeleton = isRunning && !state.currentPause;
const showSkeleton = isRunning && !state.currentPause && !state.isPaused;
// ── 自动滚底逻辑 ────────────────────────────────────────────────────────────
const scrollRef = useRef<HTMLDivElement>(null);
@@ -212,12 +212,34 @@ export function AIWritingPanel() {
新建
</Button>
)}
{isRunning && (
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={stopWriting}>
<Square className="size-3" />
{isRunning && !state.isPaused && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs gap-1 animate-breathe-stop text-red-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-950/30"
onClick={pauseWriting}
>
<Square className="size-3 fill-current" />
停止
</Button>
)}
{state.isPaused && (
<>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs gap-1 text-primary hover:text-primary hover:bg-primary/8"
onClick={resumeWriting}
>
<Play className="size-3 fill-current" />
继续
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={reset}>
<RotateCcw className="size-3" />
重置
</Button>
</>
)}
{(isDone || isError) && (
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={reset}>
<RotateCcw className="size-3" />
@@ -336,10 +358,19 @@ export function AIWritingPanel() {
isError ? 'text-destructive' : 'text-muted-foreground',
)}
>
{isRunning && (
<span className="inline-block size-1.5 rounded-full bg-blue-500 animate-pulse" />
{state.isPaused ? (
<>
<span className="inline-block size-1.5 rounded-full bg-amber-400" />
已暂停
</>
) : (
<>
{isRunning && (
<span className="inline-block size-1.5 rounded-full bg-blue-500 animate-pulse" />
)}
{STATUS_LABEL[state.status] ?? state.status}
</>
)}
{STATUS_LABEL[state.status] ?? state.status}
</div>
)}
</div>
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCanvasGraphContext } from '../contexts/CanvasGraphContext';
import { startWritingSession, subscribeToProgress, resumeWriting } from '../api/ai-writing';
import { startWritingSession, subscribeToProgress, resumeWriting as resumeWritingApi } from '../api/ai-writing';
import type {
AIWritingRequest,
AIWritingState,
@@ -19,6 +19,7 @@ import type {
const INITIAL_STATE: AIWritingState = {
sessionId: null,
status: 'idle',
isPaused: false,
progressEvents: [],
materials: [],
outline: null,
@@ -194,6 +195,9 @@ export function useAIWriting() {
case 'await_user': {
const pp = event.pausePoint;
// Skip duplicate on SSE reconnect: backend re-injects the saved await_user event
// when queue is empty. If we already have this pause set, nothing needs to change.
if (prev.currentPause?.pausePoint === pp) return prev;
const pauseStatus = pp
? (`awaiting_${pp}` as AIWritingState['status'])
: prev.status;
@@ -273,6 +277,18 @@ export function useAIWriting() {
async (payload: UserInterventionPayload) => {
if (!state.sessionId || !state.currentPause) return;
// 若处于暂停状态,先重连 SSE 再提交,实现"干预即续接"
if (state.isPaused) {
abortRef.current?.abort();
abortRef.current = new AbortController();
unsubRef.current = subscribeToProgress(
state.sessionId,
handleEvent,
abortRef.current.signal,
);
setState((prev) => ({ ...prev, isPaused: false }));
}
const pausePoint = state.currentPause.pausePoint;
const record: CompletedIntervention = {
pausePoint: pausePoint as CompletedIntervention['pausePoint'],
@@ -323,7 +339,7 @@ export function useAIWriting() {
}
try {
await resumeWriting(state.sessionId, payload);
await resumeWritingApi(state.sessionId, payload);
setState((prev) => ({
...prev,
currentPause: null,
@@ -340,16 +356,28 @@ export function useAIWriting() {
setState((prev) => ({ ...prev, error: String(err) }));
}
},
[state.sessionId, state.currentPause, state.materials, state.outline,
state.currentDraftTitle, state.currentDraftMarkdown, state.reviewResult],
[state.sessionId, state.currentPause, state.isPaused, state.materials, state.outline,
state.currentDraftTitle, state.currentDraftMarkdown, state.reviewResult, handleEvent],
);
const stopWriting = useCallback(() => {
const pauseWriting = useCallback(() => {
abortRef.current?.abort();
unsubRef.current?.();
setState((prev) => ({ ...prev, status: 'idle' }));
setState((prev) => ({ ...prev, isPaused: true }));
}, []);
const resumeWriting = useCallback(() => {
if (!state.sessionId) return;
abortRef.current?.abort();
abortRef.current = new AbortController();
unsubRef.current = subscribeToProgress(
state.sessionId,
handleEvent,
abortRef.current.signal,
);
setState((prev) => ({ ...prev, isPaused: false }));
}, [state.sessionId, handleEvent]);
const reset = useCallback(() => {
abortRef.current?.abort();
unsubRef.current?.();
@@ -359,5 +387,5 @@ export function useAIWriting() {
useEffect(() => () => { abortRef.current?.abort(); }, []);
return { state, startWriting, submitIntervention, stopWriting, reset };
return { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset };
}
@@ -184,6 +184,7 @@ export interface CompletedIntervention {
export interface AIWritingState {
sessionId: string | null;
status: WritingStatus;
isPaused: boolean;
progressEvents: ProgressEvent[];
materials: AIMaterial[];
outline: AIOutline | null;
@@ -130,6 +130,12 @@ _session_error_flags: dict[str, bool] = {}
# session_id → valid_article_types(list[dict] with key/label)
_session_article_types: dict[str, list[dict]] = {}
# session_id → pending await_user event tuple for SSE reconnect re-delivery
# asyncio.Queue is consume-once; if the old SSE generator dequeued the await_user event
# before the client disconnected, the event is lost from the queue. We save a copy here
# so that a new SSE connection (e.g., after user clicks "继续") can re-inject it.
_session_pending_pause: dict[str, tuple] = {}
# ── 工具函数 ──────────────────────────────────────────────────────────────────
@@ -403,6 +409,9 @@ async def resume_writing(session_id: str, body: ResumeRequest, request: Request)
except Exception:
logger.exception("ai_writing: failed to save intervention: session=%s", session_id)
# Clear pending pause so SSE reconnect won't re-inject the stale await_user event
_session_pending_pause.pop(session_id, None)
asyncio.create_task(
_stream_graph_segment(session_id, Command(resume=intervention), config, AI_WRITING_GRAPH)
)
@@ -450,7 +459,11 @@ async def _stream_graph_segment(
interrupt_list = node_output if isinstance(node_output, (list, tuple)) else [node_output]
for intr in interrupt_list:
interrupt_data = intr.value if hasattr(intr, "value") else intr
await queue.put(("await_user", interrupt_data))
event_tuple = ("await_user", interrupt_data)
await queue.put(event_tuple)
# Save for SSE reconnect re-delivery: if the old generator consumed this
# event before the client disconnected, a new connection would block forever.
_session_pending_pause[session_id] = event_tuple
return # 等待 resume,不关闭队列
if not isinstance(node_output, dict):
@@ -552,6 +565,7 @@ async def _stream_graph_segment(
_session_interventions.pop(session_id, None)
_session_error_flags.pop(session_id, None)
_session_article_types.pop(session_id, None)
_session_pending_pause.pop(session_id, None)
def _event_type(evt: dict) -> str:
@@ -577,6 +591,12 @@ async def _sse_generator(session_id: str) -> AsyncGenerator[str, None]:
queue = _event_queues[session_id]
yield _sse("connected", {"session_id": session_id})
# Re-inject the saved await_user event if the queue is empty and a pending pause exists.
# This covers the case where the old SSE generator dequeued the await_user event and
# yielded it to a disconnecting client — the event is then lost from the consume-once queue.
if queue.empty() and session_id in _session_pending_pause:
await queue.put(_session_pending_pause[session_id])
while True:
item = await queue.get()
if item is None: