mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-01 12:02:05 +00:00
refactor(desktop): extract use-prompt-actions standalone helpers into utils
The usePromptActions hook is the textbook "god hook" AGENTS.md warns against. As a first, safe slice, pull its module-level standalone helpers (no closure over hook state) into a focused, testable use-prompt-actions-utils.ts sibling: - error classifiers: isSessionNotFoundError, isSessionBusyError, isProviderSetupError, inlineErrorMessage - session-busy retry: withSessionBusyRetry (+ its constants) - attachment IO: base64FromDataUrl, imageFilenameFromPath, readImageForRemoteAttach, readFileDataUrlForAttach, friendlyRemoteAttachError - misc: delay, isSessionIdCandidate, blobToDataUrl, renderCommandsCatalog, slashStatusText, appendText, visibleUserOrdinal, visibleUserIndexAtOrdinal, the _submitInFlight guard set, and the GatewayRequest type Pure restructuring, no behavior change; the usePromptActions and uploadComposerAttachment exports (and their import paths) are unchanged. Adds unit tests for the pure helpers. use-prompt-actions.ts: 1,956 -> 1,772.
This commit is contained in:
parent
a81c5922a2
commit
bde2dc1051
3 changed files with 360 additions and 206 deletions
|
|
@ -0,0 +1,127 @@
|
|||
import type { AppendMessage } from '@assistant-ui/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
|
||||
import {
|
||||
appendText,
|
||||
base64FromDataUrl,
|
||||
friendlyRemoteAttachError,
|
||||
imageFilenameFromPath,
|
||||
inlineErrorMessage,
|
||||
isSessionBusyError,
|
||||
isSessionIdCandidate,
|
||||
isSessionNotFoundError,
|
||||
slashStatusText,
|
||||
visibleUserIndexAtOrdinal,
|
||||
visibleUserOrdinal
|
||||
} from './use-prompt-actions-utils'
|
||||
|
||||
describe('isSessionIdCandidate', () => {
|
||||
it('accepts the timestamped and hex id forms', () => {
|
||||
expect(isSessionIdCandidate('20260101_120000_abc123')).toBe(true)
|
||||
expect(isSessionIdCandidate('a'.repeat(32))).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects arbitrary text', () => {
|
||||
expect(isSessionIdCandidate('hello world')).toBe(false)
|
||||
expect(isSessionIdCandidate('abc')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('inlineErrorMessage', () => {
|
||||
it('unwraps an electron remote-method error', () => {
|
||||
expect(inlineErrorMessage(new Error("Error invoking remote method 'x': Error: boom"), 'fallback')).toBe('boom')
|
||||
})
|
||||
|
||||
it('strips a leading Error: prefix', () => {
|
||||
expect(inlineErrorMessage(new Error('Error: nope'), 'fallback')).toBe('nope')
|
||||
})
|
||||
|
||||
it('falls back for non-error, non-string input', () => {
|
||||
expect(inlineErrorMessage(undefined, 'fallback')).toBe('fallback')
|
||||
})
|
||||
})
|
||||
|
||||
describe('session error classifiers', () => {
|
||||
it('detects not-found and busy errors', () => {
|
||||
expect(isSessionNotFoundError(new Error('Session not found'))).toBe(true)
|
||||
expect(isSessionBusyError(new Error('session busy'))).toBe(true)
|
||||
expect(isSessionNotFoundError(new Error('other'))).toBe(false)
|
||||
expect(isSessionBusyError(new Error('other'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('base64FromDataUrl', () => {
|
||||
it('returns the part after the comma', () => {
|
||||
expect(base64FromDataUrl('data:image/png;base64,AAAA')).toBe('AAAA')
|
||||
})
|
||||
|
||||
it('returns empty when there is no comma', () => {
|
||||
expect(base64FromDataUrl('nope')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('imageFilenameFromPath', () => {
|
||||
it('takes the last path segment', () => {
|
||||
expect(imageFilenameFromPath('/a/b/c.png')).toBe('c.png')
|
||||
expect(imageFilenameFromPath('C:\\a\\b\\d.jpg')).toBe('d.jpg')
|
||||
})
|
||||
|
||||
it('defaults when the path is empty', () => {
|
||||
expect(imageFilenameFromPath('')).toBe('image.png')
|
||||
})
|
||||
})
|
||||
|
||||
describe('friendlyRemoteAttachError', () => {
|
||||
it('rewrites a too-large error with the parsed cap', () => {
|
||||
const err = friendlyRemoteAttachError(new Error('file is too large (20 bytes; limit 16777216 bytes)'), 'pic.png')
|
||||
expect(err.message).toBe('pic.png is too large to upload to the remote gateway (max 16 MB).')
|
||||
})
|
||||
|
||||
it('passes non-cap errors through', () => {
|
||||
const original = new Error('something else')
|
||||
expect(friendlyRemoteAttachError(original, 'pic.png')).toBe(original)
|
||||
})
|
||||
})
|
||||
|
||||
describe('slashStatusText', () => {
|
||||
it('joins command and trimmed output', () => {
|
||||
expect(slashStatusText('/model', ' gpt ')).toBe('slash:/model\ngpt')
|
||||
})
|
||||
|
||||
it('omits empty output', () => {
|
||||
expect(slashStatusText('/clear', ' ')).toBe('slash:/clear')
|
||||
})
|
||||
})
|
||||
|
||||
describe('appendText', () => {
|
||||
it('concatenates text parts and trims', () => {
|
||||
const message = {
|
||||
content: [
|
||||
{ type: 'text', text: ' a' },
|
||||
{ type: 'text', text: 'b ' }
|
||||
]
|
||||
} as unknown as AppendMessage
|
||||
|
||||
expect(appendText(message)).toBe('ab')
|
||||
})
|
||||
})
|
||||
|
||||
describe('visible user ordinals', () => {
|
||||
const messages = [
|
||||
{ role: 'user', hidden: false },
|
||||
{ role: 'assistant' },
|
||||
{ role: 'user', hidden: true },
|
||||
{ role: 'user', hidden: false }
|
||||
] as ChatMessage[]
|
||||
|
||||
it('counts visible user messages before an index', () => {
|
||||
expect(visibleUserOrdinal(messages, messages.length)).toBe(2)
|
||||
})
|
||||
|
||||
it('maps an ordinal back to a message index, skipping hidden', () => {
|
||||
expect(visibleUserIndexAtOrdinal(messages, 1)).toBe(3)
|
||||
expect(visibleUserIndexAtOrdinal(messages, 5)).toBe(-1)
|
||||
})
|
||||
})
|
||||
211
apps/desktop/src/app/session/hooks/use-prompt-actions-utils.ts
Normal file
211
apps/desktop/src/app/session/hooks/use-prompt-actions-utils.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
import type { AppendMessage } from '@assistant-ui/react'
|
||||
|
||||
import { translateNow, type Translations } from '@/i18n'
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import { type CommandsCatalogLike, filterDesktopCommandsCatalog } from '@/lib/desktop-slash-commands'
|
||||
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
|
||||
|
||||
export type GatewayRequest = <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
|
||||
export function delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
export function isSessionIdCandidate(value: string): boolean {
|
||||
const trimmed = value.trim()
|
||||
|
||||
return /^\d{8}_\d{6}_[A-Fa-f0-9]{6}$/.test(trimmed) || /^[A-Fa-f0-9]{32}$/.test(trimmed)
|
||||
}
|
||||
|
||||
export function blobToDataUrl(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
|
||||
reader.addEventListener('load', () => {
|
||||
if (typeof reader.result === 'string') {
|
||||
resolve(reader.result)
|
||||
} else {
|
||||
reject(new Error(translateNow('desktop.audioReadFailed')))
|
||||
}
|
||||
})
|
||||
reader.addEventListener('error', () => reject(reader.error || new Error(translateNow('desktop.audioReadFailed'))))
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
}
|
||||
|
||||
export function isProviderSetupError(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
|
||||
return isProviderSetupErrorMessage(message)
|
||||
}
|
||||
|
||||
export function inlineErrorMessage(error: unknown, fallback: string): string {
|
||||
const raw = error instanceof Error ? error.message : typeof error === 'string' ? error : fallback
|
||||
|
||||
return (raw.match(/Error invoking remote method '[^']+': Error: (.+)$/)?.[1] ?? raw).replace(/^Error:\s*/, '').trim()
|
||||
}
|
||||
|
||||
export function isSessionNotFoundError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
|
||||
return /session not found/i.test(message)
|
||||
}
|
||||
|
||||
// The gateway refuses prompt.submit while a turn is running (4009 "session
|
||||
// busy"). It's a transient concurrency guard, never a user-facing error: a
|
||||
// submit racing the settle edge (or a rewind interrupting mid-turn) just waits
|
||||
// a beat for the turn to wind down, then lands. Bounded so a genuinely stuck
|
||||
// turn still surfaces eventually.
|
||||
export const SESSION_BUSY_RETRY_TIMEOUT_MS = 6_000
|
||||
export const SESSION_BUSY_RETRY_INTERVAL_MS = 150
|
||||
|
||||
export function isSessionBusyError(error: unknown): boolean {
|
||||
return /session busy/i.test(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
|
||||
|
||||
// Retry a gateway call across transient "session busy" so it never reaches the
|
||||
// user — the turn settles within the deadline and the call lands.
|
||||
export async function withSessionBusyRetry<T>(call: () => Promise<T>): Promise<T> {
|
||||
const deadline = Date.now() + SESSION_BUSY_RETRY_TIMEOUT_MS
|
||||
|
||||
for (;;) {
|
||||
try {
|
||||
return await call()
|
||||
} catch (err) {
|
||||
if (isSessionBusyError(err) && Date.now() < deadline) {
|
||||
await sleep(SESSION_BUSY_RETRY_INTERVAL_MS)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hard guard: at most one prompt.submit in flight per session. Every submit
|
||||
// path — user Enter, queue drain, busy-retry, slash fallthrough — funnels
|
||||
// through submitPromptText. Without this, a stalled turn (e.g. a context-bloated
|
||||
// session whose first call hangs) let the SAME prompt launch several real turns
|
||||
// at once (the "message stacked 5×" bug). Keyed by stored/active session id.
|
||||
export const _submitInFlight = new Set<string>()
|
||||
|
||||
export function base64FromDataUrl(dataUrl: string): string {
|
||||
const comma = dataUrl.indexOf(',')
|
||||
|
||||
return comma >= 0 ? dataUrl.slice(comma + 1) : ''
|
||||
}
|
||||
|
||||
export function imageFilenameFromPath(filePath: string): string {
|
||||
return filePath.split(/[\\/]/).filter(Boolean).pop() || 'image.png'
|
||||
}
|
||||
|
||||
// Remote gateway: the local composer-image file lives on THIS machine's disk,
|
||||
// not the gateway's, so read the bytes here and upload them via
|
||||
// image.attach_bytes. Returns null when the file can't be read.
|
||||
export async function readImageForRemoteAttach(
|
||||
filePath: string
|
||||
): Promise<{ contentBase64: string; filename: string } | null> {
|
||||
const dataUrl = await window.hermesDesktop?.readFileDataUrl(filePath)
|
||||
const contentBase64 = dataUrl ? base64FromDataUrl(dataUrl) : ''
|
||||
|
||||
return contentBase64 ? { contentBase64, filename: imageFilenameFromPath(filePath) } : null
|
||||
}
|
||||
|
||||
// Read a non-image file as a data URL for upload via file.attach. Returns null
|
||||
// when the desktop bridge can't read the file (e.g. it was moved/deleted).
|
||||
export async function readFileDataUrlForAttach(filePath: string): Promise<string | null> {
|
||||
const reader = window.hermesDesktop?.readFileDataUrl
|
||||
|
||||
if (!reader) {
|
||||
return null
|
||||
}
|
||||
|
||||
const dataUrl = await reader(filePath)
|
||||
|
||||
return dataUrl || null
|
||||
}
|
||||
|
||||
// The readFileDataUrl IPC base64-loads the whole file into memory and is
|
||||
// hard-capped (DATA_URL_READ_MAX_BYTES, 16 MB) in electron/hardening.cjs, which
|
||||
// rejects with a raw "file is too large (N bytes; limit M bytes)" string. In
|
||||
// remote mode every attachment's bytes go through that read, so a big file
|
||||
// surfaces that internal message verbatim in the failure toast. Translate it
|
||||
// into a friendly "too large to upload to the remote gateway" line, parsing the
|
||||
// limit out of the message so it tracks the real cap. Non-cap errors pass
|
||||
// through unchanged.
|
||||
export function friendlyRemoteAttachError(err: unknown, label: string): Error {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
|
||||
if (!/too large/i.test(message)) {
|
||||
return err instanceof Error ? err : new Error(message)
|
||||
}
|
||||
|
||||
const limitBytes = Number(message.match(/limit (\d+) bytes/)?.[1])
|
||||
const cap = Number.isFinite(limitBytes) && limitBytes > 0 ? ` (max ${Math.floor(limitBytes / (1024 * 1024))} MB)` : ''
|
||||
|
||||
return new Error(`${label} is too large to upload to the remote gateway${cap}.`)
|
||||
}
|
||||
|
||||
export function renderCommandsCatalog(catalog: CommandsCatalogLike, copy: Translations['desktop']): string {
|
||||
const desktopCatalog = filterDesktopCommandsCatalog(catalog)
|
||||
|
||||
const sections = desktopCatalog.categories?.length
|
||||
? desktopCatalog.categories
|
||||
: [{ name: copy.desktopCommands, pairs: desktopCatalog.pairs ?? [] }]
|
||||
|
||||
const body = sections
|
||||
.filter(section => section.pairs.length > 0)
|
||||
.map(section => {
|
||||
const rows = section.pairs.map(([cmd, desc]) => `${cmd.padEnd(18)} ${desc}`)
|
||||
|
||||
return [`${section.name}:`, ...rows].join('\n')
|
||||
})
|
||||
.join('\n\n')
|
||||
|
||||
const tail = [
|
||||
desktopCatalog.skill_count ? copy.skillCommandsAvailable(desktopCatalog.skill_count) : '',
|
||||
desktopCatalog.warning ? copy.warningLine(desktopCatalog.warning) : ''
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
|
||||
return [body || 'No desktop commands available.', tail].filter(Boolean).join('\n\n')
|
||||
}
|
||||
|
||||
export function slashStatusText(command: string, output: string): string {
|
||||
return [`slash:${command}`, output.trim()].filter(Boolean).join('\n')
|
||||
}
|
||||
|
||||
export function appendText(message: AppendMessage): string {
|
||||
return message.content
|
||||
.map(part => ('text' in part ? part.text : ''))
|
||||
.join('')
|
||||
.trim()
|
||||
}
|
||||
|
||||
export function visibleUserOrdinal(messages: readonly ChatMessage[], end: number): number {
|
||||
return messages.slice(0, end).filter(m => m.role === 'user' && !m.hidden).length
|
||||
}
|
||||
|
||||
export function visibleUserIndexAtOrdinal(messages: readonly ChatMessage[], targetOrdinal: number): number {
|
||||
let ordinal = 0
|
||||
|
||||
for (let index = 0; index < messages.length; index += 1) {
|
||||
const message = messages[index]
|
||||
|
||||
if (message.role !== 'user' || message.hidden) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (ordinal === targetOrdinal) {
|
||||
return index
|
||||
}
|
||||
|
||||
ordinal += 1
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ import { useStore } from '@nanostores/react'
|
|||
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
import { getProfiles, transcribeAudio } from '@/hermes'
|
||||
import { translateNow, type Translations, useI18n } from '@/i18n'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { stripAnsi } from '@/lib/ansi'
|
||||
import { branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
|
||||
import {
|
||||
|
|
@ -19,13 +19,11 @@ import {
|
|||
type DesktopActionId,
|
||||
type DesktopPickerId,
|
||||
desktopSlashUnavailableMessage,
|
||||
filterDesktopCommandsCatalog,
|
||||
isDesktopSlashCommand,
|
||||
resolveDesktopCommand
|
||||
} from '@/lib/desktop-slash-commands'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { setMutableRef } from '@/lib/mutable-ref'
|
||||
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
|
||||
import { setSessionYolo } from '@/lib/yolo-session'
|
||||
import { clearClarifyRequest } from '@/store/clarify'
|
||||
import { openCommandPalettePage } from '@/store/command-palette'
|
||||
|
|
@ -76,153 +74,32 @@ import type {
|
|||
SlashExecResponse
|
||||
} from '../../types'
|
||||
|
||||
import {
|
||||
_submitInFlight,
|
||||
appendText,
|
||||
blobToDataUrl,
|
||||
delay,
|
||||
friendlyRemoteAttachError,
|
||||
type GatewayRequest,
|
||||
inlineErrorMessage,
|
||||
isProviderSetupError,
|
||||
isSessionBusyError,
|
||||
isSessionIdCandidate,
|
||||
isSessionNotFoundError,
|
||||
readFileDataUrlForAttach,
|
||||
readImageForRemoteAttach,
|
||||
renderCommandsCatalog,
|
||||
slashStatusText,
|
||||
visibleUserIndexAtOrdinal,
|
||||
visibleUserOrdinal,
|
||||
withSessionBusyRetry
|
||||
} from './use-prompt-actions-utils'
|
||||
|
||||
interface HandoffResult {
|
||||
ok: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function isSessionIdCandidate(value: string): boolean {
|
||||
const trimmed = value.trim()
|
||||
|
||||
return /^\d{8}_\d{6}_[A-Fa-f0-9]{6}$/.test(trimmed) || /^[A-Fa-f0-9]{32}$/.test(trimmed)
|
||||
}
|
||||
|
||||
function blobToDataUrl(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
|
||||
reader.addEventListener('load', () => {
|
||||
if (typeof reader.result === 'string') {
|
||||
resolve(reader.result)
|
||||
} else {
|
||||
reject(new Error(translateNow('desktop.audioReadFailed')))
|
||||
}
|
||||
})
|
||||
reader.addEventListener('error', () => reject(reader.error || new Error(translateNow('desktop.audioReadFailed'))))
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
}
|
||||
|
||||
function isProviderSetupError(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
|
||||
return isProviderSetupErrorMessage(message)
|
||||
}
|
||||
|
||||
function inlineErrorMessage(error: unknown, fallback: string): string {
|
||||
const raw = error instanceof Error ? error.message : typeof error === 'string' ? error : fallback
|
||||
|
||||
return (raw.match(/Error invoking remote method '[^']+': Error: (.+)$/)?.[1] ?? raw).replace(/^Error:\s*/, '').trim()
|
||||
}
|
||||
|
||||
function isSessionNotFoundError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
|
||||
return /session not found/i.test(message)
|
||||
}
|
||||
|
||||
// The gateway refuses prompt.submit while a turn is running (4009 "session
|
||||
// busy"). It's a transient concurrency guard, never a user-facing error: a
|
||||
// submit racing the settle edge (or a rewind interrupting mid-turn) just waits
|
||||
// a beat for the turn to wind down, then lands. Bounded so a genuinely stuck
|
||||
// turn still surfaces eventually.
|
||||
const SESSION_BUSY_RETRY_TIMEOUT_MS = 6_000
|
||||
const SESSION_BUSY_RETRY_INTERVAL_MS = 150
|
||||
|
||||
function isSessionBusyError(error: unknown): boolean {
|
||||
return /session busy/i.test(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
|
||||
|
||||
// Retry a gateway call across transient "session busy" so it never reaches the
|
||||
// user — the turn settles within the deadline and the call lands.
|
||||
async function withSessionBusyRetry<T>(call: () => Promise<T>): Promise<T> {
|
||||
const deadline = Date.now() + SESSION_BUSY_RETRY_TIMEOUT_MS
|
||||
|
||||
for (;;) {
|
||||
try {
|
||||
return await call()
|
||||
} catch (err) {
|
||||
if (isSessionBusyError(err) && Date.now() < deadline) {
|
||||
await sleep(SESSION_BUSY_RETRY_INTERVAL_MS)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hard guard: at most one prompt.submit in flight per session. Every submit
|
||||
// path — user Enter, queue drain, busy-retry, slash fallthrough — funnels
|
||||
// through submitPromptText. Without this, a stalled turn (e.g. a context-bloated
|
||||
// session whose first call hangs) let the SAME prompt launch several real turns
|
||||
// at once (the "message stacked 5×" bug). Keyed by stored/active session id.
|
||||
const _submitInFlight = new Set<string>()
|
||||
|
||||
function base64FromDataUrl(dataUrl: string): string {
|
||||
const comma = dataUrl.indexOf(',')
|
||||
|
||||
return comma >= 0 ? dataUrl.slice(comma + 1) : ''
|
||||
}
|
||||
|
||||
function imageFilenameFromPath(filePath: string): string {
|
||||
return filePath.split(/[\\/]/).filter(Boolean).pop() || 'image.png'
|
||||
}
|
||||
|
||||
// Remote gateway: the local composer-image file lives on THIS machine's disk,
|
||||
// not the gateway's, so read the bytes here and upload them via
|
||||
// image.attach_bytes. Returns null when the file can't be read.
|
||||
async function readImageForRemoteAttach(filePath: string): Promise<{ contentBase64: string; filename: string } | null> {
|
||||
const dataUrl = await window.hermesDesktop?.readFileDataUrl(filePath)
|
||||
const contentBase64 = dataUrl ? base64FromDataUrl(dataUrl) : ''
|
||||
|
||||
return contentBase64 ? { contentBase64, filename: imageFilenameFromPath(filePath) } : null
|
||||
}
|
||||
|
||||
// Read a non-image file as a data URL for upload via file.attach. Returns null
|
||||
// when the desktop bridge can't read the file (e.g. it was moved/deleted).
|
||||
async function readFileDataUrlForAttach(filePath: string): Promise<string | null> {
|
||||
const reader = window.hermesDesktop?.readFileDataUrl
|
||||
|
||||
if (!reader) {
|
||||
return null
|
||||
}
|
||||
|
||||
const dataUrl = await reader(filePath)
|
||||
|
||||
return dataUrl || null
|
||||
}
|
||||
|
||||
// The readFileDataUrl IPC base64-loads the whole file into memory and is
|
||||
// hard-capped (DATA_URL_READ_MAX_BYTES, 16 MB) in electron/hardening.cjs, which
|
||||
// rejects with a raw "file is too large (N bytes; limit M bytes)" string. In
|
||||
// remote mode every attachment's bytes go through that read, so a big file
|
||||
// surfaces that internal message verbatim in the failure toast. Translate it
|
||||
// into a friendly "too large to upload to the remote gateway" line, parsing the
|
||||
// limit out of the message so it tracks the real cap. Non-cap errors pass
|
||||
// through unchanged.
|
||||
function friendlyRemoteAttachError(err: unknown, label: string): Error {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
|
||||
if (!/too large/i.test(message)) {
|
||||
return err instanceof Error ? err : new Error(message)
|
||||
}
|
||||
|
||||
const limitBytes = Number(message.match(/limit (\d+) bytes/)?.[1])
|
||||
const cap = Number.isFinite(limitBytes) && limitBytes > 0 ? ` (max ${Math.floor(limitBytes / (1024 * 1024))} MB)` : ''
|
||||
|
||||
return new Error(`${label} is too large to upload to the remote gateway${cap}.`)
|
||||
}
|
||||
|
||||
type GatewayRequest = <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
|
||||
/**
|
||||
* Stage one file/image attachment into the session workspace and return the
|
||||
* attachment rewritten with the gateway-side ref. Images upload their bytes in
|
||||
|
|
@ -350,67 +227,6 @@ interface SlashActionCtx {
|
|||
sessionHint?: string
|
||||
}
|
||||
|
||||
function renderCommandsCatalog(catalog: CommandsCatalogLike, copy: Translations['desktop']): string {
|
||||
const desktopCatalog = filterDesktopCommandsCatalog(catalog)
|
||||
|
||||
const sections = desktopCatalog.categories?.length
|
||||
? desktopCatalog.categories
|
||||
: [{ name: copy.desktopCommands, pairs: desktopCatalog.pairs ?? [] }]
|
||||
|
||||
const body = sections
|
||||
.filter(section => section.pairs.length > 0)
|
||||
.map(section => {
|
||||
const rows = section.pairs.map(([cmd, desc]) => `${cmd.padEnd(18)} ${desc}`)
|
||||
|
||||
return [`${section.name}:`, ...rows].join('\n')
|
||||
})
|
||||
.join('\n\n')
|
||||
|
||||
const tail = [
|
||||
desktopCatalog.skill_count ? copy.skillCommandsAvailable(desktopCatalog.skill_count) : '',
|
||||
desktopCatalog.warning ? copy.warningLine(desktopCatalog.warning) : ''
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
|
||||
return [body || 'No desktop commands available.', tail].filter(Boolean).join('\n\n')
|
||||
}
|
||||
|
||||
function slashStatusText(command: string, output: string): string {
|
||||
return [`slash:${command}`, output.trim()].filter(Boolean).join('\n')
|
||||
}
|
||||
|
||||
function appendText(message: AppendMessage): string {
|
||||
return message.content
|
||||
.map(part => ('text' in part ? part.text : ''))
|
||||
.join('')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function visibleUserOrdinal(messages: readonly ChatMessage[], end: number): number {
|
||||
return messages.slice(0, end).filter(m => m.role === 'user' && !m.hidden).length
|
||||
}
|
||||
|
||||
function visibleUserIndexAtOrdinal(messages: readonly ChatMessage[], targetOrdinal: number): number {
|
||||
let ordinal = 0
|
||||
|
||||
for (let index = 0; index < messages.length; index += 1) {
|
||||
const message = messages[index]
|
||||
|
||||
if (message.role !== 'user' || message.hidden) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (ordinal === targetOrdinal) {
|
||||
return index
|
||||
}
|
||||
|
||||
ordinal += 1
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
interface RestoreMessageTarget {
|
||||
text?: string
|
||||
userOrdinal?: number | null
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue