Download apps/desktop/src/lib/chat-runtime.ts from SaylorTwift/hermes-agent: direct link, hf CLI and curl.
- Browser
- Download file 18.3 kB
-
https://huggingface.co/SaylorTwift/hermes-agent/resolve/main/apps/desktop/src/lib/chat-runtime.ts
- Command line
-
hf download hf://SaylorTwift/hermes-agent/apps/desktop/src/lib/chat-runtime.ts
-
curl -L -o chat-runtime.ts https://huggingface.co/SaylorTwift/hermes-agent/resolve/main/apps/desktop/src/lib/chat-runtime.ts
18.3 kB
| 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<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) | |
| } | |
| /** | |
| * 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<id>`) or conversation | |
| * (`#issuecomment-<id>`) 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:<localpath>` 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:<path>: 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<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 = normalize(value) | |
| return !trimmed || trimmed === 'default' || trimmed === 'none' ? '' : trimmed | |
| } | |
| export function quickModelOptions( | |
| data: ModelOptionsResult | 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) | |
| } | |
| // 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-<ms>`, | |
| // `<seconds>-<i>-<role>`, 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<ChatMessage, 'timestamp'>, 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<ChatMessagePart, { type: 'text' }> => 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<ThreadMessage, { role: 'assistant' }>['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<string>() | |
| 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<number | undefined>( | |
| (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 | |
| } | |