hermes-agent/apps/desktop/src/lib/chat-runtime.ts
2026-05-01 19:28:02 -05:00

314 lines
8 KiB
TypeScript

import type { ThreadMessage } from '@assistant-ui/react'
import type { QuickModelOption } from '@/app/chat/composer/types'
import type { ClientSessionState, CommandDispatchResponse } from '@/app/types'
import { formatRefValue } from '@/components/assistant-ui/directive-text'
import { type ChatMessage, type ChatMessagePart, chatMessageText, textPart } from '@/lib/chat-messages'
import type { ComposerAttachment } from '@/store/composer'
import type { ModelOptionsResponse, SessionInfo } from '@/types/hermes'
export const INTERRUPTED_MARKER = '\n\n_[interrupted]_'
export const SLASH_COMMAND_RE = /^\/[^\s/]*(?:\s|$)/
export const BUILTIN_PERSONALITIES = [
'helpful',
'concise',
'technical',
'creative',
'teacher',
'kawaii',
'catgirl',
'pirate',
'shakespeare',
'surfer',
'noir',
'uwu',
'philosopher',
'hype'
]
const THINKING_STATUS_PREFIX_RE =
/^\s*(?:(?:[^\s.]{1,16})\s+)?(?:processing|thinking|reasoning|analyzing|pondering|contemplating|musing|cogitating|ruminating|deliberating|mulling|reflecting|computing|synthesizing|formulating|brainstorming)\.\.\.\s*/i
const EMPTY_THINKING_PLACEHOLDER_RE =
/\b(?:current rewritten thinking|next thinking to process|provide the thinking content|don't see any .*thinking)\b/i
export function createClientSessionState(
storedSessionId: string | null = null,
messages: ChatMessage[] = []
): ClientSessionState {
return {
storedSessionId,
messages,
busy: false,
awaitingResponse: false,
streamId: null,
sawAssistantPayload: false,
pendingBranchGroup: null,
interrupted: false
}
}
export function sessionTitle(session: SessionInfo): string {
return session.title?.trim() || session.preview?.trim() || 'Untitled session'
}
export function coerceGatewayText(value: unknown): string {
if (typeof value === 'string') {
return value
}
if (value === null || value === undefined) {
return ''
}
if (Array.isArray(value)) {
return value
.map(item => {
if (typeof item === 'string') {
return item
}
if (item && typeof item === 'object') {
const row = item as Record<string, unknown>
if (typeof row.text === 'string') {
return row.text
}
if (typeof row.output_text === 'string') {
return row.output_text
}
}
return ''
})
.join('')
}
if (typeof value === 'object') {
const row = value as Record<string, unknown>
if (typeof row.text === 'string') {
return row.text
}
if (typeof row.output_text === 'string') {
return row.output_text
}
try {
return JSON.stringify(value)
} catch {
return ''
}
}
return String(value)
}
export function coerceThinkingText(value: unknown): string {
const text = coerceGatewayText(value).replace(THINKING_STATUS_PREFIX_RE, '').trim()
return EMPTY_THINKING_PLACEHOLDER_RE.test(text) ? '' : text
}
export function isImageGenerationTool(name?: string): boolean {
return name === 'image_generate'
}
export function contextPath(path: string, cwd: string): string {
if (!cwd) {
return path
}
const normalizedCwd = cwd.endsWith('/') ? cwd : `${cwd}/`
return path.startsWith(normalizedCwd) ? path.slice(normalizedCwd.length) : path
}
export function attachmentId(kind: ComposerAttachment['kind'], value: string): string {
return `${kind}:${value}`
}
export function pathLabel(path: string): string {
return path.split(/[\\/]/).filter(Boolean).pop() || path
}
export function attachmentDisplayText(attachment: ComposerAttachment): string | null {
if (attachment.refText) {
return attachment.refText
}
if (attachment.kind === 'image') {
const id = attachment.detail || attachment.path || attachment.label
return id ? `@image:${formatRefValue(id)}` : null
}
return null
}
export function personalityNamesFromConfig(config: unknown): string[] {
const root = config && typeof config === 'object' ? (config as Record<string, unknown>) : {}
const agent = root.agent && typeof root.agent === 'object' ? (root.agent as Record<string, unknown>) : {}
const personalities = agent.personalities
return personalities && typeof personalities === 'object' && !Array.isArray(personalities)
? Object.keys(personalities as Record<string, unknown>)
: []
}
export function normalizePersonalityValue(value: string): string {
const trimmed = value.trim().toLowerCase()
return !trimmed || trimmed === 'default' || trimmed === 'none' ? '' : trimmed
}
export function parseSlashCommand(command: string) {
const match = command.replace(/^\/+/, '').match(/^(\S+)\s*(.*)$/)
return match ? { name: match[1], arg: match[2].trim() } : { name: '', arg: '' }
}
export function parseCommandDispatch(raw: unknown): CommandDispatchResponse | null {
if (!raw || typeof raw !== 'object') {
return null
}
const row = raw as Record<string, unknown>
const str = (value: unknown) => (typeof value === 'string' ? value : undefined)
switch (row.type) {
case 'exec':
case 'plugin':
return { type: row.type, output: str(row.output) }
case 'alias':
return typeof row.target === 'string' ? { type: 'alias', target: row.target } : null
case 'skill':
return typeof row.name === 'string' ? { type: 'skill', name: row.name, message: str(row.message) } : null
case 'send':
return typeof row.message === 'string' ? { type: 'send', message: row.message } : null
default:
return null
}
}
export function quickModelOptions(
data: ModelOptionsResponse | undefined,
currentProvider: string,
currentModel: string
): QuickModelOption[] {
const seen = new Set<string>()
const options: QuickModelOption[] = []
const providers = [...(data?.providers ?? [])].sort((a, b) => {
if (a.slug === currentProvider) {
return -1
}
if (b.slug === currentProvider) {
return 1
}
if (a.is_current) {
return -1
}
if (b.is_current) {
return 1
}
return 0
})
const add = (provider: string, providerName: string, model: string) => {
const key = `${provider}:${model}`
if (!model || seen.has(key)) {
return
}
seen.add(key)
options.push({ provider, providerName, model })
}
if (currentProvider && currentModel) {
add(currentProvider, currentProvider, currentModel)
}
for (const provider of providers) {
const models = [...(provider.models ?? [])].sort((a, b) => {
if (provider.slug === currentProvider && a === currentModel) {
return -1
}
if (provider.slug === currentProvider && b === currentModel) {
return 1
}
return 0
})
for (const model of models) {
add(provider.slug, provider.name, model)
}
if (options.length >= 8) {
break
}
}
return options.slice(0, 8)
}
export function toRuntimeMessage(message: ChatMessage): ThreadMessage {
const role =
message.role === 'user' || message.role === 'assistant' || message.role === 'system' ? message.role : 'assistant'
const createdAt = message.timestamp
? new Date(message.timestamp * 1000)
: new Date(Number(message.id.match(/\d+/)?.[0]) || Date.now())
if (role === 'user') {
return {
id: message.id,
role,
content: message.parts.filter((part): part is Extract<ChatMessagePart, { type: 'text' }> => part.type === 'text'),
attachments: [],
createdAt,
metadata: { custom: {} }
} as ThreadMessage
}
if (role === 'system') {
const text = chatMessageText(message)
return {
id: message.id,
role,
content: [textPart(text)],
createdAt,
metadata: { custom: {} }
} as ThreadMessage
}
return {
id: message.id,
role,
content: message.parts as Extract<ThreadMessage, { role: 'assistant' }>['content'],
createdAt,
status: message.pending ? { type: 'running' } : { type: 'complete', reason: 'stop' },
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}