refactor(desktop): split src/hermes.ts into src/api/ domain modules
2,248 lines of gateway REST client become twelve modules by domain, with hermes.ts left as a barrel so all 144 importers stay put. The import graph is a star — every domain module imports only ./client, and client imports nothing back — so there are no cycles. The barrel names client's public exports rather than re-exporting it wholesale. Splitting a module forces its private helpers into exports so siblings can reach them, and export * would then republish them: profileScoped, connectionScoped and capabilityScoped were private to hermes.ts and have to stay that way, or a call site can assemble its own request scope and drift from the api layer.
This commit is contained in:
154
apps/desktop/src/api/client.ts
Normal file
154
apps/desktop/src/api/client.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { JsonRpcGatewayClient } from '@hermes/shared'
|
||||
|
||||
import type { HermesApiRequest } from '@/global'
|
||||
|
||||
// Desktop startup fires a burst of read-only data calls (config, profiles,
|
||||
// model info/options, cron) the moment the backend passes readiness. On a
|
||||
// profile-heavy or remote install these can each take tens of seconds — e.g.
|
||||
// /api/profiles runs list_profiles(), which does a recursive skill-tree walk
|
||||
// per profile — so the 15s default (DEFAULT_FETCH_TIMEOUT_MS in hardening.ts)
|
||||
// times out a backend that is alive-but-busy, surfacing as a spurious
|
||||
// "Timed out connecting to Hermes backend" that hangs the UI (#48504).
|
||||
//
|
||||
// Give the boot burst a generous per-call timeout instead of raising the
|
||||
// global default: interactive/runtime calls and the liveness poll (/api/status)
|
||||
// keep the short default so a genuinely-dead backend is still detected fast.
|
||||
export const STARTUP_REQUEST_TIMEOUT_MS = 60_000
|
||||
const DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS = 30_000
|
||||
// prompt.submit is effectively fire-and-forget: turn completion is signaled by
|
||||
// stream / message.complete events, NOT by the RPC return. A long turn (MoA
|
||||
// presets running references + aggregator in series, deep reasoning, large tool
|
||||
// chains) can legitimately take minutes to ACK, so bounding the ack by the
|
||||
// generic 30s default surfaces a false "request timed out" toast while the turn
|
||||
// is still running and will succeed (issue #55024). Match the backend's
|
||||
// agent-turn ceiling (agent.gateway_timeout = 1800s) so the ack timeout only
|
||||
// ever fires when the turn itself would have been abandoned server-side.
|
||||
export const PROMPT_SUBMIT_REQUEST_TIMEOUT_MS = 1_800_000
|
||||
|
||||
export class HermesGateway extends JsonRpcGatewayClient {
|
||||
constructor() {
|
||||
super({
|
||||
closedErrorMessage: 'Hermes gateway connection closed',
|
||||
connectErrorMessage: 'Could not connect to Hermes gateway',
|
||||
createRequestId: nextId => nextId,
|
||||
notConnectedErrorMessage: 'Hermes gateway is not connected',
|
||||
requestTimeoutMs: DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Profile that profile-scoped REST settings (config/env/skills/tools/model/…)
|
||||
// should target. Mirrors $activeGatewayProfile, pushed in from the store via
|
||||
// setApiRequestProfile so this module needs no store import (avoids a cycle).
|
||||
// Electron main consumes request.profile as request scope. Local calls whose
|
||||
// REST handlers accept profile reuse the primary dashboard via ?profile=;
|
||||
// unscoped handlers retain a profile backend. Remote overrides still route to
|
||||
// their owning backend. Null → primary, so single-profile users are unaffected.
|
||||
let _apiProfile: null | string = null
|
||||
|
||||
export function setApiRequestProfile(profile: null | string): void {
|
||||
_apiProfile = profile || null
|
||||
}
|
||||
|
||||
export function profileScoped(profile?: null | string): { profile?: string } {
|
||||
const selected = profile === undefined ? _apiProfile : profile
|
||||
|
||||
return selected ? { profile: selected } : {}
|
||||
}
|
||||
|
||||
/** Profile that profile-scoped REST/WS calls should target (null → primary).
|
||||
* Read-only twin of setApiRequestProfile for modules (e.g. voice playback)
|
||||
* that build their own connection URLs and must stay on the same backend. */
|
||||
export function getApiRequestProfile(): null | string {
|
||||
return _apiProfile
|
||||
}
|
||||
|
||||
// Registry connection serving the active gateway (null → the local pool).
|
||||
// Pushed from store/gateway's setActive — the single seam BOTH
|
||||
// ensureGatewayProfile and ensureGatewayAgent funnel through — so WS calls
|
||||
// that dial their own backend (pluginSocket) resolve it through the SAME
|
||||
// source of truth those paths maintain for $connection. That makes the plugin
|
||||
// socket follow registry-agent activations too, not just profile switches.
|
||||
// Same no-store-import contract as _apiProfile (avoids a cycle).
|
||||
let _apiConnectionId: null | string = null
|
||||
|
||||
export function setApiRequestConnection(connectionId: null | string): void {
|
||||
_apiConnectionId = connectionId || null
|
||||
}
|
||||
|
||||
// Registry connection scope for a REST request. A registered remote gateway
|
||||
// owns its own state.db — cron jobs and their run sessions live THERE — so
|
||||
// requests for gateway-owned data must carry the connection id for the main
|
||||
// process to route them to that host (hermes:api's registry branch). Null
|
||||
// resolves to no tag, keeping single-source users byte-identical; explicit
|
||||
// 'local' must remain tagged when the legacy primary points elsewhere.
|
||||
export function connectionScoped(): { connectionId?: string } {
|
||||
return _apiConnectionId ? { connectionId: _apiConnectionId } : {}
|
||||
}
|
||||
|
||||
/** Send a REST request to the renderer's active registry source. Request-level
|
||||
* routing may override the active source for an explicitly-owned resource.
|
||||
*
|
||||
* Helpers under `api/` go through here rather than calling the preload bridge
|
||||
* directly, so the connection tag cannot be forgotten on a new one — with one
|
||||
* exception. A capabilityScoped() helper must NOT: that scope says "the local
|
||||
* pool" by omitting `connectionId` entirely, and an absent key cannot override
|
||||
* the ambient tag spread underneath it, so a 'local' pin would silently route
|
||||
* to whatever remote gateway happened to be active. Those helpers call the
|
||||
* bridge directly and own their routing end to end. */
|
||||
export function hermesApi<T>(request: HermesApiRequest): Promise<T> {
|
||||
return window.hermesDesktop.api<T>({ ...connectionScoped(), ...request })
|
||||
}
|
||||
|
||||
// ── Capability scope: (connection, profile) routing for the Capabilities
|
||||
// surface (skills / toolsets / MCP / hub / env / toolset config) ────────────
|
||||
//
|
||||
// A profile is not a machine-global name — it belongs to ONE gateway. The
|
||||
// Capabilities surface can be pointed at any (connection, profile) pair
|
||||
// (SkillsView's scope selector, Bot Mode's fixedProfile/fixedConnection), so
|
||||
// its REST helpers accept either the legacy string form or an explicit scope
|
||||
// object:
|
||||
//
|
||||
// - `undefined` / string → the legacy profile path, PLUS the active registry
|
||||
// connection tag (connectionScoped, same contract the cron helpers adopted
|
||||
// in #87882). Without the tag, a window activated onto a registered remote
|
||||
// gateway read the LOCAL pool's skills/tools/MCP — the wrong machine.
|
||||
// - `{ connectionId, profile }` → explicit pin. `''`/`'local'` connection
|
||||
// ids mean the local pool and deliberately DROP the ambient connection
|
||||
// tag, so a local-profile pick made while a remote gateway is active still
|
||||
// routes to the local machine.
|
||||
export type ProfileScope = null | string | { connectionId?: null | string; profile?: null | string }
|
||||
|
||||
export function capabilityScoped(scope?: ProfileScope): { connectionId?: string; profile?: string } {
|
||||
if (scope && typeof scope === 'object') {
|
||||
const profile = (scope.profile ?? '').trim()
|
||||
const connectionId = (scope.connectionId ?? '').trim()
|
||||
|
||||
return {
|
||||
...(profile ? { profile } : {}),
|
||||
...(connectionId && connectionId !== 'local' ? { connectionId } : {})
|
||||
}
|
||||
}
|
||||
|
||||
return { ...profileScoped(scope), ...connectionScoped() }
|
||||
}
|
||||
|
||||
/** Stable cache-key for a capability scope: `profile` for the local/legacy
|
||||
* path, `connectionId::profile` for an explicit remote pin. Mirrors
|
||||
* normalizeProfileKey for plain strings so existing keys stay byte-identical. */
|
||||
export function profileScopeKey(scope?: ProfileScope): string {
|
||||
if (scope && typeof scope === 'object') {
|
||||
const profile = (scope.profile ?? '').trim() || 'default'
|
||||
const connectionId = (scope.connectionId ?? '').trim()
|
||||
|
||||
return connectionId && connectionId !== 'local' ? `${connectionId}::${profile}` : profile
|
||||
}
|
||||
|
||||
return (scope ?? '').trim() || 'default'
|
||||
}
|
||||
|
||||
/** Registry connection id that connection-scoped WS calls should target
|
||||
* (null → the local pool). Read-only twin of setApiRequestConnection. */
|
||||
export function getApiRequestConnection(): null | string {
|
||||
return _apiConnectionId
|
||||
}
|
||||
239
apps/desktop/src/api/config.ts
Normal file
239
apps/desktop/src/api/config.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import type {
|
||||
ConfigSchemaResponse,
|
||||
CustomEndpointsResponse,
|
||||
CustomEndpointUpdate,
|
||||
CustomEndpointValidationResponse,
|
||||
EnvVarInfo,
|
||||
HermesConfig,
|
||||
HermesConfigRecord,
|
||||
LogsResponse,
|
||||
OAuthPollResponse,
|
||||
OAuthProvidersResponse,
|
||||
OAuthStartResponse,
|
||||
OAuthSubmitResponse,
|
||||
StatusResponse
|
||||
} from '@/types/hermes'
|
||||
|
||||
import { capabilityScoped, hermesApi, type ProfileScope, profileScoped, STARTUP_REQUEST_TIMEOUT_MS } from './client'
|
||||
|
||||
export function getStatus(): Promise<StatusResponse> {
|
||||
return hermesApi<StatusResponse>({
|
||||
...profileScoped(),
|
||||
path: '/api/status'
|
||||
})
|
||||
}
|
||||
|
||||
export function getLogs(params: {
|
||||
component?: string
|
||||
file?: string
|
||||
level?: string
|
||||
lines?: number
|
||||
search?: string
|
||||
}): Promise<LogsResponse> {
|
||||
const query = new URLSearchParams()
|
||||
|
||||
if (params.file) {
|
||||
query.set('file', params.file)
|
||||
}
|
||||
|
||||
if (typeof params.lines === 'number') {
|
||||
query.set('lines', String(params.lines))
|
||||
}
|
||||
|
||||
if (params.level && params.level !== 'ALL') {
|
||||
query.set('level', params.level)
|
||||
}
|
||||
|
||||
if (params.component && params.component !== 'all') {
|
||||
query.set('component', params.component)
|
||||
}
|
||||
|
||||
if (params.search) {
|
||||
query.set('search', params.search)
|
||||
}
|
||||
|
||||
const suffix = query.toString()
|
||||
|
||||
return hermesApi<LogsResponse>({
|
||||
...profileScoped(),
|
||||
path: suffix ? `/api/logs?${suffix}` : '/api/logs'
|
||||
})
|
||||
}
|
||||
|
||||
export function getHermesConfig(profile?: string): Promise<HermesConfig> {
|
||||
return hermesApi<HermesConfig>({
|
||||
...profileScoped(profile),
|
||||
path: '/api/config',
|
||||
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
|
||||
})
|
||||
}
|
||||
|
||||
export function getHermesConfigRecord(profile?: ProfileScope): Promise<HermesConfigRecord> {
|
||||
return window.hermesDesktop.api<HermesConfigRecord>({
|
||||
...capabilityScoped(profile),
|
||||
path: '/api/config'
|
||||
})
|
||||
}
|
||||
|
||||
export function getHermesConfigDefaults(): Promise<HermesConfigRecord> {
|
||||
return hermesApi<HermesConfigRecord>({
|
||||
...profileScoped(),
|
||||
path: '/api/config/defaults',
|
||||
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
|
||||
})
|
||||
}
|
||||
|
||||
export function getHermesConfigSchema(profile?: null | string): Promise<ConfigSchemaResponse> {
|
||||
return hermesApi<ConfigSchemaResponse>({
|
||||
...profileScoped(profile),
|
||||
path: '/api/config/schema'
|
||||
})
|
||||
}
|
||||
|
||||
export function saveHermesConfig(config: HermesConfigRecord, profile?: null | string): Promise<{ ok: boolean }> {
|
||||
return hermesApi<{ ok: boolean }>({
|
||||
...profileScoped(profile),
|
||||
path: '/api/config',
|
||||
method: 'PUT',
|
||||
body: { config }
|
||||
})
|
||||
}
|
||||
|
||||
export function getEnvVars(profile?: null | string): Promise<Record<string, EnvVarInfo>> {
|
||||
return hermesApi<Record<string, EnvVarInfo>>({
|
||||
...profileScoped(profile),
|
||||
path: '/api/env'
|
||||
})
|
||||
}
|
||||
|
||||
export function setEnvVar(key: string, value: string, profile?: ProfileScope): Promise<{ ok: boolean }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean }>({
|
||||
...capabilityScoped(profile),
|
||||
path: '/api/env',
|
||||
method: 'PUT',
|
||||
body: { key, value }
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteEnvVar(key: string, profile?: ProfileScope): Promise<{ ok: boolean }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean }>({
|
||||
...capabilityScoped(profile),
|
||||
path: '/api/env',
|
||||
method: 'DELETE',
|
||||
body: { key }
|
||||
})
|
||||
}
|
||||
|
||||
export function revealEnvVar(key: string, profile?: ProfileScope): Promise<{ key: string; value: string }> {
|
||||
return window.hermesDesktop.api<{ key: string; value: string }>({
|
||||
...capabilityScoped(profile),
|
||||
path: '/api/env/reveal',
|
||||
method: 'POST',
|
||||
body: { key }
|
||||
})
|
||||
}
|
||||
|
||||
export function validateProviderCredential(
|
||||
key: string,
|
||||
value: string,
|
||||
apiKey?: string
|
||||
): Promise<{ ok: boolean; reachable: boolean; message: string; models?: string[] }> {
|
||||
return hermesApi<{ ok: boolean; reachable: boolean; message: string; models?: string[] }>({
|
||||
...profileScoped(),
|
||||
path: '/api/providers/validate',
|
||||
method: 'POST',
|
||||
body: { key, value, api_key: apiKey ?? '' }
|
||||
})
|
||||
}
|
||||
|
||||
export function getCustomEndpoints(): Promise<CustomEndpointsResponse> {
|
||||
return hermesApi<CustomEndpointsResponse>({
|
||||
...profileScoped(),
|
||||
path: '/api/providers/custom-endpoints'
|
||||
})
|
||||
}
|
||||
|
||||
export function saveCustomEndpoint(endpoint: CustomEndpointUpdate): Promise<CustomEndpointsResponse> {
|
||||
return hermesApi<CustomEndpointsResponse>({
|
||||
...profileScoped(),
|
||||
path: '/api/providers/custom-endpoints',
|
||||
method: 'POST',
|
||||
body: endpoint
|
||||
})
|
||||
}
|
||||
|
||||
export function validateCustomEndpoint(endpoint: CustomEndpointUpdate): Promise<CustomEndpointValidationResponse> {
|
||||
return hermesApi<CustomEndpointValidationResponse>({
|
||||
path: '/api/providers/custom-endpoints/validate',
|
||||
method: 'POST',
|
||||
body: endpoint
|
||||
})
|
||||
}
|
||||
|
||||
export function activateCustomEndpoint(id: string): Promise<{ ok: boolean; provider: string; model: string }> {
|
||||
return hermesApi<{ ok: boolean; provider: string; model: string }>({
|
||||
...profileScoped(),
|
||||
path: `/api/providers/custom-endpoints/${encodeURIComponent(id)}/activate`,
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteCustomEndpoint(id: string): Promise<CustomEndpointsResponse> {
|
||||
return hermesApi<CustomEndpointsResponse>({
|
||||
...profileScoped(),
|
||||
path: `/api/providers/custom-endpoints/${encodeURIComponent(id)}`,
|
||||
method: 'DELETE'
|
||||
})
|
||||
}
|
||||
|
||||
export function listOAuthProviders(): Promise<OAuthProvidersResponse> {
|
||||
return hermesApi<OAuthProvidersResponse>({
|
||||
...profileScoped(),
|
||||
path: '/api/providers/oauth'
|
||||
})
|
||||
}
|
||||
|
||||
export function disconnectOAuthProvider(providerId: string): Promise<{ ok: boolean; provider: string }> {
|
||||
return hermesApi<{ ok: boolean; provider: string }>({
|
||||
...profileScoped(),
|
||||
path: `/api/providers/oauth/${encodeURIComponent(providerId)}`,
|
||||
method: 'DELETE'
|
||||
})
|
||||
}
|
||||
|
||||
export function startOAuthLogin(providerId: string, profile?: ProfileScope): Promise<OAuthStartResponse> {
|
||||
return window.hermesDesktop.api<OAuthStartResponse>({
|
||||
...capabilityScoped(profile),
|
||||
path: `/api/providers/oauth/${encodeURIComponent(providerId)}/start`,
|
||||
method: 'POST',
|
||||
body: {}
|
||||
})
|
||||
}
|
||||
|
||||
export function submitOAuthCode(providerId: string, sessionId: string, code: string): Promise<OAuthSubmitResponse> {
|
||||
return hermesApi<OAuthSubmitResponse>({
|
||||
...profileScoped(),
|
||||
path: `/api/providers/oauth/${encodeURIComponent(providerId)}/submit`,
|
||||
method: 'POST',
|
||||
body: { session_id: sessionId, code }
|
||||
})
|
||||
}
|
||||
|
||||
export function pollOAuthSession(
|
||||
providerId: string,
|
||||
sessionId: string,
|
||||
profile?: ProfileScope
|
||||
): Promise<OAuthPollResponse> {
|
||||
return window.hermesDesktop.api<OAuthPollResponse>({
|
||||
...capabilityScoped(profile),
|
||||
path: `/api/providers/oauth/${encodeURIComponent(providerId)}/poll/${encodeURIComponent(sessionId)}`
|
||||
})
|
||||
}
|
||||
|
||||
export function cancelOAuthSession(sessionId: string): Promise<{ ok: boolean }> {
|
||||
return hermesApi<{ ok: boolean }>({
|
||||
...profileScoped(),
|
||||
path: `/api/providers/oauth/sessions/${encodeURIComponent(sessionId)}`,
|
||||
method: 'DELETE'
|
||||
})
|
||||
}
|
||||
153
apps/desktop/src/api/cron.ts
Normal file
153
apps/desktop/src/api/cron.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import type {
|
||||
AutomationBlueprint,
|
||||
CronDeliveryTarget,
|
||||
CronJob,
|
||||
CronJobCreatePayload,
|
||||
CronJobUpdates,
|
||||
SessionInfo
|
||||
} from '@/types/hermes'
|
||||
|
||||
import { connectionScoped, hermesApi, profileScoped, STARTUP_REQUEST_TIMEOUT_MS } from './client'
|
||||
|
||||
// The cron trigger endpoint intentionally waits for the whole job so its
|
||||
// response reflects the persisted execution result. Agent jobs can run far
|
||||
// longer than the Electron fetch default; keep this override local to the one
|
||||
// synchronous long-operation endpoint rather than weakening all API timeouts.
|
||||
const CRON_TRIGGER_REQUEST_TIMEOUT_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
// Cron jobs are stored per-profile (<HERMES_HOME>/cron/jobs.json), and the
|
||||
// backend's list endpoint defaults to 'all'. Pass a concrete profile key to
|
||||
// list just that profile's jobs, or 'all' for the unified cross-profile view.
|
||||
// Omitting the arg keeps the legacy 'all' default for non-profile callers.
|
||||
// profileScoped() still rides along for backend-process routing.
|
||||
export function getCronJobs(profile?: string): Promise<CronJob[]> {
|
||||
const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : ''
|
||||
|
||||
return hermesApi<CronJob[]>({
|
||||
...profileScoped(),
|
||||
...connectionScoped(),
|
||||
path: `/api/cron/jobs${suffix}`,
|
||||
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
|
||||
})
|
||||
}
|
||||
|
||||
export function getCronJob(jobId: string): Promise<CronJob> {
|
||||
return hermesApi<CronJob>({
|
||||
...profileScoped(),
|
||||
...connectionScoped(),
|
||||
path: `/api/cron/jobs/${encodeURIComponent(jobId)}`
|
||||
})
|
||||
}
|
||||
|
||||
export async function getCronJobRuns(jobId: string, limit = 20): Promise<SessionInfo[]> {
|
||||
const { runs } = await hermesApi<{ runs: SessionInfo[] }>({
|
||||
...profileScoped(),
|
||||
...connectionScoped(),
|
||||
path: `/api/cron/jobs/${encodeURIComponent(jobId)}/runs?limit=${limit}`
|
||||
})
|
||||
|
||||
return runs ?? []
|
||||
}
|
||||
|
||||
// The single source of truth for cron delivery targets (local + configured
|
||||
// gateways). Both the manual cron editor and the blueprint dialog use this so
|
||||
// they never offer a platform that isn't connected. Mirrors the dashboard.
|
||||
export async function getCronDeliveryTargets(): Promise<CronDeliveryTarget[]> {
|
||||
const { targets } = await hermesApi<{ targets: CronDeliveryTarget[] }>({
|
||||
...profileScoped(),
|
||||
...connectionScoped(),
|
||||
path: '/api/cron/delivery-targets'
|
||||
})
|
||||
|
||||
return targets ?? []
|
||||
}
|
||||
|
||||
export function createCronJob(body: CronJobCreatePayload): Promise<CronJob> {
|
||||
return hermesApi<CronJob>({
|
||||
...profileScoped(),
|
||||
...connectionScoped(),
|
||||
path: '/api/cron/jobs',
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
}
|
||||
|
||||
export function updateCronJob(jobId: string, updates: CronJobUpdates): Promise<CronJob> {
|
||||
return hermesApi<CronJob>({
|
||||
...profileScoped(),
|
||||
...connectionScoped(),
|
||||
path: `/api/cron/jobs/${encodeURIComponent(jobId)}`,
|
||||
method: 'PUT',
|
||||
body: { updates }
|
||||
})
|
||||
}
|
||||
|
||||
export function pauseCronJob(jobId: string): Promise<CronJob> {
|
||||
return hermesApi<CronJob>({
|
||||
...profileScoped(),
|
||||
...connectionScoped(),
|
||||
path: `/api/cron/jobs/${encodeURIComponent(jobId)}/pause`,
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
|
||||
export function resumeCronJob(jobId: string): Promise<CronJob> {
|
||||
return hermesApi<CronJob>({
|
||||
...profileScoped(),
|
||||
...connectionScoped(),
|
||||
path: `/api/cron/jobs/${encodeURIComponent(jobId)}/resume`,
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
|
||||
export function triggerCronJob(jobId: string): Promise<CronJob> {
|
||||
return hermesApi<CronJob>({
|
||||
...profileScoped(),
|
||||
...connectionScoped(),
|
||||
path: `/api/cron/jobs/${encodeURIComponent(jobId)}/trigger`,
|
||||
method: 'POST',
|
||||
timeoutMs: CRON_TRIGGER_REQUEST_TIMEOUT_MS
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteCronJob(jobId: string): Promise<{ ok: boolean }> {
|
||||
return hermesApi<{ ok: boolean }>({
|
||||
...profileScoped(),
|
||||
...connectionScoped(),
|
||||
path: `/api/cron/jobs/${encodeURIComponent(jobId)}`,
|
||||
method: 'DELETE'
|
||||
})
|
||||
}
|
||||
|
||||
// Automation Blueprints — parameterized cron templates the backend serves from
|
||||
// cron/blueprint_catalog.py. getAutomationBlueprints returns the gallery
|
||||
// (deliver options already rewritten to this machine's configured gateways);
|
||||
// instantiateAutomationBlueprint fills the slots and creates a real cron job via
|
||||
// the same create_job path as createCronJob.
|
||||
//
|
||||
// Profile-scoping is intentionally asymmetric: the GET catalog is global (the
|
||||
// list endpoint takes no profile — only deliver options are rewritten from the
|
||||
// configured gateways), so it carries only the profileScoped() header for
|
||||
// routing. instantiate creates a real per-profile job, so it names the target
|
||||
// profile explicitly via ?profile=. This mirrors the dashboard's api.ts.
|
||||
export function getAutomationBlueprints(): Promise<{ blueprints: AutomationBlueprint[] }> {
|
||||
return hermesApi<{ blueprints: AutomationBlueprint[] }>({
|
||||
...profileScoped(),
|
||||
...connectionScoped(),
|
||||
path: '/api/cron/blueprints',
|
||||
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
|
||||
})
|
||||
}
|
||||
|
||||
export function instantiateAutomationBlueprint(
|
||||
body: { blueprint: string; values: Record<string, string> },
|
||||
profile: string
|
||||
): Promise<CronJob> {
|
||||
return hermesApi<CronJob>({
|
||||
...profileScoped(),
|
||||
...connectionScoped(),
|
||||
path: `/api/cron/blueprints/instantiate?profile=${encodeURIComponent(profile)}`,
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
}
|
||||
147
apps/desktop/src/api/mcp.ts
Normal file
147
apps/desktop/src/api/mcp.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import type { McpCatalogResponse, McpServerSummary } from '@/types/hermes'
|
||||
|
||||
import { capabilityScoped, hermesApi, type ProfileScope, profileScoped } from './client'
|
||||
|
||||
export interface McpTestResult {
|
||||
ok: boolean
|
||||
error?: string
|
||||
/** `schema_chars` (converted registry-schema size, chars) is additive —
|
||||
* older backends omit it and the cost overlay shows no token estimate. */
|
||||
tools: { name: string; description: string; schema_chars?: number }[]
|
||||
/** Capability counts (absent on older backends / failed probes). */
|
||||
prompts?: number
|
||||
resources?: number
|
||||
}
|
||||
|
||||
export interface McpOAuthFlow {
|
||||
flow_id: string
|
||||
server_name: string
|
||||
status: 'starting' | 'authorization_required' | 'approved' | 'error'
|
||||
authorization_url: string | null
|
||||
error: string | null
|
||||
tools?: { name: string; description: string }[]
|
||||
}
|
||||
|
||||
/** Connect to the server, list its tools, disconnect. Slow (spawns/handshakes
|
||||
* for real) — well past the 15s default fetch timeout. */
|
||||
export function testMcpServer(name: string, profile?: ProfileScope): Promise<McpTestResult> {
|
||||
return window.hermesDesktop.api<McpTestResult>({
|
||||
...capabilityScoped(profile),
|
||||
path: `/api/mcp/servers/${encodeURIComponent(name)}/test`,
|
||||
method: 'POST',
|
||||
timeoutMs: 60_000
|
||||
})
|
||||
}
|
||||
|
||||
/** Replace the whole `mcp_servers` map (the mcp.json editor's save). Unlike
|
||||
* `saveHermesConfig`, this REPLACES rather than deep-merges, so deletes,
|
||||
* re-enables (dropping `enabled: false`), and removed nested fields persist. */
|
||||
export function saveMcpServers(
|
||||
servers: Record<string, Record<string, unknown>>,
|
||||
profile?: ProfileScope
|
||||
): Promise<{ ok: boolean }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean }>({
|
||||
...capabilityScoped(profile),
|
||||
path: '/api/mcp/servers',
|
||||
method: 'PUT',
|
||||
body: { servers }
|
||||
})
|
||||
}
|
||||
|
||||
/** Start an MCP OAuth flow and return the authorization URL. */
|
||||
export function authMcpServer(name: string, profile?: ProfileScope): Promise<McpOAuthFlow> {
|
||||
return window.hermesDesktop.api<McpOAuthFlow>({
|
||||
...capabilityScoped(profile),
|
||||
path: `/api/mcp/servers/${encodeURIComponent(name)}/auth`,
|
||||
method: 'POST',
|
||||
timeoutMs: 60_000
|
||||
})
|
||||
}
|
||||
|
||||
export function getMcpOAuthFlow(flowId: string, profile?: ProfileScope): Promise<McpOAuthFlow> {
|
||||
return window.hermesDesktop.api<McpOAuthFlow>({
|
||||
...capabilityScoped(profile),
|
||||
path: `/api/mcp/oauth/flows/${encodeURIComponent(flowId)}`
|
||||
})
|
||||
}
|
||||
|
||||
/** Cancel an in-flight MCP OAuth flow server-side, freeing the per-server
|
||||
* "already in progress" slot so a retry doesn't 409. */
|
||||
export function cancelMcpOAuthFlow(flowId: string, profile?: null | string): Promise<{ ok: boolean; status: string }> {
|
||||
return hermesApi<{ ok: boolean; status: string }>({
|
||||
...profileScoped(profile),
|
||||
path: `/api/mcp/oauth/flows/${encodeURIComponent(flowId)}`,
|
||||
method: 'DELETE'
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MCP servers — structured list / test / enable toggle / catalog (parity with
|
||||
// `hermes mcp` and the dashboard MCP page). Raw JSON editing stays in
|
||||
// config.yaml via saveHermesConfig.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function listMcpServers(): Promise<{ servers: McpServerSummary[] }> {
|
||||
return hermesApi<{ servers: McpServerSummary[] }>({
|
||||
...profileScoped(),
|
||||
path: '/api/mcp/servers'
|
||||
})
|
||||
}
|
||||
|
||||
/** Add one server to `mcp_servers` (validated + name-collision-checked
|
||||
* server-side — the same endpoint the dashboard's add form uses). */
|
||||
export function addMcpServer(body: {
|
||||
name: string
|
||||
url?: string
|
||||
command?: string
|
||||
args?: string[]
|
||||
env?: Record<string, string>
|
||||
auth?: string
|
||||
}): Promise<McpServerSummary> {
|
||||
return hermesApi<McpServerSummary>({
|
||||
...profileScoped(),
|
||||
path: '/api/mcp/servers',
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
}
|
||||
|
||||
/** Remove one server from `mcp_servers` (the inline setup card's rollback
|
||||
* when a directory install is cancelled after the config write). */
|
||||
export function removeMcpServer(name: string): Promise<{ ok: boolean }> {
|
||||
return hermesApi<{ ok: boolean }>({
|
||||
...profileScoped(),
|
||||
path: `/api/mcp/servers/${encodeURIComponent(name)}`,
|
||||
method: 'DELETE'
|
||||
})
|
||||
}
|
||||
|
||||
export function setMcpServerEnabled(name: string, enabled: boolean): Promise<{ ok: boolean }> {
|
||||
return hermesApi<{ ok: boolean }>({
|
||||
...profileScoped(),
|
||||
path: `/api/mcp/servers/${encodeURIComponent(name)}/enabled`,
|
||||
method: 'PUT',
|
||||
body: { enabled }
|
||||
})
|
||||
}
|
||||
|
||||
export function getMcpCatalog(profile?: ProfileScope): Promise<McpCatalogResponse> {
|
||||
return window.hermesDesktop.api<McpCatalogResponse>({
|
||||
...capabilityScoped(profile),
|
||||
path: '/api/mcp/catalog'
|
||||
})
|
||||
}
|
||||
|
||||
export function installMcpCatalogEntry(
|
||||
name: string,
|
||||
env: Record<string, string> = {},
|
||||
profile?: ProfileScope
|
||||
): Promise<{ ok: boolean; name?: string; pid?: number; action?: string; background?: boolean }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean; name?: string; pid?: number; action?: string; background?: boolean }>({
|
||||
...capabilityScoped(profile),
|
||||
path: '/api/mcp/catalog/install',
|
||||
method: 'POST',
|
||||
body: { name, env, enable: true },
|
||||
timeoutMs: 60_000
|
||||
})
|
||||
}
|
||||
131
apps/desktop/src/api/messaging.ts
Normal file
131
apps/desktop/src/api/messaging.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import type {
|
||||
MessagingPlatformsResponse,
|
||||
MessagingPlatformTestResponse,
|
||||
MessagingPlatformUpdate,
|
||||
PairingResponse,
|
||||
PairingUser,
|
||||
WebhookCreatePayload,
|
||||
WebhookCreateResponse,
|
||||
WebhookEnableResponse,
|
||||
WebhooksResponse
|
||||
} from '@/types/hermes'
|
||||
|
||||
import { hermesApi, profileScoped } from './client'
|
||||
|
||||
export function getMessagingPlatforms(profile?: null | string): Promise<MessagingPlatformsResponse> {
|
||||
return hermesApi<MessagingPlatformsResponse>({
|
||||
...profileScoped(profile),
|
||||
path: '/api/messaging/platforms'
|
||||
})
|
||||
}
|
||||
|
||||
export function updateMessagingPlatform(
|
||||
platformId: string,
|
||||
body: MessagingPlatformUpdate,
|
||||
profile?: null | string
|
||||
): Promise<{ ok: boolean; platform: string }> {
|
||||
return hermesApi<{ ok: boolean; platform: string }>({
|
||||
...profileScoped(profile),
|
||||
path: `/api/messaging/platforms/${encodeURIComponent(platformId)}`,
|
||||
method: 'PUT',
|
||||
body
|
||||
})
|
||||
}
|
||||
|
||||
export function testMessagingPlatform(
|
||||
platformId: string,
|
||||
profile?: null | string
|
||||
): Promise<MessagingPlatformTestResponse> {
|
||||
return hermesApi<MessagingPlatformTestResponse>({
|
||||
...profileScoped(profile),
|
||||
path: `/api/messaging/platforms/${encodeURIComponent(platformId)}/test`,
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
|
||||
// -- Pairing (who may DM the bot) --------------------------------------------
|
||||
// Unknown DMers get a one-time code and land in `pending` until an admin
|
||||
// approves them. Approval grants on the row's `request_id`, never on the code:
|
||||
// the code is the requester's proof that the channel is theirs and is never
|
||||
// returned by the API, while an authenticated admin is only ever identifying
|
||||
// a row they can already see.
|
||||
|
||||
export function getPairing(profile?: null | string): Promise<PairingResponse> {
|
||||
return hermesApi<PairingResponse>({
|
||||
...profileScoped(profile),
|
||||
path: '/api/pairing'
|
||||
})
|
||||
}
|
||||
|
||||
export function approvePairing(
|
||||
platform: string,
|
||||
requestId: string,
|
||||
profile?: null | string
|
||||
): Promise<{ ok: boolean; user: PairingUser }> {
|
||||
return hermesApi<{ ok: boolean; user: PairingUser }>({
|
||||
...profileScoped(profile),
|
||||
path: '/api/pairing/approve',
|
||||
method: 'POST',
|
||||
// These endpoints read the profile off the body, not the query string —
|
||||
// `profileScoped()` alone would approve into the wrong profile's store.
|
||||
body: { platform, request_id: requestId, ...profileScoped(profile) }
|
||||
})
|
||||
}
|
||||
|
||||
export function revokePairing(platform: string, userId: string, profile?: null | string): Promise<{ ok: boolean }> {
|
||||
return hermesApi<{ ok: boolean }>({
|
||||
...profileScoped(profile),
|
||||
path: '/api/pairing/revoke',
|
||||
method: 'POST',
|
||||
body: { platform, user_id: userId, ...profileScoped(profile) }
|
||||
})
|
||||
}
|
||||
|
||||
// -- Webhooks (subscription CRUD) --------------------------------------------
|
||||
// The webhook receiver is its own gateway platform; subscriptions live in a
|
||||
// shared JSON store the CLI/dashboard also drive. Enable mutates config and
|
||||
// best-effort restarts the gateway; subscription changes hot-reload.
|
||||
|
||||
export function getWebhooks(): Promise<WebhooksResponse> {
|
||||
return hermesApi<WebhooksResponse>({
|
||||
...profileScoped(),
|
||||
path: '/api/webhooks'
|
||||
})
|
||||
}
|
||||
|
||||
export function enableWebhooks(): Promise<WebhookEnableResponse> {
|
||||
return hermesApi<WebhookEnableResponse>({
|
||||
...profileScoped(),
|
||||
path: '/api/webhooks/enable',
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
|
||||
export function createWebhook(body: WebhookCreatePayload): Promise<WebhookCreateResponse> {
|
||||
return hermesApi<WebhookCreateResponse>({
|
||||
...profileScoped(),
|
||||
path: '/api/webhooks',
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteWebhook(name: string): Promise<{ ok: boolean }> {
|
||||
return hermesApi<{ ok: boolean }>({
|
||||
...profileScoped(),
|
||||
path: `/api/webhooks/${encodeURIComponent(name)}`,
|
||||
method: 'DELETE'
|
||||
})
|
||||
}
|
||||
|
||||
export function setWebhookEnabled(
|
||||
name: string,
|
||||
enabled: boolean
|
||||
): Promise<{ enabled: boolean; name: string; ok: boolean }> {
|
||||
return hermesApi<{ enabled: boolean; name: string; ok: boolean }>({
|
||||
...profileScoped(),
|
||||
path: `/api/webhooks/${encodeURIComponent(name)}/enabled`,
|
||||
method: 'PUT',
|
||||
body: { enabled }
|
||||
})
|
||||
}
|
||||
129
apps/desktop/src/api/models.ts
Normal file
129
apps/desktop/src/api/models.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import type {
|
||||
AnalyticsResponse,
|
||||
AuxiliaryModelsResponse,
|
||||
MoaConfigResponse,
|
||||
ModelAssignmentRequest,
|
||||
ModelAssignmentResponse,
|
||||
ModelInfoResponse,
|
||||
ModelOptionsResponse
|
||||
} from '@/types/hermes'
|
||||
|
||||
import { capabilityScoped, hermesApi, type ProfileScope, profileScoped, STARTUP_REQUEST_TIMEOUT_MS } from './client'
|
||||
|
||||
export function getGlobalModelInfo(profile?: null | string): Promise<ModelInfoResponse> {
|
||||
return hermesApi<ModelInfoResponse>({
|
||||
...profileScoped(profile),
|
||||
path: '/api/model/info',
|
||||
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
|
||||
})
|
||||
}
|
||||
|
||||
export function getUsageAnalytics(days = 30, profile?: ProfileScope): Promise<AnalyticsResponse> {
|
||||
return window.hermesDesktop.api<AnalyticsResponse>({
|
||||
...capabilityScoped(profile),
|
||||
path: `/api/analytics/usage?days=${Math.max(1, Math.floor(days))}`
|
||||
})
|
||||
}
|
||||
|
||||
export function getGlobalModelOptions(
|
||||
opts?: {
|
||||
refresh?: boolean
|
||||
includeUnconfigured?: boolean
|
||||
explicitOnly?: boolean
|
||||
},
|
||||
profile?: null | string
|
||||
): Promise<ModelOptionsResponse> {
|
||||
const params = new URLSearchParams()
|
||||
|
||||
if (opts?.refresh) {
|
||||
params.set('refresh', '1')
|
||||
}
|
||||
|
||||
if (opts?.includeUnconfigured) {
|
||||
params.set('include_unconfigured', '1')
|
||||
}
|
||||
|
||||
if (opts?.explicitOnly !== false) {
|
||||
params.set('explicit_only', '1')
|
||||
}
|
||||
|
||||
return hermesApi<ModelOptionsResponse>({
|
||||
...profileScoped(profile),
|
||||
path: params.size > 0 ? `/api/model/options?${params.toString()}` : '/api/model/options',
|
||||
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
|
||||
})
|
||||
}
|
||||
|
||||
export interface RecommendedDefaultModel {
|
||||
provider: string
|
||||
model: string
|
||||
/** True/false for Nous (free vs paid tier); null for other providers. */
|
||||
free_tier: boolean | null
|
||||
}
|
||||
|
||||
// Recommended default model for a freshly-authenticated provider. Mirrors the
|
||||
// curation `hermes model` does — for Nous it honors the free/paid tier so a
|
||||
// free user gets a free model instead of a paid default.
|
||||
export function getRecommendedDefaultModel(
|
||||
provider: string,
|
||||
profile?: null | string
|
||||
): Promise<RecommendedDefaultModel> {
|
||||
return hermesApi<RecommendedDefaultModel>({
|
||||
...profileScoped(profile),
|
||||
path: `/api/model/recommended-default?provider=${encodeURIComponent(provider)}`
|
||||
})
|
||||
}
|
||||
|
||||
export function setGlobalModel(
|
||||
provider: string,
|
||||
model: string
|
||||
): Promise<{ ok: boolean; provider: string; model: string }> {
|
||||
return hermesApi<{ ok: boolean; provider: string; model: string }>({
|
||||
...profileScoped(),
|
||||
path: '/api/model/set',
|
||||
method: 'POST',
|
||||
body: {
|
||||
scope: 'main',
|
||||
provider,
|
||||
model
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function getAuxiliaryModels(profile?: null | string): Promise<AuxiliaryModelsResponse> {
|
||||
return hermesApi<AuxiliaryModelsResponse>({
|
||||
...profileScoped(profile),
|
||||
path: '/api/model/auxiliary'
|
||||
})
|
||||
}
|
||||
|
||||
export function getMoaModels(profile?: null | string): Promise<MoaConfigResponse> {
|
||||
return hermesApi<MoaConfigResponse>({
|
||||
...profileScoped(profile),
|
||||
path: '/api/model/moa'
|
||||
})
|
||||
}
|
||||
|
||||
export function saveMoaModels(
|
||||
body: MoaConfigResponse,
|
||||
profile?: null | string
|
||||
): Promise<MoaConfigResponse & { ok: boolean }> {
|
||||
return hermesApi<MoaConfigResponse & { ok: boolean }>({
|
||||
...profileScoped(profile),
|
||||
path: '/api/model/moa',
|
||||
method: 'PUT',
|
||||
body
|
||||
})
|
||||
}
|
||||
|
||||
export function setModelAssignment(
|
||||
body: ModelAssignmentRequest,
|
||||
profile?: null | string
|
||||
): Promise<ModelAssignmentResponse> {
|
||||
return hermesApi<ModelAssignmentResponse>({
|
||||
...profileScoped(profile),
|
||||
path: '/api/model/set',
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
}
|
||||
128
apps/desktop/src/api/plugins.ts
Normal file
128
apps/desktop/src/api/plugins.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import type { HermesConnection } from '@/global'
|
||||
import { reconnectBackoffDelayMs } from '@/lib/reconnect-backoff'
|
||||
|
||||
import { getApiRequestConnection, getApiRequestProfile, hermesApi, profileScoped } from './client'
|
||||
|
||||
/** Resolve the ACTIVE backend's connection descriptor, (connectionId,
|
||||
* profile)-scoped — mirroring how store/profile resolves $connection: a
|
||||
* registry agent's descriptor comes from getConnectionFor (its SOURCE
|
||||
* connection), everything else from the profile-keyed local pool. The
|
||||
* getConnectionFor bridge is optional (older Desktop mains); without it the
|
||||
* profile-scoped pool lookup is the best available answer. */
|
||||
async function activeConnection(): Promise<HermesConnection> {
|
||||
const getConnectionFor = window.hermesDesktop.getConnectionFor
|
||||
const connectionId = getApiRequestConnection()
|
||||
|
||||
if (connectionId && getConnectionFor) {
|
||||
return getConnectionFor({ connectionId, profile: getApiRequestProfile() })
|
||||
}
|
||||
|
||||
return window.hermesDesktop.getConnection(getApiRequestProfile())
|
||||
}
|
||||
|
||||
/** Options for a plugin REST call — mirrors the app's own `hermesDesktop.api`
|
||||
* shape, minus the path (which is namespace-derived). */
|
||||
export interface PluginRestOptions {
|
||||
method?: string
|
||||
body?: unknown
|
||||
/** Single-file multipart upload (see HermesApiRequest.upload). */
|
||||
upload?: { filename: string; contentType?: string; bytes: ArrayBuffer }
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
// Normalize `path` to a leading-slash suffix relative to `/api/plugins/<id>`.
|
||||
// The namespace is the boundary — reject `..` so a relative segment can't
|
||||
// normalize out into another plugin's API or a core route. Check the path
|
||||
// portion only (before any query/hash).
|
||||
function pluginPathSuffix(caller: string, path: string): string {
|
||||
const suffix = path.startsWith('/') ? path : `/${path}`
|
||||
|
||||
if (suffix.split(/[?#]/, 1)[0].split('/').includes('..')) {
|
||||
throw new Error(`${caller}: illegal path traversal in "${path}"`)
|
||||
}
|
||||
|
||||
return suffix
|
||||
}
|
||||
|
||||
/** The plugin REST door. Every call is scoped BY CONSTRUCTION to the plugin's
|
||||
* own backend namespace — `path` is relative to `/api/plugins/<pluginId>`
|
||||
* ('/board' → `/api/plugins/kanban/board`), so a plugin can't address another
|
||||
* plugin's API or a core route through it. Profile-aware like every desktop
|
||||
* REST call. Broader reach (core endpoints, another namespace) is the future
|
||||
* declared-capability seam; today the namespace IS the boundary. */
|
||||
export async function pluginRest<T>(pluginId: string, path: string, opts: PluginRestOptions = {}): Promise<T> {
|
||||
if (!window.hermesDesktop?.api) {
|
||||
throw new Error('Hermes desktop bridge unavailable')
|
||||
}
|
||||
|
||||
const suffix = pluginPathSuffix('pluginRest', path)
|
||||
|
||||
return hermesApi<T>({
|
||||
path: `/api/plugins/${pluginId}${suffix}`,
|
||||
method: opts.method,
|
||||
body: opts.body,
|
||||
upload: opts.upload,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
...profileScoped()
|
||||
})
|
||||
}
|
||||
|
||||
/** The plugin WebSocket door — the live twin of `pluginRest`, scoped the same
|
||||
* way: `path` is relative to `/api/plugins/<pluginId>` ('/events' → the
|
||||
* plugin's own event stream). Token-mode backends auth via the same query
|
||||
* credential the app's own sockets use; OAuth remotes resolve null (callers
|
||||
* keep their polling fallback — every consumer must have one anyway, since a
|
||||
* socket can drop). Auto-reconnects with backoff until disposed. */
|
||||
export function pluginSocket(pluginId: string, path: string, onMessage: (data: unknown) => void): () => void {
|
||||
const suffix = pluginPathSuffix('pluginSocket', path)
|
||||
|
||||
let socket: null | WebSocket = null
|
||||
let disposed = false
|
||||
let attempt = 0
|
||||
|
||||
const connect = async () => {
|
||||
const connection = await activeConnection().catch(() => null)
|
||||
|
||||
// No bridge / OAuth cookie auth (WS tickets are single-use, core-managed):
|
||||
// stay on the polling fallback rather than half-working.
|
||||
if (disposed || !connection || connection.authMode === 'oauth') {
|
||||
return
|
||||
}
|
||||
|
||||
const base = connection.baseUrl.replace(/^http/, 'ws')
|
||||
const join = suffix.includes('?') ? '&' : '?'
|
||||
socket = new WebSocket(
|
||||
`${base}/api/plugins/${pluginId}${suffix}${join}token=${encodeURIComponent(connection.token)}`
|
||||
)
|
||||
|
||||
socket.onmessage = event => {
|
||||
attempt = 0
|
||||
|
||||
try {
|
||||
onMessage(JSON.parse(String(event.data)))
|
||||
} catch {
|
||||
// Non-JSON frame — plugin streams are JSON by contract; skip it.
|
||||
}
|
||||
}
|
||||
|
||||
socket.onclose = () => {
|
||||
socket = null
|
||||
|
||||
if (!disposed) {
|
||||
// Full-jitter exponential backoff: same rationale as the gateway
|
||||
// socket reconnect loops — an immediate-retry loop across many
|
||||
// desktop clients floods the gateway with connection attempts
|
||||
// during a restart.
|
||||
window.setTimeout(() => void connect(), reconnectBackoffDelayMs(attempt, { baseDelayMs: 500, capMs: 30_000 }))
|
||||
attempt += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void connect()
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
socket?.close()
|
||||
}
|
||||
}
|
||||
89
apps/desktop/src/api/profiles.ts
Normal file
89
apps/desktop/src/api/profiles.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import type {
|
||||
ProfileCreatePayload,
|
||||
ProfileDesktopOverlay,
|
||||
ProfileSetupCommand,
|
||||
ProfileSoul,
|
||||
ProfilesResponse
|
||||
} from '@/types/hermes'
|
||||
|
||||
import { hermesApi, STARTUP_REQUEST_TIMEOUT_MS } from './client'
|
||||
|
||||
export function getProfiles(): Promise<ProfilesResponse> {
|
||||
return hermesApi<ProfilesResponse>({
|
||||
path: '/api/profiles',
|
||||
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
|
||||
})
|
||||
}
|
||||
|
||||
export function createProfile(body: ProfileCreatePayload): Promise<{ name: string; ok: boolean; path: string }> {
|
||||
return hermesApi<{ name: string; ok: boolean; path: string }>({
|
||||
path: '/api/profiles',
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
}
|
||||
|
||||
export function renameProfile(name: string, newName: string): Promise<{ name: string; ok: boolean; path: string }> {
|
||||
return hermesApi<{ name: string; ok: boolean; path: string }>({
|
||||
path: `/api/profiles/${encodeURIComponent(name)}`,
|
||||
method: 'PATCH',
|
||||
body: { new_name: newName }
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteProfile(name: string): Promise<{ ok: boolean; path: string }> {
|
||||
return hermesApi<{ ok: boolean; path: string }>({
|
||||
path: `/api/profiles/${encodeURIComponent(name)}`,
|
||||
method: 'DELETE'
|
||||
})
|
||||
}
|
||||
|
||||
export function getProfileSoul(name: string): Promise<ProfileSoul> {
|
||||
return hermesApi<ProfileSoul>({
|
||||
path: `/api/profiles/${encodeURIComponent(name)}/soul`
|
||||
})
|
||||
}
|
||||
|
||||
export function updateProfileSoul(name: string, content: string): Promise<{ ok: boolean }> {
|
||||
return hermesApi<{ ok: boolean }>({
|
||||
path: `/api/profiles/${encodeURIComponent(name)}/soul`,
|
||||
method: 'PUT',
|
||||
body: { content }
|
||||
})
|
||||
}
|
||||
|
||||
export function getProfileSetupCommand(name: string): Promise<ProfileSetupCommand> {
|
||||
return hermesApi<ProfileSetupCommand>({
|
||||
path: `/api/profiles/${encodeURIComponent(name)}/setup-command`
|
||||
})
|
||||
}
|
||||
|
||||
/** Export a profile to a shareable .tar.gz on the backend's filesystem.
|
||||
* `extraFiles` stages extra root-level files (desktop.json — the appearance/
|
||||
* interface overlay) into the archive alongside the profile's own artifacts. */
|
||||
export function exportProfileArchive(
|
||||
name: string,
|
||||
opts: { extraFiles?: Record<string, string>; output?: string } = {}
|
||||
): Promise<{ archive: string; ok: boolean }> {
|
||||
return hermesApi<{ archive: string; ok: boolean }>({
|
||||
path: `/api/profiles/${encodeURIComponent(name)}/export`,
|
||||
method: 'POST',
|
||||
body: { extra_files: opts.extraFiles ?? {}, output: opts.output ?? '' },
|
||||
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
|
||||
})
|
||||
}
|
||||
|
||||
/** Import a profile .tar.gz as a new profile. Returns the bundled desktop
|
||||
* appearance overlay too (when the archive carried one) so the caller can
|
||||
* apply theme/layout without another round-trip. */
|
||||
export function importProfileArchive(
|
||||
archive: string,
|
||||
name?: string
|
||||
): Promise<{ desktop: null | ProfileDesktopOverlay; name: string; ok: boolean; path: string }> {
|
||||
return hermesApi<{ desktop: null | ProfileDesktopOverlay; name: string; ok: boolean; path: string }>({
|
||||
path: '/api/profiles/import',
|
||||
method: 'POST',
|
||||
body: { archive, name: name || null },
|
||||
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
|
||||
})
|
||||
}
|
||||
465
apps/desktop/src/api/sessions.ts
Normal file
465
apps/desktop/src/api/sessions.ts
Normal file
@@ -0,0 +1,465 @@
|
||||
import { isMissingRestEndpoint } from '@/lib/gateway-rpc'
|
||||
import { recordTranscriptTail } from '@/store/transcript-tail'
|
||||
import type {
|
||||
PaginatedSessions,
|
||||
SessionInfo,
|
||||
SessionMessage,
|
||||
SessionMessagesResponse,
|
||||
SessionSearchResponse
|
||||
} from '@/types/hermes'
|
||||
|
||||
import { hermesApi } from './client'
|
||||
|
||||
const SESSION_LIST_REQUEST_TIMEOUT_MS = 60_000
|
||||
|
||||
/**
|
||||
* Trim a page to its window WITHOUT discarding pinned rows.
|
||||
*
|
||||
* The list endpoints deliberately back-fill pinned conversations past their
|
||||
* LIMIT — a pin means "always reachable", so an aged-out pinned chat is
|
||||
* appended after the recency window. A plain `slice(0, limit)` throws exactly
|
||||
* those rows away again, which is why pins silently stopped rendering past
|
||||
* some count: the sidebar could only ever show the pins that happened to fall
|
||||
* inside the most-recent page.
|
||||
*/
|
||||
function pageWindow(sessions: SessionInfo[], limit: number): SessionInfo[] {
|
||||
if (sessions.length <= limit) {
|
||||
return sessions
|
||||
}
|
||||
|
||||
const recent = sessions.slice(0, limit)
|
||||
|
||||
return [...recent, ...sessions.slice(limit).filter(session => session.pinned)]
|
||||
}
|
||||
|
||||
export async function listSessions(
|
||||
limit = 40,
|
||||
minMessages = 0,
|
||||
archived: 'exclude' | 'include' | 'only' = 'exclude',
|
||||
order: 'created' | 'recent' = 'recent'
|
||||
): Promise<PaginatedSessions> {
|
||||
const result = await hermesApi<PaginatedSessions>({
|
||||
path:
|
||||
`/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}` +
|
||||
`&archived=${archived}&order=${order}`,
|
||||
timeoutMs: SESSION_LIST_REQUEST_TIMEOUT_MS
|
||||
})
|
||||
|
||||
return {
|
||||
...result,
|
||||
sessions: pageWindow(result.sessions, limit),
|
||||
offset: 0
|
||||
}
|
||||
}
|
||||
|
||||
// Unified, read-only session list aggregated across ALL profiles. Served by the
|
||||
// primary backend straight off each profile's state.db — no per-profile backend
|
||||
// is spawned. Single-profile users get the same rows as listSessions(), tagged
|
||||
// profile="default".
|
||||
// Source scoping lets callers split the unified list into independent slices:
|
||||
// recents pass `excludeSources: ['cron']`, the cron-jobs section passes
|
||||
// `source: 'cron'`. Without this a burst of (always-newest) cron sessions
|
||||
// consumes the whole recents page and starves real conversations.
|
||||
export interface SessionSourceFilter {
|
||||
source?: string
|
||||
excludeSources?: string[]
|
||||
}
|
||||
|
||||
export async function listAllProfileSessions(
|
||||
limit = 40,
|
||||
minMessages = 0,
|
||||
archived: 'exclude' | 'include' | 'only' = 'exclude',
|
||||
order: 'created' | 'recent' = 'recent',
|
||||
profile: 'all' | (string & {}) = 'all',
|
||||
filter: SessionSourceFilter = {}
|
||||
): Promise<PaginatedSessions> {
|
||||
const sourceParam = filter.source ? `&source=${encodeURIComponent(filter.source)}` : ''
|
||||
|
||||
const excludeParam = filter.excludeSources?.length
|
||||
? `&exclude_sources=${encodeURIComponent(filter.excludeSources.join(','))}`
|
||||
: ''
|
||||
|
||||
const result = await hermesApi<PaginatedSessions>({
|
||||
path:
|
||||
`/api/profiles/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}` +
|
||||
`&archived=${archived}&order=${order}&profile=${encodeURIComponent(profile)}${sourceParam}${excludeParam}`,
|
||||
timeoutMs: SESSION_LIST_REQUEST_TIMEOUT_MS
|
||||
})
|
||||
|
||||
return {
|
||||
...result,
|
||||
sessions: pageWindow(result.sessions, limit),
|
||||
offset: 0
|
||||
}
|
||||
}
|
||||
|
||||
// Batched sidebar slices in one request: recents (scoped to the active profile),
|
||||
// cron, and messaging. The backend opens each profile's state.db once and runs
|
||||
// all three filtered queries, replacing three separate listAllProfileSessions
|
||||
// calls that each reopened + re-counted every profile DB per refresh. Electron
|
||||
// splices remote profiles per slice (see interceptSessionRequestForRemote).
|
||||
export interface SidebarSessionSlice {
|
||||
sessions: SessionInfo[]
|
||||
/** Per-profile "the window came back full, more rows exist on disk" flags —
|
||||
* what pagination needs, without a COUNT(*) per profile DB per refresh. */
|
||||
profiles_truncated?: Record<string, boolean>
|
||||
/** Per-profile tokens and spend over every session, not just this window.
|
||||
* Absent from the legacy per-slice endpoint, which has no aggregate. */
|
||||
profiles_usage?: Record<string, { cost_usd: number; tokens: number }>
|
||||
}
|
||||
|
||||
/** Which profiles filled their per-profile window in a returned page. The
|
||||
* legacy per-slice endpoint doesn't report this, so derive it from the rows:
|
||||
* a profile at (or over) the cap still has more on disk. Pinned rows are
|
||||
* discounted — they're back-filled past the LIMIT, so counting them fakes a
|
||||
* full page and leaves a "Load more" that can never resolve. */
|
||||
function profilesTruncatedFrom(sessions: SessionInfo[], cap: number): Record<string, boolean> {
|
||||
const counts = new Map<string, number>()
|
||||
|
||||
for (const session of sessions) {
|
||||
const key = session.profile || 'default'
|
||||
|
||||
counts.set(key, (counts.get(key) ?? 0) + (session.pinned ? 0 : 1))
|
||||
}
|
||||
|
||||
return Object.fromEntries([...counts].map(([name, count]) => [name, count >= cap]))
|
||||
}
|
||||
|
||||
export interface SidebarSessionsResponse {
|
||||
recents: SidebarSessionSlice
|
||||
cron: SidebarSessionSlice
|
||||
messaging: SidebarSessionSlice
|
||||
errors?: Array<{ profile: string; error: string }>
|
||||
}
|
||||
|
||||
export interface SidebarSessionsRequest {
|
||||
recentsProfile: 'all' | (string & {})
|
||||
recentsLimit: number
|
||||
recentsExclude: string[]
|
||||
cronLimit: number
|
||||
messagingLimit: number
|
||||
messagingExclude: string[]
|
||||
}
|
||||
|
||||
// The batched /sidebar endpoint shipped later than the per-slice route, so a
|
||||
// newer desktop can meet an older backend that 404s it ("No such API
|
||||
// endpoint"). Endpoint-missing is a capability signal, not a transient
|
||||
// failure: remember it (per renderer lifetime — a runtime home change reloads
|
||||
// the window and re-probes) and serve every subsequent refresh straight from
|
||||
// the three proven per-slice calls instead of re-probing a known-dead route
|
||||
// once per turn/broadcast.
|
||||
let sidebarBatchEndpointMissing = false
|
||||
|
||||
// Capability flags are per-backend facts. A hard re-home reloads the window
|
||||
// (module state resets naturally), but a soft gateway switch re-dials in
|
||||
// place — the next backend may well have the batched route, so the switch
|
||||
// paths call this to re-probe rather than leak the old backend's capability.
|
||||
export function resetSidebarBatchCapability() {
|
||||
sidebarBatchEndpointMissing = false
|
||||
}
|
||||
|
||||
// Compatibility fallback: reassemble the three sidebar slices from the
|
||||
// per-slice endpoint, mirroring the batched route's semantics (min_messages=1,
|
||||
// archived excluded, recency order; every slice scoped to the caller's profile).
|
||||
// Rides the same Electron remote-splice
|
||||
// interception as the pre-batching desktop, so remote profiles stay correct.
|
||||
async function listSidebarSessionsLegacy(req: SidebarSessionsRequest): Promise<SidebarSessionsResponse> {
|
||||
const [recents, cron, messaging] = await Promise.all([
|
||||
listAllProfileSessions(req.recentsLimit, 1, 'exclude', 'recent', req.recentsProfile, {
|
||||
excludeSources: req.recentsExclude
|
||||
}),
|
||||
listAllProfileSessions(req.cronLimit, 1, 'exclude', 'recent', req.recentsProfile, { source: 'cron' }),
|
||||
listAllProfileSessions(req.messagingLimit, 1, 'exclude', 'recent', req.recentsProfile, {
|
||||
excludeSources: req.messagingExclude
|
||||
})
|
||||
])
|
||||
|
||||
const errors = [...(recents.errors ?? []), ...(cron.errors ?? []), ...(messaging.errors ?? [])]
|
||||
|
||||
return {
|
||||
recents: {
|
||||
profiles_truncated: profilesTruncatedFrom(recents.sessions, req.recentsLimit),
|
||||
sessions: recents.sessions
|
||||
},
|
||||
cron: { sessions: cron.sessions },
|
||||
messaging: { sessions: messaging.sessions },
|
||||
...(errors.length ? { errors } : {})
|
||||
}
|
||||
}
|
||||
|
||||
/** The PR each of these sessions opened, recovered from its own transcript —
|
||||
* for sessions whose recorded branch can't answer (they started on trunk and
|
||||
* did the work in a worktree). Also returns every id it looked at, so the
|
||||
* caller can remember a miss and never ask again. */
|
||||
export function scanSessionPullRequests(
|
||||
ids: string[]
|
||||
): Promise<{ pull_requests: Record<string, { number: number; url: string }>; scanned: string[] }> {
|
||||
return hermesApi<{
|
||||
pull_requests: Record<string, { number: number; url: string }>
|
||||
scanned: string[]
|
||||
}>({
|
||||
path: '/api/profiles/sessions/pull-requests',
|
||||
method: 'POST',
|
||||
body: { ids }
|
||||
})
|
||||
}
|
||||
|
||||
export async function listSidebarSessions(req: SidebarSessionsRequest): Promise<SidebarSessionsResponse> {
|
||||
if (sidebarBatchEndpointMissing) {
|
||||
return listSidebarSessionsLegacy(req)
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
recents_profile: req.recentsProfile,
|
||||
recents_limit: String(Math.max(1, req.recentsLimit)),
|
||||
cron_limit: String(Math.max(1, req.cronLimit)),
|
||||
messaging_limit: String(Math.max(1, req.messagingLimit))
|
||||
})
|
||||
|
||||
if (req.recentsExclude.length) {
|
||||
params.set('recents_exclude', req.recentsExclude.join(','))
|
||||
}
|
||||
|
||||
if (req.messagingExclude.length) {
|
||||
params.set('messaging_exclude', req.messagingExclude.join(','))
|
||||
}
|
||||
|
||||
let result: SidebarSessionsResponse
|
||||
|
||||
try {
|
||||
result = await hermesApi<SidebarSessionsResponse>({
|
||||
path: `/api/profiles/sessions/sidebar?${params.toString()}`,
|
||||
timeoutMs: SESSION_LIST_REQUEST_TIMEOUT_MS
|
||||
})
|
||||
} catch (err) {
|
||||
// Safe to read a 404 as route-missing here: this GET has no path params,
|
||||
// so it cannot 404 on a bad id.
|
||||
if (!isMissingRestEndpoint(err)) {
|
||||
throw err
|
||||
}
|
||||
|
||||
// Older backend without the batched route (desktop/runtime version skew).
|
||||
sidebarBatchEndpointMissing = true
|
||||
|
||||
return listSidebarSessionsLegacy(req)
|
||||
}
|
||||
|
||||
return {
|
||||
recents: { ...result.recents, sessions: result.recents?.sessions ?? [] },
|
||||
cron: { ...result.cron, sessions: result.cron?.sessions ?? [] },
|
||||
messaging: { ...result.messaging, sessions: result.messaging?.sessions ?? [] },
|
||||
errors: result.errors
|
||||
}
|
||||
}
|
||||
|
||||
// Mutations take the owning `profile` so Electron can route them to the correct
|
||||
// remote backend or local profile scope. Omit for the current/default profile.
|
||||
export function setSessionArchived(id: string, archived: boolean, profile?: string | null): Promise<{ ok: boolean }> {
|
||||
return hermesApi<{ ok: boolean }>({
|
||||
...(profile ? { profile } : {}),
|
||||
path: `/api/sessions/${encodeURIComponent(id)}`,
|
||||
method: 'PATCH',
|
||||
body: { archived }
|
||||
})
|
||||
}
|
||||
|
||||
// Mirror a sidebar pin to the backend "keep" flag so the sessions.auto_archive
|
||||
// sweep (which runs backend-side, blind to Desktop localStorage) never hides a
|
||||
// pinned chat. Best-effort: the sidebar stays localStorage-driven for its own
|
||||
// display; this only feeds the backend policy.
|
||||
export function setSessionPinnedRemote(id: string, pinned: boolean, profile?: string | null): Promise<{ ok: boolean }> {
|
||||
return hermesApi<{ ok: boolean }>({
|
||||
...(profile ? { profile } : {}),
|
||||
path: `/api/sessions/${encodeURIComponent(id)}`,
|
||||
method: 'PATCH',
|
||||
body: { pinned }
|
||||
})
|
||||
}
|
||||
|
||||
// Mirror a sidebar unread toggle to the backend read-state watermark
|
||||
// (sessions.last_read_at via SessionDB.set_session_read). Same profile
|
||||
// routing as the other session mutations: a remote session's row lives only
|
||||
// on its remote host, so the owning profile must travel with the request.
|
||||
export function setSessionUnreadRemote(id: string, unread: boolean, profile?: string | null): Promise<{ ok: boolean }> {
|
||||
return hermesApi<{ ok: boolean }>({
|
||||
...(profile ? { profile } : {}),
|
||||
path: `/api/sessions/${encodeURIComponent(id)}`,
|
||||
method: 'PATCH',
|
||||
body: { unread }
|
||||
})
|
||||
}
|
||||
|
||||
export function searchSessions(query: string): Promise<SessionSearchResponse> {
|
||||
return hermesApi<SessionSearchResponse>({
|
||||
path: `/api/sessions/search?q=${encodeURIComponent(query)}`
|
||||
})
|
||||
}
|
||||
|
||||
// Resolves a single session row by id on one backend (the active profile, or
|
||||
// the given `profile`). The backend resolves exact ids and unique prefixes and
|
||||
// 404s when the id isn't on that profile — so a cheap by-id lookup replaces the
|
||||
// cross-profile list scan when locating an unknown id's owner.
|
||||
export function getSession(id: string, profile?: string | null): Promise<SessionInfo> {
|
||||
const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : ''
|
||||
|
||||
return hermesApi<SessionInfo>({
|
||||
...(profile ? { profile } : {}),
|
||||
path: `/api/sessions/${encodeURIComponent(id)}${suffix}`
|
||||
})
|
||||
}
|
||||
|
||||
// Reads another profile's transcript. For a remote profile Electron reroutes
|
||||
// this GET to the remote backend (which serves its own state.db); for a local
|
||||
// profile the primary opens that profile's state.db via ?profile=. Omit for
|
||||
// the current/default profile.
|
||||
export function getSessionMessages(
|
||||
id: string,
|
||||
profile?: string | null,
|
||||
page: { limit?: number; offset?: number; order?: 'latest' | 'oldest'; includeCompacted?: boolean } = {}
|
||||
): Promise<SessionMessagesResponse> {
|
||||
const query = new URLSearchParams()
|
||||
|
||||
if (profile) {
|
||||
query.set('profile', profile)
|
||||
}
|
||||
|
||||
if (page.limit !== undefined) {
|
||||
query.set('limit', String(page.limit))
|
||||
}
|
||||
|
||||
if (page.offset !== undefined) {
|
||||
query.set('offset', String(page.offset))
|
||||
}
|
||||
|
||||
if (page.order) {
|
||||
query.set('order', page.order)
|
||||
}
|
||||
|
||||
if (page.includeCompacted !== undefined) {
|
||||
query.set('include_compacted', String(page.includeCompacted))
|
||||
}
|
||||
|
||||
const suffix = query.size ? `?${query.toString()}` : ''
|
||||
|
||||
return hermesApi<SessionMessagesResponse>({
|
||||
...(profile ? { profile } : {}),
|
||||
path: `/api/sessions/${encodeURIComponent(id)}/messages${suffix}`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The initial hydration page: enough tail to fill the transcript window a few
|
||||
* times over, small enough that opening a long session doesn't ship (and
|
||||
* convert) hundreds of rows nobody has scrolled to. Older rows load on demand
|
||||
* via `getOlderSessionMessages` when "Show earlier" exhausts the in-memory
|
||||
* store (see app/chat/transcript-backfill).
|
||||
*/
|
||||
export const LATEST_SESSION_MESSAGES_LIMIT = 120
|
||||
|
||||
export function getLatestSessionMessages(id: string, profile?: string | null): Promise<SessionMessagesResponse> {
|
||||
// includeCompacted: durable display history must include rows preserved by
|
||||
// in-place compaction (active=0, compacted=1); without them the transcript
|
||||
// silently ends at the compaction boundary and earlier turns are unreachable.
|
||||
return getSessionMessages(id, profile, {
|
||||
limit: LATEST_SESSION_MESSAGES_LIMIT,
|
||||
order: 'latest',
|
||||
includeCompacted: true
|
||||
}).then(page => {
|
||||
// Record whether the tail was truncated (page came back full) and where
|
||||
// the next older page starts, so "Show earlier" can backfill over REST
|
||||
// (app/chat/transcript-backfill). Keyed under both the requested id and
|
||||
// the resolved id — callers hold either.
|
||||
recordTranscriptTail(id, page, profile)
|
||||
|
||||
if (page.session_id && page.session_id !== id) {
|
||||
recordTranscriptTail(page.session_id, page, profile)
|
||||
}
|
||||
|
||||
return page
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* One page of messages OLDER than the `offset` newest rows.
|
||||
*
|
||||
* Backend semantics (`_handle_session_messages` → `SessionDB.get_messages`
|
||||
* with `latest=True`): the offset is measured back from the NEWEST message
|
||||
* and the selected page is returned in chronological order. So after a tail
|
||||
* hydration of N rows, `getOlderSessionMessages(id, profile, N)` returns the
|
||||
* page immediately preceding it, ready to prepend.
|
||||
*
|
||||
* Legacy backends without pagination support return the full transcript and
|
||||
* no `pagination` metadata — callers detect that via the missing field and
|
||||
* treat the response as the complete history (see transcript-backfill).
|
||||
*/
|
||||
export function getOlderSessionMessages(
|
||||
id: string,
|
||||
profile: string | null | undefined,
|
||||
offset: number,
|
||||
limit: number = LATEST_SESSION_MESSAGES_LIMIT
|
||||
): Promise<SessionMessagesResponse> {
|
||||
return getSessionMessages(id, profile, { includeCompacted: true, limit, offset, order: 'latest' })
|
||||
}
|
||||
|
||||
export async function getAllSessionMessages(
|
||||
id: string,
|
||||
profile?: string | null,
|
||||
options: { maxJsonChars?: number } = {}
|
||||
): Promise<SessionMessagesResponse> {
|
||||
const messages: SessionMessage[] = []
|
||||
const pageSize = 500
|
||||
const maxJsonChars = options.maxJsonChars ?? 32_000_000
|
||||
let jsonChars = 0
|
||||
let offset = 0
|
||||
let resolvedSessionId = id
|
||||
|
||||
while (true) {
|
||||
const page = await getSessionMessages(id, profile, {
|
||||
limit: pageSize,
|
||||
offset,
|
||||
order: 'oldest',
|
||||
includeCompacted: true
|
||||
})
|
||||
|
||||
resolvedSessionId = page.session_id
|
||||
jsonChars += (JSON.stringify(page.messages) ?? '').length
|
||||
|
||||
if (jsonChars > maxJsonChars) {
|
||||
throw new Error(
|
||||
'Session transcript exceeds the Desktop safe-load limit; use the Web Dashboard export for this session.'
|
||||
)
|
||||
}
|
||||
|
||||
messages.push(...page.messages)
|
||||
|
||||
// Legacy backends ignore pagination and return the full transcript.
|
||||
if (!page.pagination || page.messages.length === 0 || page.messages.length < page.pagination.limit) {
|
||||
break
|
||||
}
|
||||
|
||||
offset += page.messages.length
|
||||
}
|
||||
|
||||
return { session_id: resolvedSessionId, messages }
|
||||
}
|
||||
|
||||
export function deleteSession(id: string, profile?: string | null): Promise<{ ok: boolean }> {
|
||||
return hermesApi<{ ok: boolean }>({
|
||||
...(profile ? { profile } : {}),
|
||||
path: `/api/sessions/${encodeURIComponent(id)}`,
|
||||
method: 'DELETE'
|
||||
})
|
||||
}
|
||||
|
||||
export function renameSession(
|
||||
id: string,
|
||||
title: string,
|
||||
profile?: string | null
|
||||
): Promise<{ ok: boolean; title: string }> {
|
||||
return hermesApi<{ ok: boolean; title: string }>({
|
||||
...(profile ? { profile } : {}),
|
||||
path: `/api/sessions/${encodeURIComponent(id)}`,
|
||||
method: 'PATCH',
|
||||
body: { title, ...(profile ? { profile } : {}) }
|
||||
})
|
||||
}
|
||||
162
apps/desktop/src/api/skills.ts
Normal file
162
apps/desktop/src/api/skills.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import type {
|
||||
SkillHubPreview,
|
||||
SkillHubScanResult,
|
||||
SkillHubSearchResponse,
|
||||
SkillHubSourcesResponse,
|
||||
SkillInfo,
|
||||
StarmapGraph
|
||||
} from '@/types/hermes'
|
||||
import type { ActionResponse } from '@/types/hermes'
|
||||
|
||||
import { capabilityScoped, hermesApi, type ProfileScope, profileScoped } from './client'
|
||||
|
||||
export function getSkills(profile?: ProfileScope): Promise<SkillInfo[]> {
|
||||
return window.hermesDesktop.api<SkillInfo[]>({
|
||||
...capabilityScoped(profile),
|
||||
path: '/api/skills'
|
||||
})
|
||||
}
|
||||
|
||||
/** Raw SKILL.md text (frontmatter included) for ANY skill — bundled, hub, or
|
||||
* learned — backing the Capabilities detail pane's full-skill view. */
|
||||
export function getSkillContent(
|
||||
name: string,
|
||||
profile?: ProfileScope
|
||||
): Promise<{ content: string; name: string; path: string }> {
|
||||
return window.hermesDesktop.api<{ content: string; name: string; path: string }>({
|
||||
...capabilityScoped(profile),
|
||||
path: `/api/skills/content?name=${encodeURIComponent(name)}`
|
||||
})
|
||||
}
|
||||
|
||||
export function setSkillEnabled(
|
||||
name: string,
|
||||
enabled: boolean,
|
||||
profile?: ProfileScope
|
||||
): Promise<{ ok: boolean; name: string; enabled: boolean }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean; name: string; enabled: boolean }>({
|
||||
...capabilityScoped(profile),
|
||||
path: '/api/skills/toggle',
|
||||
method: 'PUT',
|
||||
body: { name, enabled }
|
||||
})
|
||||
}
|
||||
|
||||
export function getStarmapGraph(): Promise<StarmapGraph> {
|
||||
return hermesApi<StarmapGraph>({
|
||||
...profileScoped(),
|
||||
// Backend REST contract — stays /api/learning even though the UI feature is
|
||||
// now "star map". Renaming this would break against an un-upgraded backend.
|
||||
path: '/api/learning/graph'
|
||||
})
|
||||
}
|
||||
|
||||
export interface LearningNodeDetail {
|
||||
content: string
|
||||
kind: 'memory' | 'skill'
|
||||
label: string
|
||||
ok: boolean
|
||||
}
|
||||
|
||||
export function getLearningNode(id: string, profile?: ProfileScope): Promise<LearningNodeDetail> {
|
||||
return window.hermesDesktop.api<LearningNodeDetail>({
|
||||
...capabilityScoped(profile),
|
||||
path: `/api/learning/node?id=${encodeURIComponent(id)}`
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteLearningNode(id: string, profile?: ProfileScope): Promise<{ message: string; ok: boolean }> {
|
||||
return window.hermesDesktop.api<{ message: string; ok: boolean }>({
|
||||
...capabilityScoped(profile),
|
||||
path: '/api/learning/node',
|
||||
method: 'DELETE',
|
||||
body: { id }
|
||||
})
|
||||
}
|
||||
|
||||
export function editLearningNode(
|
||||
id: string,
|
||||
content: string,
|
||||
profile?: ProfileScope
|
||||
): Promise<{ message: string; ok: boolean }> {
|
||||
return window.hermesDesktop.api<{ message: string; ok: boolean }>({
|
||||
...capabilityScoped(profile),
|
||||
path: '/api/learning/node',
|
||||
method: 'PUT',
|
||||
body: { content, id }
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skills hub — search / preview / scan / install (parity with `hermes skills`
|
||||
// and the dashboard's Browse-hub tab). Installs spawn background actions whose
|
||||
// logs are tailed via getActionStatus().
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const HUB_REQUEST_TIMEOUT_MS = 45_000
|
||||
|
||||
export function getSkillHubSources(profile?: null | string): Promise<SkillHubSourcesResponse> {
|
||||
return hermesApi<SkillHubSourcesResponse>({
|
||||
...profileScoped(profile),
|
||||
path: '/api/skills/hub/sources',
|
||||
timeoutMs: HUB_REQUEST_TIMEOUT_MS
|
||||
})
|
||||
}
|
||||
|
||||
export function searchSkillsHub(
|
||||
query: string,
|
||||
source = 'all',
|
||||
limit = 20,
|
||||
profile?: null | string
|
||||
): Promise<SkillHubSearchResponse> {
|
||||
const params = new URLSearchParams({ q: query, source, limit: String(limit) })
|
||||
|
||||
return hermesApi<SkillHubSearchResponse>({
|
||||
...profileScoped(profile),
|
||||
path: `/api/skills/hub/search?${params.toString()}`,
|
||||
timeoutMs: HUB_REQUEST_TIMEOUT_MS
|
||||
})
|
||||
}
|
||||
|
||||
export function previewSkillHub(identifier: string, profile?: null | string): Promise<SkillHubPreview> {
|
||||
return hermesApi<SkillHubPreview>({
|
||||
...profileScoped(profile),
|
||||
path: `/api/skills/hub/preview?identifier=${encodeURIComponent(identifier)}`,
|
||||
timeoutMs: HUB_REQUEST_TIMEOUT_MS
|
||||
})
|
||||
}
|
||||
|
||||
export function scanSkillHub(identifier: string, profile?: null | string): Promise<SkillHubScanResult> {
|
||||
return hermesApi<SkillHubScanResult>({
|
||||
...profileScoped(profile),
|
||||
path: `/api/skills/hub/scan?identifier=${encodeURIComponent(identifier)}`,
|
||||
timeoutMs: HUB_REQUEST_TIMEOUT_MS
|
||||
})
|
||||
}
|
||||
|
||||
export function installSkillFromHub(identifier: string, profile?: ProfileScope): Promise<ActionResponse> {
|
||||
return window.hermesDesktop.api<ActionResponse>({
|
||||
...capabilityScoped(profile),
|
||||
path: '/api/skills/hub/install',
|
||||
method: 'POST',
|
||||
body: { identifier }
|
||||
})
|
||||
}
|
||||
|
||||
export function uninstallSkillFromHub(name: string, profile?: ProfileScope): Promise<ActionResponse> {
|
||||
return window.hermesDesktop.api<ActionResponse>({
|
||||
...capabilityScoped(profile),
|
||||
path: '/api/skills/hub/uninstall',
|
||||
method: 'POST',
|
||||
body: { name }
|
||||
})
|
||||
}
|
||||
|
||||
export function updateSkillsFromHub(profile?: ProfileScope): Promise<ActionResponse> {
|
||||
return window.hermesDesktop.api<ActionResponse>({
|
||||
...capabilityScoped(profile),
|
||||
path: '/api/skills/hub/update',
|
||||
method: 'POST',
|
||||
body: {}
|
||||
})
|
||||
}
|
||||
247
apps/desktop/src/api/system.ts
Normal file
247
apps/desktop/src/api/system.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import type {
|
||||
ActionResponse,
|
||||
ActionStatusResponse,
|
||||
AudioSpeakResponse,
|
||||
AudioTranscriptionResponse,
|
||||
BackendUpdateCheckResponse,
|
||||
CuratorStatusResponse,
|
||||
DebugShareResponse,
|
||||
ElevenLabsVoicesResponse,
|
||||
MemoryProviderConfig,
|
||||
MemoryProviderOAuthStatus,
|
||||
MemoryStatusResponse
|
||||
} from '@/types/hermes'
|
||||
|
||||
import { capabilityScoped, hermesApi, type ProfileScope, profileScoped } from './client'
|
||||
|
||||
export const AUDIO_SPEAK_MIN_REQUEST_TIMEOUT_MS = 180_000
|
||||
export const AUDIO_SPEAK_MAX_REQUEST_TIMEOUT_MS = 600_000
|
||||
const AUDIO_SPEAK_TIMEOUT_MS_PER_CHAR = 35
|
||||
|
||||
export function audioSpeakRequestTimeoutMs(text: string): number {
|
||||
const estimated = Math.max(
|
||||
AUDIO_SPEAK_MIN_REQUEST_TIMEOUT_MS,
|
||||
Math.ceil(String(text || '').length * AUDIO_SPEAK_TIMEOUT_MS_PER_CHAR)
|
||||
)
|
||||
|
||||
return Math.min(AUDIO_SPEAK_MAX_REQUEST_TIMEOUT_MS, estimated)
|
||||
}
|
||||
|
||||
export const AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS = 180_000
|
||||
export const AUDIO_TRANSCRIBE_MAX_REQUEST_TIMEOUT_MS = 600_000
|
||||
// The transcribe payload is the base64 audio data URL itself, so its string
|
||||
// length tracks clip size. ~0.1ms/char keeps short clips at the floor while
|
||||
// letting multi-minute recordings scale toward the cap (a base64 char is
|
||||
// ~0.75 bytes, so at 128kbps ≈ 21k chars/s of audio this budgets ~2s of
|
||||
// timeout per 1s of audio before the cap clamps it).
|
||||
const AUDIO_TRANSCRIBE_TIMEOUT_MS_PER_CHAR = 0.1
|
||||
|
||||
export function audioTranscribeRequestTimeoutMs(dataUrl: string): number {
|
||||
const estimated = Math.max(
|
||||
AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS,
|
||||
Math.ceil(String(dataUrl || '').length * AUDIO_TRANSCRIBE_TIMEOUT_MS_PER_CHAR)
|
||||
)
|
||||
|
||||
return Math.min(AUDIO_TRANSCRIBE_MAX_REQUEST_TIMEOUT_MS, estimated)
|
||||
}
|
||||
|
||||
// surface=declared serves the curated desktop schema; the dashboard consumes the raw plugin schema.
|
||||
export function getMemoryProviderConfig(provider: string, profile?: null | string): Promise<MemoryProviderConfig> {
|
||||
return hermesApi<MemoryProviderConfig>({
|
||||
...profileScoped(profile),
|
||||
path: `/api/memory/providers/${encodeURIComponent(provider)}/config?surface=declared`
|
||||
})
|
||||
}
|
||||
|
||||
export function saveMemoryProviderConfig(
|
||||
provider: string,
|
||||
values: Record<string, string>,
|
||||
profile?: null | string
|
||||
): Promise<{ ok: boolean }> {
|
||||
return hermesApi<{ ok: boolean }>({
|
||||
...profileScoped(profile),
|
||||
path: `/api/memory/providers/${encodeURIComponent(provider)}/config?surface=declared`,
|
||||
method: 'PUT',
|
||||
body: { values }
|
||||
})
|
||||
}
|
||||
|
||||
// Memory-provider OAuth connect (provider-keyed; 404s for providers without an
|
||||
// OAuth flow). Profile-scoped: the grant lands in the active profile's config.
|
||||
export function startMemoryProviderOAuth(
|
||||
provider: string,
|
||||
profile?: null | string
|
||||
): Promise<MemoryProviderOAuthStatus> {
|
||||
return hermesApi<MemoryProviderOAuthStatus>({
|
||||
...profileScoped(profile),
|
||||
path: `/api/memory/providers/${encodeURIComponent(provider)}/oauth/start`,
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
|
||||
export function getMemoryProviderOAuthStatus(
|
||||
provider: string,
|
||||
profile?: null | string
|
||||
): Promise<MemoryProviderOAuthStatus> {
|
||||
return hermesApi<MemoryProviderOAuthStatus>({
|
||||
...profileScoped(profile),
|
||||
path: `/api/memory/providers/${encodeURIComponent(provider)}/oauth/status`
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Memory data + curator (parity with `hermes memory` / `hermes curator`).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getMemoryStatus(): Promise<MemoryStatusResponse> {
|
||||
return hermesApi<MemoryStatusResponse>({
|
||||
...profileScoped(),
|
||||
path: '/api/memory'
|
||||
})
|
||||
}
|
||||
|
||||
export function resetMemory(target: 'all' | 'memory' | 'user'): Promise<{ ok: boolean; deleted: string[] }> {
|
||||
return hermesApi<{ ok: boolean; deleted: string[] }>({
|
||||
...profileScoped(),
|
||||
path: '/api/memory/reset',
|
||||
method: 'POST',
|
||||
body: { target }
|
||||
})
|
||||
}
|
||||
|
||||
export function getCuratorStatus(): Promise<CuratorStatusResponse> {
|
||||
return hermesApi<CuratorStatusResponse>({
|
||||
...profileScoped(),
|
||||
path: '/api/curator'
|
||||
})
|
||||
}
|
||||
|
||||
export function setCuratorPaused(paused: boolean): Promise<{ ok: boolean; paused: boolean }> {
|
||||
return hermesApi<{ ok: boolean; paused: boolean }>({
|
||||
...profileScoped(),
|
||||
path: '/api/curator/paused',
|
||||
method: 'PUT',
|
||||
body: { paused }
|
||||
})
|
||||
}
|
||||
|
||||
export function runCurator(): Promise<ActionResponse> {
|
||||
return hermesApi<ActionResponse>({
|
||||
...profileScoped(),
|
||||
path: '/api/curator/run',
|
||||
method: 'POST',
|
||||
body: {}
|
||||
})
|
||||
}
|
||||
|
||||
export function restartGateway(): Promise<ActionResponse> {
|
||||
return hermesApi<ActionResponse>({
|
||||
...profileScoped(),
|
||||
path: '/api/gateway/restart',
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
|
||||
export function updateHermes(): Promise<ActionResponse> {
|
||||
return hermesApi<ActionResponse>({
|
||||
...profileScoped(),
|
||||
path: '/api/hermes/update',
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
|
||||
/** Query the connected backend's own update state. In remote mode this is the
|
||||
* authoritative source for the backend's behind-count + "what's changed",
|
||||
* distinct from the Electron client clone's git state. */
|
||||
export function checkHermesUpdate(force = false): Promise<BackendUpdateCheckResponse> {
|
||||
return hermesApi<BackendUpdateCheckResponse>({
|
||||
...profileScoped(),
|
||||
path: `/api/hermes/update/check${force ? '?force=true' : ''}`
|
||||
})
|
||||
}
|
||||
|
||||
export function getActionStatus(name: string, lines = 200, profile?: ProfileScope): Promise<ActionStatusResponse> {
|
||||
return window.hermesDesktop.api<ActionStatusResponse>({
|
||||
...capabilityScoped(profile),
|
||||
path: `/api/actions/${encodeURIComponent(name)}/status?lines=${Math.max(1, lines)}`
|
||||
})
|
||||
}
|
||||
|
||||
export function transcribeAudio(dataUrl: string, mimeType?: string): Promise<AudioTranscriptionResponse> {
|
||||
return hermesApi<AudioTranscriptionResponse>({
|
||||
path: '/api/audio/transcribe',
|
||||
method: 'POST',
|
||||
...profileScoped(),
|
||||
body: {
|
||||
data_url: dataUrl,
|
||||
mime_type: mimeType
|
||||
},
|
||||
// Transcription blocks until provider STT, file handling, and response
|
||||
// encoding finish. Remote providers and long clips regularly exceed the
|
||||
// default 15s Electron backend timeout.
|
||||
timeoutMs: audioTranscribeRequestTimeoutMs(dataUrl)
|
||||
})
|
||||
}
|
||||
|
||||
export function speakText(text: string): Promise<AudioSpeakResponse> {
|
||||
return hermesApi<AudioSpeakResponse>({
|
||||
...profileScoped(),
|
||||
path: '/api/audio/speak',
|
||||
method: 'POST',
|
||||
body: { text },
|
||||
// TTS blocks until provider synthesis, file read, and base64 encoding
|
||||
// finish. Remote providers and large messages regularly exceed the
|
||||
// default 15s Electron backend timeout.
|
||||
timeoutMs: audioSpeakRequestTimeoutMs(text)
|
||||
})
|
||||
}
|
||||
|
||||
export function getElevenLabsVoices(profile?: null | string): Promise<ElevenLabsVoicesResponse> {
|
||||
return hermesApi<ElevenLabsVoicesResponse>({
|
||||
path: '/api/audio/elevenlabs/voices',
|
||||
...profileScoped(profile)
|
||||
})
|
||||
}
|
||||
|
||||
/** `gh` CLI presence + auth state, for the composer's GitHub skill pill
|
||||
* (GitHub is deliberately not an MCP — the github/* skills are the
|
||||
* integration). Backend caches for 5 minutes; `refresh` bypasses. */
|
||||
export function getGhAuthStatus(refresh = false): Promise<{ available: boolean; authenticated: boolean }> {
|
||||
return hermesApi<{ available: boolean; authenticated: boolean }>({
|
||||
...profileScoped(),
|
||||
path: `/api/git/gh-auth${refresh ? '?refresh=true' : ''}`
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Maintenance operations (parity with `hermes doctor` / `hermes security
|
||||
// audit` / `hermes backup` / `hermes debug share` and the dashboard System
|
||||
// page). All except debug share are spawn-based background actions tailed via
|
||||
// getActionStatus().
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function runDoctor(): Promise<ActionResponse> {
|
||||
return hermesApi<ActionResponse>({ path: '/api/ops/doctor', method: 'POST', body: {} })
|
||||
}
|
||||
|
||||
export function runSecurityAudit(): Promise<ActionResponse> {
|
||||
return hermesApi<ActionResponse>({ path: '/api/ops/security-audit', method: 'POST', body: {} })
|
||||
}
|
||||
|
||||
export function runBackup(): Promise<ActionResponse & { archive?: string }> {
|
||||
return hermesApi<ActionResponse & { archive?: string }>({
|
||||
path: '/api/ops/backup',
|
||||
method: 'POST',
|
||||
body: {}
|
||||
})
|
||||
}
|
||||
|
||||
export function runDebugShare(): Promise<DebugShareResponse> {
|
||||
return hermesApi<DebugShareResponse>({
|
||||
path: '/api/ops/debug-share',
|
||||
method: 'POST',
|
||||
body: {},
|
||||
// Synchronous upload of report + logs to the paste service.
|
||||
timeoutMs: 120_000
|
||||
})
|
||||
}
|
||||
141
apps/desktop/src/api/toolsets.ts
Normal file
141
apps/desktop/src/api/toolsets.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import type {
|
||||
ActionResponse,
|
||||
ComputerUseStatus,
|
||||
TerminalBackendsResponse,
|
||||
ToolsetConfig,
|
||||
ToolsetInfo,
|
||||
ToolsetModelsResponse
|
||||
} from '@/types/hermes'
|
||||
|
||||
import { capabilityScoped, hermesApi, type ProfileScope, profileScoped } from './client'
|
||||
|
||||
// The optional trailing `profile` on every capability fetcher below is the
|
||||
// Capabilities view's profile-scope override: it lets the Skills/Tools/MCP
|
||||
// panels configure ANY profile without swapping the app-wide active profile.
|
||||
// Omitting it (every pre-existing caller) means `profileScoped(undefined)`
|
||||
// falls back to the app-wide `_apiProfile`, so behavior is byte-identical.
|
||||
export function getToolsets(profile?: ProfileScope): Promise<ToolsetInfo[]> {
|
||||
return window.hermesDesktop.api<ToolsetInfo[]>({
|
||||
...capabilityScoped(profile),
|
||||
path: '/api/tools/toolsets'
|
||||
})
|
||||
}
|
||||
|
||||
export function setToolsetEnabled(
|
||||
name: string,
|
||||
enabled: boolean,
|
||||
profile?: ProfileScope
|
||||
): Promise<{ ok: boolean; name: string; enabled: boolean }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean; name: string; enabled: boolean }>({
|
||||
...capabilityScoped(profile),
|
||||
path: `/api/tools/toolsets/${encodeURIComponent(name)}`,
|
||||
method: 'PUT',
|
||||
body: { enabled }
|
||||
})
|
||||
}
|
||||
|
||||
export function getToolsetConfig(name: string, profile?: ProfileScope): Promise<ToolsetConfig> {
|
||||
return window.hermesDesktop.api<ToolsetConfig>({
|
||||
...capabilityScoped(profile),
|
||||
path: `/api/tools/toolsets/${encodeURIComponent(name)}/config`
|
||||
})
|
||||
}
|
||||
|
||||
export function getToolsetModels(
|
||||
name: string,
|
||||
provider?: string,
|
||||
profile?: ProfileScope
|
||||
): Promise<ToolsetModelsResponse> {
|
||||
const suffix = provider ? `?provider=${encodeURIComponent(provider)}` : ''
|
||||
|
||||
return window.hermesDesktop.api<ToolsetModelsResponse>({
|
||||
...capabilityScoped(profile),
|
||||
path: `/api/tools/toolsets/${encodeURIComponent(name)}/models${suffix}`
|
||||
})
|
||||
}
|
||||
|
||||
export function selectToolsetModel(
|
||||
name: string,
|
||||
model: string,
|
||||
provider?: string,
|
||||
profile?: ProfileScope
|
||||
): Promise<{ ok: boolean; name: string; model: string }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean; name: string; model: string }>({
|
||||
...capabilityScoped(profile),
|
||||
path: `/api/tools/toolsets/${encodeURIComponent(name)}/model`,
|
||||
method: 'PUT',
|
||||
body: { model, provider }
|
||||
})
|
||||
}
|
||||
|
||||
export interface SelectToolsetProviderResponse {
|
||||
ok: boolean
|
||||
name: string
|
||||
provider: string
|
||||
/** Present when the selection was scoped to one web capability. */
|
||||
capability?: string
|
||||
/** Present (true) when a managed Nous row was selected but the Portal
|
||||
* entitlement is missing — the row won't activate until the user signs
|
||||
* in to Nous Portal. */
|
||||
needs_nous_auth?: boolean
|
||||
/** The managed feature key (e.g. "browser") when needs_nous_auth is set. */
|
||||
feature?: string
|
||||
}
|
||||
|
||||
export function selectToolsetProvider(
|
||||
name: string,
|
||||
provider: string,
|
||||
capability?: 'search' | 'extract',
|
||||
profile?: ProfileScope
|
||||
): Promise<SelectToolsetProviderResponse> {
|
||||
return window.hermesDesktop.api<SelectToolsetProviderResponse>({
|
||||
...capabilityScoped(profile),
|
||||
path: `/api/tools/toolsets/${encodeURIComponent(name)}/provider`,
|
||||
method: 'PUT',
|
||||
body: capability ? { provider, capability } : { provider }
|
||||
})
|
||||
}
|
||||
|
||||
export function runToolsetPostSetup(
|
||||
name: string,
|
||||
key: string,
|
||||
profile?: ProfileScope
|
||||
): Promise<ActionResponse & { key: string }> {
|
||||
return window.hermesDesktop.api<ActionResponse & { key: string }>({
|
||||
...capabilityScoped(profile),
|
||||
path: `/api/tools/toolsets/${encodeURIComponent(name)}/post-setup`,
|
||||
method: 'POST',
|
||||
body: { key }
|
||||
})
|
||||
}
|
||||
|
||||
export function getTerminalBackends(): Promise<TerminalBackendsResponse> {
|
||||
return hermesApi<TerminalBackendsResponse>({
|
||||
...profileScoped(),
|
||||
path: '/api/tools/terminal/backends'
|
||||
})
|
||||
}
|
||||
|
||||
export function selectTerminalBackend(backend: string): Promise<{ ok: boolean; backend: string }> {
|
||||
return hermesApi<{ ok: boolean; backend: string }>({
|
||||
...profileScoped(),
|
||||
path: '/api/tools/terminal/backend',
|
||||
method: 'PUT',
|
||||
body: { backend }
|
||||
})
|
||||
}
|
||||
|
||||
export function getComputerUseStatus(): Promise<ComputerUseStatus> {
|
||||
return hermesApi<ComputerUseStatus>({
|
||||
...profileScoped(),
|
||||
path: '/api/tools/computer-use/status'
|
||||
})
|
||||
}
|
||||
|
||||
export function grantComputerUsePermissions(): Promise<ActionResponse> {
|
||||
return hermesApi<ActionResponse>({
|
||||
...profileScoped(),
|
||||
path: '/api/tools/computer-use/permissions/grant',
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user