暂停继续
This commit is contained in:
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:
|
||||
|
||||
Reference in new issue
Block a user