409 lines
14 KiB
TypeScript
409 lines
14 KiB
TypeScript
import { TR_PROVIDER_DESCRIPTIONS } from '@/i18n/tr-provider-descriptions'
|
|
import { asText, normalize } from '@/lib/text'
|
|
import type { ConfigFieldSchema, HermesConfigRecord, ToolsetInfo } from '@/types/hermes'
|
|
|
|
import { BUILTIN_PERSONALITIES, ENUM_OPTIONS, PROVIDER_GROUPS, SECTIONS } from './constants'
|
|
|
|
// Canonical implementations live in @/lib/text; re-exported here so the many
|
|
// settings/capabilities call sites keep their import path.
|
|
export { asText, includesQuery, prettyName } from '@/lib/text'
|
|
|
|
/** Strip leading emoji from toolset titles (CLI registry prefixes labels with icons). */
|
|
export const stripToolsetLabel = (label: string): string =>
|
|
label.replace(/^[\p{Emoji}\p{Extended_Pictographic}\s]+/u, '').trim() || label
|
|
|
|
export const toolsetDisplayLabel = (toolset: Pick<ToolsetInfo, 'label' | 'name'>): string =>
|
|
stripToolsetLabel(asText(toolset.label || toolset.name))
|
|
|
|
export const toolNames = (t: ToolsetInfo) => (Array.isArray(t.tools) ? t.tools.map(asText).filter(Boolean) : [])
|
|
|
|
export const withoutKey = <T>(record: Record<string, T>, key: string) => {
|
|
const next = { ...record }
|
|
delete next[key]
|
|
|
|
return next
|
|
}
|
|
|
|
export const redactedValue = (v: string) => (v.length <= 8 ? '••••' : `${v.slice(0, 4)}...${v.slice(-4)}`)
|
|
|
|
// Longest-prefix match so a more specific group like ``MINIMAX_CN_`` is
|
|
// chosen over its shorter parent ``MINIMAX_``. Falls back to the bucket
|
|
// "Other" used by the Keys settings view for un-grouped env vars.
|
|
export const providerGroup = (key: string) => {
|
|
let best: (typeof PROVIDER_GROUPS)[number] | undefined
|
|
|
|
for (const candidate of PROVIDER_GROUPS) {
|
|
if (!key.startsWith(candidate.prefix)) {
|
|
continue
|
|
}
|
|
|
|
if (!best || candidate.prefix.length > best.prefix.length) {
|
|
best = candidate
|
|
}
|
|
}
|
|
|
|
return best?.name ?? 'Other'
|
|
}
|
|
|
|
export const providerMeta = (name: string) =>
|
|
PROVIDER_GROUPS.find(g => g.name === name && (g.description || g.docsUrl)) ??
|
|
PROVIDER_GROUPS.find(g => g.name === name)
|
|
|
|
export const providerPriority = (name: string) => providerMeta(name)?.priority ?? 99
|
|
|
|
export function providerDescription(name: string, description: string | undefined, locale: string): string | undefined {
|
|
const original = description?.trim()
|
|
|
|
return locale === 'tr' && original && original === providerMeta(name)?.description
|
|
? (TR_PROVIDER_DESCRIPTIONS[name] ?? original)
|
|
: original
|
|
}
|
|
|
|
const POLLUTING_PATH_PARTS = new Set(['__proto__', 'constructor', 'prototype'])
|
|
|
|
function isSafePart(part: string): boolean {
|
|
return part.length > 0 && !POLLUTING_PATH_PARTS.has(part)
|
|
}
|
|
|
|
function configPathParts(path: string): string[] {
|
|
const parts = path.split('.')
|
|
|
|
if (!parts.every(isSafePart)) {
|
|
throw new Error(`Unsafe config path: ${path}`)
|
|
}
|
|
|
|
return parts
|
|
}
|
|
|
|
function safeSet(target: Record<string, unknown>, key: string, value: unknown): void {
|
|
if (key === '__proto__' || key === 'constructor' || key === 'prototype' || !key) {
|
|
throw new Error(`Unsafe config key: ${key}`)
|
|
}
|
|
|
|
Object.defineProperty(target, key, {
|
|
value,
|
|
writable: true,
|
|
enumerable: true,
|
|
configurable: true
|
|
})
|
|
}
|
|
|
|
export function getNested(obj: HermesConfigRecord, path: string): unknown {
|
|
let cur: unknown = obj
|
|
|
|
for (const part of configPathParts(path)) {
|
|
if (cur == null || typeof cur !== 'object') {
|
|
return undefined
|
|
}
|
|
|
|
if (!Object.prototype.hasOwnProperty.call(cur, part)) {
|
|
return undefined
|
|
}
|
|
|
|
cur = (cur as Record<string, unknown>)[part]
|
|
}
|
|
|
|
return cur
|
|
}
|
|
|
|
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
|
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
|
|
/**
|
|
* Structural diff between two config snapshots: an object holding only the
|
|
* branches of `next` that changed relative to `base`. Plain-object values are
|
|
* compared key by key so editing one field doesn't drag its untouched
|
|
* siblings back into the result; arrays and scalars are compared as whole
|
|
* values.
|
|
*
|
|
* The autosave path sends this instead of the full draft so a field the user
|
|
* never touched — one an agent may have changed via `hermes config set`
|
|
* while Settings was open with a stale snapshot — is never resent with its
|
|
* now-stale value. `PUT /api/config` deep-merges onto disk, so an omitted
|
|
* key keeps whatever is currently there.
|
|
*/
|
|
export function diffConfig(base: HermesConfigRecord, next: HermesConfigRecord): HermesConfigRecord {
|
|
const patch: HermesConfigRecord = {}
|
|
|
|
for (const key of Object.keys(next)) {
|
|
const baseValue = base[key]
|
|
const nextValue = next[key]
|
|
|
|
if (isPlainObject(baseValue) && isPlainObject(nextValue)) {
|
|
const nested = diffConfig(baseValue, nextValue)
|
|
|
|
if (Object.keys(nested).length > 0) {
|
|
patch[key] = nested
|
|
}
|
|
} else if (JSON.stringify(baseValue) !== JSON.stringify(nextValue)) {
|
|
patch[key] = nextValue
|
|
}
|
|
}
|
|
|
|
return patch
|
|
}
|
|
|
|
/**
|
|
* True when an edit clears the entire "Enabled Toolsets" list — i.e. the
|
|
* previous config had a non-empty toolsets array and the next one is an
|
|
* explicit empty array.
|
|
*
|
|
* A *missing* toolsets key is deliberately NOT a clear: `PUT /api/config`
|
|
* deep-merges the override onto the stored config (`_deep_merge` preserves base
|
|
* keys absent from the override), so an import that omits `toolsets` leaves the
|
|
* existing toolsets intact. Prompting there would warn about a wipe that never
|
|
* happens. Only an explicit `[]` actually empties the list.
|
|
*
|
|
* Clearing every toolset silently disables memory, terminal, web search,
|
|
* delegation, and most tools, and config auto-saves with no undo, so callers
|
|
* use this to confirm the destructive transition before applying it. Any edit
|
|
* that keeps at least one toolset — or that never had one — returns false.
|
|
*/
|
|
export function clearsEnabledToolsets(prev: HermesConfigRecord, next: HermesConfigRecord): boolean {
|
|
const prevToolsets = getNested(prev, 'toolsets')
|
|
const nextToolsets = getNested(next, 'toolsets')
|
|
const hadToolsets = Array.isArray(prevToolsets) && prevToolsets.length > 0
|
|
const clearsToolsets = Array.isArray(nextToolsets) && nextToolsets.length === 0
|
|
|
|
return hadToolsets && clearsToolsets
|
|
}
|
|
|
|
// Voice renders only fields for the selected TTS/STT provider. Search and the
|
|
// page share this rule so every indexed field can actually mount when opened.
|
|
export function voiceFieldVisible(key: string, config: HermesConfigRecord): boolean {
|
|
const match = /^(tts|stt)\.([^.]+)\./.exec(key)
|
|
|
|
if (!match) {
|
|
return true
|
|
}
|
|
|
|
const [, domain, provider] = match
|
|
|
|
if (domain === 'stt' && !getNested(config, 'stt.enabled')) {
|
|
return false
|
|
}
|
|
|
|
return provider === String(getNested(config, `${domain}.provider`) ?? '')
|
|
}
|
|
|
|
export function inferFieldSchema(value: unknown): ConfigFieldSchema {
|
|
if (typeof value === 'boolean') {
|
|
return { type: 'boolean' }
|
|
}
|
|
|
|
if (typeof value === 'number') {
|
|
return { type: 'number' }
|
|
}
|
|
|
|
if (Array.isArray(value)) {
|
|
return { type: 'list' }
|
|
}
|
|
|
|
return { type: 'string' }
|
|
}
|
|
|
|
// Backend schema omits some declared keys; config presence is the availability signal.
|
|
export function sectionFieldEntries(
|
|
schema: Record<string, ConfigFieldSchema>,
|
|
config: HermesConfigRecord
|
|
): Map<string, [string, ConfigFieldSchema][]> {
|
|
return new Map(
|
|
SECTIONS.map(s => [
|
|
s.id,
|
|
s.keys.flatMap(k => {
|
|
const value = getNested(config, k)
|
|
const field = schema[k] ?? (value === undefined ? undefined : inferFieldSchema(value))
|
|
|
|
return field ? [[k, field] as [string, ConfigFieldSchema]] : []
|
|
})
|
|
])
|
|
)
|
|
}
|
|
|
|
export function setNested(obj: HermesConfigRecord, path: string, value: unknown): HermesConfigRecord {
|
|
const clone = structuredClone(obj)
|
|
const parts = configPathParts(path)
|
|
let cur: Record<string, unknown> = clone
|
|
|
|
for (let i = 0; i < parts.length - 1; i += 1) {
|
|
const part = parts[i]
|
|
|
|
if (!isSafePart(part)) {
|
|
throw new Error(`Unsafe config path part: ${part}`)
|
|
}
|
|
|
|
const existing = Object.prototype.hasOwnProperty.call(cur, part) ? cur[part] : undefined
|
|
|
|
if (existing == null || typeof existing !== 'object') {
|
|
safeSet(cur, part, {})
|
|
}
|
|
|
|
cur = cur[part] as Record<string, unknown>
|
|
}
|
|
|
|
safeSet(cur, parts[parts.length - 1], value)
|
|
|
|
return clone
|
|
}
|
|
|
|
function personalityOptions(config: HermesConfigRecord): string[] {
|
|
const custom = getNested(config, 'agent.personalities')
|
|
|
|
const customNames =
|
|
custom && typeof custom === 'object' && !Array.isArray(custom) ? Object.keys(custom as Record<string, unknown>) : []
|
|
|
|
return [...new Set(['', ...BUILTIN_PERSONALITIES, ...customNames])]
|
|
}
|
|
|
|
// Built-in provider names, mirroring `tts_tool.py:BUILTIN_TTS_PROVIDERS` and
|
|
// `transcription_tools.py:BUILTIN_STT_PROVIDERS`. The runtime rejects a built-in
|
|
// name as a command provider before any config lookup
|
|
// (`_resolve_command_provider_config`: `key = provider.lower().strip()`, then
|
|
// `if key in BUILTIN_*_PROVIDERS: return None`), so a ``providers.edge`` block
|
|
// declaring ``type: command`` still dispatches to native Edge.
|
|
//
|
|
// These are deliberately NOT derived from `ENUM_OPTIONS`, which is a *display*
|
|
// list and already drifts from the runtime sets: it omits `deepinfra` (TTS) and
|
|
// `deepinfra`/`local_command` (STT). Filtering on the display list would offer
|
|
// those names as command providers that the runtime would never honour.
|
|
const BUILTIN_TTS_PROVIDERS = new Set([
|
|
'edge',
|
|
'elevenlabs',
|
|
'openai',
|
|
'minimax',
|
|
'xai',
|
|
'mistral',
|
|
'gemini',
|
|
'neutts',
|
|
'kittentts',
|
|
'piper',
|
|
'deepinfra'
|
|
])
|
|
|
|
const BUILTIN_STT_PROVIDERS = new Set([
|
|
'local',
|
|
'local_command',
|
|
'groq',
|
|
'openai',
|
|
'mistral',
|
|
'xai',
|
|
'elevenlabs',
|
|
'deepinfra'
|
|
])
|
|
|
|
// A user-declared command provider, mirroring the runtime discriminator
|
|
// (`tts_tool.py:_is_command_provider_config` / `transcription_tools.py`): `type`
|
|
// is OPTIONAL and case/space-insensitive (absent or normalizing to "command"),
|
|
// and `command` MUST be a non-empty string. So a canonical block written as just
|
|
// ``{ command: "curl …" }`` with no ``type:`` — a fully valid runtime provider
|
|
// under ``providers.*`` — qualifies too, while built-in blocks (which carry
|
|
// ``voice``/``model`` and no ``command``) and the ``providers`` container itself
|
|
// (no ``command``) are skipped.
|
|
function isCommandProvider(value: unknown): boolean {
|
|
if (value == null || typeof value !== 'object' || Array.isArray(value)) {
|
|
return false
|
|
}
|
|
|
|
const record = value as Record<string, unknown>
|
|
const type = normalize(record.type)
|
|
|
|
if (type !== '' && type !== 'command') {
|
|
return false
|
|
}
|
|
|
|
return typeof record.command === 'string' && record.command.trim() !== ''
|
|
}
|
|
|
|
// Names of user-defined command providers, so the settings dropdown can offer
|
|
// them alongside the built-ins instead of only whichever one is currently active
|
|
// (otherwise, once you switch away from a custom provider it drops off the list
|
|
// and can only be reselected by hand-editing config.yaml).
|
|
//
|
|
// Mirrors the runtime's dual resolution (`tts_tool.py:_get_named_provider_config`,
|
|
// `transcription_tools.py`): the CANONICAL location is nested —
|
|
// ``tts.providers.<name>`` / ``stt.providers.<name>`` — with a back-compat
|
|
// fallback to a top-level ``tts.<name>`` / ``stt.<name>`` block. We enumerate
|
|
// both (deduped), keeping only sections that satisfy isCommandProvider and whose
|
|
// name the runtime would actually resolve as a command provider — built-ins are
|
|
// excluded case-insensitively, matching the runtime's `provider.lower().strip()`
|
|
// guard, so a ``providers.EDGE`` command block is not offered.
|
|
function commandProviderNames(config: HermesConfigRecord, section: 'tts' | 'stt'): string[] {
|
|
const builtins = section === 'tts' ? BUILTIN_TTS_PROVIDERS : BUILTIN_STT_PROVIDERS
|
|
const names = new Set<string>()
|
|
|
|
for (const path of [`${section}.providers`, section]) {
|
|
const block = getNested(config, path)
|
|
|
|
if (!block || typeof block !== 'object' || Array.isArray(block)) {
|
|
continue
|
|
}
|
|
|
|
for (const [name, value] of Object.entries(block as Record<string, unknown>)) {
|
|
if (isCommandProvider(value) && !builtins.has(normalize(name))) {
|
|
names.add(name)
|
|
}
|
|
}
|
|
}
|
|
|
|
return [...names]
|
|
}
|
|
|
|
// Voice sets per OpenAI speech model, per the OpenAI TTS API docs: tts-1 and
|
|
// tts-1-hd support 9 voices; gpt-4o-mini-tts supports those plus ballad,
|
|
// verse, marin, and cedar (13 total). Unknown/future models get the full
|
|
// union (the field is free-input anyway — this only narrows suggestions).
|
|
const OPENAI_TTS1_VOICES = new Set(['alloy', 'ash', 'coral', 'echo', 'fable', 'nova', 'onyx', 'sage', 'shimmer'])
|
|
|
|
export function enumOptionsFor(
|
|
key: string,
|
|
value: unknown,
|
|
config: HermesConfigRecord,
|
|
dynamicOptions?: string[]
|
|
): string[] | undefined {
|
|
let opts = dynamicOptions ?? (key === 'display.personality' ? personalityOptions(config) : ENUM_OPTIONS[key])
|
|
|
|
// Merge in user-defined command-type providers so custom local TTS/STT
|
|
// backends declared in config.yaml are selectable, not just the built-ins.
|
|
// The `includes` guard keeps the list duplicate-free should the display list
|
|
// ever carry a name we also enumerate.
|
|
if (!dynamicOptions && opts && (key === 'tts.provider' || key === 'stt.provider')) {
|
|
const section = key.slice(0, 3) as 'tts' | 'stt'
|
|
const custom = commandProviderNames(config, section).filter(name => !opts!.includes(name))
|
|
|
|
if (custom.length > 0) {
|
|
opts = [...opts, ...custom]
|
|
}
|
|
}
|
|
|
|
// Narrow OpenAI voice suggestions to what the selected model actually
|
|
// accepts — offering `marin` against tts-1 would 400 at the API.
|
|
if (!dynamicOptions && opts && key === 'tts.openai.voice') {
|
|
const model = asText(getNested(config, 'tts.openai.model'))
|
|
|
|
if (model === 'tts-1' || model === 'tts-1-hd') {
|
|
opts = opts.filter(voice => OPENAI_TTS1_VOICES.has(voice))
|
|
}
|
|
}
|
|
|
|
if (!opts) {
|
|
return undefined
|
|
}
|
|
|
|
const current = asText(value)
|
|
|
|
return current && !opts.includes(current) ? [...opts, current] : opts
|
|
}
|
|
|
|
// Built-in memory (MEMORY.md/USER.md) is controlled by memory_enabled, not
|
|
// memory.provider — only a real external plugin name gets provider-shaped
|
|
// affordances (config panel, OAuth connect). See #49513.
|
|
export function isExternalMemoryProvider(value: unknown): value is string {
|
|
if (typeof value !== 'string') {
|
|
return false
|
|
}
|
|
|
|
const normalized = value.trim().toLowerCase()
|
|
|
|
return normalized !== '' && normalized !== 'builtin' && normalized !== 'built-in' && normalized !== 'none'
|
|
}
|