fix: keep Pascal agent running in background

This commit is contained in:
134 2026-07-13 18:14:23 +08:00
parent 59c57e7cf8
commit c6cabe5296
6 changed files with 465 additions and 17 deletions

View File

@ -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<ReturnType<typeof createPascalAgentMcpSession>>
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 },

View File

@ -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<string, string> = {
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<AgentStatus | null>(null)
const [conversationId, setConversationId] = useState<string | null>(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<PendingPascalAgentRun | null>(null)
const conversationIdRef = useRef<string | null>(null)
const endRef = useRef<HTMLDivElement>(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<typeof setTimeout> | 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、模型和接口地址。
</div>
) : null}
{error ? <div className="mb-2 text-destructive text-xs">{error.message}</div> : null}
{error && !pendingRun ? (
<div className="mb-2 text-destructive text-xs">{error.message}</div>
) : null}
{conversationError ? (
<div className="mb-2 text-destructive text-xs">{conversationError}</div>
) : null}
@ -610,7 +779,7 @@ export function AiDesignAgentPanel({ sceneId }: AiDesignAgentPanelProps) {
<button
aria-label="停止建模"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-primary text-primary-foreground"
onClick={() => stop()}
onClick={() => void stopAgent()}
title="停止建模"
type="button"
>

View File

@ -0,0 +1,46 @@
import { describe, expect, test } from 'bun:test'
import {
cancelPascalAgentJob,
finishPascalAgentJob,
isPascalAgentJobActive,
startPascalAgentJob,
} from './pascal-agent-jobs'
describe('Pascal agent background jobs', () => {
test('keeps one active job per owner and conversation', () => {
const input = {
conversationId: crypto.randomUUID(),
ownerId: 'owner-a',
sceneId: 'scene-a',
}
const job = startPascalAgentJob(input)
expect(job).not.toBeNull()
expect(startPascalAgentJob(input)).toBeNull()
expect(isPascalAgentJobActive(input)).toBe(true)
finishPascalAgentJob(job)
expect(isPascalAgentJobActive(input)).toBe(false)
})
test('cancels only the matching owner, conversation, and scene', () => {
const input = {
conversationId: crypto.randomUUID(),
ownerId: 'owner-b',
sceneId: 'scene-b',
}
const job = startPascalAgentJob(input)
expect(
cancelPascalAgentJob({
...input,
sceneId: 'other-scene',
}),
).toBe(false)
expect(cancelPascalAgentJob(input)).toBe(true)
expect(job?.controller.signal.aborted).toBe(true)
expect(isPascalAgentJobActive(input)).toBe(false)
finishPascalAgentJob(job)
})
})

View File

@ -0,0 +1,65 @@
export interface PascalAgentJob {
controller: AbortController
conversationId: string
ownerId: string
sceneId: string
startedAt: number
}
type PascalAgentJobRegistry = Map<string, PascalAgentJob>
const globalForPascalAgentJobs = globalThis as typeof globalThis & {
__pascalAgentJobs?: PascalAgentJobRegistry
}
if (!globalForPascalAgentJobs.__pascalAgentJobs) {
globalForPascalAgentJobs.__pascalAgentJobs = new Map<string, PascalAgentJob>()
}
const jobs = globalForPascalAgentJobs.__pascalAgentJobs
function jobKey(ownerId: string, conversationId: string): string {
return `${ownerId}:${conversationId}`
}
export function startPascalAgentJob(input: {
conversationId: string
ownerId: string
sceneId: string
}): PascalAgentJob | null {
const key = jobKey(input.ownerId, input.conversationId)
if (jobs.has(key)) return null
const job: PascalAgentJob = {
...input,
controller: new AbortController(),
startedAt: Date.now(),
}
jobs.set(key, job)
return job
}
export function isPascalAgentJobActive(input: {
conversationId: string
ownerId: string
sceneId: string
}): boolean {
const job = jobs.get(jobKey(input.ownerId, input.conversationId))
return Boolean(job && job.sceneId === input.sceneId && !job.controller.signal.aborted)
}
export function cancelPascalAgentJob(input: {
conversationId: string
ownerId: string
sceneId: string
}): boolean {
const job = jobs.get(jobKey(input.ownerId, input.conversationId))
if (!job || job.sceneId !== input.sceneId) return false
job.controller.abort(new Error('Pascal agent job cancelled by user'))
return true
}
export function finishPascalAgentJob(job: PascalAgentJob | null): void {
if (!job) return
const key = jobKey(job.ownerId, job.conversationId)
if (jobs.get(key) === job) jobs.delete(key)
}

View File

@ -0,0 +1,39 @@
import { describe, expect, test } from 'bun:test'
import type { UIMessage } from 'ai'
import {
hasCompletedPascalAgentResponse,
parsePendingPascalAgentRun,
} from './pascal-agent-recovery'
describe('Pascal agent conversation recovery', () => {
test('parses valid pending runs and rejects incomplete values', () => {
const pending = {
conversationId: crypto.randomUUID(),
startedAt: Date.now(),
userMessageId: crypto.randomUUID(),
}
expect(parsePendingPascalAgentRun(JSON.stringify(pending))).toEqual(pending)
expect(parsePendingPascalAgentRun('{"conversationId":"missing-fields"}')).toBeNull()
expect(parsePendingPascalAgentRun('invalid')).toBeNull()
})
test('detects a completed assistant response after the submitted message', () => {
const userMessageId = crypto.randomUUID()
const messages: UIMessage[] = [
{ id: userMessageId, role: 'user', parts: [{ type: 'text', text: '调整布局' }] },
{
id: crypto.randomUUID(),
role: 'assistant',
parts: [
{
type: 'data-pascal-tool-activity',
data: { state: 'output-available', toolName: 'apply_patch' },
},
],
},
]
expect(hasCompletedPascalAgentResponse(messages, userMessageId)).toBe(true)
expect(hasCompletedPascalAgentResponse(messages, crypto.randomUUID())).toBe(false)
})
})

View File

@ -0,0 +1,51 @@
import type { UIMessage } from 'ai'
export interface PendingPascalAgentRun {
conversationId: string
startedAt: number
userMessageId: string
}
export function parsePendingPascalAgentRun(value: string | null): PendingPascalAgentRun | null {
if (!value) return null
try {
const parsed = JSON.parse(value) as Partial<PendingPascalAgentRun>
if (
typeof parsed.conversationId !== 'string' ||
typeof parsed.startedAt !== 'number' ||
typeof parsed.userMessageId !== 'string'
) {
return null
}
return parsed as PendingPascalAgentRun
} catch {
return null
}
}
export function hasCompletedPascalAgentResponse(
messages: UIMessage[],
userMessageId: string,
): boolean {
const userIndex = messages.findIndex(
(message) => message.id === userMessageId && message.role === 'user',
)
if (userIndex < 0) return false
return messages.slice(userIndex + 1).some(
(message) =>
message.role === 'assistant' &&
message.parts.some((part) => {
if (part.type === 'text') return Boolean(part.text.trim())
if (part.type === 'data-pascal-tool-activity') {
const state = (part.data as { state?: unknown } | undefined)?.state
return state === 'output-available' || state === 'output-error'
}
if (part.type.startsWith('tool-')) {
const state = (part as { state?: unknown }).state
return state === 'output-available' || state === 'output-error'
}
return false
}),
)
}