184 lines
6.0 KiB
TypeScript
184 lines
6.0 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react'
|
|
|
|
import { Switch } from '@/components/ui/switch'
|
|
import { getSkills, getToolsets, toggleSkill } from '@/hermes'
|
|
import { Brain, Wrench } from '@/lib/icons'
|
|
import { notify, notifyError } from '@/store/notifications'
|
|
import type { SkillInfo, ToolsetInfo } from '@/types/hermes'
|
|
|
|
import { asText, includesQuery, prettyName, toolNames } from './helpers'
|
|
import { ListRow, LoadingState, Pill, SectionHeading, SettingsContent } from './primitives'
|
|
import type { SearchProps } from './types'
|
|
|
|
export function ToolsSettings({ query }: SearchProps) {
|
|
const [skills, setSkills] = useState<SkillInfo[] | null>(null)
|
|
const [toolsets, setToolsets] = useState<ToolsetInfo[] | null>(null)
|
|
const [savingSkill, setSavingSkill] = useState<string | null>(null)
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
Promise.all([getSkills(), getToolsets()])
|
|
.then(([s, t]) => {
|
|
if (cancelled) {
|
|
return
|
|
}
|
|
|
|
setSkills(s)
|
|
setToolsets(t)
|
|
})
|
|
.catch(err => notifyError(err, 'Capabilities failed to load'))
|
|
|
|
return () => void (cancelled = true)
|
|
}, [])
|
|
|
|
const filteredSkills = useMemo(() => {
|
|
if (!skills) {
|
|
return []
|
|
}
|
|
|
|
const q = query.trim().toLowerCase()
|
|
|
|
return skills
|
|
.filter(s => !q || includesQuery(s.name, q) || includesQuery(s.description, q) || includesQuery(s.category, q))
|
|
.sort(
|
|
(a, b) => asText(a.category).localeCompare(asText(b.category)) || asText(a.name).localeCompare(asText(b.name))
|
|
)
|
|
}, [query, skills])
|
|
|
|
const filteredToolsets = useMemo(() => {
|
|
if (!toolsets) {
|
|
return []
|
|
}
|
|
|
|
const q = query.trim().toLowerCase()
|
|
|
|
return toolsets
|
|
.filter(t => {
|
|
if (!q) {
|
|
return true
|
|
}
|
|
|
|
return (
|
|
includesQuery(t.name, q) ||
|
|
includesQuery(t.label, q) ||
|
|
includesQuery(t.description, q) ||
|
|
toolNames(t).some(n => includesQuery(n, q))
|
|
)
|
|
})
|
|
.sort((a, b) => asText(a.label || a.name).localeCompare(asText(b.label || b.name)))
|
|
}, [query, toolsets])
|
|
|
|
const skillGroups = useMemo(() => {
|
|
const groups = new Map<string, SkillInfo[]>()
|
|
|
|
for (const skill of filteredSkills) {
|
|
const cat = asText(skill.category) || 'other'
|
|
groups.set(cat, [...(groups.get(cat) ?? []), skill])
|
|
}
|
|
|
|
return Array.from(groups).sort(([a], [b]) => a.localeCompare(b))
|
|
}, [filteredSkills])
|
|
|
|
async function handleToggleSkill(skill: SkillInfo, enabled: boolean) {
|
|
setSavingSkill(skill.name)
|
|
|
|
try {
|
|
await toggleSkill(skill.name, enabled)
|
|
setSkills(c => c?.map(s => (s.name === skill.name ? { ...s, enabled } : s)) ?? c)
|
|
notify({
|
|
kind: 'success',
|
|
title: enabled ? 'Skill enabled' : 'Skill disabled',
|
|
message: `${skill.name} applies to new sessions.`
|
|
})
|
|
} catch (err) {
|
|
notifyError(err, `Failed to update ${skill.name}`)
|
|
} finally {
|
|
setSavingSkill(null)
|
|
}
|
|
}
|
|
|
|
if (!skills || !toolsets) {
|
|
return <LoadingState label="Loading skills and toolsets..." />
|
|
}
|
|
|
|
return (
|
|
<SettingsContent>
|
|
<div className="mb-6">
|
|
<SectionHeading icon={Brain} meta={`${filteredSkills.filter(s => s.enabled).length} enabled`} title="Skills" />
|
|
{skillGroups.map(([category, list]) => (
|
|
<div className="mt-4 first:mt-0" key={category}>
|
|
<div className="mb-1 text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
|
{prettyName(category)}
|
|
</div>
|
|
<div className="divide-y divide-border/40">
|
|
{list.map(skill => (
|
|
<ListRow
|
|
action={
|
|
<Switch
|
|
checked={skill.enabled}
|
|
disabled={savingSkill === skill.name}
|
|
onCheckedChange={c => void handleToggleSkill(skill, c)}
|
|
/>
|
|
}
|
|
description={asText(skill.description)}
|
|
key={asText(skill.name)}
|
|
title={asText(skill.name)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="mb-6">
|
|
<SectionHeading
|
|
icon={Wrench}
|
|
meta={`${filteredToolsets.filter(t => t.enabled).length} enabled`}
|
|
title="Toolsets"
|
|
/>
|
|
<div className="divide-y divide-border/40">
|
|
{filteredToolsets.map(toolset => {
|
|
const tools = toolNames(toolset)
|
|
const label = asText(toolset.label || toolset.name)
|
|
|
|
return (
|
|
<ListRow
|
|
action={
|
|
<div className="flex shrink-0 items-center gap-1.5">
|
|
<Pill tone={toolset.enabled ? 'primary' : 'muted'}>{toolset.enabled ? 'Enabled' : 'Disabled'}</Pill>
|
|
<Pill tone={toolset.configured ? 'primary' : 'muted'}>
|
|
{toolset.configured ? 'Configured' : 'Needs keys'}
|
|
</Pill>
|
|
</div>
|
|
}
|
|
below={
|
|
tools.length > 0 && (
|
|
<div className="mt-3 flex flex-wrap gap-1">
|
|
{tools.slice(0, 10).map(t => (
|
|
<span
|
|
className="rounded-md bg-muted px-1.5 py-0.5 font-mono text-[0.64rem] text-muted-foreground"
|
|
key={t}
|
|
>
|
|
{t}
|
|
</span>
|
|
))}
|
|
{tools.length > 10 && (
|
|
<span className="rounded-md bg-muted px-1.5 py-0.5 text-[0.64rem] text-muted-foreground">
|
|
+{tools.length - 10} more
|
|
</span>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
description={asText(toolset.description)}
|
|
key={asText(toolset.name) || label}
|
|
title={label}
|
|
/>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
</SettingsContent>
|
|
)
|
|
}
|