feat: persist Pascal agent conversations

This commit is contained in:
134 2026-07-13 03:12:17 +08:00
parent cf1091fc2c
commit 64dec38ff3
9 changed files with 984 additions and 49 deletions

View File

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

View File

@ -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 })
}

View File

@ -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<boolean> {
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 })
}

View File

@ -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<string, string> = {
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<AgentStatus | null>(null)
const [conversationId, setConversationId] = useState<string | null>(null)
const [conversations, setConversations] = useState<ConversationSummary[]>([])
const [conversationBusy, setConversationBusy] = useState(false)
const [conversationError, setConversationError] = useState('')
const [historyOpen, setHistoryOpen] = useState(false)
const [historyReady, setHistoryReady] = useState(false)
const conversationIdRef = useRef<string | null>(null)
const endRef = useRef<HTMLDivElement>(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<string | null> => {
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 (
<div className="flex h-full min-h-0 flex-col bg-background">
<div className="relative flex h-full min-h-0 flex-col bg-background">
<div className="flex h-12 shrink-0 items-center justify-between border-border/70 border-b px-3">
<div className="flex min-w-0 items-center gap-2">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground">
@ -218,23 +389,98 @@ export function AiDesignAgentPanel({ sceneId }: AiDesignAgentPanelProps) {
</div>
</div>
</div>
<button
aria-label="清空对话"
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-40"
disabled={isRunning || messages.length === 0}
onClick={() => {
setMessages([])
window.localStorage.removeItem(historyKey)
}}
title="清空对话"
type="button"
>
<Trash2 className="h-4 w-4" />
</button>
<div className="flex items-center gap-0.5">
<button
aria-label="历史对话"
className={`flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground ${
historyOpen ? 'bg-muted text-foreground' : ''
}`}
onClick={() => setHistoryOpen((open) => !open)}
title="历史对话"
type="button"
>
<History className="h-4 w-4" />
</button>
<button
aria-label="新建对话"
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-40"
disabled={isRunning || conversationBusy}
onClick={() => void createConversation()}
title="新建对话"
type="button"
>
<SquarePen className="h-4 w-4" />
</button>
<button
aria-label="删除当前对话"
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-40"
disabled={isRunning || conversationBusy || !conversationId}
onClick={() => {
if (conversationId) void deleteConversation(conversationId)
}}
title="删除当前对话"
type="button"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
{historyOpen ? (
<div className="absolute top-12 right-2 left-2 z-30 overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-lg">
<div className="flex h-10 items-center justify-between border-border/70 border-b px-3">
<span className="font-medium text-xs">历史对话</span>
<span className="text-[11px] text-muted-foreground">{conversations.length} 条</span>
</div>
<div className="max-h-72 overflow-y-auto p-1.5">
{conversations.length === 0 ? (
<div className="px-3 py-8 text-center text-muted-foreground text-xs">
暂无历史对话
</div>
) : (
conversations.map((conversation) => (
<div
className={`group flex items-center rounded-md ${
conversation.id === conversationId ? 'bg-muted' : 'hover:bg-muted/70'
}`}
key={conversation.id}
>
<button
className="min-w-0 flex-1 px-2.5 py-2 text-left"
disabled={conversationBusy || isRunning}
onClick={() => void activateConversation(conversation.id)}
type="button"
>
<div className="truncate font-medium text-xs">{conversation.title}</div>
<div className="mt-0.5 flex items-center gap-2 text-[10px] text-muted-foreground">
<span>{formatConversationTime(conversation.updatedAt)}</span>
<span>{conversation.messageCount} 条消息</span>
</div>
</button>
<button
aria-label={`删除对话:${conversation.title}`}
className="mr-1 flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground opacity-60 transition-colors hover:bg-background hover:text-destructive group-hover:opacity-100"
disabled={conversationBusy || isRunning}
onClick={() => void deleteConversation(conversation.id)}
title="删除对话"
type="button"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
))
)}
</div>
</div>
) : null}
<div className="min-h-0 flex-1 overflow-y-auto px-3 py-3">
{messages.length === 0 ? (
{!historyReady ? (
<div className="flex min-h-full items-center justify-center text-muted-foreground text-xs">
<LoaderCircle className="mr-2 h-3.5 w-3.5 animate-spin" />
正在恢复对话
</div>
) : messages.length === 0 ? (
<div className="flex min-h-full flex-col justify-center py-6">
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-xl bg-muted text-foreground">
<Wrench className="h-5 w-5" />
@ -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 (
<p className="whitespace-pre-wrap leading-6" key={`${message.id}-${index}`}>
@ -303,16 +549,19 @@ export function AiDesignAgentPanel({ sceneId }: AiDesignAgentPanelProps) {
</div>
) : null}
{error ? <div className="mb-2 text-destructive text-xs">{error.message}</div> : null}
{conversationError ? (
<div className="mb-2 text-destructive text-xs">{conversationError}</div>
) : null}
<div className="flex items-end gap-2 rounded-lg border border-input bg-background p-2 shadow-sm focus-within:ring-1 focus-within:ring-ring">
<textarea
aria-label="输入建模要求"
className="max-h-32 min-h-9 min-w-0 flex-1 resize-none bg-transparent px-1 py-2 text-sm outline-none placeholder:text-muted-foreground"
disabled={agentStatus?.configured === false}
disabled={agentStatus?.configured === false || conversationBusy}
onChange={(event) => setInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
submit()
void submit()
}
}}
placeholder="描述你想创建或修改的空间"
@ -333,8 +582,8 @@ 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 transition-opacity disabled:opacity-35"
disabled={!input.trim() || agentStatus?.configured === false}
onClick={submit}
disabled={!input.trim() || agentStatus?.configured === false || conversationBusy}
onClick={() => void submit()}
title="发送"
type="button"
>

View File

@ -0,0 +1,50 @@
import { describe, expect, test } from 'bun:test'
import type { UIMessage } from 'ai'
import { conversationTitle, sanitizeAgentMessages } from './pascal-agent-conversations'
describe('Pascal agent conversation history', () => {
test('keeps text and compact tool activity while dropping tool payloads', () => {
const messages = [
{
id: 'assistant-1',
role: 'assistant',
parts: [
{ type: 'text', text: '正在建模' },
{
type: 'tool-create_room',
state: 'output-available',
toolCallId: 'call-1',
input: { width: 10 },
output: { graph: { veryLarge: true } },
},
],
},
] as UIMessage[]
expect(sanitizeAgentMessages(messages)).toEqual([
{
id: 'assistant-1',
role: 'assistant',
parts: [
{ type: 'text', text: '正在建模' },
{
type: 'data-pascal-tool-activity',
data: { toolName: 'create_room', state: 'output-available' },
},
],
},
])
})
test('derives a concise title from the first user message', () => {
const messages = [
{
id: 'user-1',
role: 'user',
parts: [{ type: 'text', text: ' 设计一个\n两室一厅住宅 ' }],
},
] as UIMessage[]
expect(conversationTitle(messages)).toBe('设计一个 两室一厅住宅')
})
})

View File

@ -0,0 +1,117 @@
import type {
AgentConversation,
AgentConversationMessage,
AgentConversationSummary,
SceneStore,
} from '@pascal-app/mcp/storage'
import type { UIMessage } from 'ai'
import { getSceneStore } from './scene-store-server'
const MAX_MESSAGES = 80
const MAX_TEXT_LENGTH = 20_000
const DEFAULT_TITLE = '新对话'
type ConversationStore = Required<
Pick<
SceneStore,
| 'createAgentConversation'
| 'listAgentConversations'
| 'loadAgentConversation'
| 'saveAgentConversation'
| 'deleteAgentConversation'
>
>
type UnknownPart = {
data?: unknown
errorText?: unknown
state?: unknown
text?: unknown
toolName?: unknown
type?: unknown
}
function isConversationStore(store: SceneStore): store is SceneStore & ConversationStore {
return (
typeof store.createAgentConversation === 'function' &&
typeof store.listAgentConversations === 'function' &&
typeof store.loadAgentConversation === 'function' &&
typeof store.saveAgentConversation === 'function' &&
typeof store.deleteAgentConversation === 'function'
)
}
export async function getAgentConversationStore(): Promise<ConversationStore> {
const store = await getSceneStore()
if (!isConversationStore(store)) {
throw new Error('Agent conversation storage is unavailable')
}
return store
}
function toolNameFromPart(part: UnknownPart): string | null {
if (typeof part.toolName === 'string' && part.toolName) return part.toolName
if (typeof part.type === 'string' && part.type.startsWith('tool-')) return part.type.slice(5)
return null
}
function sanitizePart(part: unknown): unknown | null {
if (!part || typeof part !== 'object') return null
const value = part as UnknownPart
if (value.type === 'text' && typeof value.text === 'string') {
return { type: 'text', text: value.text.slice(0, MAX_TEXT_LENGTH) }
}
if (value.type === 'data-pascal-tool-activity' && value.data && typeof value.data === 'object') {
const data = value.data as UnknownPart
const toolName = typeof data.toolName === 'string' ? data.toolName : null
if (!toolName) return null
return {
type: 'data-pascal-tool-activity',
data: {
toolName,
state: typeof data.state === 'string' ? data.state : 'output-available',
...(typeof data.errorText === 'string' ? { errorText: data.errorText.slice(0, 500) } : {}),
},
}
}
const toolName = toolNameFromPart(value)
if (!toolName) return null
return {
type: 'data-pascal-tool-activity',
data: {
toolName,
state: typeof value.state === 'string' ? value.state : 'output-available',
...(typeof value.errorText === 'string' ? { errorText: value.errorText.slice(0, 500) } : {}),
},
}
}
export function sanitizeAgentMessages(messages: UIMessage[]): AgentConversationMessage[] {
const sanitized: AgentConversationMessage[] = []
for (const [messageIndex, message] of messages.slice(-MAX_MESSAGES).entries()) {
if (!['system', 'user', 'assistant'].includes(message.role)) continue
const parts: unknown[] = message.parts.map(sanitizePart).filter((part) => part !== null)
if (parts.length === 0) continue
sanitized.push({
id:
typeof message.id === 'string' && message.id.length > 0
? message.id.slice(0, 128)
: `message-${messageIndex}`,
role: message.role,
parts,
})
}
return sanitized
}
export function conversationTitle(messages: UIMessage[]): string {
const userMessage = messages.find((message) => message.role === 'user')
const textPart = userMessage?.parts.find((part) => part.type === 'text') as
| { text?: string }
| undefined
const title = textPart?.text?.trim().replace(/\s+/g, ' ')
return title ? title.slice(0, 32) : DEFAULT_TITLE
}
export type { AgentConversation, AgentConversationSummary }

View File

@ -0,0 +1,72 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import * as fs from 'node:fs/promises'
import * as os from 'node:os'
import * as path from 'node:path'
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
import { SqliteSceneStore } from './sqlite-scene-store'
const graph = {
nodes: {
site: {
object: 'node',
id: 'site',
type: 'site',
parentId: null,
visible: true,
metadata: {},
},
},
rootNodeIds: ['site'],
} as SceneGraph
describe('agent conversation storage', () => {
let rootDir: string
let store: SqliteSceneStore
beforeEach(async () => {
rootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'pascal-conversation-test-'))
store = new SqliteSceneStore({ databasePath: path.join(rootDir, 'pascal.db') })
await store.save({ id: 'home', name: 'Home', ownerId: 'user-1', graph })
})
afterEach(async () => {
store.close()
await fs.rm(rootDir, { recursive: true, force: true })
})
test('creates, saves, lists, and reloads a conversation', async () => {
const created = await store.createAgentConversation({
sceneId: 'home',
ownerId: 'user-1',
title: ' 两室一厅 ',
})
const messages = [
{ id: 'user-1', role: 'user' as const, parts: [{ type: 'text', text: '开始建模' }] },
]
await store.saveAgentConversation({
id: created.id,
sceneId: 'home',
ownerId: 'user-1',
messages,
})
expect(await store.listAgentConversations({ sceneId: 'home', ownerId: 'user-1' })).toEqual([
expect.objectContaining({ id: created.id, title: '两室一厅', messageCount: 1 }),
])
expect(await store.loadAgentConversation(created.id, 'user-1')).toEqual(
expect.objectContaining({ id: created.id, messages }),
)
})
test('isolates conversations by owner and cascades scene deletion', async () => {
const created = await store.createAgentConversation({
sceneId: 'home',
ownerId: 'user-1',
})
expect(await store.loadAgentConversation(created.id, 'user-2')).toBeNull()
expect(await store.deleteAgentConversation(created.id, 'user-2')).toBe(false)
expect(await store.delete('home', { expectedVersion: 1 })).toBe(true)
expect(await store.loadAgentConversation(created.id, 'user-1')).toBeNull()
})
})

View File

@ -1,4 +1,4 @@
import { createHash } from 'node:crypto'
import { createHash, randomUUID } from 'node:crypto'
import { mkdirSync } from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
@ -7,6 +7,12 @@ import { z } from 'zod'
import { generateSlug, isValidSlug, sanitizeSlug } from './slug'
import { openSqliteDatabase, type SqliteDatabase } from './sqlite-driver'
import {
type AgentConversation,
type AgentConversationCreateOptions,
type AgentConversationListOptions,
type AgentConversationMessage,
type AgentConversationSaveOptions,
type AgentConversationSummary,
type ProjectCreateOptions,
type ProjectStatus,
type SceneEvent,
@ -28,6 +34,21 @@ const DEFAULT_MAX_SCENE_BYTES = 10 * 1024 * 1024
const DEFAULT_LIST_LIMIT = 100
const MAX_NAME_LENGTH = 200
const MIN_NAME_LENGTH = 1
const DEFAULT_CONVERSATION_LIMIT = 50
const MAX_CONVERSATION_MESSAGES = 100
const MAX_CONVERSATION_BYTES = 512 * 1024
const MAX_CONVERSATION_TITLE_LENGTH = 80
interface AgentConversationRow {
id: string
scene_id: string
owner_id: string
title: string
messages_json: string
message_count: number
created_at: string
updated_at: string
}
export interface SqliteSceneStoreOptions {
/** Exact SQLite database file path. If omitted, resolved from env. */
@ -266,6 +287,73 @@ function rowToSceneEvent(row: SceneEventRow): SceneEvent {
}
}
function parseConversationMessages(raw: string): AgentConversationMessage[] {
const parsed = JSON.parse(raw) as unknown
if (!Array.isArray(parsed)) return []
return parsed.filter(
(message): message is AgentConversationMessage =>
Boolean(message) &&
typeof message === 'object' &&
typeof (message as AgentConversationMessage).id === 'string' &&
['system', 'user', 'assistant'].includes((message as AgentConversationMessage).role) &&
Array.isArray((message as AgentConversationMessage).parts),
)
}
function conversationRowToSummary(row: AgentConversationRow): AgentConversationSummary {
return {
id: row.id,
sceneId: row.scene_id,
ownerId: row.owner_id,
title: row.title,
messageCount: Number(row.message_count),
createdAt: row.created_at,
updatedAt: row.updated_at,
}
}
function conversationRowToConversation(row: AgentConversationRow): AgentConversation {
return {
...conversationRowToSummary(row),
messages: parseConversationMessages(row.messages_json),
}
}
function assertConversationOwner(ownerId: string): void {
if (!/^[a-zA-Z0-9_-]{1,128}$/.test(ownerId)) {
throw new SceneInvalidError('Invalid conversation owner')
}
}
function normalizeConversationTitle(title = '新对话'): string {
const normalized = title.trim().replace(/\s+/g, ' ')
if (!normalized) return '新对话'
return normalized.slice(0, MAX_CONVERSATION_TITLE_LENGTH)
}
function serializeConversationMessages(messages: AgentConversationMessage[]): string {
if (messages.length > MAX_CONVERSATION_MESSAGES) {
throw new SceneInvalidError(`Conversation exceeds ${MAX_CONVERSATION_MESSAGES} messages`)
}
for (const message of messages) {
if (
!message ||
typeof message.id !== 'string' ||
message.id.length === 0 ||
message.id.length > 128 ||
!['system', 'user', 'assistant'].includes(message.role) ||
!Array.isArray(message.parts)
) {
throw new SceneInvalidError('Conversation contains an invalid message')
}
}
const json = JSON.stringify(messages)
if (Buffer.byteLength(json, 'utf8') > MAX_CONVERSATION_BYTES) {
throw new SceneTooLargeError('Conversation history is too large')
}
return json
}
/**
* SQLite-backed implementation of `SceneStore`.
*
@ -585,6 +673,118 @@ export class SqliteSceneStore implements SceneStore {
return rows.map((row) => rowToSceneEvent(row as SceneEventRow))
}
async createAgentConversation(opts: AgentConversationCreateOptions): Promise<AgentConversation> {
assertConversationOwner(opts.ownerId)
const sceneId = sanitizeSlug(opts.sceneId)
const id = opts.id ?? randomUUID()
const title = normalizeConversationTitle(opts.title)
const now = new Date().toISOString()
return this.withWriteTransaction((db) => {
if (!this.getRow(db, sceneId)) {
throw new SceneNotFoundError(`Scene "${sceneId}" not found`)
}
db.query(
`INSERT INTO agent_conversations (
id, scene_id, owner_id, title, messages_json, message_count, created_at, updated_at
) VALUES (?, ?, ?, ?, '[]', 0, ?, ?)`,
).run(id, sceneId, opts.ownerId, title, now, now)
return {
id,
sceneId,
ownerId: opts.ownerId,
title,
messages: [],
messageCount: 0,
createdAt: now,
updatedAt: now,
}
})
}
async listAgentConversations(
opts: AgentConversationListOptions,
): Promise<AgentConversationSummary[]> {
assertConversationOwner(opts.ownerId)
const requestedLimit = opts.limit ?? DEFAULT_CONVERSATION_LIMIT
const limit = Math.min(
DEFAULT_CONVERSATION_LIMIT,
Number.isInteger(requestedLimit) && requestedLimit > 0
? requestedLimit
: DEFAULT_CONVERSATION_LIMIT,
)
const db = await this.database()
const rows = db
.query(
`SELECT id, scene_id, owner_id, title, messages_json, message_count, created_at, updated_at
FROM agent_conversations
WHERE scene_id = ? AND owner_id = ?
ORDER BY updated_at DESC
LIMIT ?`,
)
.all(sanitizeSlug(opts.sceneId), opts.ownerId, limit)
return rows.map((row) => conversationRowToSummary(row as AgentConversationRow))
}
async loadAgentConversation(id: string, ownerId: string): Promise<AgentConversation | null> {
assertConversationOwner(ownerId)
const db = await this.database()
const row = db
.query(
`SELECT id, scene_id, owner_id, title, messages_json, message_count, created_at, updated_at
FROM agent_conversations
WHERE id = ? AND owner_id = ?`,
)
.get(id, ownerId) as AgentConversationRow | undefined
return row ? conversationRowToConversation(row) : null
}
async saveAgentConversation(opts: AgentConversationSaveOptions): Promise<AgentConversation> {
assertConversationOwner(opts.ownerId)
const sceneId = sanitizeSlug(opts.sceneId)
const messagesJson = serializeConversationMessages(opts.messages)
const now = new Date().toISOString()
return this.withWriteTransaction((db) => {
const existing = db
.query(
`SELECT id, scene_id, owner_id, title, messages_json, message_count, created_at, updated_at
FROM agent_conversations
WHERE id = ? AND scene_id = ? AND owner_id = ?`,
)
.get(opts.id, sceneId, opts.ownerId) as AgentConversationRow | undefined
if (!existing) throw new SceneNotFoundError('Conversation not found')
const title = normalizeConversationTitle(opts.title ?? existing.title)
db.query(
`UPDATE agent_conversations
SET title = ?, messages_json = ?, message_count = ?, updated_at = ?
WHERE id = ? AND scene_id = ? AND owner_id = ?`,
).run(title, messagesJson, opts.messages.length, now, opts.id, sceneId, opts.ownerId)
return {
id: opts.id,
sceneId,
ownerId: opts.ownerId,
title,
messages: opts.messages,
messageCount: opts.messages.length,
createdAt: existing.created_at,
updatedAt: now,
}
})
}
async deleteAgentConversation(id: string, ownerId: string): Promise<boolean> {
assertConversationOwner(ownerId)
return this.withWriteTransaction((db) => {
const result = db
.query('DELETE FROM agent_conversations WHERE id = ? AND owner_id = ?')
.run(id, ownerId)
return result.changes > 0
})
}
close(): void {
this.db?.close()
this.db = null
@ -653,6 +853,21 @@ export class SqliteSceneStore implements SceneStore {
CREATE INDEX IF NOT EXISTS scene_events_scene_event_idx
ON scene_events(scene_id, event_id);
CREATE TABLE IF NOT EXISTS agent_conversations (
id TEXT PRIMARY KEY,
scene_id TEXT NOT NULL,
owner_id TEXT NOT NULL,
title TEXT NOT NULL CHECK (length(title) >= 1 AND length(title) <= 80),
messages_json TEXT NOT NULL DEFAULT '[]',
message_count INTEGER NOT NULL DEFAULT 0 CHECK (message_count >= 0),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (scene_id) REFERENCES scenes(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS agent_conversations_scene_owner_updated_idx
ON agent_conversations(scene_id, owner_id, updated_at DESC);
`)
}

View File

@ -118,6 +118,49 @@ export interface ProjectStatus {
updatedAt: string
}
export type AgentConversationRole = 'system' | 'user' | 'assistant'
export interface AgentConversationMessage {
id: string
role: AgentConversationRole
parts: unknown[]
}
export interface AgentConversationSummary {
id: string
sceneId: SceneId
ownerId: string
title: string
messageCount: number
createdAt: string
updatedAt: string
}
export interface AgentConversation extends AgentConversationSummary {
messages: AgentConversationMessage[]
}
export interface AgentConversationCreateOptions {
id?: string
sceneId: SceneId
ownerId: string
title?: string
}
export interface AgentConversationListOptions {
sceneId: SceneId
ownerId: string
limit?: number
}
export interface AgentConversationSaveOptions {
id: string
sceneId: SceneId
ownerId: string
messages: AgentConversationMessage[]
title?: string
}
export interface SceneStore {
readonly backend: 'sqlite' | 'supabase'
createProject?(opts: ProjectCreateOptions): Promise<ProjectStatus>
@ -129,6 +172,11 @@ export interface SceneStore {
rename(id: SceneId, newName: string, opts?: SceneMutateOptions): Promise<SceneMeta>
appendSceneEvent?(opts: SceneEventAppendOptions): Promise<SceneEvent>
listSceneEvents?(sceneId: SceneId, opts?: SceneEventListOptions): Promise<SceneEvent[]>
createAgentConversation?(opts: AgentConversationCreateOptions): Promise<AgentConversation>
listAgentConversations?(opts: AgentConversationListOptions): Promise<AgentConversationSummary[]>
loadAgentConversation?(id: string, ownerId: string): Promise<AgentConversation | null>
saveAgentConversation?(opts: AgentConversationSaveOptions): Promise<AgentConversation>
deleteAgentConversation?(id: string, ownerId: string): Promise<boolean>
}
export class SceneNotFoundError extends Error {