feat: persist Pascal agent conversations
This commit is contained in:
parent
cf1091fc2c
commit
64dec38ff3
@ -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 {
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -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"
|
||||
>
|
||||
|
||||
@ -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('设计一个 两室一厅住宅')
|
||||
})
|
||||
})
|
||||
@ -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 }
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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);
|
||||
`)
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user