import type { ThreadMessage } from '@assistant-ui/react' import type { ModelOptionsResult } from '@hermes/shared' import type { QuickModelOption } from '@/app/chat/composer/types' import type { ClientSessionState } from '@/app/types' import { formatRefValue } from '@/components/assistant-ui/directive-text' import { type ChatMessage, type ChatMessagePart, chatMessageText, textPart } from '@/lib/chat-messages' import { normalize } from '@/lib/text' import type { ComposerAttachment } from '@/store/composer' import type { SessionInfo } from '@/types/hermes' export { BUILTIN_PERSONALITIES } from '@/lib/personalities' 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, branch: '', cwd: '', model: '', provider: '', reasoningEffort: '', serviceTier: '', fast: false, yolo: false, personality: '', busy: false, awaitingResponse: false, streamId: null, sawAssistantPayload: false, adoptedRunningTurn: false, pendingBranchGroup: null, interrupted: false, interimBoundaryPending: false, needsInput: false, turnStartedAt: null, turnLive: false, usage: null } } export function sessionTitle(session: SessionInfo): string { return session.title?.trim() || session.preview?.trim() || 'Untitled session' } /** What a session is called before it has been sent — and before its composer * has been typed into, which is the only thing that can name it earlier. */ export const NEW_SESSION_TITLE = 'New 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 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 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) } /** * Normalize a reasoning/thinking text payload from the gateway. * * Only the leading status prefix (e.g. "Hermes is thinking...") and the * obvious placeholder echoes are stripped. We deliberately do NOT trim * the delta — reasoning streams as small chunks (often individual tokens * with leading or trailing spaces), and trimming each chunk before * concatenation collapses adjacent words together. Whitespace between * tokens belongs to the data, not chrome. */ export function coerceThinkingText(value: unknown): string { const raw = coerceGatewayText(value).replace(THINKING_STATUS_PREFIX_RE, '') return EMPTY_THINKING_PLACEHOLDER_RE.test(raw) ? '' : raw } 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 } // IDs are content-derived (`kind:value`), not uuids, so upsertAttachment's // exact-match dedupe only catches a re-attach when the raw value matches // byte-for-byte. Normalize the value first so a trailing slash, a `\` path // separator, etc. don't slip past dedupe as a "different" attachment. function normalizeAttachmentValue(kind: ComposerAttachment['kind'], value: string): string { const trimmed = value.trim() if (kind === 'url') { try { // The WHATWG URL parser only collapses an EMPTY path to '/' (bare // origin) — it does not treat '/a' and '/a/' as equivalent, so strip a // trailing slash ourselves once the URL is otherwise canonicalized // (scheme/host case, default ports, etc.). return new URL(trimmed).toString().replace(/\/+$/, '') } catch { return trimmed } } if (kind === 'file' || kind === 'folder' || kind === 'image') { const posix = trimmed.replace(/\\/g, '/') // Don't collapse a bare root ('/' or 'C:/') down to an empty string. return posix.length > 1 ? posix.replace(/\/+$/, '') : posix } return trimmed } export function attachmentId(kind: ComposerAttachment['kind'], value: string): string { return `${kind}:${normalizeAttachmentValue(kind, value)}` } /** A GitHub PR review-thread (`#discussion_r`) or conversation * (`#issuecomment-`) deep link — the one paste shape that can resolve to * a structured review attachment instead of a plain `@url:` chip. */ export const PR_COMMENT_URL_RE = /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/\d+(?:\/[^#\s]*)?#(?:discussion_r|issuecomment-)\d+$/ /** The send-time expansion of a `review` attachment. `detail` holds the * resolved comment as JSON (HermesPrComment shape); a malformed payload falls * back to the attachment's URL ref so the send never throws. */ export function reviewCommentBlock(detail: string): null | string { try { const c = JSON.parse(detail) const anchor = c.path ? `${c.path}${c.line ? `:${c.startLine && c.startLine !== c.line ? `${c.startLine}-` : ''}${c.line}` : ''}` : `PR #${c.prNumber}` const hunk = c.diffHunk ? `\n--- diff hunk ---\n${String(c.diffHunk).trim()}` : '' return `\`\`\`review-comment ${anchor}\n@${c.author} on ${c.url}\n\n${String(c.body).trim()}${hunk}\n\`\`\`` } catch { return null } } export function pathLabel(path: string): string { return path.split(/[\\/]/).filter(Boolean).pop() || path } export function attachmentDisplayText(attachment: ComposerAttachment): string | null { // Session switches / draft restores can leave undefined holes in the // composer attachments array (see AttachmentList's filter(Boolean) + #49624). // Every consumer funnels through here, so guard the chokepoint too. if (!attachment) { return null } if (attachment.kind === 'terminal' && attachment.detail) { return `\`\`\`terminal\n${attachment.detail.trim()}\n\`\`\`` } // A resolved PR review comment: expand to a fenced block carrying the // anchor (file:line), author, body, and — when present — the diff hunk the // comment sits on, so "address this" needs no re-explaining what "this" is. // A malformed payload falls through to the refText (the pasted URL). if (attachment.kind === 'review' && attachment.detail) { const block = reviewCommentBlock(attachment.detail) if (block) { return block } } 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 } /** * Display ref for the optimistic (in-flight) user bubble. * * Images prefer their bounded base64 thumbnail over a file path. A raw `data:` * URL renders inline with zero network, while an `@image:` ref would * route through `/api/media` and can 403 in remote mode. Full-resolution bytes * are loaded separately for the model and on-demand lightbox, not retained in * the optimistic message. * * Everything else (files, folders, terminals, post-sync `@file:` refs) falls * through to `attachmentDisplayText`. */ export function optimisticAttachmentRef(attachment: ComposerAttachment): string | null { if (!attachment) { return null } if (attachment.kind === 'image') { if (attachment.thumbnailUrl?.startsWith('data:')) { // The pill and the in-flight bubble render the bounded thumbnail. Full // bytes are read separately for lightbox/download and model upload. return attachment.thumbnailUrl } if (attachment.previewUrl?.startsWith('data:')) { // Backward compatibility for drafts created by older shells without a // separate thumbnail. return attachment.previewUrl } // A newly attached image has no thumbnail while its queued resize is still // pending. Do not fall through to @image:: the optimistic bubble would // fetch and paint the full source, recreating the freeze if Send wins the // race. The model upload remains path/byte based and is unaffected. return null } return attachmentDisplayText(attachment) } export function personalityNamesFromConfig(config: unknown): string[] { const root = config && typeof config === 'object' ? (config as Record) : {} const agent = root.agent && typeof root.agent === 'object' ? (root.agent as Record) : {} const personalities = agent.personalities return personalities && typeof personalities === 'object' && !Array.isArray(personalities) ? Object.keys(personalities as Record) : [] } export function normalizePersonalityValue(value: string): string { const trimmed = normalize(value) return !trimmed || trimmed === 'default' || trimmed === 'none' ? '' : trimmed } export function quickModelOptions( data: ModelOptionsResult | undefined, currentProvider: string, currentModel: string ): QuickModelOption[] { const seen = new Set() 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) } // A message's display time. `timestamp` (Unix seconds) is authoritative when // present. Without it we fall back to *now* rather than digging digits out of // the id: message ids come in incompatible shapes — `assistant-`, // `--`, session-style `20260728_184420_…` — and feeding any // of them to `new Date()` (which reads ms) lands on the 1970 epoch, rendering // as an absurd "20663d ago". A timestamp-less message is a freshly created // optimistic/streaming one, so *now* is the right age anyway. export function messageCreatedAt(message: Pick, nowMs = Date.now()): Date { return typeof message.timestamp === 'number' && Number.isFinite(message.timestamp) && message.timestamp > 0 ? new Date(message.timestamp * 1000) : new Date(nowMs) } export function toRuntimeMessage(message: ChatMessage): ThreadMessage { const role = message.role === 'user' || message.role === 'assistant' || message.role === 'system' ? message.role : 'assistant' const createdAt = messageCreatedAt(message) // Reactions and the durable row id ride metadata.custom for every role — the // established channel for per-message extras (attachmentRefs below). const reactionMeta = { ...(message.rowId !== undefined ? { rowId: message.rowId } : {}), ...(message.reactions?.length ? { reactions: message.reactions } : {}) } const timelineMeta = typeof message.timestamp === 'number' && Number.isFinite(message.timestamp) && message.timestamp > 0 ? { timelineTimestamp: message.timestamp } : {} if (role === 'user') { return { id: message.id, role, content: message.parts.filter((part): part is Extract => part.type === 'text'), attachments: [], createdAt, metadata: { custom: { attachmentRefs: message.attachmentRefs ?? [], ...reactionMeta, ...timelineMeta } } } as ThreadMessage } if (role === 'system') { const text = chatMessageText(message) return { id: message.id, role, content: [textPart(text)], createdAt, metadata: { custom: { ...timelineMeta, ...(message.asyncResult ? { asyncResult: message.asyncResult } : {}) } } } as ThreadMessage } return { id: message.id, role, content: message.parts as Extract['content'], createdAt, status: message.error ? { type: 'incomplete', reason: 'error', error: message.error } : message.pending ? { type: 'running' } : { type: 'complete', reason: 'stop' }, metadata: { unstable_state: null, unstable_annotations: [], unstable_data: [], steps: [], // Carries ChatMessage.interim to AssistantMessage's footer gate. custom: { ...(message.interim ? { interim: true } : {}), ...timelineMeta, ...(message.completedAt !== undefined ? { timelineCompletedAt: message.completedAt } : {}), ...(message.durationS !== undefined ? { durationS: message.durationS } : {}), // Structured failure layer for the error card (see lib/error-surface). ...(message.errorSurface ? { errorSurface: message.errorSurface } : {}), ...reactionMeta } } } as ThreadMessage } export type ToolMergeCache = WeakMap< ChatMessage, { merged: ChatMessage; parts: ChatMessagePart[]; prev: ChatMessage; prevParts: ChatMessagePart[] } > export function createToolMergeCache(): ToolMergeCache { return new WeakMap() } // A settled assistant message with only tool calls — no prose, no reasoning. // The model routinely emits a follow-up batch of calls as its own text-less // message; on screen it looks like one continuous run, but assistant-ui can't // group tool calls across a message boundary. function isToolOnlyAssistant(message: ChatMessage): boolean { return ( message.role === 'assistant' && !message.pending && !message.error && !message.hidden && message.parts.length > 0 && message.parts.every(part => part.type === 'tool-call') ) } /** * Concatenate a tool-only follow-up message's parts onto its predecessor's, * dropping any incoming `tool-call` part whose `toolCallId` the predecessor * already carries. A repeated id here is the SAME call re-attached (structural * carry-over re-adding a cached row's tool calls, or a live-turn projection * that also exists as a committed row — #87857): folding both copies into one * message manufactures the duplicate key that crashes assistant-ui's * `useResources`, and renaming it would render the same call twice. Genuinely * new calls in the same follow-up row are preserved. */ export function concatToolPartsUnique( prevParts: readonly ChatMessagePart[], nextParts: readonly ChatMessagePart[] ): ChatMessagePart[] { const seen = new Set() for (const part of prevParts) { if (part.type === 'tool-call' && part.toolCallId) { seen.add(part.toolCallId) } } const out = [...prevParts] for (const part of nextParts) { if (part.type === 'tool-call' && part.toolCallId) { if (seen.has(part.toolCallId)) { continue } seen.add(part.toolCallId) } out.push(part) } return out } /** * Fold each settled tool-only assistant message into the preceding assistant * message so its calls join that message's tool group (and can collapse into * the auto-scrolling window). Render-only — never mutates the `$messages` store * — and settle-only: pending messages are left alone, so a live turn is never * merged/un-merged mid-stream. `cache` keys merged results by source identity, * so a stable turn yields stable merged objects (no re-render churn). */ export function coalesceToolOnlyAssistants(messages: ChatMessage[], cache: ToolMergeCache): ChatMessage[] { const out: ChatMessage[] = [] for (const message of messages) { const prev = out.at(-1) if (prev && prev.role === 'assistant' && !prev.pending && !prev.hidden && isToolOnlyAssistant(message)) { const cached = cache.get(message) const merged = cached && cached.prev === prev && cached.prevParts === prev.parts && cached.parts === message.parts ? cached.merged : { ...prev, completedAt: [prev.completedAt, message.completedAt, ...message.parts.map(part => part.completedAt)] .filter((value): value is number => value !== undefined) .reduce( (latest, value) => (latest === undefined ? value : Math.max(latest, value)), undefined ), parts: concatToolPartsUnique(prev.parts, message.parts) } cache.set(message, { merged, parts: message.parts, prev, prevParts: prev.parts }) out[out.length - 1] = merged continue } out.push(message) } return out }