From c6cabe5296affb0bf1b5bdc870f2d27a124e7c9d Mon Sep 17 00:00:00 2001 From: 134 Date: Mon, 13 Jul 2026 18:14:23 +0800 Subject: [PATCH] fix: keep Pascal agent running in background --- .../apps/editor/app/api/agent/chat/route.ts | 96 ++++++++- .../components/ai-design-agent-panel.tsx | 185 +++++++++++++++++- .../apps/editor/lib/pascal-agent-jobs.test.ts | 46 +++++ .../apps/editor/lib/pascal-agent-jobs.ts | 65 ++++++ .../editor/lib/pascal-agent-recovery.test.ts | 39 ++++ .../apps/editor/lib/pascal-agent-recovery.ts | 51 +++++ 6 files changed, 465 insertions(+), 17 deletions(-) create mode 100644 services/pascal-editor/apps/editor/lib/pascal-agent-jobs.test.ts create mode 100644 services/pascal-editor/apps/editor/lib/pascal-agent-jobs.ts create mode 100644 services/pascal-editor/apps/editor/lib/pascal-agent-recovery.test.ts create mode 100644 services/pascal-editor/apps/editor/lib/pascal-agent-recovery.ts diff --git a/services/pascal-editor/apps/editor/app/api/agent/chat/route.ts b/services/pascal-editor/apps/editor/app/api/agent/chat/route.ts index f0e8a3957..597ced10d 100644 --- a/services/pascal-editor/apps/editor/app/api/agent/chat/route.ts +++ b/services/pascal-editor/apps/editor/app/api/agent/chat/route.ts @@ -1,5 +1,6 @@ import { createOpenAI } from '@ai-sdk/openai' import { + consumeStream, convertToModelMessages, createUIMessageStream, createUIMessageStreamResponse, @@ -18,6 +19,12 @@ import { sanitizeAgentMessages, } from '@/lib/pascal-agent-conversations' import { pascalAgentStepPolicy, requiresPascalMutation } from '@/lib/pascal-agent-intent' +import { + cancelPascalAgentJob, + finishPascalAgentJob, + isPascalAgentJobActive, + startPascalAgentJob, +} from '@/lib/pascal-agent-jobs' import { createPascalAgentMcpSession } from '@/lib/pascal-agent-mcp' import { buildPascalAgentPrompt } from '@/lib/pascal-agent-prompt' import { getPascalHostIdentity } from '@/lib/pascal-host-identity' @@ -37,6 +44,15 @@ const requestSchema = z.object({ .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/), }) +const jobSchema = z.object({ + conversationId: z.string().uuid(), + sceneId: z + .string() + .min(1) + .max(64) + .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/), +}) + const AGENT_ERROR_MESSAGE = 'AI design agent is temporarily unavailable. Please try again.' const AGENT_NO_MUTATION_MESSAGE = '模型未执行任何建模动作,请重试或换用支持工具调用的多模态模型。' const SCENE_ERROR_MESSAGE = 'The current scene could not be loaded.' @@ -60,13 +76,48 @@ export function GET(request: Request) { return NextResponse.json({ error: 'host_user_required' }, { status: 401 }) } const config = getPascalAgentConfig() + const url = new URL(request.url) + const parsedJob = jobSchema.safeParse({ + conversationId: url.searchParams.get('conversationId'), + sceneId: url.searchParams.get('sceneId'), + }) + const ownerId = identity.userId ?? 'local' return NextResponse.json({ + active: parsedJob.success ? isPascalAgentJobActive({ ...parsedJob.data, ownerId }) : false, configured: config.configured, maxSteps: config.maxSteps, model: config.model, }) } +export async function DELETE(request: Request) { + const accessError = accessErrorResponse(validatePascalAgentAccess(request.headers)) + if (accessError) return accessError + const identity = getPascalHostIdentity(request.headers) + if (!identity.allowed) { + return NextResponse.json({ error: 'host_user_required' }, { status: 401 }) + } + + let body: unknown + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'invalid_json' }, { status: 400 }) + } + const parsed = jobSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json({ error: 'invalid_request' }, { status: 400 }) + } + + const cancelled = cancelPascalAgentJob({ + ...parsed.data, + ownerId: identity.userId ?? 'local', + }) + return cancelled + ? NextResponse.json({ cancelled: true }, { status: 202 }) + : NextResponse.json({ cancelled: false }, { status: 404 }) +} + export async function POST(request: Request) { const accessError = accessErrorResponse(validatePascalAgentAccess(request.headers)) if (accessError) return accessError @@ -130,7 +181,22 @@ export async function POST(request: Request) { }) } - const abortSignal = AbortSignal.any([request.signal, AbortSignal.timeout(config.totalTimeoutMs)]) + const job = parsed.data.conversationId + ? startPascalAgentJob({ + conversationId: parsed.data.conversationId, + ownerId, + sceneId: parsed.data.sceneId, + }) + : null + if (parsed.data.conversationId && !job) { + return NextResponse.json({ error: 'agent_job_already_running' }, { status: 409 }) + } + + const jobController = job?.controller ?? new AbortController() + const abortSignal = AbortSignal.any([ + jobController.signal, + AbortSignal.timeout(config.totalTimeoutMs), + ]) let session: Awaited> try { session = await createPascalAgentMcpSession({ @@ -139,6 +205,7 @@ export async function POST(request: Request) { stepTimeoutMs: config.stepTimeoutMs, }) } catch (error) { + finishPascalAgentJob(job) console.error('Pascal agent could not load the requested scene.', error) return NextResponse.json( { error: 'scene_unavailable', message: SCENE_ERROR_MESSAGE }, @@ -256,17 +323,20 @@ export async function POST(request: Request) { }, onError: streamErrorText, onFinish: async ({ messages }) => { - if (!parsed.data.conversationId || !conversationStore) return try { - await conversationStore.saveAgentConversation({ - id: parsed.data.conversationId, - sceneId: parsed.data.sceneId, - ownerId, - messages: sanitizeAgentMessages(messages), - title: conversationTitle(messages), - }) + if (parsed.data.conversationId && conversationStore) { + await conversationStore.saveAgentConversation({ + id: parsed.data.conversationId, + sceneId: parsed.data.sceneId, + ownerId, + messages: sanitizeAgentMessages(messages), + title: conversationTitle(messages), + }) + } } catch (error) { console.error('Pascal agent conversation persistence failed.', error) + } finally { + finishPascalAgentJob(job) } }, originalMessages: uiMessages, @@ -274,10 +344,18 @@ export async function POST(request: Request) { try { return createUIMessageStreamResponse({ + consumeSseStream: ({ stream: responseStream }) => + consumeStream({ + stream: responseStream, + onError: (streamError) => { + console.error('Pascal agent background stream failed.', streamError) + }, + }), stream, }) } catch (error) { await finalize(false) + finishPascalAgentJob(job) console.error('Pascal agent failed before streaming.', error) return NextResponse.json( { error: 'agent_failed', message: AGENT_ERROR_MESSAGE }, diff --git a/services/pascal-editor/apps/editor/components/ai-design-agent-panel.tsx b/services/pascal-editor/apps/editor/components/ai-design-agent-panel.tsx index ea7a3371f..e599dfef7 100644 --- a/services/pascal-editor/apps/editor/components/ai-design-agent-panel.tsx +++ b/services/pascal-editor/apps/editor/components/ai-design-agent-panel.tsx @@ -14,8 +14,13 @@ import { Trash2, Wrench, } from 'lucide-react' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { announcePascalAgentActivity } from '@/lib/pascal-agent-browser-events' +import { + hasCompletedPascalAgentResponse, + type PendingPascalAgentRun, + parsePendingPascalAgentRun, +} from '@/lib/pascal-agent-recovery' import { withPascalBasePath } from '@/lib/pascal-base-path' interface AiDesignAgentPanelProps { @@ -23,6 +28,7 @@ interface AiDesignAgentPanelProps { } interface AgentStatus { + active?: boolean configured: boolean maxSteps: number model: string @@ -60,6 +66,9 @@ const STARTER_PROMPTS = [ '增加二层,并创建连接楼梯', ] +const PENDING_RUN_GRACE_MS = 10_000 +const PENDING_RUN_MAX_AGE_MS = 12 * 60_000 + const TOOL_LABELS: Record = { add_door: '添加门', add_window: '添加窗户', @@ -164,6 +173,7 @@ function processingStatusText(messages: UIMessage[], status: string): string { export function AiDesignAgentPanel({ sceneId }: AiDesignAgentPanelProps) { const activeConversationKey = `pascal-agent-active-conversation:${sceneId}` + const pendingRunKey = `pascal-agent-pending-run:${sceneId}` const [input, setInput] = useState('') const [agentStatus, setAgentStatus] = useState(null) const [conversationId, setConversationId] = useState(null) @@ -172,6 +182,7 @@ export function AiDesignAgentPanel({ sceneId }: AiDesignAgentPanelProps) { const [conversationError, setConversationError] = useState('') const [historyOpen, setHistoryOpen] = useState(false) const [historyReady, setHistoryReady] = useState(false) + const [pendingRun, setPendingRun] = useState(null) const conversationIdRef = useRef(null) const endRef = useRef(null) const transport = useMemo( @@ -190,15 +201,19 @@ export function AiDesignAgentPanel({ sceneId }: AiDesignAgentPanelProps) { }), [sceneId], ) - const { error, messages, sendMessage, setMessages, status, stop } = useChat({ + const { clearError, error, messages, sendMessage, setMessages, status, stop } = useChat({ id: `pascal-agent:${sceneId}`, experimental_throttle: 80, transport, }) - const isRunning = status === 'submitted' || status === 'streaming' + const chatIsRunning = status === 'submitted' || status === 'streaming' + const isRunning = chatIsRunning || pendingRun !== null const runningStatusText = useMemo( - () => processingStatusText(messages, status), - [messages, status], + () => + pendingRun && !chatIsRunning + ? '任务仍在后台执行,正在同步最新结果' + : processingStatusText(messages, status), + [chatIsRunning, messages, pendingRun, status], ) const latestMessageRenderKey = useMemo(() => { const latest = messages.at(-1) @@ -218,6 +233,34 @@ export function AiDesignAgentPanel({ sceneId }: AiDesignAgentPanelProps) { } }, [isRunning, sceneId]) + const clearPendingRun = useCallback( + (conversationId?: string) => { + setPendingRun((current) => { + if (conversationId && current?.conversationId !== conversationId) return current + window.localStorage.removeItem(pendingRunKey) + return null + }) + }, + [pendingRunKey], + ) + + const trackPendingRun = useCallback( + (run: PendingPascalAgentRun) => { + window.localStorage.setItem(pendingRunKey, JSON.stringify(run)) + setPendingRun(run) + }, + [pendingRunKey], + ) + + useEffect(() => { + const stored = parsePendingPascalAgentRun(window.localStorage.getItem(pendingRunKey)) + if (!stored || Date.now() - stored.startedAt > PENDING_RUN_MAX_AGE_MS) { + window.localStorage.removeItem(pendingRunKey) + return + } + setPendingRun(stored) + }, [pendingRunKey]) + useEffect(() => { let active = true fetch(withPascalBasePath('/api/agent/chat')) @@ -280,6 +323,99 @@ export function AiDesignAgentPanel({ sceneId }: AiDesignAgentPanelProps) { } }, [activeConversationKey, sceneId, setMessages]) + useEffect(() => { + if (!historyReady || !pendingRun) return + + let active = true + let timer: ReturnType | undefined + + const schedule = (delay: number) => { + if (!active) return + if (timer) clearTimeout(timer) + timer = setTimeout(() => void sync(), delay) + } + + const sync = async () => { + if (!active) return + const age = Date.now() - pendingRun.startedAt + if (age > PENDING_RUN_MAX_AGE_MS) { + clearPendingRun(pendingRun.conversationId) + setConversationError('后台建模任务等待超时,请检查场景结果后重试。') + return + } + + try { + const query = new URLSearchParams({ + conversationId: pendingRun.conversationId, + sceneId, + }) + const [statusResponse, detailResponse] = await Promise.all([ + fetch(withPascalBasePath(`/api/agent/chat?${query.toString()}`), { + cache: 'no-store', + }), + fetch(withPascalBasePath(`/api/agent/conversations/${pendingRun.conversationId}`), { + cache: 'no-store', + }), + ]) + if (!statusResponse.ok || !detailResponse.ok) throw new Error('recovery_unavailable') + + const statusData = (await statusResponse.json()) as AgentStatus + const detail = (await detailResponse.json()) as { conversation: Conversation } + if (!active) return + + if ( + hasCompletedPascalAgentResponse(detail.conversation.messages, pendingRun.userMessageId) + ) { + conversationIdRef.current = detail.conversation.id + setConversationId(detail.conversation.id) + setMessages(detail.conversation.messages) + window.localStorage.setItem(activeConversationKey, detail.conversation.id) + clearError() + setConversationError('') + clearPendingRun(pendingRun.conversationId) + return + } + + if (!statusData.active && age >= PENDING_RUN_GRACE_MS) { + clearPendingRun(pendingRun.conversationId) + setConversationError('上一次建模任务未能完成,请检查当前场景后重试。') + return + } + setConversationError('') + } catch { + if (age >= PENDING_RUN_MAX_AGE_MS) { + clearPendingRun(pendingRun.conversationId) + setConversationError('无法恢复后台建模任务,请稍后重试。') + return + } + } + + schedule(document.hidden ? 8000 : 1500) + } + + const resumeSync = () => { + if (document.visibilityState === 'visible') void sync() + } + + document.addEventListener('visibilitychange', resumeSync) + window.addEventListener('focus', resumeSync) + void sync() + return () => { + active = false + if (timer) clearTimeout(timer) + document.removeEventListener('visibilitychange', resumeSync) + window.removeEventListener('focus', resumeSync) + } + }, [ + activeConversationKey, + clearError, + clearPendingRun, + historyReady, + pendingRun, + sceneId, + setMessages, + ]) + useEffect(() => { if (status !== 'ready' || !conversationIdRef.current || !historyReady) return fetch(withPascalBasePath(`/api/agent/conversations?sceneId=${encodeURIComponent(sceneId)}`)) @@ -395,7 +531,38 @@ export function AiDesignAgentPanel({ sceneId }: AiDesignAgentPanelProps) { if (!activeId) activeId = await createConversation(text.slice(0, 32)) if (!activeId) return setInput('') - await sendMessage({ role: 'user', parts: [{ type: 'text', text }] }) + setConversationError('') + clearError() + const userMessageId = crypto.randomUUID() + trackPendingRun({ + conversationId: activeId, + startedAt: Date.now(), + userMessageId, + }) + try { + await sendMessage({ id: userMessageId, role: 'user', parts: [{ type: 'text', text }] }) + } catch { + // Recovery polling keeps the server-side task attached to the conversation. + } + } + + const stopAgent = async () => { + const run = pendingRun + stop() + if (!run) return + try { + await fetch(withPascalBasePath('/api/agent/chat'), { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + conversationId: run.conversationId, + sceneId, + }), + }) + } finally { + clearPendingRun(run.conversationId) + setConversationError('已停止本次建模任务。') + } } return ( @@ -586,7 +753,9 @@ export function AiDesignAgentPanel({ sceneId }: AiDesignAgentPanelProps) { 请先配置 PASCAL_AGENT_API_KEY、模型和接口地址。 ) : null} - {error ?
{error.message}
: null} + {error && !pendingRun ? ( +
{error.message}
+ ) : null} {conversationError ? (
{conversationError}
) : null} @@ -610,7 +779,7 @@ export function AiDesignAgentPanel({ sceneId }: AiDesignAgentPanelProps) {