From 64dec38ff3c000d005459d62fa9d8402a2835b29 Mon Sep 17 00:00:00 2001 From: 134 Date: Mon, 13 Jul 2026 03:12:17 +0800 Subject: [PATCH] feat: persist Pascal agent conversations --- .../apps/editor/app/api/agent/chat/route.ts | 43 ++- .../app/api/agent/conversations/[id]/route.ts | 57 +++ .../app/api/agent/conversations/route.ts | 86 +++++ .../components/ai-design-agent-panel.tsx | 343 +++++++++++++++--- .../lib/pascal-agent-conversations.test.ts | 50 +++ .../editor/lib/pascal-agent-conversations.ts | 117 ++++++ .../storage/agent-conversation-store.test.ts | 72 ++++ .../mcp/src/storage/sqlite-scene-store.ts | 217 ++++++++++- .../packages/mcp/src/storage/types.ts | 48 +++ 9 files changed, 984 insertions(+), 49 deletions(-) create mode 100644 services/pascal-editor/apps/editor/app/api/agent/conversations/[id]/route.ts create mode 100644 services/pascal-editor/apps/editor/app/api/agent/conversations/route.ts create mode 100644 services/pascal-editor/apps/editor/lib/pascal-agent-conversations.test.ts create mode 100644 services/pascal-editor/apps/editor/lib/pascal-agent-conversations.ts create mode 100644 services/pascal-editor/packages/mcp/src/storage/agent-conversation-store.test.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 7ff5ed4db..f0e8a3957 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 @@ -12,6 +12,11 @@ import { NextResponse } from 'next/server' import { z } from 'zod' import { validatePascalAgentAccess } from '@/lib/pascal-agent-access' import { getPascalAgentConfig } from '@/lib/pascal-agent-config' +import { + conversationTitle, + getAgentConversationStore, + sanitizeAgentMessages, +} from '@/lib/pascal-agent-conversations' import { pascalAgentStepPolicy, requiresPascalMutation } from '@/lib/pascal-agent-intent' import { createPascalAgentMcpSession } from '@/lib/pascal-agent-mcp' import { buildPascalAgentPrompt } from '@/lib/pascal-agent-prompt' @@ -23,6 +28,7 @@ export const maxDuration = 600 export const runtime = 'nodejs' const requestSchema = z.object({ + conversationId: z.string().uuid().optional(), messages: z.array(z.unknown()).min(1).max(100), sceneId: z .string() @@ -94,6 +100,7 @@ export async function POST(request: Request) { ) } const mutationRequired = requiresPascalMutation(parsed.data.messages) + const uiMessages = parsed.data.messages as UIMessage[] const operations = await getSceneOperations() const storedScene = await operations.loadStoredScene(parsed.data.sceneId) @@ -104,6 +111,25 @@ export async function POST(request: Request) { ) } + const ownerId = identity.userId ?? 'local' + const conversationStore = parsed.data.conversationId ? await getAgentConversationStore() : null + if (parsed.data.conversationId && conversationStore) { + const conversation = await conversationStore.loadAgentConversation( + parsed.data.conversationId, + ownerId, + ) + if (!conversation || conversation.sceneId !== parsed.data.sceneId) { + return NextResponse.json({ error: 'conversation_not_found' }, { status: 404 }) + } + await conversationStore.saveAgentConversation({ + id: parsed.data.conversationId, + sceneId: parsed.data.sceneId, + ownerId, + messages: sanitizeAgentMessages(uiMessages), + title: conversationTitle(uiMessages), + }) + } + const abortSignal = AbortSignal.any([request.signal, AbortSignal.timeout(config.totalTimeoutMs)]) let session: Awaited> try { @@ -124,7 +150,7 @@ export async function POST(request: Request) { baseURL: config.baseUrl, name: 'pascal-agent', }) - const initialMessages = convertToModelMessages(parsed.data.messages as UIMessage[]) + const initialMessages = convertToModelMessages(uiMessages, { ignoreIncompleteToolCalls: true }) const systemPrompt = buildPascalAgentPrompt({ sceneId: parsed.data.sceneId, sceneName: session.sceneName, @@ -229,6 +255,21 @@ 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), + }) + } catch (error) { + console.error('Pascal agent conversation persistence failed.', error) + } + }, + originalMessages: uiMessages, }) try { diff --git a/services/pascal-editor/apps/editor/app/api/agent/conversations/[id]/route.ts b/services/pascal-editor/apps/editor/app/api/agent/conversations/[id]/route.ts new file mode 100644 index 000000000..301d8cfb9 --- /dev/null +++ b/services/pascal-editor/apps/editor/app/api/agent/conversations/[id]/route.ts @@ -0,0 +1,57 @@ +import { NextResponse } from 'next/server' +import { z } from 'zod' +import { validatePascalAgentAccess } from '@/lib/pascal-agent-access' +import { getAgentConversationStore } from '@/lib/pascal-agent-conversations' +import { getPascalHostIdentity } from '@/lib/pascal-host-identity' + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +const idSchema = z.string().uuid() + +function ownerIdForRequest(request: Request): string | NextResponse { + const access = validatePascalAgentAccess(request.headers) + if (access === 'missing_proxy_secret') { + return NextResponse.json({ error: 'agent_proxy_not_configured' }, { status: 503 }) + } + if (access === 'unauthorized') { + return NextResponse.json({ error: 'unauthorized' }, { status: 401 }) + } + const identity = getPascalHostIdentity(request.headers) + if (!identity.allowed) { + return NextResponse.json({ error: 'host_user_required' }, { status: 401 }) + } + return identity.userId ?? 'local' +} + +export async function GET(request: Request, context: { params: Promise<{ id: string }> }) { + const ownerId = ownerIdForRequest(request) + if (ownerId instanceof NextResponse) return ownerId + const parsed = idSchema.safeParse((await context.params).id) + if (!parsed.success) { + return NextResponse.json({ error: 'invalid_conversation_id' }, { status: 400 }) + } + + const store = await getAgentConversationStore() + const conversation = await store.loadAgentConversation(parsed.data, ownerId) + if (!conversation) { + return NextResponse.json({ error: 'conversation_not_found' }, { status: 404 }) + } + return NextResponse.json({ conversation }) +} + +export async function DELETE(request: Request, context: { params: Promise<{ id: string }> }) { + const ownerId = ownerIdForRequest(request) + if (ownerId instanceof NextResponse) return ownerId + const parsed = idSchema.safeParse((await context.params).id) + if (!parsed.success) { + return NextResponse.json({ error: 'invalid_conversation_id' }, { status: 400 }) + } + + const store = await getAgentConversationStore() + const deleted = await store.deleteAgentConversation(parsed.data, ownerId) + if (!deleted) { + return NextResponse.json({ error: 'conversation_not_found' }, { status: 404 }) + } + return new NextResponse(null, { status: 204 }) +} diff --git a/services/pascal-editor/apps/editor/app/api/agent/conversations/route.ts b/services/pascal-editor/apps/editor/app/api/agent/conversations/route.ts new file mode 100644 index 000000000..bef5647b6 --- /dev/null +++ b/services/pascal-editor/apps/editor/app/api/agent/conversations/route.ts @@ -0,0 +1,86 @@ +import { NextResponse } from 'next/server' +import { z } from 'zod' +import { validatePascalAgentAccess } from '@/lib/pascal-agent-access' +import { getAgentConversationStore } from '@/lib/pascal-agent-conversations' +import { getPascalHostIdentity } from '@/lib/pascal-host-identity' +import { getSceneOperations } from '@/lib/scene-store-server' + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +const sceneIdSchema = z + .string() + .min(1) + .max(64) + .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/) + +const createSchema = z.object({ + sceneId: sceneIdSchema, + title: z.string().max(80).optional(), +}) + +function requestIdentity(request: Request) { + const access = validatePascalAgentAccess(request.headers) + if (access === 'missing_proxy_secret') { + return { error: NextResponse.json({ error: 'agent_proxy_not_configured' }, { status: 503 }) } + } + if (access === 'unauthorized') { + return { error: NextResponse.json({ error: 'unauthorized' }, { status: 401 }) } + } + const identity = getPascalHostIdentity(request.headers) + if (!identity.allowed) { + return { error: NextResponse.json({ error: 'host_user_required' }, { status: 401 }) } + } + return { ownerId: identity.userId ?? 'local', userId: identity.userId } +} + +async function ownsScene(sceneId: string, userId: string | null): Promise { + const operations = await getSceneOperations() + const scene = await operations.loadStoredScene(sceneId) + return Boolean(scene && (!userId || scene.ownerId === userId)) +} + +export async function GET(request: Request) { + const identity = requestIdentity(request) + if ('error' in identity) return identity.error + const parsed = sceneIdSchema.safeParse(new URL(request.url).searchParams.get('sceneId')) + if (!parsed.success) { + return NextResponse.json({ error: 'invalid_scene_id' }, { status: 400 }) + } + if (!(await ownsScene(parsed.data, identity.userId))) { + return NextResponse.json({ error: 'scene_unavailable' }, { status: 404 }) + } + + const store = await getAgentConversationStore() + const conversations = await store.listAgentConversations({ + sceneId: parsed.data, + ownerId: identity.ownerId, + }) + return NextResponse.json({ conversations }) +} + +export async function POST(request: Request) { + const identity = requestIdentity(request) + if ('error' in identity) return identity.error + let body: unknown + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'invalid_json' }, { status: 400 }) + } + const parsed = createSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json({ error: 'invalid_request' }, { status: 400 }) + } + if (!(await ownsScene(parsed.data.sceneId, identity.userId))) { + return NextResponse.json({ error: 'scene_unavailable' }, { status: 404 }) + } + + const store = await getAgentConversationStore() + const conversation = await store.createAgentConversation({ + sceneId: parsed.data.sceneId, + ownerId: identity.ownerId, + title: parsed.data.title, + }) + return NextResponse.json({ conversation }, { status: 201 }) +} 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 b1693c999..46ba08def 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 @@ -7,8 +7,10 @@ import { Bot, Check, CircleAlert, + History, LoaderCircle, Square, + SquarePen, Trash2, Wrench, } from 'lucide-react' @@ -25,7 +27,25 @@ interface AgentStatus { model: string } +interface ConversationSummary { + id: string + sceneId: string + title: string + messageCount: number + createdAt: string + updatedAt: string +} + +interface Conversation extends ConversationSummary { + messages: UIMessage[] +} + type DisplayPart = { + data?: { + errorText?: string + state?: string + toolName?: string + } errorText?: string state?: string text?: string @@ -60,15 +80,28 @@ const TOOL_LABELS: Record = { validate_scene: '验证建筑结构', } -function textOnlyHistory(messages: UIMessage[]): UIMessage[] { - return messages - .map((message) => ({ - id: message.id, - parts: message.parts.filter((part) => part.type === 'text'), - role: message.role, - })) - .filter((message) => message.parts.length > 0) - .slice(-40) as UIMessage[] +function displayPartFrom(rawPart: unknown): DisplayPart { + const part = rawPart as DisplayPart + if (part.type === 'data-pascal-tool-activity' && part.data?.toolName) { + return { + type: `tool-${part.data.toolName}`, + toolName: part.data.toolName, + state: part.data.state, + errorText: part.data.errorText, + } + } + return part +} + +function formatConversationTime(value: string): string { + const date = new Date(value) + if (Number.isNaN(date.getTime())) return '' + return new Intl.DateTimeFormat('zh-CN', { + month: 'numeric', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }).format(date) } function toolNameFromPart(part: DisplayPart): string | null { @@ -107,10 +140,16 @@ function ToolActivity({ part }: { part: DisplayPart }) { } export function AiDesignAgentPanel({ sceneId }: AiDesignAgentPanelProps) { - const historyKey = `pascal-agent-history:${sceneId}` + const activeConversationKey = `pascal-agent-active-conversation:${sceneId}` const [input, setInput] = useState('') const [agentStatus, setAgentStatus] = useState(null) + const [conversationId, setConversationId] = useState(null) + const [conversations, setConversations] = useState([]) + const [conversationBusy, setConversationBusy] = useState(false) + const [conversationError, setConversationError] = useState('') + const [historyOpen, setHistoryOpen] = useState(false) const [historyReady, setHistoryReady] = useState(false) + const conversationIdRef = useRef(null) const endRef = useRef(null) const transport = useMemo( () => @@ -119,6 +158,7 @@ export function AiDesignAgentPanel({ sceneId }: AiDesignAgentPanelProps) { prepareSendMessagesRequest(request) { return { body: { + ...(conversationIdRef.current ? { conversationId: conversationIdRef.current } : {}), messages: request.messages, sceneId, }, @@ -138,7 +178,7 @@ export function AiDesignAgentPanel({ sceneId }: AiDesignAgentPanelProps) { if (!latest) return '' return latest.parts .map((part) => { - const displayPart = part as DisplayPart + const displayPart = displayPartFrom(part) return `${displayPart.type}:${displayPart.state ?? ''}:${displayPart.text?.length ?? 0}` }) .join('|') @@ -163,38 +203,169 @@ export function AiDesignAgentPanel({ sceneId }: AiDesignAgentPanelProps) { }, []) useEffect(() => { - try { - const saved = window.localStorage.getItem(historyKey) - if (saved) { - const parsed = JSON.parse(saved) as UIMessage[] - if (Array.isArray(parsed)) setMessages(parsed) + let active = true + const load = async () => { + try { + const response = await fetch( + withPascalBasePath(`/api/agent/conversations?sceneId=${encodeURIComponent(sceneId)}`), + ) + if (!response.ok) throw new Error('无法加载历史对话') + const data = (await response.json()) as { conversations: ConversationSummary[] } + if (!active) return + setConversations(data.conversations) + + const savedId = window.localStorage.getItem(activeConversationKey) + const selected = data.conversations.find((item) => item.id === savedId) + ? savedId + : data.conversations[0]?.id + if (!selected) { + setMessages([]) + return + } + const detailResponse = await fetch( + withPascalBasePath(`/api/agent/conversations/${selected}`), + ) + if (!detailResponse.ok) throw new Error('无法恢复对话') + const detail = (await detailResponse.json()) as { conversation: Conversation } + if (!active) return + conversationIdRef.current = detail.conversation.id + setConversationId(detail.conversation.id) + setMessages(detail.conversation.messages) + window.localStorage.setItem(activeConversationKey, detail.conversation.id) + } catch (loadError) { + if (active) { + setConversationError(loadError instanceof Error ? loadError.message : '无法加载历史对话') + } + } finally { + if (active) setHistoryReady(true) } - } catch { - window.localStorage.removeItem(historyKey) - } finally { - setHistoryReady(true) } - }, [historyKey, setMessages]) + void load() + return () => { + active = false + } + }, [activeConversationKey, sceneId, setMessages]) useEffect(() => { - if (!historyReady) return - window.localStorage.setItem(historyKey, JSON.stringify(textOnlyHistory(messages))) - }, [historyKey, historyReady, messages]) + if (status !== 'ready' || !conversationIdRef.current || !historyReady) return + fetch(withPascalBasePath(`/api/agent/conversations?sceneId=${encodeURIComponent(sceneId)}`)) + .then(async (response) => { + if (!response.ok) return null + return (await response.json()) as { conversations: ConversationSummary[] } + }) + .then((data) => { + if (data) setConversations(data.conversations) + }) + .catch(() => undefined) + }, [historyReady, sceneId, status]) useEffect(() => { if (!latestMessageRenderKey) return endRef.current?.scrollIntoView({ behavior: isRunning ? 'smooth' : 'auto' }) }, [isRunning, latestMessageRenderKey]) - const submit = () => { + const activateConversation = async (id: string) => { + if (isRunning || id === conversationIdRef.current) { + setHistoryOpen(false) + return + } + setConversationBusy(true) + setConversationError('') + try { + const response = await fetch(withPascalBasePath(`/api/agent/conversations/${id}`)) + if (!response.ok) throw new Error('无法加载该对话') + const data = (await response.json()) as { conversation: Conversation } + conversationIdRef.current = data.conversation.id + setConversationId(data.conversation.id) + setMessages(data.conversation.messages) + window.localStorage.setItem(activeConversationKey, data.conversation.id) + setHistoryOpen(false) + } catch (loadError) { + setConversationError(loadError instanceof Error ? loadError.message : '无法加载该对话') + } finally { + setConversationBusy(false) + } + } + + const createConversation = async (title = '新对话'): Promise => { + if (isRunning || conversationBusy) return null + setConversationBusy(true) + setConversationError('') + try { + const response = await fetch(withPascalBasePath('/api/agent/conversations'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sceneId, title }), + }) + if (!response.ok) throw new Error('无法新建对话') + const data = (await response.json()) as { conversation: Conversation } + conversationIdRef.current = data.conversation.id + setConversationId(data.conversation.id) + setMessages([]) + setConversations((current) => [ + data.conversation, + ...current.filter((item) => item.id !== data.conversation.id), + ]) + window.localStorage.setItem(activeConversationKey, data.conversation.id) + setHistoryOpen(false) + return data.conversation.id + } catch (createError) { + setConversationError(createError instanceof Error ? createError.message : '无法新建对话') + return null + } finally { + setConversationBusy(false) + } + } + + const deleteConversation = async (id: string) => { + if (isRunning || conversationBusy) return + setConversationBusy(true) + setConversationError('') + try { + const response = await fetch(withPascalBasePath(`/api/agent/conversations/${id}`), { + method: 'DELETE', + }) + if (!response.ok) throw new Error('无法删除对话') + const remaining = conversations.filter((item) => item.id !== id) + setConversations(remaining) + if (conversationIdRef.current === id) { + conversationIdRef.current = null + setConversationId(null) + setMessages([]) + window.localStorage.removeItem(activeConversationKey) + const next = remaining[0] + if (next) { + const detailResponse = await fetch( + withPascalBasePath(`/api/agent/conversations/${next.id}`), + ) + if (detailResponse.ok) { + const data = (await detailResponse.json()) as { conversation: Conversation } + conversationIdRef.current = data.conversation.id + setConversationId(data.conversation.id) + setMessages(data.conversation.messages) + window.localStorage.setItem(activeConversationKey, data.conversation.id) + } + } + } + } catch (deleteError) { + setConversationError(deleteError instanceof Error ? deleteError.message : '无法删除对话') + } finally { + setConversationBusy(false) + } + } + + const submit = async () => { const text = input.trim() - if (!text || isRunning || agentStatus?.configured === false) return - sendMessage({ role: 'user', parts: [{ type: 'text', text }] }) + if (!text || isRunning || conversationBusy || agentStatus?.configured === false) return + let activeId = conversationIdRef.current + if (!activeId) activeId = await createConversation(text.slice(0, 32)) + if (!activeId) return setInput('') + await sendMessage({ role: 'user', parts: [{ type: 'text', text }] }) } return ( -
+
@@ -218,23 +389,98 @@ export function AiDesignAgentPanel({ sceneId }: AiDesignAgentPanelProps) {
- +
+ + + +
+ {historyOpen ? ( +
+
+ 历史对话 + {conversations.length} 条 +
+
+ {conversations.length === 0 ? ( +
+ 暂无历史对话 +
+ ) : ( + conversations.map((conversation) => ( +
+ + +
+ )) + )} +
+
+ ) : null} +
- {messages.length === 0 ? ( + {!historyReady ? ( +
+ + 正在恢复对话 +
+ ) : messages.length === 0 ? (
@@ -271,7 +517,7 @@ export function AiDesignAgentPanel({ sceneId }: AiDesignAgentPanelProps) { } > {message.parts.map((rawPart, index) => { - const part = rawPart as DisplayPart + const part = displayPartFrom(rawPart) if (part.type === 'text' && part.text) { return (

@@ -303,16 +549,19 @@ export function AiDesignAgentPanel({ sceneId }: AiDesignAgentPanelProps) {

) : null} {error ?
{error.message}
: null} + {conversationError ? ( +
{conversationError}
+ ) : null}