feat(desktop): unify Skills/Tools/MCP into the Capabilities page
Merge the old Skills + Toolsets tabs and pull MCP out of Settings into one master-detail "Capabilities" hub (Skills / Tools / MCP / Browse Hub). - Skills: usage-sorted from real per-skill activity, provenance badges (learned / built-in / hub), edit + archive for learned skills, per-tab bulk toggle; full-bleed empty states. - Tools: usage-aware, container-queried rows (no early two-column collapse). - Settings panels move onto the shared config-record query cache; the deleted Settings MCP page redirects (/settings?tab=mcp → /skills?tab=mcp). - Lazy, profile-scoped, TTL-cached usage analytics so the heavy 365-day scan never blocks the Skills/MCP tabs. Full en/zh strings (ja/zh-hant inherit).
This commit is contained in:
parent
e0325cf769
commit
7e6d60aadc
22 changed files with 964 additions and 983 deletions
|
|
@ -0,0 +1,72 @@
|
|||
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||
import { deleteLearningNode } from '@/hermes'
|
||||
import { notify } from '@/store/notifications'
|
||||
|
||||
export const ARCHIVE_SKILL_DESCRIPTION = 'The skill is archived and can be restored with `hermes curator restore`.'
|
||||
|
||||
export function notifySkillArchived(): void {
|
||||
// TODO(i18n): literals until the UX settles.
|
||||
notify({ kind: 'success', message: 'Restorable via hermes curator restore.', title: 'Skill archived' })
|
||||
}
|
||||
|
||||
export async function archiveLearningSkill(id: string): Promise<void> {
|
||||
const res = await deleteLearningNode(id)
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(res.message || 'Archive failed')
|
||||
}
|
||||
}
|
||||
|
||||
/** Fire-and-forget a mutation whose UI already applied optimistically; a failure just rolls it back + reports. */
|
||||
export function fireOptimistic(action: Promise<void>, rollback: () => void, onFailure: (err: unknown) => void): void {
|
||||
void action.catch(err => {
|
||||
rollback()
|
||||
onFailure(err)
|
||||
})
|
||||
}
|
||||
|
||||
interface ArchiveSkillConfirmDialogProps {
|
||||
/** Apply optimistic UI updates; return rollback if the background archive fails. */
|
||||
onApply: () => () => void
|
||||
onClose: () => void
|
||||
onFailure?: (err: unknown, skillName: string) => void
|
||||
onSuccess?: () => void
|
||||
open: boolean
|
||||
skillId: string
|
||||
skillName: string
|
||||
}
|
||||
|
||||
/** Shared archive confirm for learned skills (capabilities page + memory graph). */
|
||||
export function ArchiveSkillConfirmDialog({
|
||||
onApply,
|
||||
onClose,
|
||||
onFailure,
|
||||
onSuccess,
|
||||
open,
|
||||
skillId,
|
||||
skillName
|
||||
}: ArchiveSkillConfirmDialogProps) {
|
||||
return (
|
||||
<ConfirmDialog
|
||||
confirmLabel="Archive"
|
||||
description={ARCHIVE_SKILL_DESCRIPTION}
|
||||
destructive
|
||||
dismissOnConfirm
|
||||
onClose={onClose}
|
||||
onConfirm={() => {
|
||||
const rollback = onApply()
|
||||
|
||||
fireOptimistic(
|
||||
archiveLearningSkill(skillId).then(() => {
|
||||
notifySkillArchived()
|
||||
onSuccess?.()
|
||||
}),
|
||||
rollback,
|
||||
err => onFailure?.(err, skillName)
|
||||
)
|
||||
}}
|
||||
open={open}
|
||||
title={`Archive ${skillName}?`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -134,7 +134,7 @@ export function ComputerUsePanel({ onConfiguredChange }: ComputerUsePanelProps)
|
|||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="mt-3 flex items-center gap-2 px-1 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2 px-1 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
Checking Computer Use status…
|
||||
</div>
|
||||
|
|
@ -147,7 +147,7 @@ export function ComputerUsePanel({ onConfiguredChange }: ComputerUsePanelProps)
|
|||
|
||||
if (!status.platform_supported) {
|
||||
return (
|
||||
<p className="mt-3 px-1 text-xs text-muted-foreground">
|
||||
<p className="px-1 text-xs text-muted-foreground">
|
||||
Computer Use isn't supported on this platform ({status.platform}).
|
||||
</p>
|
||||
)
|
||||
|
|
@ -155,7 +155,7 @@ export function ComputerUsePanel({ onConfiguredChange }: ComputerUsePanelProps)
|
|||
|
||||
if (!status.installed) {
|
||||
return (
|
||||
<p className="mt-3 px-1 text-xs text-muted-foreground">
|
||||
<p className="px-1 text-xs text-muted-foreground">
|
||||
Install the cua-driver backend below to drive this machine.
|
||||
{status.can_grant && ' Then grant Accessibility and Screen Recording here.'}
|
||||
</p>
|
||||
|
|
@ -165,7 +165,7 @@ export function ComputerUsePanel({ onConfiguredChange }: ComputerUsePanelProps)
|
|||
const failingChecks = status.checks.filter(c => c.status !== 'ok')
|
||||
|
||||
return (
|
||||
<div className="mt-3 grid gap-2">
|
||||
<div className="grid gap-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 px-1">
|
||||
<div className="min-w-0">
|
||||
{status.can_grant ? (
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { ChangeEvent, ReactNode } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
|
|
@ -6,18 +7,13 @@ import { Input } from '@/components/ui/input'
|
|||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
getElevenLabsVoices,
|
||||
getHermesConfigDefaults,
|
||||
getHermesConfigRecord,
|
||||
getHermesConfigSchema,
|
||||
saveHermesConfig
|
||||
} from '@/hermes'
|
||||
import { getElevenLabsVoices, getHermesConfigSchema, saveHermesConfig } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { ConfigFieldSchema, HermesConfigRecord } from '@/types/hermes'
|
||||
|
||||
import { setHermesConfigCache, useHermesConfigRecord } from '../hooks/use-config-record'
|
||||
import { CONTROL_TEXT, EMPTY_SELECT_VALUE, FIELD_DESCRIPTIONS, FIELD_LABELS, SECTIONS } from './constants'
|
||||
import { fieldCopyForSchemaKey } from './field-copy'
|
||||
import { enumOptionsFor, getNested, prettyName, setNested } from './helpers'
|
||||
|
|
@ -225,31 +221,32 @@ export function ConfigSettings({
|
|||
}) {
|
||||
const { t } = useI18n()
|
||||
const c = t.settings.config
|
||||
// The editable draft is local (debounced autosave watches it), but it's seeded
|
||||
// from — and saved back through — the shared config cache, so edits are visible
|
||||
// in the MCP/model surfaces and reopening the page doesn't reload-flash.
|
||||
const [config, setConfig] = useState<HermesConfigRecord | null>(null)
|
||||
const [_defaults, setDefaults] = useState<HermesConfigRecord | null>(null)
|
||||
const [schema, setSchema] = useState<Record<string, ConfigFieldSchema> | null>(null)
|
||||
const { data: loadedConfig } = useHermesConfigRecord()
|
||||
const { data: schemaResponse } = useQuery({
|
||||
queryKey: ['hermes-config-schema'],
|
||||
queryFn: getHermesConfigSchema,
|
||||
staleTime: 5 * 60 * 1000
|
||||
})
|
||||
const schema = schemaResponse?.fields ?? null
|
||||
const [elevenLabsVoiceOptions, setElevenLabsVoiceOptions] = useState<string[] | null>(null)
|
||||
const [elevenLabsVoiceLabels, setElevenLabsVoiceLabels] = useState<Record<string, string>>({})
|
||||
const saveVersionRef = useRef(0)
|
||||
const [saveVersion, setSaveVersion] = useState(0)
|
||||
|
||||
// Seed the local draft once, the first time the shared record lands.
|
||||
// Background refetches thereafter must not clobber in-progress edits.
|
||||
const configSeeded = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
Promise.all([getHermesConfigRecord(), getHermesConfigDefaults(), getHermesConfigSchema()])
|
||||
.then(([c, d, s]) => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
setConfig(c)
|
||||
setDefaults(d)
|
||||
setSchema(s.fields)
|
||||
})
|
||||
.catch(err => notifyError(err, c.failedLoad))
|
||||
|
||||
return () => void (cancelled = true)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- load once on mount; copy is stable
|
||||
}, [])
|
||||
if (loadedConfig && !configSeeded.current) {
|
||||
configSeeded.current = true
|
||||
setConfig(loadedConfig)
|
||||
}
|
||||
}, [loadedConfig])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
|
@ -284,6 +281,9 @@ export function ConfigSettings({
|
|||
void (async () => {
|
||||
try {
|
||||
await saveHermesConfig(config)
|
||||
// Mirror the saved record into the shared cache so MCP/model surfaces
|
||||
// reflect the edit without their own refetch.
|
||||
setHermesConfigCache(config)
|
||||
|
||||
if (saveVersionRef.current === v) {
|
||||
onConfigSaved?.()
|
||||
|
|
|
|||
|
|
@ -159,9 +159,9 @@ export function CredentialKeyCard({
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group/card rounded-[6px] px-2 py-1 transition-colors',
|
||||
'@container group/card rounded-[6px] px-2 py-1 transition-colors',
|
||||
expandable && 'cursor-pointer',
|
||||
expandable && !expanded && 'hover:bg-(--ui-row-hover-background)',
|
||||
expandable && !expanded && 'row-hover',
|
||||
expanded && 'bg-(--ui-bg-quaternary) ring-1 ring-(--ui-stroke-secondary)'
|
||||
)}
|
||||
onClick={expandable ? onToggle : undefined}
|
||||
|
|
@ -178,7 +178,7 @@ export function CredentialKeyCard({
|
|||
role={expandable ? 'button' : undefined}
|
||||
tabIndex={expandable ? 0 : undefined}
|
||||
>
|
||||
<div className="grid gap-3 py-2 sm:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] sm:items-center">
|
||||
<div className="grid gap-3 py-2 @2xl:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] @2xl:items-center">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className={cn('size-2 shrink-0 rounded-full', info.is_set ? 'bg-primary' : 'bg-(--ui-stroke-secondary)')}
|
||||
|
|
@ -199,7 +199,7 @@ export function CredentialKeyCard({
|
|||
</div>
|
||||
|
||||
<div
|
||||
className="min-w-0 sm:justify-self-end"
|
||||
className="min-w-0 @2xl:justify-self-end"
|
||||
onClick={e => e.stopPropagation()}
|
||||
onFocus={() => {
|
||||
if (expandable && !expanded) {
|
||||
|
|
@ -236,9 +236,9 @@ export function ProviderKeyRows({ expanded, group, onExpand, onToggle, rowProps
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group/card rounded-[6px] px-2 py-1 transition-colors',
|
||||
'@container group/card rounded-[6px] px-2 py-1 transition-colors',
|
||||
expandable && 'cursor-pointer',
|
||||
expandable && !expanded && 'hover:bg-(--ui-row-hover-background)',
|
||||
expandable && !expanded && 'row-hover',
|
||||
expanded && 'bg-(--ui-bg-quaternary) ring-1 ring-(--ui-stroke-secondary)'
|
||||
)}
|
||||
onClick={expandable ? onToggle : undefined}
|
||||
|
|
@ -255,7 +255,7 @@ export function ProviderKeyRows({ expanded, group, onExpand, onToggle, rowProps
|
|||
role={expandable ? 'button' : undefined}
|
||||
tabIndex={expandable ? 0 : undefined}
|
||||
>
|
||||
<div className="grid gap-3 py-2 sm:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] sm:items-center">
|
||||
<div className="grid gap-3 py-2 @2xl:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] @2xl:items-center">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
|
|
@ -279,7 +279,7 @@ export function ProviderKeyRows({ expanded, group, onExpand, onToggle, rowProps
|
|||
</div>
|
||||
|
||||
<div
|
||||
className="min-w-0 sm:justify-self-end"
|
||||
className="min-w-0 @2xl:justify-self-end"
|
||||
onClick={e => e.stopPropagation()}
|
||||
onFocus={() => {
|
||||
if (expandable && !expanded) {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
import { asText } from '@/lib/text'
|
||||
import type { HermesConfigRecord, ToolsetInfo } from '@/types/hermes'
|
||||
|
||||
import { BUILTIN_PERSONALITIES, ENUM_OPTIONS, PROVIDER_GROUPS } from './constants'
|
||||
|
||||
export const asText = (v: unknown): string => (typeof v === 'string' ? v : v == null ? '' : String(v))
|
||||
|
||||
export const includesQuery = (v: unknown, q: string) => asText(v).toLowerCase().includes(q)
|
||||
|
||||
export const prettyName = (v: string) => v.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
||||
// 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 =>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useRef } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
import { codiconIcon } from '@/components/ui/codicon'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
|
|
@ -12,6 +13,7 @@ import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
|||
import { OverlayIconButton } from '../overlays/overlay-chrome'
|
||||
import { OverlayMain, OverlayNavItem, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout'
|
||||
import { OverlayView } from '../overlays/overlay-view'
|
||||
import { SKILLS_ROUTE } from '../routes'
|
||||
|
||||
import { AboutSettings } from './about-settings'
|
||||
import { AppearanceSettings } from './appearance-settings'
|
||||
|
|
@ -19,7 +21,6 @@ import { ConfigSettings } from './config-settings'
|
|||
import { SECTIONS } from './constants'
|
||||
import { GatewaySettings } from './gateway-settings'
|
||||
import { KEYS_VIEWS, KeysSettings, type KeysView } from './keys-settings'
|
||||
import { McpSettings } from './mcp-settings'
|
||||
import { NotificationsSettings } from './notifications-settings'
|
||||
import { PROVIDER_VIEWS, ProvidersSettings, type ProviderView } from './providers-settings'
|
||||
import { SessionsSettings } from './sessions-settings'
|
||||
|
|
@ -30,14 +31,25 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
|
|||
'providers',
|
||||
'gateway',
|
||||
'keys',
|
||||
'mcp',
|
||||
'notifications',
|
||||
'sessions',
|
||||
'about'
|
||||
]
|
||||
|
||||
export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) {
|
||||
export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) {
|
||||
const { t } = useI18n()
|
||||
const navigate = useNavigate()
|
||||
const { search } = useLocation()
|
||||
|
||||
// MCP moved out of Settings into Capabilities (/skills?tab=mcp). Keep old
|
||||
// `/settings?tab=mcp` deep links working — `useRouteEnumParam` would silently
|
||||
// coerce the unknown tab to the default view otherwise.
|
||||
useEffect(() => {
|
||||
if (new URLSearchParams(search).get('tab') === 'mcp') {
|
||||
navigate(`${SKILLS_ROUTE}?tab=mcp`, { replace: true })
|
||||
}
|
||||
}, [navigate, search])
|
||||
|
||||
const [activeView, setActiveView] = useRouteEnumParam('tab', SETTINGS_VIEWS, 'config:model' as SettingsViewId)
|
||||
// Providers subnav (Accounts vs API keys) lives in its own param so each
|
||||
// sub-view is deep-linkable and survives a refresh.
|
||||
|
|
@ -109,7 +121,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
|||
label={t.settings.nav.notifications}
|
||||
onClick={() => setActiveView('notifications')}
|
||||
/>
|
||||
<div className="my-2 h-px bg-border/30" />
|
||||
<div aria-hidden className="h-2" />
|
||||
<OverlayNavItem
|
||||
active={activeView === 'providers'}
|
||||
icon={Zap}
|
||||
|
|
@ -164,19 +176,13 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
<OverlayNavItem
|
||||
active={activeView === 'mcp'}
|
||||
icon={Wrench}
|
||||
label={t.settings.nav.mcp}
|
||||
onClick={() => setActiveView('mcp')}
|
||||
/>
|
||||
<OverlayNavItem
|
||||
active={activeView === 'sessions'}
|
||||
icon={Archive}
|
||||
label={t.settings.nav.archivedChats}
|
||||
onClick={() => setActiveView('sessions')}
|
||||
/>
|
||||
<div className="my-2 h-px bg-border/30" />
|
||||
<div aria-hidden className="h-2" />
|
||||
<OverlayNavItem
|
||||
active={activeView === 'about'}
|
||||
icon={Info}
|
||||
|
|
@ -231,8 +237,6 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
|||
<ProvidersSettings onClose={onClose} onViewChange={setProviderView} view={providerView} />
|
||||
) : activeView === 'keys' ? (
|
||||
<KeysSettings view={keysView} />
|
||||
) : activeView === 'mcp' ? (
|
||||
<McpSettings gateway={gateway} onConfigSaved={onConfigSaved} />
|
||||
) : activeView === 'notifications' ? (
|
||||
<NotificationsSettings />
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -1,534 +0,0 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
getHermesConfigRecord,
|
||||
getMcpCatalog,
|
||||
type HermesGateway,
|
||||
installMcpCatalogEntry,
|
||||
type McpCatalogEntry,
|
||||
saveHermesConfig,
|
||||
setMcpServerEnabled,
|
||||
testMcpServer
|
||||
} from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { Wrench } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $activeSessionId } from '@/store/session'
|
||||
import type { HermesConfigRecord, McpServerTestResponse } from '@/types/hermes'
|
||||
|
||||
import { EmptyState, LoadingState, Pill, SettingsContent } from './primitives'
|
||||
import { useDeepLinkHighlight } from './use-deep-link-highlight'
|
||||
|
||||
interface McpSettingsProps {
|
||||
gateway?: HermesGateway | null
|
||||
onConfigSaved?: () => void
|
||||
}
|
||||
|
||||
type McpServers = Record<string, Record<string, unknown>>
|
||||
type McpView = 'catalog' | 'servers'
|
||||
|
||||
const EMPTY_SERVER = {
|
||||
command: '',
|
||||
args: [],
|
||||
env: {}
|
||||
}
|
||||
|
||||
function getServers(config: HermesConfigRecord | null): McpServers {
|
||||
const raw = config?.mcp_servers
|
||||
|
||||
return raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as McpServers) : {}
|
||||
}
|
||||
|
||||
const transportLabel = (server: Record<string, unknown>) =>
|
||||
typeof server.transport === 'string'
|
||||
? server.transport
|
||||
: typeof server.url === 'string'
|
||||
? 'http'
|
||||
: typeof server.command === 'string'
|
||||
? 'stdio'
|
||||
: 'custom'
|
||||
|
||||
export function McpSettings({ gateway, onConfigSaved }: McpSettingsProps) {
|
||||
const { t } = useI18n()
|
||||
const m = t.settings.mcp
|
||||
const activeSessionId = useStore($activeSessionId)
|
||||
const [view, setView] = useState<McpView>('servers')
|
||||
const [config, setConfig] = useState<HermesConfigRecord | null>(null)
|
||||
const [selected, setSelected] = useState<string | null>(null)
|
||||
const [name, setName] = useState('')
|
||||
const [body, setBody] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [reloading, setReloading] = useState(false)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [testResult, setTestResult] = useState<McpServerTestResponse | null>(null)
|
||||
const [togglingEnabled, setTogglingEnabled] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
getHermesConfigRecord()
|
||||
.then(next => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
setConfig(next)
|
||||
const first = Object.keys(getServers(next)).sort()[0] ?? null
|
||||
setSelected(first)
|
||||
})
|
||||
.catch(err => notifyError(err, m.failedLoad))
|
||||
|
||||
return () => void (cancelled = true)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- load once on mount; copy is stable
|
||||
}, [])
|
||||
|
||||
const servers = useMemo(() => getServers(config), [config])
|
||||
const names = useMemo(() => Object.keys(servers).sort(), [servers])
|
||||
|
||||
useDeepLinkHighlight({
|
||||
block: 'nearest',
|
||||
elementId: serverName => `mcp-server-${serverName}`,
|
||||
onResolve: setSelected,
|
||||
param: 'server',
|
||||
ready: serverName => Boolean(config) && serverName in servers
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const server = selected ? servers[selected] : null
|
||||
|
||||
setName(selected ?? '')
|
||||
setBody(JSON.stringify(server ?? EMPTY_SERVER, null, 2))
|
||||
setTestResult(null)
|
||||
}, [selected, servers])
|
||||
|
||||
const refreshConfig = useCallback(async () => {
|
||||
try {
|
||||
const next = await getHermesConfigRecord()
|
||||
setConfig(next)
|
||||
} catch (err) {
|
||||
notifyError(err, m.failedLoad)
|
||||
}
|
||||
}, [m.failedLoad])
|
||||
|
||||
if (!config) {
|
||||
return <LoadingState label={m.loading} />
|
||||
}
|
||||
|
||||
const saveServer = async () => {
|
||||
const nextName = name.trim()
|
||||
|
||||
if (!nextName) {
|
||||
notify({ kind: 'error', title: m.nameRequiredTitle, message: m.nameRequiredMessage })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let parsed: Record<string, unknown>
|
||||
|
||||
try {
|
||||
const raw = JSON.parse(body)
|
||||
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
throw new Error(m.objectRequired)
|
||||
}
|
||||
|
||||
parsed = raw as Record<string, unknown>
|
||||
} catch (err) {
|
||||
notifyError(err, m.invalidJson)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
|
||||
try {
|
||||
const nextServers = { ...servers }
|
||||
|
||||
if (selected && selected !== nextName) {
|
||||
delete nextServers[selected]
|
||||
}
|
||||
|
||||
nextServers[nextName] = parsed
|
||||
|
||||
const nextConfig = { ...config, mcp_servers: nextServers }
|
||||
await saveHermesConfig(nextConfig)
|
||||
setConfig(nextConfig)
|
||||
setSelected(nextName)
|
||||
onConfigSaved?.()
|
||||
notify({ kind: 'success', title: m.savedTitle, message: m.savedMessage(nextName) })
|
||||
} catch (err) {
|
||||
notifyError(err, m.saveFailed)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const removeServer = async (serverName: string) => {
|
||||
setSaving(true)
|
||||
|
||||
try {
|
||||
const nextServers = { ...servers }
|
||||
delete nextServers[serverName]
|
||||
|
||||
const nextConfig = { ...config, mcp_servers: nextServers }
|
||||
await saveHermesConfig(nextConfig)
|
||||
setConfig(nextConfig)
|
||||
setSelected(Object.keys(nextServers).sort()[0] ?? null)
|
||||
onConfigSaved?.()
|
||||
} catch (err) {
|
||||
notifyError(err, m.removeFailed)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const reloadMcp = async () => {
|
||||
if (!gateway) {
|
||||
notify({ kind: 'warning', title: m.gatewayUnavailableTitle, message: m.gatewayUnavailableMessage })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setReloading(true)
|
||||
|
||||
try {
|
||||
await gateway.request('reload.mcp', {
|
||||
confirm: true,
|
||||
session_id: activeSessionId ?? undefined
|
||||
})
|
||||
notify({ kind: 'success', title: m.reloadedTitle, message: m.reloadedMessage })
|
||||
} catch (err) {
|
||||
notifyError(err, m.reloadFailed)
|
||||
} finally {
|
||||
setReloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const runTest = async (serverName: string) => {
|
||||
setTesting(true)
|
||||
setTestResult(null)
|
||||
|
||||
try {
|
||||
const result = await testMcpServer(serverName)
|
||||
setTestResult(result)
|
||||
} catch (err) {
|
||||
setTestResult({ ok: false, error: err instanceof Error ? err.message : String(err), tools: [] })
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleEnabled = async (serverName: string, enabled: boolean) => {
|
||||
setTogglingEnabled(true)
|
||||
|
||||
try {
|
||||
await setMcpServerEnabled(serverName, enabled)
|
||||
// Mirror the change locally so the editor and list stay in sync.
|
||||
const nextServers = { ...servers, [serverName]: { ...servers[serverName], enabled } }
|
||||
setConfig({ ...config, mcp_servers: nextServers })
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: enabled ? m.serverEnabled(serverName) : m.serverDisabled(serverName),
|
||||
message: ''
|
||||
})
|
||||
} catch (err) {
|
||||
notifyError(err, m.toggleFailed(serverName))
|
||||
} finally {
|
||||
setTogglingEnabled(false)
|
||||
}
|
||||
}
|
||||
|
||||
const selectedEnabled = selected ? servers[selected]?.enabled !== false : true
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<div className="mb-4 flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<TabButton active={view === 'servers'} label={m.tabServers} onClick={() => setView('servers')} />
|
||||
<TabButton active={view === 'catalog'} label={m.tabCatalog} onClick={() => setView('catalog')} />
|
||||
</div>
|
||||
{view === 'servers' && (
|
||||
<div className="flex items-center gap-4">
|
||||
<Button onClick={() => setSelected(null)} size="xs" variant="text">
|
||||
{m.newServer}
|
||||
</Button>
|
||||
<Button disabled={reloading} onClick={() => void reloadMcp()} size="xs" variant="text">
|
||||
{reloading ? m.reloading : m.reload}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{view === 'catalog' ? (
|
||||
<McpCatalogBrowser onInstalled={() => void refreshConfig()} />
|
||||
) : (
|
||||
<div className="grid min-h-0 gap-6 lg:grid-cols-[16rem_minmax(0,1fr)]">
|
||||
<div className="min-h-64">
|
||||
{names.length === 0 ? (
|
||||
<EmptyState description={m.emptyDesc} title={m.emptyTitle} />
|
||||
) : (
|
||||
<div className="grid gap-0.5">
|
||||
{names.map(serverName => {
|
||||
const server = servers[serverName]
|
||||
const active = selected === serverName
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'scroll-mt-2 rounded-md px-2 py-2 text-left transition-colors hover:bg-(--chrome-action-hover)',
|
||||
active ? 'bg-(--ui-bg-tertiary) text-foreground' : 'text-muted-foreground'
|
||||
)}
|
||||
id={`mcp-server-${serverName}`}
|
||||
key={serverName}
|
||||
onClick={() => setSelected(serverName)}
|
||||
type="button"
|
||||
>
|
||||
<div className="truncate text-sm font-medium">{serverName}</div>
|
||||
<div className="mt-1 flex items-center gap-1.5">
|
||||
<Pill>{transportLabel(server)}</Pill>
|
||||
{(server.enabled === false || server.disabled === true) && <Pill>{m.disabled}</Pill>}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid content-start gap-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Wrench className="size-4 text-muted-foreground" />
|
||||
{selected ? m.editServer : m.newServer}
|
||||
</div>
|
||||
{selected && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button disabled={testing} onClick={() => void runTest(selected)} size="xs" variant="text">
|
||||
{testing ? m.testing : m.test}
|
||||
</Button>
|
||||
<Switch
|
||||
aria-label={selectedEnabled ? m.disableServer(selected) : m.enableServer(selected)}
|
||||
checked={selectedEnabled}
|
||||
disabled={togglingEnabled}
|
||||
onCheckedChange={checked => void toggleEnabled(selected, checked)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{testResult && (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-3 text-xs',
|
||||
testResult.ok ? 'text-emerald-400' : 'text-destructive'
|
||||
)}
|
||||
>
|
||||
{testResult.ok ? m.testOk(testResult.tools.length) : `${m.testFailed}: ${testResult.error ?? ''}`}
|
||||
{testResult.ok && testResult.tools.length > 0 && (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
{testResult.tools.map(tool => (
|
||||
<span
|
||||
className="rounded-md bg-(--ui-bg-quinary) px-1.5 py-0.5 font-mono text-[0.65rem] text-(--ui-text-tertiary)"
|
||||
key={tool.name}
|
||||
title={tool.description}
|
||||
>
|
||||
{tool.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<label className="grid gap-1.5">
|
||||
<span className="text-xs text-muted-foreground">{m.name}</span>
|
||||
<Input onChange={event => setName(event.currentTarget.value)} placeholder="filesystem" value={name} />
|
||||
</label>
|
||||
<label className="grid gap-1.5">
|
||||
<span className="text-xs text-muted-foreground">{m.serverJson}</span>
|
||||
<Textarea
|
||||
className="min-h-80 font-mono text-xs"
|
||||
onChange={event => setBody(event.currentTarget.value)}
|
||||
spellCheck={false}
|
||||
value={body}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex items-center justify-between">
|
||||
{selected ? (
|
||||
<Button
|
||||
className="text-destructive hover:text-destructive"
|
||||
disabled={saving}
|
||||
onClick={() => void removeServer(selected)}
|
||||
size="xs"
|
||||
variant="text"
|
||||
>
|
||||
{m.remove}
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<Button disabled={saving} onClick={() => void saveServer()} size="sm">
|
||||
{saving ? t.common.saving : m.saveServer}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
|
||||
function TabButton({ active, label, onClick }: { active: boolean; label: string; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'cursor-pointer text-sm font-medium transition-colors',
|
||||
active ? 'text-foreground' : 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/** Nous-approved MCP catalog browser — the desktop counterpart of
|
||||
* `hermes mcp catalog` / `hermes mcp install` and the dashboard MCP page. */
|
||||
function McpCatalogBrowser({ onInstalled }: { onInstalled: () => void }) {
|
||||
const { t } = useI18n()
|
||||
const m = t.settings.mcp
|
||||
const [entries, setEntries] = useState<McpCatalogEntry[] | null>(null)
|
||||
const [installing, setInstalling] = useState<null | string>(null)
|
||||
// Per-entry env var drafts for catalog entries that need credentials.
|
||||
const [envDrafts, setEnvDrafts] = useState<Record<string, Record<string, string>>>({})
|
||||
const [envOpenFor, setEnvOpenFor] = useState<null | string>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
getMcpCatalog()
|
||||
.then(response => {
|
||||
if (!cancelled) {
|
||||
setEntries(response.entries)
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
if (!cancelled) {
|
||||
notifyError(err, m.catalogLoadFailed)
|
||||
setEntries([])
|
||||
}
|
||||
})
|
||||
|
||||
return () => void (cancelled = true)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- load once on mount
|
||||
}, [])
|
||||
|
||||
const install = async (entry: McpCatalogEntry) => {
|
||||
const required = entry.required_env.filter(env => env.required)
|
||||
const draft = envDrafts[entry.name] ?? {}
|
||||
|
||||
if (required.some(env => !draft[env.name]?.trim())) {
|
||||
if (envOpenFor !== entry.name) {
|
||||
setEnvOpenFor(entry.name)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
notify({ kind: 'error', title: m.catalogEnvPrompt(entry.name), message: m.catalogEnvRequired })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setInstalling(entry.name)
|
||||
|
||||
try {
|
||||
await installMcpCatalogEntry(entry.name, draft)
|
||||
notify({ kind: 'success', title: m.catalogInstallStarted(entry.name), message: '' })
|
||||
setEntries(
|
||||
current =>
|
||||
current?.map(row => (row.name === entry.name ? { ...row, installed: true, enabled: true } : row)) ?? current
|
||||
)
|
||||
setEnvOpenFor(null)
|
||||
onInstalled()
|
||||
} catch (err) {
|
||||
notifyError(err, m.catalogInstallFailed(entry.name))
|
||||
} finally {
|
||||
setInstalling(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (entries === null) {
|
||||
return <LoadingState label={m.catalogLoading} />
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
return <EmptyState description={m.catalogEmpty} title={m.tabCatalog} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{entries.map(entry => {
|
||||
const envOpen = envOpenFor === entry.name
|
||||
const draft = envDrafts[entry.name] ?? {}
|
||||
|
||||
return (
|
||||
<div className="px-0 py-2.5" key={entry.name}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">{entry.name}</span>
|
||||
<Pill>{entry.transport}</Pill>
|
||||
{entry.installed && (
|
||||
<Badge className="bg-emerald-500/15 text-emerald-400">
|
||||
{entry.enabled ? m.catalogEnabled : m.catalogInstalled}
|
||||
</Badge>
|
||||
)}
|
||||
{entry.needs_install && !entry.installed && <Pill>{m.catalogNeedsInstall}</Pill>}
|
||||
</div>
|
||||
<Button
|
||||
disabled={entry.installed || installing !== null}
|
||||
onClick={() => void install(entry)}
|
||||
size="xs"
|
||||
variant="textStrong"
|
||||
>
|
||||
{installing === entry.name
|
||||
? m.catalogInstalling
|
||||
: entry.installed
|
||||
? m.catalogInstalled
|
||||
: m.catalogInstall}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{entry.description}</p>
|
||||
{envOpen && entry.required_env.length > 0 && (
|
||||
<div className="mt-2 grid max-w-md gap-2">
|
||||
{entry.required_env.map(env => (
|
||||
<label className="grid gap-1" key={env.name}>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{env.prompt || env.name}
|
||||
{env.required ? ' *' : ''}
|
||||
</span>
|
||||
<Input
|
||||
onChange={event =>
|
||||
setEnvDrafts(prev => ({
|
||||
...prev,
|
||||
[entry.name]: { ...prev[entry.name], [env.name]: event.currentTarget.value }
|
||||
}))
|
||||
}
|
||||
type="password"
|
||||
value={draft[env.name] ?? ''}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -8,7 +8,6 @@ import {
|
|||
getAuxiliaryModels,
|
||||
getGlobalModelInfo,
|
||||
getGlobalModelOptions,
|
||||
getHermesConfigRecord,
|
||||
getMoaModels,
|
||||
getRecommendedDefaultModel,
|
||||
saveHermesConfig,
|
||||
|
|
@ -28,7 +27,8 @@ import { AlertTriangle, Cpu, Loader2 } from '@/lib/icons'
|
|||
import { cn } from '@/lib/utils'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { startManualLocalEndpoint, startManualProviderOAuth } from '@/store/onboarding'
|
||||
import type { HermesConfigRecord } from '@/types/hermes'
|
||||
|
||||
import { invalidateHermesConfig, setHermesConfigCache, useHermesConfigRecord } from '../hooks/use-config-record'
|
||||
|
||||
import { CONTROL_TEXT } from './constants'
|
||||
import { getNested, setNested } from './helpers'
|
||||
|
|
@ -136,9 +136,10 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||
const [moa, setMoa] = useState<MoaConfigResponse | null>(null)
|
||||
const [selectedMoaPreset, setSelectedMoaPreset] = useState('')
|
||||
const [newMoaPresetName, setNewMoaPresetName] = useState('')
|
||||
// Full profile config, kept so the reasoning/speed defaults round-trip
|
||||
// (read agent.* → write back the whole record) like the generic config page.
|
||||
const [config, setConfig] = useState<HermesConfigRecord | null>(null)
|
||||
// agent.* defaults round-trip through the shared config cache (read → write
|
||||
// back the whole record), so a save here shows in the MCP/config surfaces.
|
||||
const { data: config } = useHermesConfigRecord()
|
||||
const setConfig = setHermesConfigCache
|
||||
const [applying, setApplying] = useState(false)
|
||||
const [editingAuxTask, setEditingAuxTask] = useState<null | string>(null)
|
||||
const [auxDraft, setAuxDraft] = useState<{ model: string; provider: string }>({ model: '', provider: '' })
|
||||
|
|
@ -155,12 +156,11 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||
setError('')
|
||||
|
||||
try {
|
||||
const [modelInfo, modelOptions, auxiliaryModels, moaModels, cfg] = await Promise.all([
|
||||
const [modelInfo, modelOptions, auxiliaryModels, moaModels] = await Promise.all([
|
||||
getGlobalModelInfo(),
|
||||
getGlobalModelOptions(),
|
||||
getAuxiliaryModels(),
|
||||
getMoaModels().catch(() => null),
|
||||
getHermesConfigRecord()
|
||||
getMoaModels().catch(() => null)
|
||||
])
|
||||
|
||||
setMainModel({ model: modelInfo.model, provider: modelInfo.provider })
|
||||
|
|
@ -174,7 +174,9 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||
setSelectedMoaPreset(prev => (prev && moaModels.presets[prev] ? prev : moaModels.default_preset))
|
||||
}
|
||||
|
||||
setConfig(cfg)
|
||||
// The config record loads via its own shared query; a model switch can
|
||||
// change it server-side (aux slots), so nudge that cache to refetch.
|
||||
void invalidateHermesConfig()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
|
|
@ -191,9 +193,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||
// MoA reference/aggregator slots must never be the moa virtual provider —
|
||||
// that would create a recursive MoA tree (the backend rejects it on save).
|
||||
// Hide it from the slot selectors so it isn't offered as a dead choice.
|
||||
const moaSlotProviderOptions = providerOptions.filter(
|
||||
provider => (provider.slug || '').toLowerCase() !== 'moa'
|
||||
)
|
||||
const moaSlotProviderOptions = providerOptions.filter(provider => (provider.slug || '').toLowerCase() !== 'moa')
|
||||
|
||||
const selectedProviderRow = useMemo(
|
||||
() => providers.find(provider => provider.slug === selectedProvider),
|
||||
|
|
@ -312,6 +312,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||
const rawEffort = String(getNested(config ?? {}, 'agent.reasoning_effort') ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
|
||||
const effortValue = rawEffort === 'false' || rawEffort === 'disabled' ? 'none' : rawEffort || 'medium'
|
||||
|
||||
const fastOn = isFastTier(getNested(config ?? {}, 'agent.service_tier'))
|
||||
|
|
@ -785,6 +786,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||
...moa,
|
||||
default_preset: selectedMoaPreset || moa.default_preset
|
||||
}
|
||||
|
||||
void saveMoa(next)
|
||||
}}
|
||||
size="sm"
|
||||
|
|
@ -802,12 +804,14 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||
const presets = { ...moa.presets }
|
||||
delete presets[selectedMoaPreset]
|
||||
const fallback = Object.keys(presets)[0]
|
||||
|
||||
const next: MoaConfigResponse = {
|
||||
...moa,
|
||||
presets,
|
||||
default_preset: moa.default_preset === selectedMoaPreset ? fallback : moa.default_preset,
|
||||
active_preset: moa.active_preset === selectedMoaPreset ? '' : moa.active_preset
|
||||
}
|
||||
|
||||
setSelectedMoaPreset(Object.keys(moa.presets).find(name => name !== selectedMoaPreset) || '')
|
||||
void saveMoa(next)
|
||||
}}
|
||||
|
|
@ -826,6 +830,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||
disabled={!newMoaPresetName.trim() || !!moa.presets[newMoaPresetName.trim()] || applying}
|
||||
onClick={() => {
|
||||
const name = newMoaPresetName.trim()
|
||||
|
||||
const next: MoaConfigResponse = {
|
||||
...moa,
|
||||
presets: {
|
||||
|
|
@ -833,6 +838,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||
[name]: { ...currentMoaPreset, reference_models: [...currentMoaPreset.reference_models] }
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedMoaPreset(name)
|
||||
setNewMoaPresetName('')
|
||||
void saveMoa(next)
|
||||
|
|
|
|||
|
|
@ -76,23 +76,28 @@ export function ListRow({
|
|||
wide?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid gap-3 py-3 sm:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] sm:items-center',
|
||||
wide && 'sm:grid-cols-1 sm:items-start'
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[length:var(--conversation-text-font-size)] font-medium text-foreground">{title}</div>
|
||||
{description && (
|
||||
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{description}
|
||||
</div>
|
||||
// Container-queried, not viewport-queried: the label/control split keys on
|
||||
// the row's own pane width, so a narrow detail column (messaging, split
|
||||
// views) stacks instead of squishing the label against minmax(15rem,…).
|
||||
<div className="@container">
|
||||
<div
|
||||
className={cn(
|
||||
'grid gap-3 py-3',
|
||||
!wide && '@2xl:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] @2xl:items-center'
|
||||
)}
|
||||
{hint && <div className="mt-1 block font-mono text-[0.68rem] text-muted-foreground/45">{hint}</div>}
|
||||
{below}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[length:var(--conversation-text-font-size)] font-medium text-foreground">{title}</div>
|
||||
{description && (
|
||||
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
{hint && <div className="mt-1 block font-mono text-[0.68rem] text-muted-foreground/45">{hint}</div>}
|
||||
{below}
|
||||
</div>
|
||||
{action && <div className={cn('min-w-0', !wide && '@2xl:justify-self-end')}>{action}</div>}
|
||||
</div>
|
||||
{action && <div className={cn('min-w-0', !wide && 'sm:justify-self-end')}>{action}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -101,13 +106,6 @@ export function LoadingState({ label }: { label: string }) {
|
|||
return <PageLoader label={label} />
|
||||
}
|
||||
|
||||
export function EmptyState({ title, description }: { title: string; description: string }) {
|
||||
return (
|
||||
<div className="grid min-h-48 place-items-center text-center">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{title}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{description}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// Canonical implementation lives in components/ui; re-exported so the many
|
||||
// settings call sites keep their import path.
|
||||
export { EmptyState } from '@/components/ui/empty-state'
|
||||
|
|
|
|||
|
|
@ -66,6 +66,12 @@ function config(overrides: Partial<ToolsetConfig> = {}): ToolsetConfig {
|
|||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Radix menus/selects call these on open; jsdom implements neither, so the
|
||||
// dropdown never opens without the stubs (mirrors model-settings.test.tsx).
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
Element.prototype.hasPointerCapture = vi.fn(() => false)
|
||||
Element.prototype.releasePointerCapture = vi.fn()
|
||||
|
||||
getToolsetConfig.mockResolvedValue(config())
|
||||
getToolsetModels.mockResolvedValue({
|
||||
name: 'tts',
|
||||
|
|
@ -165,8 +171,10 @@ describe('ToolsetConfigPanel', () => {
|
|||
const elevenlabs = await screen.findByRole('button', { name: /ElevenLabs/ })
|
||||
fireEvent.click(elevenlabs)
|
||||
|
||||
// Click "Set" to reveal the input for the unset key.
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Set' }))
|
||||
// Open the credential actions menu (Radix opens on pointerdown), then "Set".
|
||||
const trigger = await screen.findByRole('button', { name: /Actions for ELEVENLABS_API_KEY/ })
|
||||
fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false, pointerType: 'mouse' })
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: 'Set' }))
|
||||
|
||||
const input = await screen.findByPlaceholderText('ElevenLabs API key')
|
||||
fireEvent.change(input, { target: { value: 'sk-test-123' } })
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
|
|
@ -512,32 +511,31 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
|
|||
onConfiguredChange?.()
|
||||
}
|
||||
|
||||
const emptyMessage = useMemo(() => {
|
||||
if (loading || !cfg) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!cfg.has_category) {
|
||||
return copy.noProviderOptions
|
||||
}
|
||||
|
||||
if (providers.length === 0) {
|
||||
return copy.noProviders
|
||||
}
|
||||
|
||||
return null
|
||||
}, [cfg, copy, loading, providers.length])
|
||||
|
||||
if (loading) {
|
||||
return <PageLoader className="min-h-32" label={copy.loadingConfig} />
|
||||
// Inline row, not a full block loader — a big centered spinner is what
|
||||
// caused the Skills/Tools tab-switch layout jump; this reads as "more
|
||||
// config incoming" without reserving a tall empty area.
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-1 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
{copy.loadingConfig}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (emptyMessage) {
|
||||
return <p className="px-1 py-3 text-xs text-muted-foreground">{emptyMessage}</p>
|
||||
// Nothing to configure → render nothing. An inspector explaining that there
|
||||
// is nothing to explain is noise (the old expander UX needed the message so
|
||||
// an expanded-empty panel didn't look broken; the always-open detail doesn't).
|
||||
if (!cfg || !cfg.has_category) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (providers.length === 0) {
|
||||
return <p className="px-1 py-3 text-xs text-muted-foreground">{copy.noProviders}</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3 grid gap-2">
|
||||
<div className="grid gap-2">
|
||||
{providers.map(provider => {
|
||||
const isActive = activeProvider === provider.name
|
||||
const configured = providerConfigured(provider, envState)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ export type SettingsView =
|
|||
| 'about'
|
||||
| 'gateway'
|
||||
| 'keys'
|
||||
| 'mcp'
|
||||
| 'notifications'
|
||||
| 'providers'
|
||||
| 'sessions'
|
||||
|
|
|
|||
|
|
@ -9,10 +9,7 @@ describe('withActive', () => {
|
|||
const curated = ['hermes-4', 'hermes-4-mini']
|
||||
|
||||
it('prepends a custom model missing from the curated list', () => {
|
||||
expect(withActive(curated, 'anthropic/claude-opus-4.7')).toEqual([
|
||||
'anthropic/claude-opus-4.7',
|
||||
...curated
|
||||
])
|
||||
expect(withActive(curated, 'anthropic/claude-opus-4.7')).toEqual(['anthropic/claude-opus-4.7', ...curated])
|
||||
})
|
||||
|
||||
it('leaves the list untouched when the active model is already curated', () => {
|
||||
|
|
|
|||
|
|
@ -1,24 +1,32 @@
|
|||
// @vitest-environment jsdom
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type * as HermesApi from '@/hermes'
|
||||
import { queryClient } from '@/lib/query-client'
|
||||
|
||||
const getSkills = vi.fn()
|
||||
const getToolsets = vi.fn()
|
||||
const toggleSkill = vi.fn()
|
||||
const toggleToolset = vi.fn()
|
||||
const getToolsetConfig = vi.fn()
|
||||
const selectToolsetProvider = vi.fn()
|
||||
const getUsageAnalytics = vi.fn()
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
// Partial mock: keep the real module (SkillsView pulls in @/store/profile,
|
||||
// whose import-time subscription calls setApiRequestProfile) and stub only the
|
||||
// calls we assert on.
|
||||
vi.mock('@/hermes', async importOriginal => ({
|
||||
...(await importOriginal<typeof HermesApi>()),
|
||||
getSkills: () => getSkills(),
|
||||
getToolsets: () => getToolsets(),
|
||||
toggleSkill: (name: string, enabled: boolean) => toggleSkill(name, enabled),
|
||||
toggleToolset: (name: string, enabled: boolean) => toggleToolset(name, enabled),
|
||||
getToolsetConfig: (name: string) => getToolsetConfig(name),
|
||||
selectToolsetProvider: (toolset: string, provider: string) => selectToolsetProvider(toolset, provider),
|
||||
deleteEnvVar: vi.fn(),
|
||||
revealEnvVar: vi.fn(),
|
||||
setEnvVar: vi.fn()
|
||||
getUsageAnalytics: (days: number) => getUsageAnalytics(days)
|
||||
}))
|
||||
|
||||
// Notifications hit nanostores/timers we don't care about here.
|
||||
|
|
@ -43,9 +51,12 @@ function toolset(overrides: Record<string, unknown> = {}) {
|
|||
function renderSkills() {
|
||||
return import('./index').then(({ SkillsView }) =>
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/skills?tab=toolsets']}>
|
||||
<SkillsView />
|
||||
</MemoryRouter>
|
||||
// SkillsView reads skills/toolsets via useQuery, so it needs a provider.
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={['/skills?tab=toolsets']}>
|
||||
<SkillsView />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -54,12 +65,15 @@ beforeEach(() => {
|
|||
getSkills.mockResolvedValue([])
|
||||
getToolsets.mockResolvedValue([toolset()])
|
||||
toggleToolset.mockResolvedValue({ ok: true, name: 'web', enabled: false })
|
||||
getToolsetConfig.mockResolvedValue({ has_category: false, active_provider: null, providers: [] })
|
||||
getToolsetConfig.mockResolvedValue({ has_category: true, active_provider: null, providers: [] })
|
||||
getUsageAnalytics.mockResolvedValue({ tools: [] })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
// Shared singleton client — drop cached skills/toolsets so each test refetches.
|
||||
queryClient.clear()
|
||||
})
|
||||
|
||||
describe('SkillsView toolset management', () => {
|
||||
|
|
@ -79,23 +93,20 @@ describe('SkillsView toolset management', () => {
|
|||
|
||||
await renderSkills()
|
||||
|
||||
expect(await screen.findByText('Cron Jobs')).toBeTruthy()
|
||||
// The label renders in both the row and the auto-selected detail header, so
|
||||
// assert via the switch's (emoji-stripped) accessible name and the absence
|
||||
// of the emoji rather than a single-match text lookup.
|
||||
await screen.findByRole('switch', { name: 'Toggle Cron Jobs toolset' })
|
||||
expect(screen.queryByText(/⏰/)).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the configured pill alongside the switch', async () => {
|
||||
it('renders the provider config panel inline for the selected toolset', async () => {
|
||||
// The master-detail UI dropped the resting "Configured" pill and the
|
||||
// "Configure" expander: the detail column auto-selects the first toolset
|
||||
// and renders its config panel directly, which fetches on mount.
|
||||
await renderSkills()
|
||||
|
||||
await screen.findByRole('switch', { name: 'Toggle Web Search toolset' })
|
||||
expect(screen.getByText('Configured')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('expands the provider config panel when the configured pill is clicked', async () => {
|
||||
await renderSkills()
|
||||
|
||||
const configureBtn = await screen.findByRole('button', { name: 'Configure Web Search' })
|
||||
fireEvent.click(configureBtn)
|
||||
|
||||
await waitFor(() => expect(getToolsetConfig).toHaveBeenCalledWith('web'))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
6
apps/desktop/src/app/skills/store.ts
Normal file
6
apps/desktop/src/app/skills/store.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { Codecs, persistentAtom } from '@/lib/persisted'
|
||||
|
||||
// Per-view sort direction for the Capabilities lists — persisted so each tab
|
||||
// remembers most/least-used across navigations and restarts.
|
||||
export const $skillsSortDesc = persistentAtom('hermes.desktop.capabilities.skillsSortDesc', true, Codecs.bool)
|
||||
export const $toolsetsSortDesc = persistentAtom('hermes.desktop.capabilities.toolsetsSortDesc', true, Codecs.bool)
|
||||
|
|
@ -26,6 +26,7 @@ export const en: Translations = {
|
|||
done: 'Done',
|
||||
error: 'Error',
|
||||
failed: 'Failed',
|
||||
formatJson: 'Format JSON',
|
||||
free: 'Free',
|
||||
loading: 'Loading…',
|
||||
notSet: 'Not set',
|
||||
|
|
@ -165,8 +166,7 @@ export const en: Translations = {
|
|||
|
||||
remoteDisplayBanner: {
|
||||
message: reason =>
|
||||
`Software rendering active — remote display detected (${reason}). GPU acceleration is disabled to prevent flickering.`,
|
||||
dismiss: 'Dismiss'
|
||||
`Software rendering active — remote display detected (${reason}). GPU acceleration is disabled to prevent flickering.`
|
||||
},
|
||||
|
||||
titlebar: {
|
||||
|
|
@ -758,11 +758,12 @@ export const en: Translations = {
|
|||
|
||||
skills: {
|
||||
tabSkills: 'Skills',
|
||||
tabToolsets: 'Toolsets',
|
||||
tabToolsets: 'Tools',
|
||||
tabMcp: 'MCP',
|
||||
tabHub: 'Browse Hub',
|
||||
all: 'All',
|
||||
searchSkills: 'Search skills...',
|
||||
searchToolsets: 'Search toolsets...',
|
||||
searchToolsets: 'Search tools...',
|
||||
refresh: 'Refresh skills',
|
||||
refreshing: 'Refreshing skills',
|
||||
loading: 'Loading capabilities...',
|
||||
|
|
@ -784,8 +785,15 @@ export const en: Translations = {
|
|||
toolsetDisabled: 'Toolset disabled',
|
||||
appliesToNewSessions: name => `${name} applies to new sessions.`,
|
||||
failedToUpdate: name => `Failed to update ${name}`,
|
||||
sortMostUsed: 'Most used',
|
||||
sortAlpha: 'A–Z',
|
||||
enableAll: 'Enable all',
|
||||
disableAll: 'Disable all',
|
||||
bulkUpdated: count => `Updated ${count} ${count === 1 ? 'item' : 'items'} for new sessions.`,
|
||||
bulkNoChange: 'Nothing to change.',
|
||||
usageCount: count => `used ${count}×`,
|
||||
hub: {
|
||||
searchPlaceholder: 'Search the skill hub (official, GitHub, community)...',
|
||||
searchPlaceholder: 'Search the skill hub',
|
||||
search: 'Search',
|
||||
searching: 'Searching...',
|
||||
connectingHubs: 'Connecting to skill hubs...',
|
||||
|
|
@ -800,6 +808,7 @@ export const en: Translations = {
|
|||
install: 'Install',
|
||||
installing: 'Installing...',
|
||||
uninstall: 'Uninstall',
|
||||
uninstalling: 'Uninstalling...',
|
||||
updateAll: 'Update installed',
|
||||
updating: 'Updating...',
|
||||
preview: 'Preview',
|
||||
|
|
@ -888,7 +897,6 @@ export const en: Translations = {
|
|||
ageHours: hours => `${hours}h ago`,
|
||||
durationSeconds: seconds => `${seconds}s`,
|
||||
durationMinutes: (minutes, seconds) => `${minutes}m ${seconds}s`,
|
||||
tokensK: k => `${k}k tok`,
|
||||
tokens: value => `${value} tok`
|
||||
},
|
||||
|
||||
|
|
@ -905,7 +913,7 @@ export const en: Translations = {
|
|||
appearance: 'Appearance',
|
||||
settings: 'Settings',
|
||||
changeTheme: 'Change theme',
|
||||
changeColorMode: 'Change color mode...',
|
||||
changeColorMode: 'Change color mode…',
|
||||
pets: {
|
||||
title: 'Pets',
|
||||
placeholder: 'Search pets…',
|
||||
|
|
@ -952,7 +960,8 @@ export const en: Translations = {
|
|||
startOver: 'Start over'
|
||||
},
|
||||
installTheme: {
|
||||
title: 'Install theme...',
|
||||
title: 'Install theme…',
|
||||
pageTitle: 'Install theme',
|
||||
placeholder: 'Search the VS Code Marketplace...',
|
||||
loading: 'Searching the Marketplace...',
|
||||
error: 'Could not reach the Marketplace.',
|
||||
|
|
@ -975,7 +984,7 @@ export const en: Translations = {
|
|||
nav: {
|
||||
newChat: { title: 'New session', detail: 'Start a fresh session' },
|
||||
settings: { title: 'Settings', detail: 'Configure Hermes desktop' },
|
||||
skills: { title: 'Skills & Tools', detail: 'Enable skills, toolsets, and providers' },
|
||||
skills: { title: 'Capabilities', detail: 'Skills, tools, and MCP servers' },
|
||||
messaging: { title: 'Messaging', detail: 'Set up Telegram, Slack, Discord, and more' },
|
||||
artifacts: { title: 'Artifacts', detail: 'Browse generated outputs' }
|
||||
},
|
||||
|
|
@ -1218,9 +1227,9 @@ export const en: Translations = {
|
|||
allProfiles: 'All profiles',
|
||||
showAllProfiles: 'Show all profiles',
|
||||
switchToProfile: name => `Switch to ${name}`,
|
||||
manageProfiles: 'Manage profiles...',
|
||||
manageProfiles: 'Manage profiles…',
|
||||
actionsFor: name => `Actions for ${name}`,
|
||||
color: 'Color...',
|
||||
color: 'Color…',
|
||||
colorFor: name => `Color for ${name}`,
|
||||
setColor: color => `Set color ${color}`,
|
||||
autoColor: 'Auto',
|
||||
|
|
@ -1233,6 +1242,8 @@ export const en: Translations = {
|
|||
env: 'env',
|
||||
defaultBadge: 'Default',
|
||||
rename: 'Rename',
|
||||
renameMenu: 'Rename…',
|
||||
editSoul: 'Edit SOUL.md…',
|
||||
copySetup: 'Copy setup',
|
||||
copying: 'Copying...',
|
||||
modelLabel: 'Model',
|
||||
|
|
@ -1433,7 +1444,7 @@ export const en: Translations = {
|
|||
sidebar: {
|
||||
nav: {
|
||||
'new-session': 'New session',
|
||||
skills: 'Skills & Tools',
|
||||
skills: 'Capabilities',
|
||||
messaging: 'Messaging',
|
||||
artifacts: 'Artifacts'
|
||||
},
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export const ja = defineLocale({
|
|||
done: '完了',
|
||||
error: 'エラー',
|
||||
failed: '失敗',
|
||||
formatJson: 'JSON を整形',
|
||||
free: '無料',
|
||||
loading: '読み込み中…',
|
||||
notSet: '未設定',
|
||||
|
|
@ -166,8 +167,7 @@ export const ja = defineLocale({
|
|||
|
||||
remoteDisplayBanner: {
|
||||
message: reason =>
|
||||
`ソフトウェアレンダリングが有効です — リモートディスプレイを検出しました(${reason})。ちらつきを防ぐため GPU アクセラレーションは無効化されています。`,
|
||||
dismiss: '閉じる'
|
||||
`ソフトウェアレンダリングが有効です — リモートディスプレイを検出しました(${reason})。ちらつきを防ぐため GPU アクセラレーションは無効化されています。`
|
||||
},
|
||||
|
||||
titlebar: {
|
||||
|
|
@ -839,6 +839,7 @@ export const ja = defineLocale({
|
|||
skills: {
|
||||
tabSkills: 'スキル',
|
||||
tabToolsets: 'ツールセット',
|
||||
tabMcp: 'MCP',
|
||||
all: 'すべて',
|
||||
searchSkills: 'スキルを検索...',
|
||||
searchToolsets: 'ツールセットを検索...',
|
||||
|
|
@ -862,7 +863,14 @@ export const ja = defineLocale({
|
|||
toolsetEnabled: 'ツールセットを有効にしました',
|
||||
toolsetDisabled: 'ツールセットを無効にしました',
|
||||
appliesToNewSessions: name => `${name} は新しいセッションに適用されます。`,
|
||||
failedToUpdate: name => `${name} の更新に失敗しました`
|
||||
failedToUpdate: name => `${name} の更新に失敗しました`,
|
||||
sortMostUsed: '使用頻度順',
|
||||
sortAlpha: 'A–Z',
|
||||
enableAll: 'すべて有効化',
|
||||
disableAll: 'すべて無効化',
|
||||
bulkUpdated: count => `${count} 件を新しいセッション向けに更新しました。`,
|
||||
bulkNoChange: '変更するものはありません。',
|
||||
usageCount: count => `${count} 回使用`
|
||||
},
|
||||
|
||||
starmap: {
|
||||
|
|
@ -907,7 +915,6 @@ export const ja = defineLocale({
|
|||
ageHours: hours => `${hours}時間前`,
|
||||
durationSeconds: seconds => `${seconds}秒`,
|
||||
durationMinutes: (minutes, seconds) => `${minutes}分 ${seconds}秒`,
|
||||
tokensK: k => `${k}k トーク`,
|
||||
tokens: value => `${value} トーク`
|
||||
},
|
||||
|
||||
|
|
@ -924,7 +931,7 @@ export const ja = defineLocale({
|
|||
appearance: '外観',
|
||||
settings: '設定',
|
||||
changeTheme: 'テーマを変更',
|
||||
changeColorMode: 'カラーモードを変更...',
|
||||
changeColorMode: 'カラーモードを変更…',
|
||||
pets: {
|
||||
title: 'ペット',
|
||||
placeholder: 'ペットを検索…',
|
||||
|
|
@ -970,7 +977,8 @@ export const ja = defineLocale({
|
|||
startOver: 'やり直す'
|
||||
},
|
||||
installTheme: {
|
||||
title: 'テーマをインストール...',
|
||||
title: 'テーマをインストール…',
|
||||
pageTitle: 'テーマをインストール',
|
||||
placeholder: 'VS Code Marketplace を検索...',
|
||||
loading: 'Marketplace を検索中...',
|
||||
error: 'Marketplace に接続できませんでした。',
|
||||
|
|
@ -1195,9 +1203,9 @@ export const ja = defineLocale({
|
|||
allProfiles: 'すべてのプロファイル',
|
||||
showAllProfiles: 'すべてのプロファイルを表示',
|
||||
switchToProfile: name => `${name} に切り替え`,
|
||||
manageProfiles: 'プロファイルを管理...',
|
||||
manageProfiles: 'プロファイルを管理…',
|
||||
actionsFor: name => `${name} のアクション`,
|
||||
color: 'カラー...',
|
||||
color: 'カラー…',
|
||||
colorFor: name => `${name} のカラー`,
|
||||
setColor: color => `カラー ${color} に設定`,
|
||||
autoColor: '自動',
|
||||
|
|
@ -1210,6 +1218,8 @@ export const ja = defineLocale({
|
|||
env: 'env',
|
||||
defaultBadge: 'デフォルト',
|
||||
rename: '名前を変更',
|
||||
renameMenu: '名前を変更…',
|
||||
editSoul: 'SOUL.md を編集…',
|
||||
copySetup: 'セットアップをコピー',
|
||||
copying: 'コピー中...',
|
||||
modelLabel: 'モデル',
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { normalize } from '@/lib/text'
|
||||
|
||||
import type { Locale } from './types'
|
||||
|
||||
export const DEFAULT_LOCALE: Locale = 'en'
|
||||
|
|
@ -74,11 +76,11 @@ export function normalizeLocale(value: unknown): Locale {
|
|||
return DEFAULT_LOCALE
|
||||
}
|
||||
|
||||
return LOCALE_ALIASES[value.trim().toLowerCase()] ?? DEFAULT_LOCALE
|
||||
return LOCALE_ALIASES[normalize(value)] ?? DEFAULT_LOCALE
|
||||
}
|
||||
|
||||
export function isSupportedLocaleValue(value: unknown): boolean {
|
||||
return typeof value === 'string' && LOCALE_ALIASES[value.trim().toLowerCase()] != null
|
||||
return typeof value === 'string' && LOCALE_ALIASES[normalize(value)] != null
|
||||
}
|
||||
|
||||
export function localeConfigValue(locale: Locale): string {
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ export interface Translations {
|
|||
done: string
|
||||
error: string
|
||||
failed: string
|
||||
formatJson: string
|
||||
free: string
|
||||
loading: string
|
||||
notSet: string
|
||||
|
|
@ -208,7 +209,6 @@ export interface Translations {
|
|||
|
||||
remoteDisplayBanner: {
|
||||
message: (reason: string) => string
|
||||
dismiss: string
|
||||
}
|
||||
|
||||
titlebar: {
|
||||
|
|
@ -656,6 +656,7 @@ export interface Translations {
|
|||
skills: {
|
||||
tabSkills: string
|
||||
tabToolsets: string
|
||||
tabMcp: string
|
||||
tabHub: string
|
||||
all: string
|
||||
searchSkills: string
|
||||
|
|
@ -681,6 +682,13 @@ export interface Translations {
|
|||
toolsetDisabled: string
|
||||
appliesToNewSessions: (name: string) => string
|
||||
failedToUpdate: (name: string) => string
|
||||
sortMostUsed: string
|
||||
sortAlpha: string
|
||||
enableAll: string
|
||||
disableAll: string
|
||||
bulkUpdated: (count: number) => string
|
||||
bulkNoChange: string
|
||||
usageCount: (count: number | string) => string
|
||||
hub: {
|
||||
searchPlaceholder: string
|
||||
search: string
|
||||
|
|
@ -696,6 +704,7 @@ export interface Translations {
|
|||
install: string
|
||||
installing: string
|
||||
uninstall: string
|
||||
uninstalling: string
|
||||
updateAll: string
|
||||
updating: string
|
||||
preview: string
|
||||
|
|
@ -779,8 +788,7 @@ export interface Translations {
|
|||
ageHours: (hours: number) => string
|
||||
durationSeconds: (seconds: string) => string
|
||||
durationMinutes: (minutes: number, seconds: number) => string
|
||||
tokensK: (k: string) => string
|
||||
tokens: (value: number) => string
|
||||
tokens: (value: number | string) => string
|
||||
}
|
||||
|
||||
commandCenter: {
|
||||
|
|
@ -843,6 +851,7 @@ export interface Translations {
|
|||
}
|
||||
installTheme: {
|
||||
title: string
|
||||
pageTitle: string
|
||||
placeholder: string
|
||||
loading: string
|
||||
error: string
|
||||
|
|
@ -1017,6 +1026,8 @@ export interface Translations {
|
|||
env: string
|
||||
defaultBadge: string
|
||||
rename: string
|
||||
renameMenu: string
|
||||
editSoul: string
|
||||
copySetup: string
|
||||
copying: string
|
||||
modelLabel: string
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export const zhHant = defineLocale({
|
|||
done: '完成',
|
||||
error: '錯誤',
|
||||
failed: '失敗',
|
||||
formatJson: '格式化 JSON',
|
||||
free: '免費',
|
||||
loading: '載入中…',
|
||||
notSet: '未設定',
|
||||
|
|
@ -160,8 +161,7 @@ export const zhHant = defineLocale({
|
|||
},
|
||||
|
||||
remoteDisplayBanner: {
|
||||
message: reason => `軟體繪圖已啟用 — 偵測到遠端顯示(${reason})。為防止畫面閃爍,已停用 GPU 加速。`,
|
||||
dismiss: '關閉'
|
||||
message: reason => `軟體繪圖已啟用 — 偵測到遠端顯示(${reason})。為防止畫面閃爍,已停用 GPU 加速。`
|
||||
},
|
||||
|
||||
titlebar: {
|
||||
|
|
@ -811,6 +811,7 @@ export const zhHant = defineLocale({
|
|||
skills: {
|
||||
tabSkills: '技能',
|
||||
tabToolsets: '工具集',
|
||||
tabMcp: 'MCP',
|
||||
all: '全部',
|
||||
searchSkills: '搜尋技能...',
|
||||
searchToolsets: '搜尋工具集...',
|
||||
|
|
@ -834,7 +835,14 @@ export const zhHant = defineLocale({
|
|||
toolsetEnabled: '工具集已啟用',
|
||||
toolsetDisabled: '工具集已停用',
|
||||
appliesToNewSessions: name => `${name} 將套用至新工作階段。`,
|
||||
failedToUpdate: name => `更新 ${name} 失敗`
|
||||
failedToUpdate: name => `更新 ${name} 失敗`,
|
||||
sortMostUsed: '最常用',
|
||||
sortAlpha: 'A–Z',
|
||||
enableAll: '全部啟用',
|
||||
disableAll: '全部停用',
|
||||
bulkUpdated: count => `已為新工作階段更新 ${count} 項。`,
|
||||
bulkNoChange: '沒有需要變更的內容。',
|
||||
usageCount: count => `已使用 ${count} 次`
|
||||
},
|
||||
|
||||
starmap: {
|
||||
|
|
@ -879,7 +887,6 @@ export const zhHant = defineLocale({
|
|||
ageHours: hours => `${hours} 小時前`,
|
||||
durationSeconds: seconds => `${seconds} 秒`,
|
||||
durationMinutes: (minutes, seconds) => `${minutes} 分 ${seconds} 秒`,
|
||||
tokensK: k => `${k}k 詞元`,
|
||||
tokens: value => `${value} 詞元`
|
||||
},
|
||||
|
||||
|
|
@ -896,7 +903,7 @@ export const zhHant = defineLocale({
|
|||
appearance: '外觀',
|
||||
settings: '設定',
|
||||
changeTheme: '變更主題',
|
||||
changeColorMode: '變更色彩模式...',
|
||||
changeColorMode: '變更色彩模式…',
|
||||
pets: {
|
||||
title: '寵物',
|
||||
placeholder: '搜尋寵物…',
|
||||
|
|
@ -942,7 +949,8 @@ export const zhHant = defineLocale({
|
|||
startOver: '重新開始'
|
||||
},
|
||||
installTheme: {
|
||||
title: '安裝主題...',
|
||||
title: '安裝主題…',
|
||||
pageTitle: '安裝主題',
|
||||
placeholder: '搜尋 VS Code Marketplace...',
|
||||
loading: '正在搜尋 Marketplace...',
|
||||
error: '無法連接到 Marketplace。',
|
||||
|
|
@ -1151,9 +1159,9 @@ export const zhHant = defineLocale({
|
|||
allProfiles: '全部設定檔',
|
||||
showAllProfiles: '顯示全部設定檔',
|
||||
switchToProfile: name => `切換至 ${name}`,
|
||||
manageProfiles: '管理設定檔...',
|
||||
manageProfiles: '管理設定檔…',
|
||||
actionsFor: name => `${name} 的動作`,
|
||||
color: '顏色...',
|
||||
color: '顏色…',
|
||||
colorFor: name => `${name} 的顏色`,
|
||||
setColor: color => `設定顏色 ${color}`,
|
||||
autoColor: '自動',
|
||||
|
|
@ -1166,6 +1174,8 @@ export const zhHant = defineLocale({
|
|||
env: 'env',
|
||||
defaultBadge: '預設',
|
||||
rename: '重新命名',
|
||||
renameMenu: '重新命名…',
|
||||
editSoul: '編輯 SOUL.md…',
|
||||
copySetup: '複製安裝指令',
|
||||
copying: '複製中…',
|
||||
modelLabel: '模型',
|
||||
|
|
@ -1419,8 +1429,7 @@ export const zhHant = defineLocale({
|
|||
copyPath: '複製路徑',
|
||||
removeFromSidebar: '從側邊欄移除',
|
||||
createFailed: '無法建立專案',
|
||||
staleBackend:
|
||||
'請更新 Hermes 後端以建立專案——目前後端比桌面應用舊(設定 → 更新 → 後端)。',
|
||||
staleBackend: '請更新 Hermes 後端以建立專案——目前後端比桌面應用舊(設定 → 更新 → 後端)。',
|
||||
deleteConfirm: '這會從 Hermes 中移除已儲存的專案。檔案、git 儲存庫和工作樹維持不變。',
|
||||
startWork: '新增工作樹',
|
||||
newWorktreeTitle: '新增工作樹',
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export const zh: Translations = {
|
|||
done: '完成',
|
||||
error: '错误',
|
||||
failed: '失败',
|
||||
formatJson: '格式化 JSON',
|
||||
free: '免费',
|
||||
loading: '加载中…',
|
||||
notSet: '未设置',
|
||||
|
|
@ -160,8 +161,7 @@ export const zh: Translations = {
|
|||
},
|
||||
|
||||
remoteDisplayBanner: {
|
||||
message: reason => `软件渲染已启用 — 检测到远程显示(${reason})。为防止画面闪烁,已禁用 GPU 加速。`,
|
||||
dismiss: '关闭'
|
||||
message: reason => `软件渲染已启用 — 检测到远程显示(${reason})。为防止画面闪烁,已禁用 GPU 加速。`
|
||||
},
|
||||
|
||||
titlebar: {
|
||||
|
|
@ -943,6 +943,7 @@ export const zh: Translations = {
|
|||
skills: {
|
||||
tabSkills: '技能',
|
||||
tabToolsets: '工具集',
|
||||
tabMcp: 'MCP',
|
||||
tabHub: '浏览技能中心',
|
||||
all: '全部',
|
||||
searchSkills: '搜索技能…',
|
||||
|
|
@ -968,8 +969,15 @@ export const zh: Translations = {
|
|||
toolsetDisabled: '工具集已禁用',
|
||||
appliesToNewSessions: name => `${name} 将应用于新会话。`,
|
||||
failedToUpdate: name => `更新 ${name} 失败`,
|
||||
sortMostUsed: '最常用',
|
||||
sortAlpha: 'A–Z',
|
||||
enableAll: '全部启用',
|
||||
disableAll: '全部停用',
|
||||
bulkUpdated: count => `已为新会话更新 ${count} 项。`,
|
||||
bulkNoChange: '没有需要更改的内容。',
|
||||
usageCount: count => `已使用 ${count} 次`,
|
||||
hub: {
|
||||
searchPlaceholder: '搜索技能中心(官方、GitHub、社区)…',
|
||||
searchPlaceholder: '搜索技能中心',
|
||||
search: '搜索',
|
||||
searching: '搜索中…',
|
||||
connectingHubs: '正在连接技能中心…',
|
||||
|
|
@ -983,6 +991,7 @@ export const zh: Translations = {
|
|||
install: '安装',
|
||||
installing: '安装中…',
|
||||
uninstall: '卸载',
|
||||
uninstalling: '卸载中…',
|
||||
updateAll: '更新已安装',
|
||||
updating: '更新中…',
|
||||
preview: '预览',
|
||||
|
|
@ -1070,7 +1079,6 @@ export const zh: Translations = {
|
|||
ageHours: hours => `${hours} 小时前`,
|
||||
durationSeconds: seconds => `${seconds} 秒`,
|
||||
durationMinutes: (minutes, seconds) => `${minutes} 分 ${seconds} 秒`,
|
||||
tokensK: k => `${k}k 词元`,
|
||||
tokens: value => `${value} 词元`
|
||||
},
|
||||
|
||||
|
|
@ -1087,7 +1095,7 @@ export const zh: Translations = {
|
|||
appearance: '外观',
|
||||
settings: '设置',
|
||||
changeTheme: '更改主题',
|
||||
changeColorMode: '更改颜色模式...',
|
||||
changeColorMode: '更改颜色模式…',
|
||||
pets: {
|
||||
title: '宠物',
|
||||
placeholder: '搜索宠物…',
|
||||
|
|
@ -1133,7 +1141,8 @@ export const zh: Translations = {
|
|||
startOver: '重新开始'
|
||||
},
|
||||
installTheme: {
|
||||
title: '安装主题...',
|
||||
title: '安装主题…',
|
||||
pageTitle: '安装主题',
|
||||
placeholder: '搜索 VS Code Marketplace...',
|
||||
loading: '正在搜索 Marketplace...',
|
||||
error: '无法连接到 Marketplace。',
|
||||
|
|
@ -1397,9 +1406,9 @@ export const zh: Translations = {
|
|||
allProfiles: '全部配置档案',
|
||||
showAllProfiles: '显示全部配置档案',
|
||||
switchToProfile: name => `切换到 ${name}`,
|
||||
manageProfiles: '管理配置档案...',
|
||||
manageProfiles: '管理配置档案…',
|
||||
actionsFor: name => `${name} 的操作`,
|
||||
color: '颜色...',
|
||||
color: '颜色…',
|
||||
colorFor: name => `${name} 的颜色`,
|
||||
setColor: color => `设置颜色 ${color}`,
|
||||
autoColor: '自动',
|
||||
|
|
@ -1412,6 +1421,8 @@ export const zh: Translations = {
|
|||
env: 'env',
|
||||
defaultBadge: '默认',
|
||||
rename: '重命名',
|
||||
renameMenu: '重命名…',
|
||||
editSoul: '编辑 SOUL.md…',
|
||||
copySetup: '复制安装命令',
|
||||
copying: '复制中…',
|
||||
modelLabel: '模型',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue