181 lines
6.9 KiB
JavaScript
181 lines
6.9 KiB
JavaScript
import * as React from 'react';
|
|
import { Check } from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
import { Button, Modal, SearchInput } from '@/components/ds';
|
|
import { allSkills, skillsWithFacet } from '@/lib/skills/registry';
|
|
import { surfaceFor } from '@/lib/skills/surfaces';
|
|
|
|
/**
|
|
* Attaching skills to an agent.
|
|
*
|
|
* Reads **the one skill registry** — the same `allSkills` the Skills page and
|
|
* Owliver itself read. There is deliberately no separate list for agents: a
|
|
* second one would drift, and an agent would end up offering a skill the
|
|
* runtime does not have.
|
|
*
|
|
* Categories are derived rather than written down. A skill's own `category`
|
|
* field when it declares one, and the pages it attaches to otherwise, so a
|
|
* filter can never offer a grouping that matches nothing — and a skill added
|
|
* tomorrow appears under its own category without this file being edited.
|
|
*/
|
|
|
|
const ALL = 'all';
|
|
|
|
/** The groupings the registry actually contains, in a stable order. */
|
|
function categoriesFor(skills) {
|
|
const named = new Set();
|
|
for (const skill of skills) {
|
|
if (skill.category) named.add(skill.category);
|
|
}
|
|
return [ALL, ...[...named].sort()];
|
|
}
|
|
|
|
/** What this skill contributes, in the terms the reader is choosing between. */
|
|
function skillSummary(skill) {
|
|
const pages = skill.pages.map((p) => surfaceFor(p)?.label || p);
|
|
const capabilities = skill.owliver?.capabilities || [];
|
|
return [
|
|
pages.length ? pages.join(', ') : 'No pages',
|
|
capabilities.length
|
|
? `${capabilities.length} capabilit${capabilities.length === 1 ? 'y' : 'ies'}`
|
|
: (skill.actions || []).length ? `${skill.actions.length} action${skill.actions.length === 1 ? '' : 's'}` : null,
|
|
].filter(Boolean).join(' · ');
|
|
}
|
|
|
|
/** @param {any} props */
|
|
export function AddSkillsModal({ open, onOpenChange, attached = [], customSkills = [], onAdd }) {
|
|
const [query, setQuery] = React.useState('');
|
|
const [category, setCategory] = React.useState(ALL);
|
|
const [picked, setPicked] = React.useState([]);
|
|
|
|
/* A fresh sheet each time, so a previous selection is not still ticked. */
|
|
React.useEffect(() => {
|
|
if (open) { setQuery(''); setCategory(ALL); setPicked([]); }
|
|
}, [open]);
|
|
|
|
/* Owliver skills only. A workforce training path is something a person
|
|
learns, not something an agent can be asked to do, and offering one here
|
|
would promise behaviour that does not exist. */
|
|
const available = React.useMemo(
|
|
() => skillsWithFacet(allSkills(customSkills), 'owliver')
|
|
.filter((s) => s.status === 'active' && !attached.includes(s.id)),
|
|
[customSkills, attached]
|
|
);
|
|
|
|
const categories = React.useMemo(() => categoriesFor(available), [available]);
|
|
|
|
const visible = React.useMemo(() => {
|
|
const q = query.trim().toLowerCase();
|
|
return available
|
|
.filter((s) => category === ALL || s.category === category)
|
|
.filter((s) => !q
|
|
|| s.name.toLowerCase().includes(q)
|
|
|| s.description.toLowerCase().includes(q)
|
|
|| s.id.includes(q));
|
|
}, [available, category, query]);
|
|
|
|
const toggle = (id) =>
|
|
setPicked((current) => (current.includes(id)
|
|
? current.filter((x) => x !== id)
|
|
: [...current, id]));
|
|
|
|
return (
|
|
<Modal
|
|
open={open}
|
|
onOpenChange={onOpenChange}
|
|
title="Add skills"
|
|
description="Select skills to extend what this agent can do. A skill still only answers on the pages it names."
|
|
size="lg"
|
|
footer={
|
|
<>
|
|
<p className="mr-auto text-body-sm text-ink-3">
|
|
{picked.length
|
|
? `${picked.length} skill${picked.length === 1 ? '' : 's'} selected`
|
|
: 'Nothing selected yet'}
|
|
</p>
|
|
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
|
<Button disabled={!picked.length} onClick={() => { onAdd(picked); onOpenChange(false); }}>
|
|
Add
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
<div className="space-y-3">
|
|
<SearchInput value={query} onChange={setQuery} placeholder="Search skills..." debounce={0} />
|
|
|
|
{categories.length > 1 && (
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{categories.map((id) => (
|
|
<button
|
|
key={id}
|
|
type="button"
|
|
onClick={() => setCategory(id)}
|
|
className={cn(
|
|
`rounded-full border px-2.5 py-1 text-caption font-medium capitalize transition-colors
|
|
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50`,
|
|
category === id
|
|
? 'border-krow-blue/40 bg-krow-blue-tint text-krow-blue'
|
|
: 'border-border bg-surface text-ink-3 hover:border-krow-blue/30 hover:text-ink-2'
|
|
)}
|
|
>
|
|
{id === ALL ? 'All skills' : id}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div className="max-h-[22rem] space-y-1.5 overflow-y-auto pr-0.5">
|
|
{visible.map((skill) => {
|
|
const chosen = picked.includes(skill.id);
|
|
return (
|
|
<button
|
|
key={skill.id}
|
|
type="button"
|
|
onClick={() => toggle(skill.id)}
|
|
aria-pressed={chosen}
|
|
className={cn(
|
|
`flex w-full items-start gap-2.5 rounded-xl border px-3 py-2.5 text-left transition-colors
|
|
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50`,
|
|
chosen
|
|
? 'border-krow-blue/50 bg-krow-blue-tint/50'
|
|
: 'border-border bg-surface hover:border-krow-blue/30 hover:bg-surface-subtle'
|
|
)}
|
|
>
|
|
<span
|
|
className={cn(
|
|
'mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded border',
|
|
chosen ? 'border-krow-blue bg-krow-blue text-white' : 'border-border bg-surface'
|
|
)}
|
|
aria-hidden="true"
|
|
>
|
|
{chosen && <Check className="h-3 w-3" />}
|
|
</span>
|
|
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block truncate text-body-sm font-medium text-ink-1">{skill.name}</span>
|
|
{skill.description && (
|
|
<span className="mt-0.5 block text-caption leading-snug text-ink-3">
|
|
{skill.description}
|
|
</span>
|
|
)}
|
|
<span className="mt-1 block truncate text-[10px] text-ink-4">{skillSummary(skill)}</span>
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
|
|
{!visible.length && (
|
|
<p className="px-1 py-6 text-center text-body-sm text-ink-3">
|
|
{available.length
|
|
? 'No skills match that.'
|
|
: 'Every available skill is already attached to this agent.'}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
export default AddSkillsModal;
|