暂停继续
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 { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -38,7 +38,7 @@ const STATUS_LABEL: Record<string, string> = {
|
|||||||
export function AIWritingPanel() {
|
export function AIWritingPanel() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { setArtifact } = useCanvasGraphContext();
|
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();
|
const { sessions, loading: sessionsLoading, refresh, rename, remove } = useAIWritingSessions();
|
||||||
|
|
||||||
// History view state — null means normal writing mode
|
// History view state — null means normal writing mode
|
||||||
@@ -48,7 +48,7 @@ export function AIWritingPanel() {
|
|||||||
const isDone = state.status === 'done';
|
const isDone = state.status === 'done';
|
||||||
const isError = state.status === 'error';
|
const isError = state.status === 'error';
|
||||||
const isRunning = !isIdle && !isDone && !isError;
|
const isRunning = !isIdle && !isDone && !isError;
|
||||||
const showSkeleton = isRunning && !state.currentPause;
|
const showSkeleton = isRunning && !state.currentPause && !state.isPaused;
|
||||||
|
|
||||||
// ── 自动滚底逻辑 ────────────────────────────────────────────────────────────
|
// ── 自动滚底逻辑 ────────────────────────────────────────────────────────────
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -212,12 +212,34 @@ export function AIWritingPanel() {
|
|||||||
新建
|
新建
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{isRunning && (
|
{isRunning && !state.isPaused && (
|
||||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={stopWriting}>
|
<Button
|
||||||
<Square className="size-3" />
|
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>
|
</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) && (
|
{(isDone || isError) && (
|
||||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={reset}>
|
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={reset}>
|
||||||
<RotateCcw className="size-3" />
|
<RotateCcw className="size-3" />
|
||||||
@@ -336,10 +358,19 @@ export function AIWritingPanel() {
|
|||||||
isError ? 'text-destructive' : 'text-muted-foreground',
|
isError ? 'text-destructive' : 'text-muted-foreground',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isRunning && (
|
{state.isPaused ? (
|
||||||
<span className="inline-block size-1.5 rounded-full bg-blue-500 animate-pulse" />
|
<>
|
||||||
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { useCanvasGraphContext } from '../contexts/CanvasGraphContext';
|
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 {
|
import type {
|
||||||
AIWritingRequest,
|
AIWritingRequest,
|
||||||
AIWritingState,
|
AIWritingState,
|
||||||
@@ -19,6 +19,7 @@ import type {
|
|||||||
const INITIAL_STATE: AIWritingState = {
|
const INITIAL_STATE: AIWritingState = {
|
||||||
sessionId: null,
|
sessionId: null,
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
|
isPaused: false,
|
||||||
progressEvents: [],
|
progressEvents: [],
|
||||||
materials: [],
|
materials: [],
|
||||||
outline: null,
|
outline: null,
|
||||||
@@ -194,6 +195,9 @@ export function useAIWriting() {
|
|||||||
|
|
||||||
case 'await_user': {
|
case 'await_user': {
|
||||||
const pp = event.pausePoint;
|
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
|
const pauseStatus = pp
|
||||||
? (`awaiting_${pp}` as AIWritingState['status'])
|
? (`awaiting_${pp}` as AIWritingState['status'])
|
||||||
: prev.status;
|
: prev.status;
|
||||||
@@ -273,6 +277,18 @@ export function useAIWriting() {
|
|||||||
async (payload: UserInterventionPayload) => {
|
async (payload: UserInterventionPayload) => {
|
||||||
if (!state.sessionId || !state.currentPause) return;
|
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 pausePoint = state.currentPause.pausePoint;
|
||||||
const record: CompletedIntervention = {
|
const record: CompletedIntervention = {
|
||||||
pausePoint: pausePoint as CompletedIntervention['pausePoint'],
|
pausePoint: pausePoint as CompletedIntervention['pausePoint'],
|
||||||
@@ -323,7 +339,7 @@ export function useAIWriting() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await resumeWriting(state.sessionId, payload);
|
await resumeWritingApi(state.sessionId, payload);
|
||||||
setState((prev) => ({
|
setState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
currentPause: null,
|
currentPause: null,
|
||||||
@@ -340,16 +356,28 @@ export function useAIWriting() {
|
|||||||
setState((prev) => ({ ...prev, error: String(err) }));
|
setState((prev) => ({ ...prev, error: String(err) }));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[state.sessionId, state.currentPause, state.materials, state.outline,
|
[state.sessionId, state.currentPause, state.isPaused, state.materials, state.outline,
|
||||||
state.currentDraftTitle, state.currentDraftMarkdown, state.reviewResult],
|
state.currentDraftTitle, state.currentDraftMarkdown, state.reviewResult, handleEvent],
|
||||||
);
|
);
|
||||||
|
|
||||||
const stopWriting = useCallback(() => {
|
const pauseWriting = useCallback(() => {
|
||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
unsubRef.current?.();
|
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(() => {
|
const reset = useCallback(() => {
|
||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
unsubRef.current?.();
|
unsubRef.current?.();
|
||||||
@@ -359,5 +387,5 @@ export function useAIWriting() {
|
|||||||
|
|
||||||
useEffect(() => () => { abortRef.current?.abort(); }, []);
|
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 {
|
export interface AIWritingState {
|
||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
status: WritingStatus;
|
status: WritingStatus;
|
||||||
|
isPaused: boolean;
|
||||||
progressEvents: ProgressEvent[];
|
progressEvents: ProgressEvent[];
|
||||||
materials: AIMaterial[];
|
materials: AIMaterial[];
|
||||||
outline: AIOutline | null;
|
outline: AIOutline | null;
|
||||||
|
|||||||
@@ -130,6 +130,12 @@ _session_error_flags: dict[str, bool] = {}
|
|||||||
# session_id → valid_article_types(list[dict] with key/label)
|
# session_id → valid_article_types(list[dict] with key/label)
|
||||||
_session_article_types: dict[str, list[dict]] = {}
|
_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:
|
except Exception:
|
||||||
logger.exception("ai_writing: failed to save intervention: session=%s", session_id)
|
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(
|
asyncio.create_task(
|
||||||
_stream_graph_segment(session_id, Command(resume=intervention), config, AI_WRITING_GRAPH)
|
_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]
|
interrupt_list = node_output if isinstance(node_output, (list, tuple)) else [node_output]
|
||||||
for intr in interrupt_list:
|
for intr in interrupt_list:
|
||||||
interrupt_data = intr.value if hasattr(intr, "value") else intr
|
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,不关闭队列
|
return # 等待 resume,不关闭队列
|
||||||
|
|
||||||
if not isinstance(node_output, dict):
|
if not isinstance(node_output, dict):
|
||||||
@@ -552,6 +565,7 @@ async def _stream_graph_segment(
|
|||||||
_session_interventions.pop(session_id, None)
|
_session_interventions.pop(session_id, None)
|
||||||
_session_error_flags.pop(session_id, None)
|
_session_error_flags.pop(session_id, None)
|
||||||
_session_article_types.pop(session_id, None)
|
_session_article_types.pop(session_id, None)
|
||||||
|
_session_pending_pause.pop(session_id, None)
|
||||||
|
|
||||||
|
|
||||||
def _event_type(evt: dict) -> str:
|
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]
|
queue = _event_queues[session_id]
|
||||||
yield _sse("connected", {"session_id": 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:
|
while True:
|
||||||
item = await queue.get()
|
item = await queue.get()
|
||||||
if item is None:
|
if item is None:
|
||||||
|
|||||||
Reference in new issue
Block a user