ai-chatbot/services/pascal-editor/apps/editor/lib/pascal-agent-conversations.ts

118 lines
3.8 KiB
TypeScript

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 }