557 lines
15 KiB
TypeScript
557 lines
15 KiB
TypeScript
import type { ThreadMessageLike } from '@assistant-ui/react'
|
|
|
|
import { mediaDisplayLabel, mediaMarkdownHref } from '@/lib/media'
|
|
import type { SessionMessage } from '@/types/hermes'
|
|
|
|
export type ChatMessagePart = Exclude<ThreadMessageLike['content'], string>[number]
|
|
|
|
export type ChatMessage = {
|
|
id: string
|
|
role: SessionMessage['role']
|
|
parts: ChatMessagePart[]
|
|
timestamp?: number
|
|
pending?: boolean
|
|
branchGroupId?: string
|
|
hidden?: boolean
|
|
}
|
|
|
|
export type GatewayEventPayload = {
|
|
text?: string
|
|
rendered?: string
|
|
status?: string
|
|
message?: string
|
|
name?: string
|
|
tool_id?: string
|
|
context?: string
|
|
preview?: string
|
|
summary?: string
|
|
error?: string | boolean
|
|
inline_diff?: string
|
|
duration_s?: number
|
|
todos?: unknown
|
|
model?: string
|
|
provider?: string
|
|
reasoning_effort?: string
|
|
service_tier?: string
|
|
fast?: boolean
|
|
running?: boolean
|
|
cwd?: string
|
|
branch?: string
|
|
personality?: string
|
|
// clarify.request
|
|
request_id?: string
|
|
question?: string
|
|
choices?: string[] | null
|
|
}
|
|
|
|
export function textPart(text: string): ChatMessagePart {
|
|
return { type: 'text', text }
|
|
}
|
|
|
|
export function reasoningPart(text: string): ChatMessagePart {
|
|
return { type: 'reasoning', text }
|
|
}
|
|
|
|
const MEDIA_LINE_RE =
|
|
/(^|\n)[\t ]*[`"']?MEDIA:\s*(?<line>`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)[`"']?[\t ]*(\n|$)/g
|
|
|
|
const MEDIA_TAG_RE = /[`"']?MEDIA:\s*(?<inline>`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)[`"']?/g
|
|
|
|
function unquoteMediaPath(value: string): string {
|
|
const trimmed = value.trim()
|
|
const quote = trimmed[0]
|
|
|
|
return quote && quote === trimmed.at(-1) && ['"', "'", '`'].includes(quote) ? trimmed.slice(1, -1) : trimmed
|
|
}
|
|
|
|
function mediaLink(value: string): string {
|
|
const path = unquoteMediaPath(value)
|
|
|
|
return `[${mediaDisplayLabel(path)}](${mediaMarkdownHref(path)})`
|
|
}
|
|
|
|
export function renderMediaTags(text: string): string {
|
|
return text
|
|
.replace(
|
|
MEDIA_LINE_RE,
|
|
(_match, lead: string, value: string, trailer: string) => `${lead}${mediaLink(value)}${trailer}`
|
|
)
|
|
.replace(MEDIA_TAG_RE, (_match, value: string) => mediaLink(value))
|
|
.replace(/[ \t]+\n/g, '\n')
|
|
.replace(/\n{3,}/g, '\n\n')
|
|
}
|
|
|
|
export function assistantTextPart(text: string): ChatMessagePart {
|
|
return textPart(renderMediaTags(text))
|
|
}
|
|
|
|
export function chatMessageText(message: ChatMessage): string {
|
|
return message.parts
|
|
.filter((part): part is Extract<ChatMessagePart, { type: 'text' }> => part.type === 'text')
|
|
.map(part => part.text)
|
|
.join('')
|
|
}
|
|
|
|
const ATTACHED_CONTEXT_MARKER_RE = /(?:^|\n)--- Attached Context ---\s*\n/
|
|
const CONTEXT_WARNINGS_MARKER_RE = /(?:^|\n)--- Context Warnings ---[\s\S]*$/
|
|
const CONTEXT_REF_RE = /@(file|folder|url|image|tool):(?:"[^"\n]+"|'[^'\n]+'|`[^`\n]+`|\S+)/g
|
|
|
|
function textFromUnknown(value: unknown, depth = 0): string {
|
|
if (typeof value === 'string') {
|
|
return value
|
|
}
|
|
|
|
if (value === null || value === undefined) {
|
|
return ''
|
|
}
|
|
|
|
if (depth > 2) {
|
|
return ''
|
|
}
|
|
|
|
if (Array.isArray(value)) {
|
|
return value.map(item => textFromUnknown(item, depth + 1)).join('')
|
|
}
|
|
|
|
if (typeof value === 'object') {
|
|
const row = value as Record<string, unknown>
|
|
const textValue = row.text ?? row.output_text ?? row.content ?? row.message
|
|
const nestedText = textFromUnknown(textValue, depth + 1)
|
|
|
|
if (nestedText) {
|
|
return nestedText
|
|
}
|
|
|
|
try {
|
|
return JSON.stringify(value)
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
return String(value)
|
|
}
|
|
|
|
function displayContentForMessage(role: SessionMessage['role'], content: unknown): string {
|
|
const textContent = textFromUnknown(content)
|
|
|
|
if (role !== 'user') {
|
|
return textContent
|
|
}
|
|
|
|
const marker = textContent.match(ATTACHED_CONTEXT_MARKER_RE)
|
|
|
|
if (!marker || marker.index === undefined) {
|
|
return textContent.replace(CONTEXT_WARNINGS_MARKER_RE, '').trim()
|
|
}
|
|
|
|
const visibleText = textContent.slice(0, marker.index).replace(CONTEXT_WARNINGS_MARKER_RE, '').trim()
|
|
const attachedContext = textContent.slice(marker.index + marker[0].length)
|
|
const refs = [...new Set(Array.from(attachedContext.matchAll(CONTEXT_REF_RE)).map(match => match[0]))]
|
|
|
|
return [refs.join('\n'), visibleText].filter(Boolean).join('\n\n') || visibleText
|
|
}
|
|
|
|
export function appendTextPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] {
|
|
const next = [...parts]
|
|
const last = next.at(-1)
|
|
|
|
if (last?.type === 'text') {
|
|
next[next.length - 1] = { ...last, text: `${last.text}${delta}` }
|
|
|
|
return next
|
|
}
|
|
|
|
next.push(textPart(delta))
|
|
|
|
return next
|
|
}
|
|
|
|
export function appendAssistantTextPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] {
|
|
const next = appendTextPart(parts, delta)
|
|
const last = next.at(-1)
|
|
|
|
if (last?.type === 'text') {
|
|
next[next.length - 1] = { ...last, text: renderMediaTags(last.text) }
|
|
}
|
|
|
|
return next
|
|
}
|
|
|
|
export function appendReasoningPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] {
|
|
const next = [...parts]
|
|
const last = next.at(-1)
|
|
|
|
if (last?.type === 'reasoning') {
|
|
next[next.length - 1] = { ...last, text: `${last.text}${delta}` }
|
|
|
|
return next
|
|
}
|
|
|
|
next.push(reasoningPart(delta))
|
|
|
|
return next
|
|
}
|
|
|
|
export function hasToolPart(message: ChatMessage): boolean {
|
|
return message.parts.some(part => part.type === 'tool-call')
|
|
}
|
|
|
|
function toolId(payload: GatewayEventPayload | undefined): string {
|
|
return payload?.tool_id || payload?.name || `tool-${Date.now()}`
|
|
}
|
|
|
|
function toolArgs(payload: GatewayEventPayload | undefined): Record<string, unknown> {
|
|
return {
|
|
...(payload?.context ? { context: payload.context } : {}),
|
|
...(payload?.preview ? { preview: payload.preview } : {})
|
|
}
|
|
}
|
|
|
|
function toolResult(payload: GatewayEventPayload | undefined): Record<string, unknown> {
|
|
return {
|
|
...(payload?.inline_diff ? { inline_diff: payload.inline_diff } : {}),
|
|
...(payload?.summary ? { summary: payload.summary } : {}),
|
|
...(payload?.message ? { message: payload.message } : {}),
|
|
...(payload?.preview ? { preview: payload.preview } : {}),
|
|
...(payload?.duration_s !== undefined ? { duration_s: payload.duration_s } : {}),
|
|
...(payload?.todos ? { todos: payload.todos } : {}),
|
|
...(payload?.error ? { error: payload.error } : {})
|
|
}
|
|
}
|
|
|
|
export function upsertToolPart(
|
|
parts: ChatMessagePart[],
|
|
payload: GatewayEventPayload | undefined,
|
|
phase: 'running' | 'complete'
|
|
): ChatMessagePart[] {
|
|
const id = toolId(payload)
|
|
const name = payload?.name || 'tool'
|
|
const next = [...parts]
|
|
|
|
const index = next.findIndex(
|
|
part => part.type === 'tool-call' && ((part.toolCallId && part.toolCallId === id) || part.toolName === name)
|
|
)
|
|
|
|
const base = {
|
|
type: 'tool-call' as const,
|
|
toolCallId: id,
|
|
toolName: name,
|
|
args: toolArgs(payload) as never,
|
|
argsText: JSON.stringify(toolArgs(payload)),
|
|
...(phase === 'complete'
|
|
? {
|
|
result: toolResult(payload),
|
|
isError: Boolean(payload?.error)
|
|
}
|
|
: {})
|
|
} satisfies ChatMessagePart
|
|
|
|
if (index === -1) {
|
|
return [...next, base]
|
|
}
|
|
|
|
next[index] = { ...next[index], ...base }
|
|
|
|
return next
|
|
}
|
|
|
|
function recordFromUnknown(value: unknown): Record<string, unknown> | null {
|
|
return value && typeof value === 'object' ? (value as Record<string, unknown>) : null
|
|
}
|
|
|
|
function parseMaybeJsonObject(value: unknown): Record<string, unknown> {
|
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
return value as Record<string, unknown>
|
|
}
|
|
|
|
if (typeof value !== 'string' || !value.trim()) {
|
|
return {}
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(value)
|
|
|
|
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {}
|
|
} catch {
|
|
return {}
|
|
}
|
|
}
|
|
|
|
function firstNonEmptyObject(...values: unknown[]): Record<string, unknown> {
|
|
for (const value of values) {
|
|
const parsed = parseMaybeJsonObject(value)
|
|
|
|
if (Object.keys(parsed).length > 0) {
|
|
return parsed
|
|
}
|
|
}
|
|
|
|
return {}
|
|
}
|
|
|
|
function parseStoredToolResult(content: unknown): unknown {
|
|
if (content && typeof content === 'object') {
|
|
return content
|
|
}
|
|
|
|
const textContent = textFromUnknown(content)
|
|
|
|
if (!textContent.trim()) {
|
|
return ''
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(textContent)
|
|
} catch {
|
|
return textContent
|
|
}
|
|
}
|
|
|
|
function toolPartFromStoredCall(call: unknown, fallbackIndex: number): ChatMessagePart {
|
|
const row = recordFromUnknown(call) ?? {}
|
|
const fn = recordFromUnknown(row.function)
|
|
const id = String(row.id || row.tool_call_id || `stored-tool-${fallbackIndex}`)
|
|
|
|
const toolName = String(
|
|
row.name || row.tool_name || fn?.name || (recordFromUnknown(row.input)?.name as string | undefined) || 'tool'
|
|
)
|
|
|
|
const args = firstNonEmptyObject(fn?.arguments, row.arguments, row.args, row.input)
|
|
|
|
return {
|
|
type: 'tool-call',
|
|
toolCallId: id,
|
|
toolName,
|
|
args: args as never,
|
|
argsText: Object.keys(args).length ? JSON.stringify(args) : ''
|
|
}
|
|
}
|
|
|
|
function applyStoredToolResult(messages: ChatMessage[], toolMessage: SessionMessage): boolean {
|
|
const toolCallId = toolMessage.tool_call_id || undefined
|
|
const toolName = toolMessage.tool_name || toolMessage.name || 'tool'
|
|
const content = toolMessage.content || toolMessage.text || toolMessage.context || toolMessage.name
|
|
|
|
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
const message = messages[i]
|
|
|
|
if (message.role !== 'assistant') {
|
|
continue
|
|
}
|
|
|
|
const partIndex = message.parts.findIndex(
|
|
part =>
|
|
part.type === 'tool-call' &&
|
|
((toolCallId && part.toolCallId === toolCallId) || (!toolCallId && part.toolName === toolName))
|
|
)
|
|
|
|
if (partIndex < 0) {
|
|
continue
|
|
}
|
|
|
|
const parts = [...message.parts]
|
|
const existing = parts[partIndex]
|
|
parts[partIndex] = {
|
|
...existing,
|
|
result: parseStoredToolResult(content),
|
|
isError: false
|
|
} as ChatMessagePart
|
|
messages[i] = { ...message, parts }
|
|
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
function applyStoredToolResultToParts(parts: ChatMessagePart[], toolMessage: SessionMessage): ChatMessagePart[] | null {
|
|
const toolCallId = toolMessage.tool_call_id || undefined
|
|
const toolName = toolMessage.tool_name || toolMessage.name || 'tool'
|
|
const content = toolMessage.content || toolMessage.text || toolMessage.context || toolMessage.name
|
|
|
|
const partIndex = parts.findIndex(
|
|
part =>
|
|
part.type === 'tool-call' &&
|
|
((toolCallId && part.toolCallId === toolCallId) || (!toolCallId && part.toolName === toolName))
|
|
)
|
|
|
|
if (partIndex < 0) {
|
|
return null
|
|
}
|
|
|
|
const next = [...parts]
|
|
const existing = next[partIndex]
|
|
next[partIndex] = {
|
|
...existing,
|
|
result: parseStoredToolResult(content),
|
|
isError: false
|
|
} as ChatMessagePart
|
|
|
|
return next
|
|
}
|
|
|
|
function storedToolMessagePart(toolMessage: SessionMessage, fallbackIndex: number): ChatMessagePart {
|
|
const name = toolMessage.tool_name || toolMessage.name || 'tool'
|
|
const context = textFromUnknown(toolMessage.context || toolMessage.text || toolMessage.content || '')
|
|
const args = context ? { context } : {}
|
|
|
|
return {
|
|
type: 'tool-call',
|
|
toolCallId: toolMessage.tool_call_id || `stored-tool-message-${fallbackIndex}`,
|
|
toolName: name,
|
|
args: args as never,
|
|
argsText: Object.keys(args).length ? JSON.stringify(args) : '',
|
|
result: context ? { context } : {},
|
|
isError: false
|
|
}
|
|
}
|
|
|
|
function withUniqueToolCallIds(messages: ChatMessage[]): ChatMessage[] {
|
|
const seen = new Set<string>()
|
|
|
|
return messages.map(message => {
|
|
let changed = false
|
|
|
|
const parts = message.parts.map((part, index) => {
|
|
if (part.type !== 'tool-call') {
|
|
return part
|
|
}
|
|
|
|
const id = part.toolCallId || `${message.id}-tool-${index}`
|
|
|
|
if (!seen.has(id)) {
|
|
seen.add(id)
|
|
|
|
if (part.toolCallId) {
|
|
return part
|
|
}
|
|
|
|
changed = true
|
|
|
|
return { ...part, toolCallId: id } as ChatMessagePart
|
|
}
|
|
|
|
changed = true
|
|
const uniqueId = `${id}-${message.id}-${index}`
|
|
seen.add(uniqueId)
|
|
|
|
return { ...part, toolCallId: uniqueId } as ChatMessagePart
|
|
})
|
|
|
|
return changed ? { ...message, parts } : message
|
|
})
|
|
}
|
|
|
|
export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
|
|
const result: ChatMessage[] = []
|
|
let pendingToolParts: ChatMessagePart[] = []
|
|
let pendingToolTimestamp: number | undefined
|
|
|
|
const flushPendingTools = (index: number) => {
|
|
if (!pendingToolParts.length) {
|
|
return
|
|
}
|
|
|
|
result.push({
|
|
id: `${pendingToolTimestamp || Date.now()}-${index}-tools`,
|
|
role: 'assistant',
|
|
parts: pendingToolParts,
|
|
timestamp: pendingToolTimestamp
|
|
})
|
|
pendingToolParts = []
|
|
pendingToolTimestamp = undefined
|
|
}
|
|
|
|
messages.forEach((message, index) => {
|
|
if (message.role === 'tool') {
|
|
const updatedPendingToolParts = applyStoredToolResultToParts(pendingToolParts, message)
|
|
|
|
if (updatedPendingToolParts) {
|
|
pendingToolParts = updatedPendingToolParts
|
|
|
|
return
|
|
}
|
|
|
|
if (applyStoredToolResult(result, message)) {
|
|
return
|
|
}
|
|
|
|
pendingToolParts = [...pendingToolParts, storedToolMessagePart(message, index)]
|
|
pendingToolTimestamp ??= message.timestamp
|
|
|
|
return
|
|
}
|
|
|
|
const content = message.content || message.text || message.context || message.name
|
|
const displayContent = displayContentForMessage(message.role, content)
|
|
const parts: ChatMessagePart[] = []
|
|
|
|
const reasoning =
|
|
message.reasoning ||
|
|
message.reasoning_content ||
|
|
(typeof message.reasoning_details === 'string' ? message.reasoning_details : '')
|
|
|
|
if (reasoning && message.role === 'assistant') {
|
|
parts.push(reasoningPart(reasoning))
|
|
}
|
|
|
|
if (displayContent) {
|
|
parts.push(message.role === 'assistant' ? assistantTextPart(displayContent) : textPart(displayContent))
|
|
}
|
|
|
|
if (message.role === 'assistant' && Array.isArray(message.tool_calls)) {
|
|
parts.push(...message.tool_calls.map((call, callIndex) => toolPartFromStoredCall(call, callIndex)))
|
|
}
|
|
|
|
if (!parts.length) {
|
|
return
|
|
}
|
|
|
|
const isToolOnlyAssistant =
|
|
message.role === 'assistant' && parts.length > 0 && parts.every(part => part.type === 'tool-call')
|
|
|
|
if (isToolOnlyAssistant) {
|
|
pendingToolParts = [...pendingToolParts, ...parts]
|
|
pendingToolTimestamp ??= message.timestamp
|
|
|
|
return
|
|
}
|
|
|
|
if (message.role === 'assistant' && pendingToolParts.length) {
|
|
const last = result.at(-1)
|
|
|
|
if (last?.role === 'assistant') {
|
|
last.parts = [...last.parts, ...pendingToolParts, ...parts]
|
|
last.timestamp = message.timestamp ?? last.timestamp
|
|
pendingToolParts = []
|
|
pendingToolTimestamp = undefined
|
|
|
|
return
|
|
}
|
|
|
|
parts.unshift(...pendingToolParts)
|
|
pendingToolParts = []
|
|
pendingToolTimestamp = undefined
|
|
} else if (message.role !== 'assistant') {
|
|
flushPendingTools(index)
|
|
}
|
|
|
|
result.push({
|
|
id: `${message.timestamp || Date.now()}-${index}-${message.role}`,
|
|
role: message.role,
|
|
parts,
|
|
timestamp: message.timestamp
|
|
})
|
|
})
|
|
flushPendingTools(messages.length)
|
|
|
|
return withUniqueToolCallIds(
|
|
result.filter(m => chatMessageText(m).trim() || m.parts.some(part => part.type !== 'text'))
|
|
)
|
|
}
|
|
|
|
export function branchGroupForUser(userMessage: ChatMessage): string {
|
|
return `branch:${userMessage.id}`
|
|
}
|