update owliver agent
This commit is contained in:
@@ -1,180 +0,0 @@
|
||||
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;
|
||||
@@ -9,13 +9,12 @@ import {
|
||||
SelectValue, Switch, Textarea,
|
||||
} from '@/components/ds';
|
||||
import { allSkills } from '@/lib/skills/registry';
|
||||
import { surfaceFor, SUPPORTED_SKILL_PAGES } from '@/lib/skills/surfaces';
|
||||
import { DOMAIN_SURFACES, surfaceFor } from '@/lib/skills/surfaces';
|
||||
import { AGENT_ICONS, KNOWLEDGE_KINDS, REASONING_MODES } from '@/lib/agents/vocabulary';
|
||||
import {
|
||||
AgentPreview, Collapse, DocField, DocSection, GroupHead, IconPicker, ItemRow, Rail, ScopeChip,
|
||||
SettingRow, Workspace,
|
||||
} from './AgentCanvas';
|
||||
import { AddSkillsModal } from './AddSkillsModal';
|
||||
|
||||
/**
|
||||
* The agent configuration workspace.
|
||||
@@ -97,9 +96,9 @@ function useActiveSection(ids) {
|
||||
}
|
||||
|
||||
/** @param {any} props */
|
||||
export function AgentConfigure({ fields, agents, customSkills = [], onChange }) {
|
||||
const [addingSkills, setAddingSkills] = React.useState(false);
|
||||
|
||||
export function AgentConfigure({
|
||||
fields, agents, customSkills = [], onChange, onOpenSkills, scopeLocked = false,
|
||||
}) {
|
||||
/* Sections are open by default: this is a document, and one that greets its
|
||||
author with four closed headers hides the thing they came to write. Closing
|
||||
is for focus, not for the initial reading. */
|
||||
@@ -332,24 +331,62 @@ export function AgentConfigure({ fields, agents, customSkills = [], onChange })
|
||||
? <>Answers on <span className="font-medium text-ink-1">{coveredLabels.join(', ')}</span>. Anywhere else it is shown as constrained and declines.</>
|
||||
: 'No page selected — this agent has nowhere to answer yet.'}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{SUPPORTED_SKILL_PAGES.map((page) => (
|
||||
<ScopeChip
|
||||
key={page}
|
||||
active={fields.pages.includes(page)}
|
||||
onClick={() => set({
|
||||
pages: fields.pages.includes(page)
|
||||
? fields.pages.filter((p) => p !== page)
|
||||
: [...fields.pages, page],
|
||||
})}
|
||||
>
|
||||
{pageLabel(page)}
|
||||
</ScopeChip>
|
||||
))}
|
||||
</div>
|
||||
{/**
|
||||
* Scope, not a page picker.
|
||||
*
|
||||
* This was every id in the vocabulary — eighteen chips including
|
||||
* Settings, Workspace, Skills and Skill Configure — so the
|
||||
* Activity Agent could be pointed at the workspace configuration
|
||||
* screen. That is not a choice anyone should be offered: those
|
||||
* surfaces hold no workforce records (they declare
|
||||
* `placements: []`, which is what `DOMAIN_SURFACES` reads), and an
|
||||
* agent aimed at one has nothing to answer from.
|
||||
*
|
||||
* A shipped agent is a *registered domain agent* — its scope is
|
||||
* part of the definition Krow ships, and the skills it may carry
|
||||
* are derived from it. So it is stated rather than edited. An
|
||||
* agent this workspace authored still chooses, from the pages
|
||||
* that are actually product domains.
|
||||
*/}
|
||||
{scopeLocked ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{fields.pages.map((page) => (
|
||||
<span
|
||||
key={page}
|
||||
className="rounded-full border border-krow-blue/40 bg-krow-blue-tint px-2.5 py-1 text-caption font-medium text-krow-blue"
|
||||
>
|
||||
{pageLabel(page)}
|
||||
</span>
|
||||
))}
|
||||
{!fields.pages.length && (
|
||||
<span className="text-body-sm text-ink-3">No surface registered.</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{/* Domain surfaces, plus anything this definition already
|
||||
declares — so an agent written by hand against a
|
||||
configuration surface keeps it rather than losing it
|
||||
silently on the next save. */}
|
||||
{[...new Set([...DOMAIN_SURFACES, ...fields.pages])].map((page) => (
|
||||
<ScopeChip
|
||||
key={page}
|
||||
active={fields.pages.includes(page)}
|
||||
onClick={() => set({
|
||||
pages: fields.pages.includes(page)
|
||||
? fields.pages.filter((p) => p !== page)
|
||||
: [...fields.pages, page],
|
||||
})}
|
||||
>
|
||||
{pageLabel(page)}
|
||||
</ScopeChip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-2 text-caption leading-relaxed text-ink-4">
|
||||
A page decides which skills exist there. An agent chooses among them — it can
|
||||
narrow that list, never widen it.
|
||||
{scopeLocked
|
||||
? 'This agent ships with Krow, and its surface is part of that definition. Its scope decides which capabilities it can carry.'
|
||||
: 'A page decides which skills exist there. An agent chooses among them — it can narrow that list, never widen it.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -361,12 +398,16 @@ export function AgentConfigure({ fields, agents, customSkills = [], onChange })
|
||||
title="Skills"
|
||||
count={fields.skills.length}
|
||||
action={(
|
||||
<Button variant="outline" size="sm" onClick={() => setAddingSkills(true)}>
|
||||
<Plus className="h-3.5 w-3.5" /> Add skills
|
||||
<Button variant="outline" size="sm" onClick={onOpenSkills}>
|
||||
<Boxes className="h-3.5 w-3.5" /> Browse catalog
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Attaching happens in the Skills workspace, not here. A
|
||||
checkbox list could say which skills exist; it could not show
|
||||
what an agent is *made of*, which is the question someone
|
||||
composing one is actually asking. */}
|
||||
{fields.skills.length > 0 ? (
|
||||
<ul className="divide-y divide-border">
|
||||
{fields.skills.map((id) => {
|
||||
@@ -386,18 +427,40 @@ export function AgentConfigure({ fields, agents, customSkills = [], onChange })
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-body-sm text-ink-3">
|
||||
No skills attached. This agent can still answer from the page's own reader.
|
||||
No skills attached. This agent can still answer from the page's own reader —
|
||||
or open the{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenSkills}
|
||||
className={`font-medium text-krow-blue underline-offset-2 hover:underline
|
||||
focus-visible:outline-none focus-visible:ring-2
|
||||
focus-visible:ring-krow-blue/40 rounded`}
|
||||
>
|
||||
skill catalog
|
||||
</button>{' '}
|
||||
to give it one.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="mt-2 text-caption text-ink-4">
|
||||
Authored in{' '}
|
||||
Attached under{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenSkills}
|
||||
className={`font-medium text-krow-blue underline-offset-2 hover:underline
|
||||
focus-visible:outline-none focus-visible:ring-2
|
||||
focus-visible:ring-krow-blue/40 rounded`}
|
||||
>
|
||||
Skills
|
||||
</button>
|
||||
, where Owliver skills and Board skills both live. Definitions are written in the{' '}
|
||||
<Link
|
||||
to="/admin/workspace/skills"
|
||||
className="font-medium text-krow-blue underline-offset-2 hover:underline"
|
||||
>
|
||||
Workspace → Skills
|
||||
skill library
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -666,14 +729,6 @@ export function AgentConfigure({ fields, agents, customSkills = [], onChange })
|
||||
</div>
|
||||
</DocSection>
|
||||
</Workspace>
|
||||
|
||||
<AddSkillsModal
|
||||
open={addingSkills}
|
||||
onOpenChange={setAddingSkills}
|
||||
attached={fields.skills}
|
||||
customSkills={customSkills}
|
||||
onAdd={(ids) => set({ skills: [...fields.skills, ...ids] })}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
434
src/components/agents/skills/AgentSkillWorkspace.jsx
Normal file
434
src/components/agents/skills/AgentSkillWorkspace.jsx
Normal file
@@ -0,0 +1,434 @@
|
||||
import * as React from 'react';
|
||||
import { ListTree, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Alert, Button, Drawer, Tabs } from '@/components/ds';
|
||||
import { surfaceFor } from '@/lib/skills/surfaces';
|
||||
import { evaluateCapability, scopeFor, testTargets } from '@/lib/agents/capabilityTest';
|
||||
import { supportedSurfaces } from './SurfaceSelect';
|
||||
import {
|
||||
boardCatalog, catalogGroups, filterBoard, filterCatalog, skillCatalog,
|
||||
} from '@/lib/skills/catalog';
|
||||
import { AgentTree } from './AgentTree';
|
||||
import { BoardSkillList } from './BoardSkillList';
|
||||
import { SkillCatalog } from './SkillCatalog';
|
||||
import { SkillDetails } from './SkillDetails';
|
||||
|
||||
/**
|
||||
* Composing an agent: what it is made of, beside what it could be made of.
|
||||
*
|
||||
* Owliver already holds the third column on every Admin page, so this screen
|
||||
* completes the workspace rather than rebuilding it — tree, catalog, and the
|
||||
* assistant that was already there. That is the reason there is no chat in this
|
||||
* file and no route out of it: attaching a skill is a thing you do *while*
|
||||
* talking to Owliver, and a second conversation would be a second Owliver.
|
||||
*
|
||||
* **One source of truth.** `attachedIds` is derived from the agent's own
|
||||
* `skills` field on every render, and the tree, the cards and the details panel
|
||||
* all read that one set. Nothing here caches "is this attached" — which is how a
|
||||
* card and a tree come to disagree after a failed save.
|
||||
*
|
||||
* The tree is a column at `lg` and a drawer below it. One tree either way: the
|
||||
* component is rendered once per presentation but its open/closed branch state
|
||||
* lives here, so opening Tools on a phone and rotating to a tablet does not shut
|
||||
* it again.
|
||||
*
|
||||
* **One Skills section, two kinds.** Owliver skills and Board skills are two
|
||||
* tabs of this one surface rather than two destinations, because the question
|
||||
* "which of those do I go to?" was the whole problem. They are not the same
|
||||
* thing behind the tabs, and the panels say so: an Owliver skill is attached to
|
||||
* this agent, a Board skill is switched on for a page. Flattening that
|
||||
* difference would have made the navigation simpler and the product a liar.
|
||||
*/
|
||||
|
||||
const OWLIVER = 'owliver';
|
||||
const BOARD = 'board';
|
||||
|
||||
/** @param {any} props */
|
||||
export function AgentSkillWorkspace({
|
||||
fields,
|
||||
customSkills = [],
|
||||
onToggleSkill,
|
||||
disabledSkills = [],
|
||||
onToggleBoardSkill,
|
||||
onTestCapability,
|
||||
test = null,
|
||||
busy = false,
|
||||
pendingId = null,
|
||||
dirty = false,
|
||||
}) {
|
||||
const [kind, setKind] = React.useState(OWLIVER);
|
||||
const [query, setQuery] = React.useState('');
|
||||
const [boardQuery, setBoardQuery] = React.useState('');
|
||||
const [group, setGroup] = React.useState('all');
|
||||
const [selectedId, setSelectedId] = React.useState(/** @type {string|null} */ (null));
|
||||
const [treeOpen, setTreeOpen] = React.useState(false);
|
||||
const [branches, setBranches] = React.useState({
|
||||
surfaces: false,
|
||||
skills: true,
|
||||
'skills-owliver': true,
|
||||
'skills-board': true,
|
||||
tools: true,
|
||||
knowledge: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* Only what this agent could actually use.
|
||||
*
|
||||
* The catalog used to be the whole registry, so the Activity Agent was
|
||||
* offered Create Position — an attachment the runtime would have refused,
|
||||
* because `skillsForContext` filters by page before an agent's own list is
|
||||
* consulted. Passing the scope makes the catalog agree with the runtime.
|
||||
*/
|
||||
const entries = React.useMemo(
|
||||
() => skillCatalog(customSkills, { pages: fields.pages }),
|
||||
[customSkills, fields.pages]
|
||||
);
|
||||
const board = React.useMemo(
|
||||
() => boardCatalog(customSkills, { pages: fields.pages }),
|
||||
[customSkills, fields.pages]
|
||||
);
|
||||
const disabledIds = React.useMemo(() => new Set(disabledSkills), [disabledSkills]);
|
||||
|
||||
/* The pages this agent answers on, in the product's own words. */
|
||||
const surfaces = React.useMemo(
|
||||
() => fields.pages.map((p) => ({ id: p, label: surfaceFor(p)?.label || p })),
|
||||
[fields.pages]
|
||||
);
|
||||
|
||||
/* `boardCatalog` is already scoped, so every entry draws somewhere this agent
|
||||
answers. Kept as its own name because the tree and the tab count read it. */
|
||||
const boardHere = board;
|
||||
|
||||
const visibleBoard = React.useMemo(
|
||||
() => filterBoard(board, { query: boardQuery }),
|
||||
[board, boardQuery]
|
||||
);
|
||||
const entryById = React.useMemo(
|
||||
() => new Map(entries.map((e) => [e.id, e])),
|
||||
[entries]
|
||||
);
|
||||
|
||||
const attachedIds = React.useMemo(() => new Set(fields.skills), [fields.skills]);
|
||||
|
||||
const groups = React.useMemo(() => catalogGroups(entries), [entries]);
|
||||
const visible = React.useMemo(
|
||||
() => filterCatalog(entries, { query, group }),
|
||||
[entries, query, group]
|
||||
);
|
||||
|
||||
/**
|
||||
* The tools this agent reaches.
|
||||
*
|
||||
* Derived from the attached skills' own declarations, never stored — a tool is
|
||||
* not something an agent carries, it is something a skill it carries can do.
|
||||
* Deduplicated by name, keeping the first skill that brought it so the tree can
|
||||
* say where it came from.
|
||||
*/
|
||||
const tools = React.useMemo(() => {
|
||||
const seen = new Map();
|
||||
for (const id of fields.skills) {
|
||||
const entry = entryById.get(id);
|
||||
if (!entry) continue;
|
||||
for (const tool of entry.tools) {
|
||||
if (!seen.has(tool.name)) seen.set(tool.name, { ...tool, via: entry.name });
|
||||
}
|
||||
}
|
||||
return [...seen.values()];
|
||||
}, [fields.skills, entryById]);
|
||||
|
||||
/* A skill attached to this agent that the registry does not have. Worth
|
||||
saying out loud: it is the one state where the tree shows something the
|
||||
runtime cannot use. */
|
||||
const unknown = React.useMemo(
|
||||
() => fields.skills.filter((id) => !entryById.has(id)),
|
||||
[fields.skills, entryById]
|
||||
);
|
||||
|
||||
const toggleBranch = React.useCallback(
|
||||
(id) => setBranches((current) => ({ ...current, [id]: !current[id] })),
|
||||
[]
|
||||
);
|
||||
|
||||
const openDetails = React.useCallback((id) => setSelectedId(id), []);
|
||||
|
||||
/**
|
||||
* The surface a capability runs on when nobody has chosen one.
|
||||
*
|
||||
* The first its definition supports — Owliver for everything shipped today.
|
||||
* The card has no selector, so this is what `+ Add Agent` and its Test button
|
||||
* mean by "this capability"; the drawer overrides it with whatever the reader
|
||||
* picked there.
|
||||
*/
|
||||
const defaultSurface = React.useCallback(
|
||||
(entry) => supportedSurfaces(entry.type)[0] || 'owliver',
|
||||
[]
|
||||
);
|
||||
|
||||
/**
|
||||
* Trying a capability — the one implementation.
|
||||
*
|
||||
* Both the card's Test button and the drawer's call this, so a test started
|
||||
* from either place resolves the same target page, builds the same scope and
|
||||
* hands the same request to the Owliver panel. Two copies of this would be two
|
||||
* definitions of what "test" means.
|
||||
*
|
||||
* It writes nothing. `evaluateCapability` reasons about a hypothetical agent
|
||||
* carrying the capability, and that agent is never stored — which is what
|
||||
* keeps Test and Add Agent separate acts.
|
||||
*/
|
||||
const runCapabilityTest = React.useCallback(({ entry, surface, question = null }) => {
|
||||
if (!onTestCapability) return;
|
||||
const target = testTargets(fields.pages, entry.surfaces.map((x) => x.id))[0];
|
||||
/* A Board section draws on a page rather than answering, so there is no
|
||||
question to ask it. */
|
||||
if (!target || surface === 'board') return;
|
||||
|
||||
const asked = question
|
||||
|| entry.suggestions?.[0]?.prompt
|
||||
|| entry.suggestions?.[0]?.label
|
||||
|| entry.name;
|
||||
|
||||
const evaluation = evaluateCapability({
|
||||
fields, skillId: entry.id, contextId: target.contextId, question: asked, customSkills, entry,
|
||||
});
|
||||
|
||||
onTestCapability({
|
||||
question: asked,
|
||||
capability: evaluation.capability,
|
||||
scope: scopeFor(evaluation, target.contextId),
|
||||
trace: { skillId: entry.id, skillName: entry.name, agentName: fields.name, surface },
|
||||
});
|
||||
}, [fields, customSkills, onTestCapability]);
|
||||
|
||||
/* Which capabilities have somewhere to be tried, so the card can disable a
|
||||
button that would do nothing rather than offering it and failing. */
|
||||
const testableIds = React.useMemo(() => new Set(
|
||||
entries
|
||||
.filter((e) => e.type !== 'board'
|
||||
&& testTargets(fields.pages, e.surfaces.map((x) => x.id)).length)
|
||||
.map((e) => e.id)
|
||||
), [entries, fields.pages]);
|
||||
|
||||
/**
|
||||
* Is this capability on, for a given surface?
|
||||
*
|
||||
* The two surfaces have two different, *real* switches, and this reads both
|
||||
* rather than inventing a third state to sit over them. Owliver is the
|
||||
* agent's own `skills` list. Board is the account's switched-off list, which
|
||||
* is what `SkillSurface` actually consults — workspace-wide, which the detail
|
||||
* panel says out loud rather than dressing up as per-agent.
|
||||
*/
|
||||
const isEnabledOn = React.useCallback((id, surf) => {
|
||||
const onOwliver = attachedIds.has(id);
|
||||
const onBoard = !disabledIds.has(id);
|
||||
if (surf === 'board') return onBoard;
|
||||
if (surf === 'both') return onOwliver && onBoard;
|
||||
return onOwliver;
|
||||
}, [attachedIds, disabledIds]);
|
||||
|
||||
/**
|
||||
* Enable or disable, on the surface the reader chose.
|
||||
*
|
||||
* Routes to the existing persistence for each half — no new store, and no
|
||||
* record of "which surface this was enabled as", because there does not need
|
||||
* to be one: the two switches *are* that record, and a derived state cannot
|
||||
* drift from them.
|
||||
*/
|
||||
const onToggleSurface = React.useCallback((id, surf) => {
|
||||
const wantOn = !isEnabledOn(id, surf);
|
||||
|
||||
if (surf === 'owliver' || surf === 'both') {
|
||||
if (attachedIds.has(id) !== wantOn) onToggleSkill(id);
|
||||
}
|
||||
if (surf === 'board' || surf === 'both') {
|
||||
if (!disabledIds.has(id) !== wantOn) onToggleBoardSkill(id, wantOn);
|
||||
}
|
||||
}, [isEnabledOn, attachedIds, disabledIds, onToggleSkill, onToggleBoardSkill]);
|
||||
|
||||
/**
|
||||
* `+ Add Agent`, from a card.
|
||||
*
|
||||
* The card carries no surface selector, so it assigns on the capability's
|
||||
* default surface — through the *same* `onToggleSurface` the drawer's Enable
|
||||
* uses, so there is one assignment path and nothing new to persist. It asks
|
||||
* Owliver nothing: adding and testing are separate acts.
|
||||
*/
|
||||
const addToAgent = React.useCallback(
|
||||
(entry) => onToggleSurface(entry.id, defaultSurface(entry)),
|
||||
[onToggleSurface, defaultSurface]
|
||||
);
|
||||
|
||||
/** `Test in Owliver`, from a card. Adds nothing — see `runCapabilityTest`. */
|
||||
const testFromCard = React.useCallback(
|
||||
(entry) => runCapabilityTest({ entry, surface: defaultSurface(entry) }),
|
||||
[runCapabilityTest, defaultSurface]
|
||||
);
|
||||
|
||||
/* Selecting from the tree on a phone closes the tree: the details panel is
|
||||
about to cover it, and leaving two overlays stacked is how a reader ends up
|
||||
dismissing one and finding another. */
|
||||
const selectFromTree = React.useCallback((id) => {
|
||||
setSelectedId(id);
|
||||
setTreeOpen(false);
|
||||
}, []);
|
||||
|
||||
const selected = selectedId ? entryById.get(selectedId) || null : null;
|
||||
|
||||
const tree = (
|
||||
<AgentTree
|
||||
agentName={fields.name}
|
||||
agentIcon={fields.icon}
|
||||
surfaces={surfaces}
|
||||
skills={fields.skills}
|
||||
boardSkills={boardHere}
|
||||
tools={tools}
|
||||
knowledge={fields.knowledge}
|
||||
entryById={entryById}
|
||||
selectedId={selectedId}
|
||||
onSelect={selectFromTree}
|
||||
onRemove={onToggleSkill}
|
||||
open={branches}
|
||||
onToggleBranch={toggleBranch}
|
||||
busy={busy}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full min-w-0 space-y-3">
|
||||
{dirty && (
|
||||
<Alert tone="info" title="This agent has unsaved edits">
|
||||
Skill changes are being kept in the draft with them. Save the agent to store everything
|
||||
together.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{unknown.length > 0 && (
|
||||
<Alert tone="warning" title={`${unknown.length} attached skill${unknown.length === 1 ? '' : 's'} not in the registry`}>
|
||||
{unknown.join(', ')} — no definition with that id is registered, so it answers nothing.
|
||||
Remove it from the tree, or restore the definition.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex min-w-0 items-start gap-4">
|
||||
{/* The tree as a column, once there is genuinely room for one. Owliver
|
||||
holds ~380px on the right of every Admin page, so below `lg` the
|
||||
remaining width belongs to the catalog and the tree becomes a
|
||||
drawer. */}
|
||||
<aside
|
||||
className={cn(
|
||||
'hidden w-[15.5rem] shrink-0 lg:block',
|
||||
/* Sticky at the same offset and height Owliver's own column uses
|
||||
(`top-14 pt-6`, `100dvh - 6.5rem` in AdminLayout), so the tree and
|
||||
the assistant are two tracks of one workspace rather than two
|
||||
panels that happen to be side by side. `dvh` for the reason the
|
||||
shell uses it: `vh` on a phone is the viewport with the browser
|
||||
chrome retracted. */
|
||||
'lg:sticky lg:top-20 lg:max-h-[calc(100dvh-6.5rem)] lg:overflow-y-auto lg:pr-1'
|
||||
)}
|
||||
>
|
||||
<div className="rounded-2xl border border-border bg-surface p-2.5 shadow-xs">
|
||||
{tree}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<div className="lg:hidden">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setTreeOpen(true)}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<ListTree className="h-3.5 w-3.5" />
|
||||
Agent tree
|
||||
<span className="tabular-nums text-ink-4">
|
||||
· {fields.skills.length} skill{fields.skills.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* One Skills heading, two kinds under it. Underline tabs rather
|
||||
than a segmented control: these are two lists of a section, and
|
||||
the console already uses this variant for exactly that on the
|
||||
agent editor above. */}
|
||||
<Tabs
|
||||
variant="underline"
|
||||
size="sm"
|
||||
ariaLabel="Skill kind"
|
||||
/* The tablist does not wrap, and "Owliver Skills" + "Board Skills"
|
||||
with their counts is wider than a 320px column. Scrolling the
|
||||
strip is what keeps that from becoming horizontal page overflow. */
|
||||
className="-mx-0.5 flex-nowrap overflow-x-auto px-0.5 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
value={kind}
|
||||
onChange={setKind}
|
||||
tabs={[
|
||||
{ value: OWLIVER, label: 'Owliver Skills', count: fields.skills.length },
|
||||
{ value: BOARD, label: 'Board Skills', count: boardHere.length },
|
||||
]}
|
||||
/>
|
||||
|
||||
{kind === OWLIVER ? (
|
||||
<SkillCatalog
|
||||
entries={visible}
|
||||
groups={groups}
|
||||
total={entries.length}
|
||||
query={query}
|
||||
onQueryChange={setQuery}
|
||||
group={group}
|
||||
onGroupChange={setGroup}
|
||||
attachedIds={attachedIds}
|
||||
onOpen={openDetails}
|
||||
onAddAgent={addToAgent}
|
||||
onTest={testFromCard}
|
||||
testableIds={testableIds}
|
||||
busy={busy}
|
||||
pendingId={pendingId}
|
||||
/>
|
||||
) : (
|
||||
<BoardSkillList
|
||||
entries={visibleBoard}
|
||||
total={board.length}
|
||||
agentPages={fields.pages}
|
||||
disabledIds={disabledIds}
|
||||
onToggle={onToggleBoardSkill}
|
||||
busy={busy}
|
||||
query={boardQuery}
|
||||
onQueryChange={setBoardQuery}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Drawer
|
||||
open={treeOpen}
|
||||
onOpenChange={setTreeOpen}
|
||||
side="left"
|
||||
size="sm"
|
||||
title="Agent"
|
||||
description="What this agent is made of."
|
||||
icon={ListTree}
|
||||
footer={(
|
||||
<Button variant="outline" onClick={() => setTreeOpen(false)} className="w-full">
|
||||
<X className="h-3.5 w-3.5" /> Close
|
||||
</Button>
|
||||
)}
|
||||
>
|
||||
{tree}
|
||||
</Drawer>
|
||||
|
||||
<SkillDetails
|
||||
entry={selected}
|
||||
open={Boolean(selected)}
|
||||
onOpenChange={(next) => { if (!next) setSelectedId(null); }}
|
||||
isEnabledOn={isEnabledOn}
|
||||
onToggle={onToggleSurface}
|
||||
busy={busy}
|
||||
fields={fields}
|
||||
onRunTest={runCapabilityTest}
|
||||
test={test}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AgentSkillWorkspace;
|
||||
329
src/components/agents/skills/AgentTree.jsx
Normal file
329
src/components/agents/skills/AgentTree.jsx
Normal file
@@ -0,0 +1,329 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
AlertTriangle, BookOpen, ChevronRight, LayoutGrid, LayoutTemplate, Wrench, X, Zap,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { agentIconFor } from '@/components/agents/icons';
|
||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||
import { glyphForGroup } from './glyphs';
|
||||
|
||||
/**
|
||||
* What this agent is made of.
|
||||
*
|
||||
* A tree because the thing genuinely is one — an agent carries skills, those
|
||||
* skills reach tools, and the agent is told things. The shape is not decoration:
|
||||
* every branch below is *derived* from the one above it, so the tree cannot show
|
||||
* a relationship the runtime does not have.
|
||||
*
|
||||
* The Tools branch is the clearest case and the reason this is worth drawing at
|
||||
* all. Tools are not attached to an agent and never have been — `tools.js` reads
|
||||
* what the reachable *skills* declare. Listing them as a child of Skills is
|
||||
* therefore the true picture, and it is what makes "attach Anomaly Detection"
|
||||
* legible as "this agent can now open Activity" without anyone writing that
|
||||
* sentence down.
|
||||
*
|
||||
* Not a `role="tree"`. That pattern owes the reader arrow-key navigation and a
|
||||
* roving tabindex, and claiming the role without the keyboard contract is worse
|
||||
* than not claiming it: a screen reader announces a widget that does not behave
|
||||
* like one. This is a nested list of real buttons — Tab reaches every control,
|
||||
* Enter and Space work because they are buttons, and `aria-expanded` says what
|
||||
* each disclosure is doing.
|
||||
*
|
||||
* **Everything hangs off the agent.** There is no branch here for "Owliver
|
||||
* Agents", "Owliver Skills" or "Board Skills" as peers — that was the shape
|
||||
* that made someone ask which of three places to go. Skills is one branch with
|
||||
* the two kinds nested inside it, which is the sentence the product wants read:
|
||||
* an agent has skills, and skills come in two kinds.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A branch header: the disclosure, its label, and how much is inside.
|
||||
*
|
||||
* `hint` is what an empty branch says. `sub` renders it one step quieter, which
|
||||
* is how Owliver Skills and Board Skills read as *kinds of* Skills rather than
|
||||
* as two more top-level things.
|
||||
*/
|
||||
function Branch({ id, icon: Icon, label, count, open, onToggle, children, hint, sub = false }) {
|
||||
const panelId = `agent-tree-${id}`;
|
||||
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-expanded={open}
|
||||
aria-controls={panelId}
|
||||
className={`group flex w-full items-center gap-1.5 rounded-lg px-1.5 py-1.5 text-left
|
||||
transition-colors duration-base hover:bg-surface-sunken
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/40`}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 shrink-0 text-ink-4 transition-transform duration-base ease-out',
|
||||
open && 'rotate-90'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Icon className="h-3.5 w-3.5 shrink-0 text-ink-4" aria-hidden="true" />
|
||||
<span
|
||||
className={cn(
|
||||
'min-w-0 flex-1 truncate text-body-sm',
|
||||
sub ? 'text-ink-3' : 'font-medium text-ink-2'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span className="shrink-0 rounded-md bg-surface-subtle px-1.5 py-0.5 text-[10px] font-semibold tabular-nums text-ink-3">
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Kept mounted so the count above and the list below cannot disagree
|
||||
about what is inside, and so opening a branch is a height transition
|
||||
rather than a mount. */}
|
||||
<div
|
||||
id={panelId}
|
||||
className={cn(
|
||||
'grid transition-[grid-template-rows] duration-base ease-out',
|
||||
open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden" inert={open ? undefined : true}>
|
||||
<ul className="ml-[0.6875rem] space-y-0.5 border-l border-border/70 pl-2.5 pt-0.5">
|
||||
{children}
|
||||
{count === 0 && (
|
||||
<li className="px-1.5 py-1.5 text-caption leading-snug text-ink-4">{hint}</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One skill on the agent.
|
||||
*
|
||||
* Two controls, never one: the row selects the skill so its details can be read,
|
||||
* and the cross detaches it. A single control doing both is how a reader loses a
|
||||
* skill while trying to look at it.
|
||||
*/
|
||||
function SkillLeaf({ entry, id, selected, onSelect, onRemove, busy }) {
|
||||
const Glyph = entry ? glyphForGroup(entry.group) : AlertTriangle;
|
||||
const name = entry?.name || id;
|
||||
|
||||
return (
|
||||
<li className="group/leaf flex items-stretch gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(id)}
|
||||
aria-current={selected ? 'true' : undefined}
|
||||
className={cn(
|
||||
`flex min-w-0 flex-1 items-center gap-1.5 rounded-lg px-1.5 py-1.5 text-left
|
||||
transition-colors duration-base focus-visible:outline-none
|
||||
focus-visible:ring-2 focus-visible:ring-krow-blue/40`,
|
||||
selected
|
||||
? 'bg-krow-blue-tint text-krow-blue'
|
||||
: 'text-ink-2 hover:bg-surface-sunken'
|
||||
)}
|
||||
>
|
||||
<Glyph
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 shrink-0',
|
||||
entry ? (selected ? 'text-krow-blue' : 'text-ink-4') : 'text-warning'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-body-sm">{name}</span>
|
||||
{/* Attachment is stated in words as well as in colour — a tinted row is
|
||||
not a fact a screen reader can read, and it is not one a reader who
|
||||
cannot separate the tint from the surface can read either. */}
|
||||
<span className="sr-only">{selected ? ' — selected' : ''}</span>
|
||||
{!entry && (
|
||||
<span className="shrink-0 text-[10px] font-medium text-warning">Unknown</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(id)}
|
||||
disabled={busy}
|
||||
aria-label={`Remove ${name} from this agent`}
|
||||
className={`grid w-6 shrink-0 place-items-center rounded-lg text-ink-4 opacity-0
|
||||
transition-all duration-base hover:bg-destructive/10 hover:text-destructive
|
||||
focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2
|
||||
focus-visible:ring-krow-blue/40 disabled:cursor-not-allowed disabled:opacity-40
|
||||
group-hover/leaf:opacity-100 group-focus-within/leaf:opacity-100`}
|
||||
>
|
||||
<X className="h-3 w-3" aria-hidden="true" />
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** A leaf that is only ever read — a tool reached, a note the agent was given. */
|
||||
function StaticLeaf({ icon: Icon, label, detail = null }) {
|
||||
return (
|
||||
<li className="flex items-start gap-1.5 rounded-lg px-1.5 py-1.5">
|
||||
<Icon className="mt-0.5 h-3.5 w-3.5 shrink-0 text-ink-4" aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-body-sm text-ink-2">{label}</span>
|
||||
{detail && <span className="block truncate text-[10px] text-ink-4">{detail}</span>}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {any} props */
|
||||
export function AgentTree({
|
||||
agentName,
|
||||
agentIcon,
|
||||
surfaces = [],
|
||||
skills = [],
|
||||
boardSkills = [],
|
||||
tools = [],
|
||||
knowledge = [],
|
||||
entryById,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onRemove,
|
||||
open,
|
||||
onToggleBranch,
|
||||
busy = false,
|
||||
className,
|
||||
}) {
|
||||
const Glyph = agentIconFor(agentIcon);
|
||||
|
||||
return (
|
||||
<nav aria-label="Agent composition" className={cn('min-w-0', className)}>
|
||||
{/* The root. Not a disclosure: an agent that could be collapsed to
|
||||
nothing would leave the panel showing a single closed row. */}
|
||||
<div className="flex items-center gap-2 rounded-xl border border-border bg-surface-subtle/60 px-2.5 py-2">
|
||||
<span
|
||||
className="grid h-7 w-7 shrink-0 place-items-center rounded-lg border border-border bg-surface"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{Glyph
|
||||
? <Glyph className="h-3.5 w-3.5 text-krow-blue" />
|
||||
: <OwliverAvatar className="h-4 w-4" rounded="rounded-md" />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-heading text-body-sm font-semibold text-ink-1">
|
||||
{agentName || 'New agent'}
|
||||
</span>
|
||||
<span className="block text-[10px] uppercase tracking-wide text-ink-4">Agent</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{/* Where it answers. First, because it bounds everything under it: a
|
||||
skill only answers on a page the agent covers. */}
|
||||
<Branch
|
||||
id="surfaces"
|
||||
icon={LayoutGrid}
|
||||
label="Surfaces"
|
||||
count={surfaces.length}
|
||||
open={open.surfaces}
|
||||
onToggle={() => onToggleBranch('surfaces')}
|
||||
hint="No page selected — this agent has nowhere to answer yet."
|
||||
>
|
||||
{surfaces.map((surface) => (
|
||||
<StaticLeaf key={surface.id} icon={LayoutGrid} label={surface.label} />
|
||||
))}
|
||||
</Branch>
|
||||
|
||||
{/* One Skills branch, two kinds inside it. */}
|
||||
<Branch
|
||||
id="skills"
|
||||
icon={Zap}
|
||||
label="Skills"
|
||||
count={skills.length + boardSkills.length}
|
||||
open={open.skills}
|
||||
onToggle={() => onToggleBranch('skills')}
|
||||
hint="Nothing yet."
|
||||
>
|
||||
<Branch
|
||||
id="skills-owliver"
|
||||
icon={Zap}
|
||||
label="Owliver Skills"
|
||||
count={skills.length}
|
||||
open={open['skills-owliver']}
|
||||
onToggle={() => onToggleBranch('skills-owliver')}
|
||||
hint="Nothing attached yet. Add one from the catalog."
|
||||
sub
|
||||
>
|
||||
{skills.map((id) => (
|
||||
<SkillLeaf
|
||||
key={id}
|
||||
id={id}
|
||||
entry={entryById.get(id) || null}
|
||||
selected={selectedId === id}
|
||||
onSelect={onSelect}
|
||||
onRemove={onRemove}
|
||||
busy={busy}
|
||||
/>
|
||||
))}
|
||||
</Branch>
|
||||
|
||||
{/* Read-only, and that is the honest shape: a board skill is switched
|
||||
on for a page, not carried by an agent, so there is nothing here to
|
||||
detach. These are the ones drawing on the surfaces above. */}
|
||||
<Branch
|
||||
id="skills-board"
|
||||
icon={LayoutTemplate}
|
||||
label="Board Skills"
|
||||
count={boardSkills.length}
|
||||
open={open['skills-board']}
|
||||
onToggle={() => onToggleBranch('skills-board')}
|
||||
hint="None drawing on this agent's surfaces."
|
||||
sub
|
||||
>
|
||||
{boardSkills.map((entry) => (
|
||||
<StaticLeaf
|
||||
key={entry.id}
|
||||
icon={LayoutTemplate}
|
||||
label={entry.name}
|
||||
detail={entry.surfaces.map((x) => x.label).join(', ')}
|
||||
/>
|
||||
))}
|
||||
</Branch>
|
||||
</Branch>
|
||||
|
||||
<Branch
|
||||
id="tools"
|
||||
icon={Wrench}
|
||||
label="Tools"
|
||||
count={tools.length}
|
||||
open={open.tools}
|
||||
onToggle={() => onToggleBranch('tools')}
|
||||
hint="Tools arrive with skills. Attach a skill that declares one."
|
||||
>
|
||||
{tools.map((tool) => (
|
||||
<StaticLeaf key={tool.name} icon={Wrench} label={tool.label} detail={tool.via} />
|
||||
))}
|
||||
</Branch>
|
||||
|
||||
<Branch
|
||||
id="knowledge"
|
||||
icon={BookOpen}
|
||||
label="Knowledge"
|
||||
count={knowledge.length}
|
||||
open={open.knowledge}
|
||||
onToggle={() => onToggleBranch('knowledge')}
|
||||
hint="Nothing attached. Add knowledge under Configure."
|
||||
>
|
||||
{knowledge.map((entry, i) => (
|
||||
<StaticLeaf
|
||||
key={entry.id || `${entry.label}-${i}`}
|
||||
icon={BookOpen}
|
||||
label={entry.label || 'Untitled'}
|
||||
detail={entry.kind}
|
||||
/>
|
||||
))}
|
||||
</Branch>
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export default AgentTree;
|
||||
227
src/components/agents/skills/BoardSkillList.jsx
Normal file
227
src/components/agents/skills/BoardSkillList.jsx
Normal file
@@ -0,0 +1,227 @@
|
||||
import * as React from 'react';
|
||||
import { LayoutTemplate, Pencil, Plus, SearchX } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Alert, Button, SearchInput, Switch } from '@/components/ds';
|
||||
|
||||
/**
|
||||
* Board skills, under the agent that stands beside them.
|
||||
*
|
||||
* The second half of one Skills section — the same heading, a tab away from the
|
||||
* Owliver catalog — so nobody has to decide between two top-level destinations
|
||||
* ever again. What it is *not* is a second copy of that catalog with the word
|
||||
* Board on it, and the reason is worth stating because it is the one thing here
|
||||
* that could quietly become a lie:
|
||||
*
|
||||
* **A Board skill is not carried by an agent.** `SkillSurface` renders a
|
||||
* section from the page it names and the account's `disabledSkills`; it does not
|
||||
* read `agent.skills` and has no agent in scope. Writing a Board skill id into
|
||||
* `agent.skills` would therefore record an assignment nothing honours — a card
|
||||
* that says "Added" and a product that behaves identically either way.
|
||||
*
|
||||
* So the relationship shown is the real one: these are the sections the pages
|
||||
* *this agent answers on* will draw, and the control offered is the one that
|
||||
* actually governs them — the workspace switch every surface already reads. It
|
||||
* is labelled as workspace-wide, because it is.
|
||||
*/
|
||||
|
||||
/** One Board skill: what it draws, where, and whether it is switched on. */
|
||||
function BoardCard({ entry, enabled, onToggle, onSurfaces, busy }) {
|
||||
const switchId = `board-${entry.id}`;
|
||||
|
||||
return (
|
||||
<li className="min-w-0 h-full">
|
||||
<article
|
||||
className={cn(
|
||||
`group relative flex h-full min-w-0 flex-col justify-between rounded-2xl border bg-surface p-5 text-left
|
||||
shadow-xs transition-all duration-300 ease-out hover:-translate-y-1 hover:shadow-lg overflow-hidden`,
|
||||
enabled ? 'border-krow-blue/40 hover:border-krow-blue' : 'border-border/90 hover:border-krow-blue/40'
|
||||
)}
|
||||
>
|
||||
{/* Soft Background Highlight on Hover */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-x-0 top-0 h-20 bg-gradient-to-b from-krow-blue-tint/40 to-transparent pointer-events-none transition-opacity duration-300',
|
||||
enabled ? 'opacity-70' : 'opacity-0 group-hover:opacity-100'
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
className={cn(
|
||||
'flex h-6 w-6 shrink-0 items-center justify-center rounded-lg border shadow-2xs transition-colors',
|
||||
enabled
|
||||
? 'border-krow-blue/30 bg-krow-blue-tint text-krow-blue'
|
||||
: 'border-border bg-surface-subtle text-ink-3 group-hover:text-krow-blue group-hover:border-krow-blue/30 group-hover:bg-krow-blue-tint/50'
|
||||
)}
|
||||
>
|
||||
<LayoutTemplate className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span className="truncate text-[11px] font-bold uppercase tracking-wider text-ink-3">
|
||||
Board Skill
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className={cn('text-[11px] font-semibold', enabled ? 'text-emerald-600' : 'text-ink-4')}>
|
||||
{enabled ? 'Active' : 'Disabled'}
|
||||
</span>
|
||||
<Switch
|
||||
id={switchId}
|
||||
checked={enabled}
|
||||
disabled={busy}
|
||||
onCheckedChange={(next) => onToggle(entry.id, next)}
|
||||
aria-label={`${entry.name} — ${enabled ? 'on' : 'off'} for this workspace`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="font-heading text-body-lg font-bold leading-snug text-ink-1 group-hover:text-krow-blue transition-colors mt-3.5">
|
||||
{entry.name}
|
||||
</h3>
|
||||
|
||||
{entry.description && (
|
||||
<p className="mt-1.5 text-body-sm leading-relaxed text-ink-3 line-clamp-2 min-h-[2.5rem]">
|
||||
{entry.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* What it actually draws. */}
|
||||
{entry.sections.length > 0 && (
|
||||
<ul className="mt-3 space-y-1.5">
|
||||
{entry.sections.map((section, i) => (
|
||||
<li
|
||||
key={section.id || `${section.page}-${i}`}
|
||||
className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5 rounded-md bg-surface-subtle px-2 py-1 text-[11px] text-ink-3 border border-border/60"
|
||||
>
|
||||
<span className="font-medium text-ink-2">{section.title}</span>
|
||||
<span className="text-ink-4">·</span>
|
||||
<span>{section.typeLabel}</span>
|
||||
<span className="text-ink-4">·</span>
|
||||
<span className={cn(onSurfaces.has(section.page) ? 'font-semibold text-krow-blue' : 'text-ink-3')}>
|
||||
{section.pageLabel}
|
||||
</span>
|
||||
<span className="text-ink-4">/ {section.placementLabel}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 mt-4 flex items-center justify-between gap-2 border-t border-border/60 pt-3 font-medium">
|
||||
<span className="text-caption text-ink-4">
|
||||
{entry.surfaces.some((s) => onSurfaces.has(s.id))
|
||||
? 'Active on this agent\'s pages'
|
||||
: 'Draws across workspace'}
|
||||
</span>
|
||||
<Link
|
||||
to={`/admin/workspace/skills/${entry.id}`}
|
||||
className="inline-flex items-center gap-1 text-caption font-semibold text-krow-blue hover:text-krow-blue-dark transition-colors"
|
||||
>
|
||||
<Pencil className="h-3 w-3" aria-hidden="true" /> Edit
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {any} props */
|
||||
export function BoardSkillList({
|
||||
entries, agentPages = [], disabledIds, onToggle, busy = false, query, onQueryChange, total,
|
||||
}) {
|
||||
const searchId = React.useId();
|
||||
const onSurfaces = React.useMemo(() => new Set(agentPages), [agentPages]);
|
||||
|
||||
/* The ones that meet this agent first */
|
||||
const ordered = React.useMemo(() => {
|
||||
const here = [];
|
||||
const elsewhere = [];
|
||||
for (const e of entries) {
|
||||
(e.surfaces.some((s) => onSurfaces.has(s.id)) ? here : elsewhere).push(e);
|
||||
}
|
||||
return [...here, ...elsewhere];
|
||||
}, [entries, onSurfaces]);
|
||||
|
||||
return (
|
||||
<section aria-label="Board skills" className="flex min-w-0 flex-col gap-4">
|
||||
<Alert tone="info" title="Board skills belong to a page, not to an agent">
|
||||
They draw sections on KROW pages and are switched on for the whole workspace —
|
||||
so a change here affects every agent answering on that page, not just this one.
|
||||
</Alert>
|
||||
|
||||
{total > 0 && (
|
||||
<div>
|
||||
<label htmlFor={searchId} className="sr-only">Search board skills</label>
|
||||
<SearchInput
|
||||
id={searchId}
|
||||
value={query}
|
||||
onChange={onQueryChange}
|
||||
placeholder="Search board skills, sections or pages..."
|
||||
debounce={0}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{total > 0 && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1.5 pt-1">
|
||||
<p className="text-xs font-medium text-ink-3" aria-live="polite">
|
||||
<span className="font-semibold text-ink-1">{ordered.length}</span> of {total} board skill{total === 1 ? '' : 's'}
|
||||
</p>
|
||||
<Link
|
||||
to="/admin/workspace/skills/new"
|
||||
className={`inline-flex items-center gap-1.5 rounded-lg border border-border/80 bg-surface px-3 py-1.5
|
||||
text-xs font-semibold text-krow-blue shadow-2xs transition-all
|
||||
hover:bg-krow-blue-tint/60 hover:border-krow-blue/40 focus-visible:outline-none
|
||||
focus-visible:ring-2 focus-visible:ring-krow-blue/40`}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" aria-hidden="true" /> New board skill
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ordered.length > 0 ? (
|
||||
<ul className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4.5">
|
||||
{ordered.map((entry) => (
|
||||
<BoardCard
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
enabled={!disabledIds.has(entry.id)}
|
||||
onToggle={onToggle}
|
||||
onSurfaces={onSurfaces}
|
||||
busy={busy}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-border bg-surface-subtle/40 px-4 py-12 text-center">
|
||||
{total > 0 ? (
|
||||
<>
|
||||
<SearchX className="mx-auto h-6 w-6 text-ink-4" aria-hidden="true" />
|
||||
<p className="mt-2.5 text-sm font-semibold text-ink-1">No board skills match that</p>
|
||||
<p className="mt-1 text-xs text-ink-3">Try a different word.</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LayoutTemplate className="mx-auto h-6 w-6 text-ink-4" aria-hidden="true" />
|
||||
<p className="mt-2.5 text-sm font-semibold text-ink-1">No board skills yet</p>
|
||||
<p className="mx-auto mt-1 max-w-[46ch] text-xs leading-relaxed text-ink-3">
|
||||
A board skill declares a section, where it sits on a page, and what it reads.
|
||||
Add one and the page it names renders it.
|
||||
</p>
|
||||
<Button asChild variant="outline" size="sm" className="mt-3.5">
|
||||
<Link to="/admin/workspace/skills/new">
|
||||
<Plus className="h-3.5 w-3.5" /> Add a board skill
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default BoardSkillList;
|
||||
177
src/components/agents/skills/SkillCard.jsx
Normal file
177
src/components/agents/skills/SkillCard.jsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import * as React from 'react';
|
||||
import { ArrowRight, Check, Layers, LayoutTemplate, Loader2, Play, Plus, Sparkles } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { glyphForGroup } from './glyphs';
|
||||
|
||||
/**
|
||||
* One capability, with the three things you can do to it.
|
||||
*
|
||||
* These are three responsibilities and they stay three controls:
|
||||
*
|
||||
* **+ Add Agent** assigns the capability to this agent, through the
|
||||
* existing `agent.skills` persistence. It never asks
|
||||
* Owliver anything.
|
||||
* **Test in Owliver** runs the capability's own example question through the
|
||||
* existing Owliver panel. It never assigns anything.
|
||||
* **Details →** opens the drawer to read it, choose a surface, and
|
||||
* enable it deliberately.
|
||||
*
|
||||
* Merging any two of them was the mistake a combined `Open / Test` made: it
|
||||
* left the reader unable to tell whether pressing it would change their agent.
|
||||
*
|
||||
* The card is a plain container, not a clickable one. Three real buttons and no
|
||||
* wrapper that swallows them — which is also why there is no `role="button"` on
|
||||
* anything here.
|
||||
*/
|
||||
|
||||
/** @type {any} */
|
||||
export const SkillCard = React.memo(/** @param {any} props */ ({
|
||||
entry, enabled, onOpen, onAddAgent, onTest, canTest = false, busy = false, isPending = false,
|
||||
}) => {
|
||||
const Glyph = glyphForGroup(entry.group);
|
||||
const samplePrompt = entry.suggestions?.[0]?.label;
|
||||
|
||||
return (
|
||||
<li className="h-full min-w-0">
|
||||
<article
|
||||
className={cn(
|
||||
`group relative flex h-full min-w-0 flex-col justify-between overflow-hidden rounded-2xl
|
||||
border bg-surface p-5 shadow-xs transition-all duration-300 ease-out hover:shadow-lg`,
|
||||
enabled
|
||||
? 'border-krow-blue/40 hover:border-krow-blue'
|
||||
: 'border-border/90 hover:border-krow-blue/40'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-x-0 top-0 h-20 bg-gradient-to-b from-krow-blue-tint/40 to-transparent transition-opacity duration-300',
|
||||
enabled ? 'opacity-70' : 'opacity-0 group-hover:opacity-100'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="relative z-10 min-w-0">
|
||||
{/* Category, and the assignment control. */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
'flex h-6 w-6 shrink-0 items-center justify-center rounded-lg border shadow-2xs transition-colors',
|
||||
enabled
|
||||
? 'border-krow-blue/30 bg-krow-blue-tint text-krow-blue'
|
||||
: 'border-border bg-surface-subtle text-ink-3 group-hover:border-krow-blue/30 group-hover:bg-krow-blue-tint/50 group-hover:text-krow-blue'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Glyph className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span className="truncate text-[11px] font-bold uppercase tracking-wider text-ink-3">
|
||||
{entry.groupLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAddAgent(entry)}
|
||||
disabled={busy || isPending}
|
||||
aria-pressed={enabled}
|
||||
aria-label={enabled
|
||||
? `Remove ${entry.name} from this agent`
|
||||
: `Add ${entry.name} to this agent`}
|
||||
className={cn(
|
||||
`inline-flex shrink-0 items-center gap-1 rounded-full border px-2.5 py-0.5 text-[11px]
|
||||
font-semibold transition-all duration-base active:scale-95
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/40
|
||||
disabled:cursor-not-allowed disabled:opacity-50`,
|
||||
enabled
|
||||
? 'border-krow-blue/30 bg-krow-blue-tint text-krow-blue'
|
||||
: 'border-border bg-surface text-ink-3 hover:border-krow-blue/30 hover:bg-krow-blue-tint/40 hover:text-krow-blue'
|
||||
)}
|
||||
>
|
||||
{isPending
|
||||
? <Loader2 className="h-3 w-3 animate-spin" aria-hidden="true" />
|
||||
: enabled
|
||||
? <Check className="h-3 w-3" aria-hidden="true" />
|
||||
: <Plus className="h-3 w-3" aria-hidden="true" />}
|
||||
{enabled ? 'Added' : 'Add Agent'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h3 className="mt-3.5 font-heading text-body-lg font-bold leading-snug text-ink-1">
|
||||
{entry.name}
|
||||
</h3>
|
||||
|
||||
{entry.description && (
|
||||
<p className="mt-1.5 line-clamp-2 min-h-[2.5rem] text-body-sm leading-relaxed text-ink-3">
|
||||
{entry.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* One of the capability's own questions — the same suggestion Test
|
||||
sends, so the card cannot show a question it would not ask. */}
|
||||
{samplePrompt && (
|
||||
<p className="mt-3 flex w-full items-center gap-1.5 rounded-lg border border-krow-blue/20 bg-krow-blue-tint/50 px-2.5 py-1.5">
|
||||
<Sparkles className="h-3.5 w-3.5 shrink-0 text-krow-blue" aria-hidden="true" />
|
||||
<span className="truncate text-[11px] font-medium italic text-ink-2">
|
||||
“{samplePrompt}”
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ul className="mt-3 flex flex-wrap items-center gap-1.5">
|
||||
{entry.surfaces?.map((s) => (
|
||||
<li
|
||||
key={s.id}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-border/60 bg-surface-subtle px-2 py-0.5 text-[11px] font-medium text-ink-2"
|
||||
>
|
||||
<Layers className="h-3 w-3 text-ink-4" aria-hidden="true" />
|
||||
{s.label}
|
||||
</li>
|
||||
))}
|
||||
|
||||
{entry.type === 'both' && (
|
||||
<li className="inline-flex items-center gap-1 rounded-md border border-krow-blue/30 bg-krow-blue-tint px-2 py-0.5 text-[11px] font-semibold text-krow-blue">
|
||||
<LayoutTemplate className="h-3 w-3" aria-hidden="true" />
|
||||
Board & Owliver
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Test and Details — neither of them changes the agent. */}
|
||||
<div className="relative z-10 mt-4 flex flex-wrap items-center justify-between gap-x-2 gap-y-1.5 border-t border-border/60 pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTest(entry)}
|
||||
disabled={!canTest || busy}
|
||||
title={canTest ? undefined : 'This capability answers on no page this agent covers.'}
|
||||
className={`inline-flex items-center gap-1.5 rounded text-caption font-semibold text-krow-blue
|
||||
transition-colors hover:text-krow-blue-dark focus-visible:outline-none
|
||||
focus-visible:ring-2 focus-visible:ring-krow-blue/40
|
||||
disabled:cursor-not-allowed disabled:text-ink-4`}
|
||||
>
|
||||
<Play className="h-3 w-3 fill-current" aria-hidden="true" />
|
||||
Test in Owliver
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(entry.id)}
|
||||
className={`inline-flex items-center gap-1 rounded text-caption font-medium text-ink-3
|
||||
transition-colors hover:text-krow-blue focus-visible:outline-none
|
||||
focus-visible:ring-2 focus-visible:ring-krow-blue/40`}
|
||||
>
|
||||
Details
|
||||
<ArrowRight
|
||||
className="h-3 w-3 transition-transform duration-base group-hover:translate-x-0.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</li>
|
||||
);
|
||||
});
|
||||
SkillCard.displayName = 'SkillCard';
|
||||
|
||||
export default SkillCard;
|
||||
148
src/components/agents/skills/SkillCatalog.jsx
Normal file
148
src/components/agents/skills/SkillCatalog.jsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import * as React from 'react';
|
||||
import { Plus, SearchX } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SearchInput } from '@/components/ds';
|
||||
import { SkillCard } from './SkillCard';
|
||||
|
||||
/**
|
||||
* The catalog — every skill this workspace has, browsable.
|
||||
*
|
||||
* The centre of the screen and the whole point of the redesign: the previous
|
||||
* dialog asked "which of these checkboxes do you want", which is a question
|
||||
* about a list. This asks "what should this agent be able to do", which is a
|
||||
* question about the agent — so the categories are Krow's own domains, the
|
||||
* cards say what a skill answers rather than how many pages it names, and
|
||||
* whether a skill is already attached is on the card instead of behind a second
|
||||
* dialog.
|
||||
*
|
||||
* The grid is `auto-fill` rather than breakpoint columns. This lives beside a
|
||||
* tree and beside Owliver, so its width is not the viewport's — a `lg:grid-cols-3`
|
||||
* would be three columns of 140px at 1280px with both neighbours open. Sizing
|
||||
* from the track means the column count follows the space the catalog actually
|
||||
* has, at every width, with no media query to get wrong.
|
||||
*/
|
||||
|
||||
/** @param {any} props */
|
||||
function CategoryFilter({ groups, value, onChange, total }) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Filter skills by category"
|
||||
className="flex flex-nowrap items-center gap-2 overflow-x-auto pb-1 sm:flex-wrap sm:overflow-x-visible"
|
||||
>
|
||||
{[{ id: 'all', label: 'All', count: total }, ...groups].map((group) => {
|
||||
const active = value === group.id;
|
||||
return (
|
||||
<button
|
||||
key={group.id}
|
||||
type="button"
|
||||
onClick={() => onChange(group.id)}
|
||||
aria-pressed={active}
|
||||
title={group.blurb || undefined}
|
||||
className={cn(
|
||||
`inline-flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5
|
||||
text-xs font-medium transition-all duration-150
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/40`,
|
||||
active
|
||||
? 'border-krow-blue/40 bg-krow-blue text-white shadow-xs'
|
||||
: 'border-border bg-surface text-ink-3 hover:border-krow-blue/30 hover:text-ink-1 hover:bg-surface-subtle'
|
||||
)}
|
||||
>
|
||||
{group.label}
|
||||
<span className={cn('text-[11px] tabular-nums font-semibold', active ? 'text-white/85' : 'text-ink-4')}>
|
||||
{group.count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {any} props */
|
||||
export function SkillCatalog({
|
||||
entries,
|
||||
groups,
|
||||
total,
|
||||
query,
|
||||
onQueryChange,
|
||||
group,
|
||||
onGroupChange,
|
||||
attachedIds,
|
||||
onOpen,
|
||||
onAddAgent,
|
||||
onTest,
|
||||
testableIds,
|
||||
busy = false,
|
||||
pendingId = null,
|
||||
}) {
|
||||
const searchId = React.useId();
|
||||
|
||||
return (
|
||||
<section aria-label="Skill catalog" className="flex min-w-0 flex-col gap-4">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label htmlFor={searchId} className="sr-only">Search skills</label>
|
||||
<SearchInput
|
||||
id={searchId}
|
||||
value={query}
|
||||
onChange={onQueryChange}
|
||||
placeholder="Search skills, capabilities or pages..."
|
||||
debounce={0}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CategoryFilter groups={groups} value={group} onChange={onGroupChange} total={total} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1.5 pt-1">
|
||||
{/* The count is read from the same array the cards come from, so a
|
||||
heading cannot claim six above four cards. */}
|
||||
<p className="text-caption font-medium text-ink-3" aria-live="polite">
|
||||
<span className="font-bold text-ink-1">{entries.length}</span> of {total} capabilit{total === 1 ? 'y' : 'ies'} for this scope
|
||||
{attachedIds.size > 0 && <span className="font-semibold text-krow-blue"> · {attachedIds.size} enabled</span>}
|
||||
</p>
|
||||
|
||||
{/* Authoring stays reachable without being a destination of its own. */}
|
||||
<Link
|
||||
to="/admin/workspace/skills/owliver/new"
|
||||
className={`inline-flex items-center gap-1.5 rounded-lg border border-border bg-surface px-3 py-1.5
|
||||
text-caption font-semibold text-krow-blue shadow-2xs transition-all duration-base
|
||||
hover:border-krow-blue/40 hover:bg-krow-blue-tint focus-visible:outline-none
|
||||
focus-visible:ring-2 focus-visible:ring-krow-blue/40`}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" aria-hidden="true" /> New skill
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{entries.length > 0 ? (
|
||||
<ul className="grid grid-cols-[repeat(auto-fill,minmax(min(100%,16rem),1fr))] gap-3">
|
||||
{entries.map((entry) => (
|
||||
<SkillCard
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
enabled={attachedIds.has(entry.id)}
|
||||
onOpen={onOpen}
|
||||
onAddAgent={onAddAgent}
|
||||
onTest={onTest}
|
||||
canTest={testableIds.has(entry.id)}
|
||||
busy={busy}
|
||||
isPending={pendingId === entry.id}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-border bg-surface-subtle/50 px-4 py-12 text-center">
|
||||
<SearchX className="mx-auto h-6 w-6 text-ink-4" aria-hidden="true" />
|
||||
<p className="mt-2.5 text-body-sm font-semibold text-ink-1">No skills match that</p>
|
||||
<p className="mt-1 text-caption text-ink-3">
|
||||
Try a different word, or widen the category filter.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default SkillCatalog;
|
||||
346
src/components/agents/skills/SkillDetails.jsx
Normal file
346
src/components/agents/skills/SkillDetails.jsx
Normal file
@@ -0,0 +1,346 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
AlertCircle, CheckCircle2, LayoutGrid, Loader2, MessageSquare, Minus, Play, Plus, Sparkles,
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
import { Alert, Badge, Button, Drawer } from '@/components/ds';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { testTargets } from '@/lib/agents/capabilityTest';
|
||||
import { SurfaceSelect, supportedSurfaces } from './SurfaceSelect';
|
||||
import { glyphForGroup } from './glyphs';
|
||||
|
||||
/**
|
||||
* One capability, configured — without leaving the workspace.
|
||||
*
|
||||
* A drawer rather than a route. The reader is in the middle of composing an
|
||||
* agent; sending them to `/skills/<id>` to answer "what does this actually do"
|
||||
* would unmount the catalog, the tree and their search, and the way back would
|
||||
* be the browser's Back button.
|
||||
*
|
||||
* Everything shown is read from the definition. The capability list is what the
|
||||
* author wrote under `## Capabilities`, the response shapes are what `owliver:`
|
||||
* declares, and the tools are the actions the skill names — so a panel can never
|
||||
* describe behaviour the runtime does not have.
|
||||
*
|
||||
* **The conversation is not here.** This panel decides *what to test*: which
|
||||
* surface, and which of the capability's own questions. Pressing Test hands
|
||||
* that to the Owliver panel already on the page, and the question and the
|
||||
* answer appear there, in the one conversation this product has. A chat inside
|
||||
* a drawer beside a chat would have been a second Owliver wearing a hat.
|
||||
*
|
||||
* **Three states are kept apart on purpose.** Choosing a surface changes
|
||||
* nothing. Testing changes nothing. Only Enable writes — so a reader can open
|
||||
* six capabilities, try all of them, close the drawer, and leave the agent
|
||||
* exactly as they found it.
|
||||
*/
|
||||
|
||||
/** A titled group of facts, omitted entirely when there are none. */
|
||||
function Section({ icon: Icon, title, children, count = undefined }) {
|
||||
return (
|
||||
<section className="space-y-1.5">
|
||||
<h3 className="flex items-center gap-1.5 text-caption font-semibold uppercase tracking-wide text-ink-4">
|
||||
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{title}
|
||||
{count !== undefined && <span className="font-normal tabular-nums">({count})</span>}
|
||||
</h3>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** How the run that was handed to Owliver is going. */
|
||||
function TestState({ test, entryId }) {
|
||||
if (!test || test.trace?.skillId !== entryId) return null;
|
||||
|
||||
const states = {
|
||||
queued: { icon: Loader2, spin: true, tone: 'text-ink-3', text: 'Handing the question to Owliver…' },
|
||||
running: { icon: Loader2, spin: true, tone: 'text-krow-blue', text: 'Owliver is responding…' },
|
||||
done: { icon: CheckCircle2, spin: false, tone: 'text-success', text: 'Test completed — read the answer in Owliver.' },
|
||||
error: { icon: AlertCircle, spin: false, tone: 'text-destructive', text: test.error || 'Test failed.' },
|
||||
};
|
||||
const state = states[test.state] || states.queued;
|
||||
const Icon = state.icon;
|
||||
|
||||
return (
|
||||
<p className={cn('flex items-start gap-1.5 text-caption font-medium', state.tone)} role="status">
|
||||
<Icon className={cn('mt-px h-3.5 w-3.5 shrink-0', state.spin && 'animate-spin')} aria-hidden="true" />
|
||||
<span className="min-w-0">{state.text}</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The drawer's content, as its own component.
|
||||
*
|
||||
* Extracted because the Drawer portals its children to `document.body`, which
|
||||
* puts the things most worth checking — the surface selector, the Board note,
|
||||
* the test state — out of reach of anything rendering this tree without a real
|
||||
* DOM. A panel whose contents cannot be asserted is a panel whose contents
|
||||
* drift.
|
||||
*
|
||||
* Stateless. Every choice lives in `SkillDetails`, because the footer's Enable
|
||||
* button needs the same `surface` this renders, and two copies of that would be
|
||||
* two answers to "what am I about to enable".
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function CapabilityBrief({
|
||||
entry, attached, surface, onSurfaceChange, question, onQuestionChange,
|
||||
canTest, target, onTest, test, busy = false, showTest = true,
|
||||
}) {
|
||||
const { shapes, described } = entry.capabilities;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Badge variant="neutral">{entry.groupLabel}</Badge>
|
||||
{attached
|
||||
? <Badge variant="success">Enabled for this agent</Badge>
|
||||
: <Badge variant="neutral">Not enabled</Badge>}
|
||||
{entry.custom && <Badge variant="info">Custom</Badge>}
|
||||
</div>
|
||||
|
||||
<Section icon={Sparkles} title="Available as">
|
||||
<SurfaceSelect type={entry.type} value={surface} onChange={onSurfaceChange} name={entry.name} />
|
||||
</Section>
|
||||
|
||||
{/**
|
||||
* The honest Board note.
|
||||
*
|
||||
* `SkillSurface` renders a page section from the page it names and the
|
||||
* account's switched-off list; it takes no agent. So there is no
|
||||
* agent-scoped Board runtime, and claiming one would be a claim about a
|
||||
* page that would not change. What exists is the workspace-level switch,
|
||||
* which is real — enabling Board here uses it, and this says plainly that
|
||||
* its reach is the workspace rather than this agent.
|
||||
*/}
|
||||
{(surface === 'board' || surface === 'both') && (
|
||||
<Alert tone="warning" title="Board is switched on workspace-wide">
|
||||
Enabling Board here uses the real switch every page surface already reads, so the section
|
||||
genuinely appears — but it appears for the whole workspace, not only for this agent.
|
||||
There is no per-agent Board runtime yet, and this does not pretend there is one.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{described.length > 0 && (
|
||||
<Section icon={MessageSquare} title="Capabilities" count={described.length}>
|
||||
<ul className="space-y-1">
|
||||
{described.map((line) => (
|
||||
<li key={line} className="flex gap-2 text-body-sm leading-relaxed text-ink-2">
|
||||
<span className="mt-[0.4375rem] h-1 w-1 shrink-0 rounded-full bg-krow-blue" aria-hidden="true" />
|
||||
<span className="min-w-0">{line}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{shapes.length > 0 && (
|
||||
<Section icon={MessageSquare} title="Answer shapes" count={shapes.length}>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{shapes.map((shape) => (
|
||||
<span
|
||||
key={shape}
|
||||
className="rounded-md border border-border bg-surface-subtle px-2 py-0.5 text-caption capitalize text-ink-3"
|
||||
>
|
||||
{shape}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* The capability's own questions, and the choice of which one to try.
|
||||
One list, not two: these are the same suggestions the panel offers on
|
||||
the page, so the test asks what the product would ask. */}
|
||||
{entry.suggestions.length > 0 && (
|
||||
<Section icon={MessageSquare} title="What you can ask" count={entry.suggestions.length}>
|
||||
<ul className="space-y-1">
|
||||
{entry.suggestions.map((s) => {
|
||||
const value = s.prompt || s.label;
|
||||
const picked = canTest && question === value;
|
||||
return (
|
||||
<li key={s.label}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onQuestionChange(value)}
|
||||
disabled={!canTest}
|
||||
aria-pressed={picked}
|
||||
className={cn(
|
||||
`w-full rounded-lg border px-2.5 py-1.5 text-left text-body-sm transition-colors
|
||||
duration-base focus-visible:outline-none focus-visible:ring-2
|
||||
focus-visible:ring-krow-blue/40 disabled:cursor-default`,
|
||||
picked
|
||||
? 'border-krow-blue/40 bg-krow-blue-tint text-krow-blue'
|
||||
: 'border-border bg-surface-subtle/60 text-ink-2 enabled:hover:border-krow-blue/30'
|
||||
)}
|
||||
>
|
||||
“{s.label}”
|
||||
{picked && <span className="sr-only"> — chosen for the test</span>}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section icon={LayoutGrid} title="Answers on" count={entry.surfaces.length}>
|
||||
{entry.surfaces.length > 0 ? (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{entry.surfaces.map((s) => (
|
||||
<span
|
||||
key={s.id}
|
||||
className="rounded-md border border-border bg-surface-subtle px-2 py-0.5 text-caption text-ink-3"
|
||||
>
|
||||
{s.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-caption leading-relaxed text-ink-4">
|
||||
A skill still only answers on the pages it names. Enabling it cannot widen that —
|
||||
the agent's own surfaces narrow it further.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-body-sm text-ink-3">This skill names no page, so it answers nowhere.</p>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{entry.tools.length > 0 && (
|
||||
<Section icon={Wrench} title="Tools it reaches" count={entry.tools.length}>
|
||||
<ul className="space-y-1.5">
|
||||
{entry.tools.map((tool) => (
|
||||
<li key={tool.name} className="rounded-lg border border-border bg-surface px-2.5 py-2">
|
||||
<p className="flex items-center gap-1.5 text-body-sm font-medium text-ink-1">
|
||||
{tool.label}
|
||||
{!tool.readOnly && (
|
||||
<span className="rounded bg-warning/15 px-1.5 py-0.5 text-[10px] font-semibold text-warning">
|
||||
Writes
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{tool.summary && (
|
||||
<p className="mt-0.5 text-caption leading-snug text-ink-3">{tool.summary}</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Test: the button and the outcome. The question and the answer live in
|
||||
the Owliver panel, which is already on this page. */}
|
||||
{showTest && (
|
||||
<Section icon={Play} title="Test">
|
||||
<p className="text-caption leading-relaxed text-ink-3">
|
||||
{canTest
|
||||
? <>Runs in the Owliver panel on this page, against <span className="font-medium text-ink-2">{target.label}</span>. Testing changes nothing — the agent is only updated when you enable it.</>
|
||||
: surface === 'board'
|
||||
? 'A Board section draws on a page rather than answering a question, so there is nothing to ask it here.'
|
||||
: 'This capability answers on no page this agent covers, so there is nothing to test against.'}
|
||||
</p>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={onTest} disabled={!canTest || busy} className="w-full">
|
||||
<Play className="h-3.5 w-3.5" /> Test in Owliver
|
||||
</Button>
|
||||
|
||||
<TestState test={test} entryId={entry.id} />
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {any} props */
|
||||
export function SkillDetails({
|
||||
entry, open, onOpenChange, isEnabledOn, onToggle, busy = false,
|
||||
fields = null, onRunTest = null, test = null,
|
||||
}) {
|
||||
/**
|
||||
* The surface this would run on.
|
||||
*
|
||||
* Held here rather than in the agent, because choosing one is not a change to
|
||||
* the agent until Enable says so. Seeded from the first surface the
|
||||
* definition supports.
|
||||
*/
|
||||
const supported = React.useMemo(() => supportedSurfaces(entry?.type), [entry?.type]);
|
||||
const [surface, setSurface] = React.useState(() => supported[0] || 'owliver');
|
||||
const [question, setQuestion] = React.useState('');
|
||||
|
||||
/* A different capability is a different set of choices. Reset rather than
|
||||
carry the last one's surface and question over to it. */
|
||||
React.useEffect(() => {
|
||||
setSurface(supported[0] || 'owliver');
|
||||
setQuestion(entry?.suggestions?.[0]?.prompt || entry?.suggestions?.[0]?.label || entry?.name || '');
|
||||
}, [entry?.id, entry?.name, entry?.suggestions, supported]);
|
||||
|
||||
const targets = React.useMemo(
|
||||
() => (entry && fields ? testTargets(fields.pages, entry.surfaces.map((s) => s.id)) : []),
|
||||
[entry, fields]
|
||||
);
|
||||
|
||||
if (!entry) return null;
|
||||
|
||||
/* Enabled *on the chosen surface* — so switching the selector re-reads the
|
||||
state rather than showing one surface's answer under another's label. */
|
||||
const attached = isEnabledOn(entry.id, surface);
|
||||
|
||||
const Glyph = glyphForGroup(entry.group);
|
||||
|
||||
/* Owliver is the only surface whose runtime answers a question, so it is the
|
||||
only one there is anything to ask. See the Board note in the brief. */
|
||||
const canTest = Boolean(onRunTest && fields && targets.length && surface !== 'board');
|
||||
const target = targets[0] || null;
|
||||
|
||||
/* The same implementation the card's Test button uses — one definition of
|
||||
what testing means, with the surface and question chosen here. */
|
||||
const runTest = () => {
|
||||
if (!canTest) return;
|
||||
onRunTest({ entry, surface, question });
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={entry.name}
|
||||
description={entry.description || undefined}
|
||||
icon={Glyph}
|
||||
size="default"
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Close</Button>
|
||||
{/* The one control in this product that changes the agent's skills.
|
||||
Last, under everything the decision should rest on. */}
|
||||
<Button
|
||||
variant={attached ? 'outline' : 'default'}
|
||||
onClick={() => onToggle(entry.id, surface)}
|
||||
disabled={busy}
|
||||
>
|
||||
{attached
|
||||
? <><Minus className="h-3.5 w-3.5" /> Disable for this agent</>
|
||||
: <><Plus className="h-3.5 w-3.5" /> Enable for this agent</>}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<CapabilityBrief
|
||||
entry={entry}
|
||||
attached={attached}
|
||||
surface={surface}
|
||||
onSurfaceChange={setSurface}
|
||||
question={question}
|
||||
onQuestionChange={setQuestion}
|
||||
canTest={canTest}
|
||||
target={target}
|
||||
onTest={runTest}
|
||||
test={test}
|
||||
busy={busy}
|
||||
showTest={Boolean(onRunTest && fields)}
|
||||
/>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
export default SkillDetails;
|
||||
109
src/components/agents/skills/SurfaceSelect.jsx
Normal file
109
src/components/agents/skills/SurfaceSelect.jsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import * as React from 'react';
|
||||
import { Check, Layers, LayoutTemplate, Sparkles } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Which surface this capability should run on.
|
||||
*
|
||||
* This used to be a read-out — two rows saying "✓ Owliver / — Board". It is now
|
||||
* the choice it always described, and the constraint is the important part:
|
||||
* **what can be picked is derived from the definition, never from a name.** A
|
||||
* skill's facets say whether it teaches Owliver, draws on a page, or both, and
|
||||
* an option the definition does not support is offered disabled with the reason
|
||||
* rather than hidden — a reader deciding between surfaces should be able to see
|
||||
* that the other one exists and why it is not available here.
|
||||
*
|
||||
* `Both` appears only when the definition genuinely supports both. Selecting it
|
||||
* forks nothing: there is one logical skill, and this is a mode on it.
|
||||
*/
|
||||
|
||||
const OPTIONS = [
|
||||
{
|
||||
id: 'owliver',
|
||||
icon: Sparkles,
|
||||
label: 'Owliver',
|
||||
hint: 'Can be asked for in the assistant.',
|
||||
supported: (type) => type === 'owliver' || type === 'both',
|
||||
},
|
||||
{
|
||||
id: 'board',
|
||||
icon: LayoutTemplate,
|
||||
label: 'Board',
|
||||
hint: 'Draws a section on the page.',
|
||||
supported: (type) => type === 'board' || type === 'both',
|
||||
},
|
||||
{
|
||||
id: 'both',
|
||||
icon: Layers,
|
||||
label: 'Both',
|
||||
hint: 'Assistant and page.',
|
||||
supported: (type) => type === 'both',
|
||||
},
|
||||
];
|
||||
|
||||
/** The surfaces this definition can be run on, in order. */
|
||||
export const supportedSurfaces = (type) =>
|
||||
OPTIONS.filter((o) => o.supported(type)).map((o) => o.id);
|
||||
|
||||
/** @param {any} props */
|
||||
export function SurfaceSelect({ type, value, onChange, name }) {
|
||||
return (
|
||||
<div role="radiogroup" aria-label={`Run ${name} as`} className="space-y-1.5">
|
||||
{OPTIONS.map((option) => {
|
||||
const available = option.supported(type);
|
||||
const selected = available && value === option.id;
|
||||
const Icon = option.icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
disabled={!available}
|
||||
onClick={() => available && onChange(option.id)}
|
||||
className={cn(
|
||||
`flex w-full items-start gap-2 rounded-xl border px-2.5 py-2 text-left
|
||||
transition-colors duration-base focus-visible:outline-none
|
||||
focus-visible:ring-2 focus-visible:ring-krow-blue/40`,
|
||||
!available && 'cursor-not-allowed border-dashed border-border bg-surface-subtle/40',
|
||||
available && !selected && 'border-border bg-surface hover:border-krow-blue/30',
|
||||
selected && 'border-krow-blue bg-krow-blue-tint'
|
||||
)}
|
||||
>
|
||||
{/* The mark carries the state for anyone not reading colour; the
|
||||
`aria-checked` above carries it for anyone not reading at all. */}
|
||||
<span
|
||||
className={cn(
|
||||
'mt-px grid h-4 w-4 shrink-0 place-items-center rounded-full border',
|
||||
selected ? 'border-krow-blue bg-krow-blue text-white' : 'border-border bg-surface'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{selected && <Check className="h-2.5 w-2.5" />}
|
||||
</span>
|
||||
|
||||
<span className="min-w-0 flex-1">
|
||||
<span
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 text-body-sm font-medium',
|
||||
!available ? 'text-ink-4' : selected ? 'text-krow-blue' : 'text-ink-1'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||
{option.label}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-caption leading-snug text-ink-3">
|
||||
{available
|
||||
? option.hint
|
||||
: `This capability does not declare ${option.id === 'both' ? 'both surfaces' : `a ${option.label} surface`}.`}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SurfaceSelect;
|
||||
23
src/components/agents/skills/glyphs.js
Normal file
23
src/components/agents/skills/glyphs.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Activity, BarChart3, Briefcase, PenLine, Users, Zap } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Group → glyph.
|
||||
*
|
||||
* A hand-written table, for the same reason `icons.js` is one: a definition
|
||||
* names a group, and only a group written down here resolves to anything. There
|
||||
* is no path from a Markdown file to a component nobody chose.
|
||||
*
|
||||
* A group nobody anticipated falls back to the generic capability mark rather
|
||||
* than to no icon at all — a card with an empty square reads as broken, and a
|
||||
* skill whose author invented a category is not broken.
|
||||
*/
|
||||
const GLYPHS = {
|
||||
analytics: BarChart3,
|
||||
hiring: Briefcase,
|
||||
workforce: Users,
|
||||
operations: Activity,
|
||||
authoring: PenLine,
|
||||
};
|
||||
|
||||
/** The glyph for a catalog group. Never null. */
|
||||
export const glyphForGroup = (group) => GLYPHS[String(group || '').trim()] || Zap;
|
||||
83
src/components/ai-assistant/AgentBadge.jsx
Normal file
83
src/components/ai-assistant/AgentBadge.jsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { agentIconFor } from '@/components/agents/icons';
|
||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||
import { useActiveAgent } from './AgentContext';
|
||||
|
||||
/**
|
||||
* Who is answering, in Owliver's own header.
|
||||
*
|
||||
* This was an agent *switcher*: the header opened a popover with a search box,
|
||||
* every registered agent, Browse all and Create agent. That made the panel a
|
||||
* second place to decide which agent answers, competing with the one place that
|
||||
* actually configures agents — the Agent Editor — and letting a reader put
|
||||
* Owliver into a state nothing on the page explained.
|
||||
*
|
||||
* It is now a label. The agent is whatever `AgentContext` resolves for the page
|
||||
* being read, which is the same resolution the runtime already uses to answer,
|
||||
* so the header cannot disagree with the reply underneath it. Switching belongs
|
||||
* to Agent Registry → Agents → Configure.
|
||||
*
|
||||
* Deliberately **not** a button, and deliberately no chevron: a disclosure
|
||||
* affordance that discloses nothing is worse than none. Nothing here is
|
||||
* clickable, so nothing here promises a menu.
|
||||
*/
|
||||
|
||||
/** The avatar for the primary agent; a lucide glyph for the rest. */
|
||||
function AgentGlyph({ agent, className = 'h-7 w-7' }) {
|
||||
const Icon = agentIconFor(agent?.icon);
|
||||
|
||||
if (!Icon) return <OwliverAvatar className={className} rounded="rounded-lg" />;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center justify-center rounded-lg border border-border/80 bg-surface-subtle text-krow-blue shadow-2xs',
|
||||
className
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The mark a constrained agent carries.
|
||||
*
|
||||
* Kept: it is a fact about the answer that follows — this agent does not cover
|
||||
* this page and will say so — not a control, and losing it would make a
|
||||
* constrained reply arrive with no warning.
|
||||
*/
|
||||
function ConstrainedTag() {
|
||||
return (
|
||||
<span className="inline-flex shrink-0 items-center rounded-md border border-amber-500/25 bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-amber-700 dark:text-amber-300">
|
||||
Constrained
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {any} props */
|
||||
export function AgentBadge({ page }) {
|
||||
const { agent, covers } = useActiveAgent();
|
||||
|
||||
return (
|
||||
<>
|
||||
{agent
|
||||
? <AgentGlyph agent={agent} />
|
||||
: <OwliverAvatar className="h-7 w-7" rounded="rounded-lg" />}
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="truncate font-heading text-body-sm font-semibold leading-tight text-ink-1">
|
||||
Owliver
|
||||
</h2>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<p className="truncate text-caption leading-tight text-ink-3">{page}</p>
|
||||
{agent && !covers && <ConstrainedTag />}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default AgentBadge;
|
||||
@@ -1,234 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Check, ChevronDown, Plus, Search, SquareArrowOutUpRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { searchAgents } from '@/lib/agents/registry';
|
||||
import { agentIconFor } from '@/components/agents/icons';
|
||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||
import { useActiveAgent } from './AgentContext';
|
||||
|
||||
/**
|
||||
* The agent switcher, inside Owliver's own header.
|
||||
*
|
||||
* Designed with high-density Krow Control Tower aesthetics: clean popover
|
||||
* geometry, smooth row hover/active states, and legible status indicators.
|
||||
*/
|
||||
|
||||
/** The avatar for the primary agent; a lucide glyph for the rest. */
|
||||
function AgentGlyph({ agent, className = 'h-7 w-7' }) {
|
||||
const Icon = agentIconFor(agent?.icon);
|
||||
|
||||
if (!Icon) return <OwliverAvatar className={className} rounded="rounded-lg" />;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center justify-center rounded-lg border border-border/80 bg-surface-subtle text-krow-blue shadow-2xs',
|
||||
className
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** The label a constrained agent carries, in the switcher and in the header. */
|
||||
function ConstrainedTag({ className = '' }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center rounded-md border border-amber-500/25 bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-amber-700 dark:text-amber-300',
|
||||
className
|
||||
)}
|
||||
>
|
||||
Constrained
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** One row in the list. */
|
||||
function AgentRow({ agent, active, covers, onSelect }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
onClick={() => onSelect(agent.id)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left transition-all',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50',
|
||||
active
|
||||
? 'bg-krow-blue-tint/70 text-ink-1'
|
||||
: 'hover:bg-surface-subtle text-ink-2'
|
||||
)}
|
||||
>
|
||||
<AgentGlyph agent={agent} className="h-7 w-7 shrink-0" />
|
||||
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="min-w-0 truncate text-body-sm font-semibold text-ink-1">
|
||||
{agent.name}
|
||||
</span>
|
||||
{!covers && <ConstrainedTag />}
|
||||
</span>
|
||||
{agent.description && (
|
||||
<span className="mt-0.5 block truncate text-[11px] leading-tight text-ink-4">
|
||||
{agent.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{active && (
|
||||
<Check className="h-4 w-4 text-krow-blue shrink-0 ml-1" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentSwitcher({ page }) {
|
||||
const navigate = useNavigate();
|
||||
const { agents, agent, covers, select, coversPage } = useActiveAgent();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [query, setQuery] = React.useState('');
|
||||
const listRef = React.useRef(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) setQuery('');
|
||||
}, [open]);
|
||||
|
||||
const matches = React.useMemo(() => {
|
||||
const found = searchAgents(agents, query);
|
||||
return [...found].sort((a, b) => Number(coversPage(b)) - Number(coversPage(a)));
|
||||
}, [agents, query, coversPage]);
|
||||
|
||||
const choose = (id) => {
|
||||
select(id);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const onListKeyDown = (event) => {
|
||||
const step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
|
||||
if (!step) return;
|
||||
const rows = [...(listRef.current?.querySelectorAll('[role="option"]') ?? [])];
|
||||
if (!rows.length) return;
|
||||
event.preventDefault();
|
||||
const at = rows.indexOf(document.activeElement);
|
||||
rows[Math.max(0, Math.min(rows.length - 1, (at === -1 ? 0 : at + step)))]?.focus();
|
||||
};
|
||||
|
||||
if (!agent) {
|
||||
return (
|
||||
<>
|
||||
<OwliverAvatar className="h-7 w-7" rounded="rounded-lg" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="font-heading text-body-sm font-semibold leading-tight text-ink-1">Owliver</h2>
|
||||
<p className="truncate text-caption leading-tight text-ink-3">{page}</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Owliver agent: ${agent.name}. Switch to another agent`}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
className="group flex min-w-0 flex-1 items-center gap-2.5 rounded-lg px-1.5 py-1 text-left transition-colors hover:bg-surface-subtle focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
<AgentGlyph agent={agent} />
|
||||
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="min-w-0 truncate font-heading text-body-sm font-semibold leading-tight text-ink-1">
|
||||
Owliver
|
||||
</span>
|
||||
<ChevronDown
|
||||
className="h-3 w-3 shrink-0 text-ink-4 transition-colors group-hover:text-krow-blue"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="truncate text-caption leading-tight text-ink-3">{page}</span>
|
||||
{!covers && <ConstrainedTag />}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent align="start" sideOffset={6} className="w-[21.5rem] p-0 shadow-lg rounded-xl border border-border/80 overflow-hidden">
|
||||
<div className="border-b border-border/60 bg-surface-subtle/40 px-3.5 py-2.5">
|
||||
<p className="font-heading text-body-sm font-semibold text-ink-1">Switch agent</p>
|
||||
<p className="mt-0.5 text-[11px] text-ink-4">
|
||||
Choose an agent to focus Owliver's scope on this page.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="px-3 pt-2.5 pb-1">
|
||||
<label className="relative block">
|
||||
<span className="sr-only">Search agents</span>
|
||||
<Search
|
||||
className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-ink-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search agents..."
|
||||
className="w-full h-8 rounded-lg border border-border/70 bg-surface-subtle/40 py-1 pl-8 pr-2.5 text-body-sm text-ink-1 placeholder:text-ink-4 transition-all focus:bg-surface focus:border-krow-blue/50 focus:outline-none focus:ring-1 focus:ring-krow-blue/30"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={listRef}
|
||||
role="listbox"
|
||||
aria-label="Agents"
|
||||
onKeyDown={onListKeyDown}
|
||||
className="max-h-[16rem] space-y-0.5 overflow-y-auto px-2 py-1.5"
|
||||
>
|
||||
{matches.map((candidate) => (
|
||||
<AgentRow
|
||||
key={candidate.id}
|
||||
agent={candidate}
|
||||
active={candidate.id === agent.id}
|
||||
covers={coversPage(candidate)}
|
||||
onSelect={choose}
|
||||
/>
|
||||
))}
|
||||
|
||||
{!matches.length && (
|
||||
<p className="px-3 py-4 text-center text-body-sm text-ink-3">No agents match that.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-border/80 bg-surface-subtle/30 px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setOpen(false); navigate('/admin/workspace/agents'); }}
|
||||
className="inline-flex items-center gap-1.5 rounded px-1.5 py-0.5 text-caption font-medium text-ink-3 transition-colors hover:text-krow-blue focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
<SquareArrowOutUpRight className="h-3 w-3" aria-hidden="true" />
|
||||
Browse all
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setOpen(false); navigate('/admin/workspace/agents/new'); }}
|
||||
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-caption font-semibold text-krow-blue transition-colors hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Create agent
|
||||
</button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export default AgentSwitcher;
|
||||
@@ -125,6 +125,36 @@ export function AssistantPanelProvider({ role, pathname, children }) {
|
||||
const [request, setRequest] = React.useState(null);
|
||||
const requestSeq = React.useRef(0);
|
||||
|
||||
/**
|
||||
* Something the workspace has *told* Owliver, waiting to be said.
|
||||
*
|
||||
* `ask` above is the page speaking as the reader — it puts a question in the
|
||||
* composer's place and a turn runs. This is the other half, and it is a
|
||||
* different act: attaching a skill to an agent is not a question, and staging
|
||||
* it as one would put words in the reader's mouth and then answer them.
|
||||
*
|
||||
* So a notice appends a single assistant turn stating what changed and what
|
||||
* can now be asked. It is the same conversation, the same panel and the same
|
||||
* thread — deliberately not a second chat, a toast queue or a system-message
|
||||
* lane, because the point of Owliver knowing about a skill is that the next
|
||||
* thing the reader types can use it.
|
||||
*
|
||||
* Held as state for the same reason a request is: the panel is unmounted
|
||||
* while collapsed, and the notice must still be here when it mounts.
|
||||
*/
|
||||
const [notice, setNotice] = React.useState(null);
|
||||
const noticeSeq = React.useRef(0);
|
||||
|
||||
/**
|
||||
* A capability test in flight: `{ id, trace, state, error }`.
|
||||
*
|
||||
* `trace` is what the agent editor was testing — the skill, the agent and the
|
||||
* surface — so the panel can say so and the drawer can show the outcome. It
|
||||
* is presentation only: nothing in the runtime reads it, and it is never
|
||||
* persisted with the thread.
|
||||
*/
|
||||
const [test, setTest] = React.useState(null);
|
||||
|
||||
const context = React.useMemo(
|
||||
() => resolveAssistantContext(role, pathname),
|
||||
[role, pathname]
|
||||
@@ -174,19 +204,68 @@ export function AssistantPanelProvider({ role, pathname, children }) {
|
||||
* match. Each call carries its own id, so asking the same question twice is
|
||||
* two turns rather than one ignored repeat.
|
||||
*/
|
||||
const ask = React.useCallback(({ question, positionId = null }) => {
|
||||
const ask = React.useCallback(({
|
||||
question, positionId = null, scope = null, capability = null, trace = null,
|
||||
}) => {
|
||||
const text = String(question || '').trim();
|
||||
if (!text) return;
|
||||
requestSeq.current += 1;
|
||||
setRequest({ id: requestSeq.current, question: text, positionId });
|
||||
const id = requestSeq.current;
|
||||
/* `scope` runs the turn against another page's skills — the agent editor
|
||||
testing a capability. See `useConversation.send`. Ordinary callers omit
|
||||
it and the turn is about the page the reader is on. */
|
||||
setRequest({ id, question: text, positionId, scope, capability, trace });
|
||||
/**
|
||||
* A capability test is a turn somebody is *waiting on the outcome of*, and
|
||||
* the panel is the only thing that knows when a turn has finished. So a
|
||||
* traced ask opens a small status the caller can watch — queued while the
|
||||
* panel is still mounting or busy, running once the turn starts, then done
|
||||
* or error. Untraced asks set nothing and behave exactly as before.
|
||||
*/
|
||||
if (trace) setTest({ id, trace, state: 'queued', error: null });
|
||||
setOpenState(true);
|
||||
}, [setOpenState]);
|
||||
|
||||
/** The panel reporting back on a traced turn. */
|
||||
const reportTest = React.useCallback((id, state, error = null) => {
|
||||
setTest((current) => (current?.id === id ? { ...current, state, error } : current));
|
||||
}, []);
|
||||
|
||||
/** Drops the test banner — on a new ordinary question, or when it is dismissed. */
|
||||
const clearTest = React.useCallback(() => setTest(null), []);
|
||||
|
||||
/** Taken by the panel once it has actually asked it. */
|
||||
const consumeRequest = React.useCallback((id) => {
|
||||
setRequest((current) => (current?.id === id ? null : current));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* States a fact in the conversation, without asking anything.
|
||||
*
|
||||
* `followUp` are the chips offered under it — a skill's own suggestions, so
|
||||
* the next step after attaching one is a question that skill was written to
|
||||
* answer rather than a blank composer.
|
||||
*
|
||||
* Never opens the panel on its own. A reader who collapsed Owliver to get the
|
||||
* full width does not want it reopened by a checkbox, and the notice is still
|
||||
* waiting in the thread when they open it again.
|
||||
*/
|
||||
const announce = React.useCallback(({ text, followUp = null }) => {
|
||||
const body = String(text || '').trim();
|
||||
if (!body) return;
|
||||
noticeSeq.current += 1;
|
||||
setNotice({
|
||||
id: noticeSeq.current,
|
||||
text: body,
|
||||
followUp: Array.isArray(followUp) && followUp.length ? followUp : null,
|
||||
});
|
||||
}, []);
|
||||
|
||||
/** Taken by the panel once it has actually said it. */
|
||||
const consumeNotice = React.useCallback((id) => {
|
||||
setNotice((current) => (current?.id === id ? null : current));
|
||||
}, []);
|
||||
|
||||
/* Width is written on every drag frame, so persistence is debounced rather
|
||||
than hitting sessionStorage sixty times a second. */
|
||||
const setWidthState = React.useCallback((next) => {
|
||||
@@ -220,8 +299,14 @@ export function AssistantPanelProvider({ role, pathname, children }) {
|
||||
ask,
|
||||
request,
|
||||
consumeRequest,
|
||||
announce,
|
||||
notice,
|
||||
consumeNotice,
|
||||
test,
|
||||
reportTest,
|
||||
clearTest,
|
||||
}), [context, supported, isOpen, isExpanded, width, setWidthState, setOpenState, setExpandedState,
|
||||
ask, request, consumeRequest]);
|
||||
ask, request, consumeRequest, announce, notice, consumeNotice, test, reportTest, clearTest]);
|
||||
|
||||
/* The page's own selection travels beside the window state, mounted here so
|
||||
both the page and the panel are inside it — a skill's data source resolves
|
||||
@@ -265,5 +350,11 @@ export function useAssistantPanel() {
|
||||
ask: () => {},
|
||||
request: null,
|
||||
consumeRequest: () => {},
|
||||
announce: () => {},
|
||||
notice: null,
|
||||
consumeNotice: () => {},
|
||||
test: null,
|
||||
reportTest: () => {},
|
||||
clearTest: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ArrowLeft, History, Maximize2, Minimize2, PanelRightClose, RotateCcw, Trash2,
|
||||
ArrowLeft, History, Maximize2, Minimize2, PanelRightClose, RotateCcw, Trash2, X,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Surface } from '@/components/ds/Surface';
|
||||
@@ -25,7 +25,7 @@ import { useAssistantFacts, useConversation, useCurrentUserName } from './useAss
|
||||
import { buildIntro, buildPrompts } from './dynamic';
|
||||
import { agentScopedDisabled, agentStarters } from '@/lib/agents/runtime';
|
||||
import { buildOwliverContext } from '@/lib/agents/context';
|
||||
import { AgentSwitcher } from './AgentSwitcher';
|
||||
import { AgentBadge } from './AgentBadge';
|
||||
import { useActiveAgent } from './AgentContext';
|
||||
import { Message, ThinkingIndicator, TurnDivider } from './AssistantMessage';
|
||||
import { PromptInput } from './PromptInput';
|
||||
@@ -221,7 +221,11 @@ export default function KrowAssistant({
|
||||
const location = useLocation();
|
||||
|
||||
/* A question a page has handed over — see the effect below `runPrompt`. */
|
||||
const { request: panelRequest, consumeRequest } = useAssistantPanel();
|
||||
const {
|
||||
request: panelRequest, consumeRequest,
|
||||
notice: panelNotice, consumeNotice,
|
||||
test: panelTest, reportTest, clearTest,
|
||||
} = useAssistantPanel();
|
||||
|
||||
/* The app's own router, not a location assignment: a full page load would
|
||||
discard the thread and the panel state along with it. */
|
||||
@@ -447,7 +451,7 @@ export default function KrowAssistant({
|
||||
);
|
||||
|
||||
const {
|
||||
messages, pending, error, busy, send, stop, reset,
|
||||
messages, pending, error, busy, send, announce, stop, reset,
|
||||
history, conversationId, openConversation, forgetConversation,
|
||||
submitFeedback, feedback,
|
||||
} = useConversation({
|
||||
@@ -604,17 +608,18 @@ export default function KrowAssistant({
|
||||
* bypasses intent routing. A typed question carries none and is routed. Both
|
||||
* land in the same `send`, so there is a single pipeline to reason about.
|
||||
*/
|
||||
const handleMessage = React.useCallback((question, capability = null, positionId = null) => {
|
||||
const handleMessage = React.useCallback((
|
||||
question, capability = null, positionId = null, scope = null,
|
||||
) => {
|
||||
const text = String(question).trim();
|
||||
if (!text || busy) return;
|
||||
setInput('');
|
||||
/* Asking something while reading History returns to the conversation — the
|
||||
answer is about to arrive there, and leaving the list up would hide it. */
|
||||
setView('chat');
|
||||
send({ question: text, capability, positionId });
|
||||
send({ question: text, capability, positionId, scope });
|
||||
}, [busy, send]);
|
||||
|
||||
const submit = React.useCallback((text) => handleMessage(text), [handleMessage]);
|
||||
|
||||
/**
|
||||
* A chip is already a complete question, so it runs on click.
|
||||
@@ -644,9 +649,54 @@ export default function KrowAssistant({
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
if (!panelRequest || busy) return;
|
||||
handleMessage(panelRequest.question, null, panelRequest.positionId);
|
||||
handleMessage(
|
||||
panelRequest.question, panelRequest.capability ?? null,
|
||||
panelRequest.positionId, panelRequest.scope,
|
||||
);
|
||||
if (panelRequest.trace) reportTest(panelRequest.id, 'running');
|
||||
consumeRequest(panelRequest.id);
|
||||
}, [panelRequest, busy, handleMessage, consumeRequest]);
|
||||
}, [panelRequest, busy, handleMessage, consumeRequest, reportTest]);
|
||||
|
||||
/**
|
||||
* Reporting the end of a traced turn.
|
||||
*
|
||||
* The panel is the only place that knows a turn finished, and a reader who
|
||||
* pressed Test is waiting on exactly that. `busy` falling is the signal;
|
||||
* `error` decides which outcome it was. Nothing is inferred about the
|
||||
* *content* of the answer — a capability that answered is a pass, and judging
|
||||
* whether the answer was any good is the reader's job, which is the whole
|
||||
* reason the answer is shown to them rather than summarised.
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
if (panelTest?.state !== 'running' || busy) return;
|
||||
reportTest(panelTest.id, error ? 'error' : 'done', error || null);
|
||||
}, [panelTest, busy, error, reportTest]);
|
||||
|
||||
/* A question the reader types themselves ends the test banner: they have
|
||||
moved on, and leaving "Testing…" above an unrelated answer would label
|
||||
something that is no longer happening. */
|
||||
const submit = React.useCallback((text) => {
|
||||
if (panelTest) clearTest();
|
||||
handleMessage(text);
|
||||
}, [handleMessage, panelTest, clearTest]);
|
||||
|
||||
/**
|
||||
* Something the workspace told Owliver, said in the thread.
|
||||
*
|
||||
* Attaching a skill to an agent is the only source today. It is stated rather
|
||||
* than asked, so it appends a turn instead of running one — see
|
||||
* `AssistantPanelContext.announce` for why those are different acts.
|
||||
*
|
||||
* Deferred while a response streams, for the same reason a request is: a
|
||||
* notice arriving mid-answer waits for the panel rather than landing between
|
||||
* a question and its reply. Reading History is left alone — the notice is in
|
||||
* the live thread when the reader comes back to it.
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
if (!panelNotice || busy) return;
|
||||
announce({ text: panelNotice.text, followUp: panelNotice.followUp });
|
||||
consumeNotice(panelNotice.id);
|
||||
}, [panelNotice, busy, announce, consumeNotice]);
|
||||
|
||||
const composer = (
|
||||
<PromptInput
|
||||
@@ -672,11 +722,12 @@ export default function KrowAssistant({
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex shrink-0 items-center gap-2.5 border-b border-white/60 px-3.5 py-2.5">
|
||||
{/* The avatar and the two lines beside it, as they always were —
|
||||
"Owliver" over the page, with the pair doubling as the agent
|
||||
switcher. The header element, its geometry and the window controls
|
||||
to the right are unchanged. */}
|
||||
<AgentSwitcher page={context.page} />
|
||||
{/* The avatar and the two lines beside it — "Owliver" over the page,
|
||||
and who is answering. It is a label, not a control: choosing an
|
||||
agent belongs to Agent Registry → Configure, not to this header.
|
||||
The header element, its geometry and the window controls to the
|
||||
right are unchanged. */}
|
||||
<AgentBadge page={context.page} />
|
||||
|
||||
{/* Window controls.
|
||||
|
||||
@@ -747,6 +798,46 @@ export default function KrowAssistant({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/**
|
||||
* A capability test running through this panel.
|
||||
*
|
||||
* One line, in the panel's own vocabulary, so the reader knows the next
|
||||
* answer is the thing they asked for rather than the page talking to
|
||||
* itself. Deliberately outside the scrolling body: it describes the turn,
|
||||
* not a message in it, and nothing about it is persisted with the thread.
|
||||
*/}
|
||||
{panelTest && (
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-white/60 bg-krow-blue-tint/50 px-3.5 py-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
'h-1.5 w-1.5 shrink-0 rounded-full',
|
||||
panelTest.state === 'error' ? 'bg-destructive'
|
||||
: panelTest.state === 'done' ? 'bg-success'
|
||||
: 'animate-pulse bg-krow-blue'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p className="min-w-0 flex-1 truncate text-caption text-ink-2">
|
||||
<span className="font-medium">
|
||||
{panelTest.state === 'done' ? 'Tested'
|
||||
: panelTest.state === 'error' ? 'Test failed' : 'Testing'}
|
||||
</span>
|
||||
{': '}
|
||||
{panelTest.trace.skillName}
|
||||
{panelTest.trace.agentName ? ` on ${panelTest.trace.agentName}` : ''}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearTest}
|
||||
aria-label="Dismiss test indicator"
|
||||
className={`shrink-0 rounded p-0.5 text-ink-4 transition-colors hover:text-ink-2
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/40`}
|
||||
>
|
||||
<X className="h-3 w-3" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Body: the only region that scrolls ─────────────────────────── */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
|
||||
@@ -26,7 +26,7 @@ import { buildFacts } from './insights';
|
||||
import { agentRequest } from '@/lib/agents/runtime';
|
||||
import { createAssistantProvider } from './provider';
|
||||
import { resolveIntent } from './routing';
|
||||
import { toSnapshots } from './blocks';
|
||||
import { doc, text as textBlock, toSnapshots } from './blocks';
|
||||
|
||||
/** One provider instance for the app's lifetime. */
|
||||
const provider = createAssistantProvider();
|
||||
@@ -397,10 +397,36 @@ export function useConversation({
|
||||
const messagesRef = React.useRef(messages);
|
||||
messagesRef.current = messages;
|
||||
|
||||
const send = React.useCallback(async ({ question, capability = null, positionId = null }) => {
|
||||
/**
|
||||
* Asks something.
|
||||
*
|
||||
* `scope` runs this one turn against a *different* page and a different set
|
||||
* of reachable skills, and exists for exactly one caller: testing a
|
||||
* capability from the agent editor. The reader is standing on the
|
||||
* configuration screen, so the panel's own context is the workspace — asking
|
||||
* "what happened today?" there would resolve against the workspace's skills
|
||||
* and prove nothing about the Activity capability being configured.
|
||||
*
|
||||
* It is an override of *which page this question is about*, never of what may
|
||||
* be read: `resolveIntent` and the provider apply the same page rules to the
|
||||
* substituted context that they apply to a real one, so a test cannot reach a
|
||||
* record the real page would not have offered. The turn lands in the ordinary
|
||||
* thread, is persisted and archived like any other, and nothing about it is
|
||||
* simulated — it is the live pipeline pointed at another surface.
|
||||
*/
|
||||
const send = React.useCallback(async ({
|
||||
question, capability = null, positionId = null, scope = null,
|
||||
}) => {
|
||||
const text = String(question).trim();
|
||||
if (!text) return;
|
||||
|
||||
/* The page this turn is about, and what is reachable there. Defaults are
|
||||
the panel's own, so every existing caller is unchanged. */
|
||||
const turnContext = scope?.contextId || contextId;
|
||||
const turnDisabled = scope?.disabledSkills || disabledSkills;
|
||||
const turnAgent = scope?.agent || agent;
|
||||
const turnCovers = scope ? true : agentCoversPage;
|
||||
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
@@ -419,7 +445,7 @@ export function useConversation({
|
||||
*/
|
||||
let intent = null;
|
||||
if (flowRef.current) {
|
||||
const skill = skillsForContext(contextId, disabledSkills, customSkills)
|
||||
const skill = skillsForContext(turnContext, turnDisabled, customSkills)
|
||||
.find((s) => s.id === flowRef.current.skillId);
|
||||
|
||||
if (!skill) setFlow(null);
|
||||
@@ -438,9 +464,14 @@ export function useConversation({
|
||||
intent = capability
|
||||
? { kind: 'answer' }
|
||||
: resolveIntent({
|
||||
question: text, contextId, disabledSkills, customSkills, roles, skillCategories,
|
||||
question: text,
|
||||
contextId: turnContext,
|
||||
disabledSkills: turnDisabled,
|
||||
customSkills, roles, skillCategories,
|
||||
courses, workforce, skillContext, positionId,
|
||||
agent, agentCoversPage, agentSuggestion, owliverContext,
|
||||
agent: turnAgent,
|
||||
agentCoversPage: turnCovers,
|
||||
agentSuggestion, owliverContext,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -476,7 +507,7 @@ export function useConversation({
|
||||
followUp: [
|
||||
...createdFollowUp(created),
|
||||
...(ready
|
||||
? suggestionsForPosition(contextId, disabledSkills, customSkills, created)
|
||||
? suggestionsForPosition(turnContext, turnDisabled, customSkills, created)
|
||||
: []),
|
||||
],
|
||||
};
|
||||
@@ -664,10 +695,10 @@ export function useConversation({
|
||||
try {
|
||||
let latest = [];
|
||||
for await (const snapshot of provider.stream({
|
||||
contextId, capability, question: text, facts, signal: controller.signal,
|
||||
contextId: turnContext, capability, question: text, facts, signal: controller.signal,
|
||||
/* What the agent *is*, never what it may read. The page settled that
|
||||
before this call, and `agentRequest` carries no records. */
|
||||
agent: agent ? agentRequest(agent, contextId) : null,
|
||||
agent: turnAgent ? agentRequest(turnAgent, turnContext) : null,
|
||||
owliverContext,
|
||||
})) {
|
||||
if (controller.signal.aborted) break;
|
||||
@@ -703,10 +734,39 @@ export function useConversation({
|
||||
onGenerateDescription, onAssignWorkers,
|
||||
onScheduleInterview,
|
||||
workforce, setFlow, disabledSkills,
|
||||
customSkills, roles, skillCategories, courses, skillContext]);
|
||||
customSkills, roles, skillCategories, courses, skillContext,
|
||||
agent, agentCoversPage, agentSuggestion, owliverContext]);
|
||||
|
||||
const stop = React.useCallback(() => abortRef.current?.abort(), []);
|
||||
|
||||
/**
|
||||
* States something in the thread without a question having been asked.
|
||||
*
|
||||
* The workspace's own voice: "Attendance Analysis is now available to this
|
||||
* agent." It is a real turn — persisted and archived by the same `persist`
|
||||
* every answer goes through, so it survives a reload and appears in History
|
||||
* exactly where it happened, rather than being a banner that evaporates.
|
||||
*
|
||||
* Nothing is generated. The caller supplies the sentence and the chips, which
|
||||
* is what keeps this from being a second answering path: no skill runs, no
|
||||
* provider is called, and nothing here can claim a figure.
|
||||
*/
|
||||
const announce = React.useCallback(({ text: body, followUp = null }) => {
|
||||
const sentence = String(body || '').trim();
|
||||
if (!sentence) return;
|
||||
|
||||
const message = {
|
||||
role: 'assistant',
|
||||
text: sentence,
|
||||
blocks: doc(textBlock(sentence)).blocks,
|
||||
...(followUp?.length ? { followUp } : null),
|
||||
};
|
||||
|
||||
const next = [...messagesRef.current, message];
|
||||
messagesRef.current = next;
|
||||
persist(next);
|
||||
}, [persist]);
|
||||
|
||||
/**
|
||||
* Back to an empty panel.
|
||||
*
|
||||
@@ -797,6 +857,7 @@ export function useConversation({
|
||||
error,
|
||||
busy: Boolean(pending),
|
||||
send,
|
||||
announce,
|
||||
stop,
|
||||
reset,
|
||||
submitFeedback,
|
||||
|
||||
@@ -262,8 +262,12 @@ export default function AdminLayout() {
|
||||
<DropdownMenuItem onClick={() => navigate('/admin/profile#security')} className="cursor-pointer">
|
||||
<Shield className="mr-2 h-4 w-4" /> Security
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => navigate('/admin/workspace')} className="cursor-pointer">
|
||||
<LayoutGrid className="mr-2 h-4 w-4" /> Workspace
|
||||
{/* Straight to the registry itself. The Workspace overview at
|
||||
`/admin/workspace` still exists and still reaches the same
|
||||
list through its own Manage Agents button — it is simply
|
||||
not a stop on the way there from this menu. */}
|
||||
<DropdownMenuItem onClick={() => navigate('/admin/workspace/agents')} className="cursor-pointer">
|
||||
<LayoutGrid className="mr-2 h-4 w-4" /> Agent Registry
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
@@ -312,7 +316,7 @@ export default function AdminLayout() {
|
||||
{[
|
||||
{ label: 'Settings', path: '/admin/settings' },
|
||||
{ label: 'Security', path: '/admin/profile#security' },
|
||||
{ label: 'Workspace', path: '/admin/workspace' },
|
||||
{ label: 'Agent Registry', path: '/admin/workspace/agents' },
|
||||
].map((item) => (
|
||||
<Link
|
||||
key={item.label}
|
||||
|
||||
181
src/lib/agents/capabilityTest.js
Normal file
181
src/lib/agents/capabilityTest.js
Normal file
@@ -0,0 +1,181 @@
|
||||
import { PLACEMENT_ROUTES } from '@/components/ai-assistant/placement';
|
||||
import { ASSISTANT_CONTEXTS } from '@/components/ai-assistant/contexts';
|
||||
import { allSkills, matchSkill, pageKeyForContext, skillsForContext } from '@/lib/skills/registry';
|
||||
import { canonicalPage, surfaceFor } from '@/lib/skills/surfaces';
|
||||
import { toolsForContext } from '@/lib/skills/tools';
|
||||
import { agentCovers, agentScopedDisabled } from './runtime';
|
||||
|
||||
/**
|
||||
* Trying a capability before committing to it.
|
||||
*
|
||||
* Two things have to be true for this to be worth having, and both are about
|
||||
* refusing to fake something:
|
||||
*
|
||||
* 1. **The evaluation is the live runtime.** `agentCovers`, `skillsForContext`,
|
||||
* `agentScopedDisabled`, `matchSkill` and `toolsForContext` are the same
|
||||
* functions the assistant routes every real question through. Nothing here
|
||||
* re-implements any of them, so a green result cannot mean something
|
||||
* different from what will happen on the page.
|
||||
*
|
||||
* 2. **Testing is not saving.** The agent this reasons about is *hypothetical*
|
||||
* — the draft's fields with the candidate capability added — and it is
|
||||
* never written anywhere. A reader can try six capabilities and leave with
|
||||
* the agent exactly as they found it.
|
||||
*
|
||||
* The other half of the test is the answer itself, and that is deliberately not
|
||||
* here: `scopeFor` returns what `send({ scope })` needs so the question runs in
|
||||
* the *existing* Owliver panel against the target page. One conversation, one
|
||||
* pipeline, no second chat.
|
||||
*/
|
||||
|
||||
/** contextId → the page key it stands for, resolved once. */
|
||||
const CONTEXTS = Object.values(PLACEMENT_ROUTES).map((contextId) => ({
|
||||
contextId,
|
||||
pageKey: pageKeyForContext(contextId),
|
||||
}));
|
||||
|
||||
/** The assistant context for a surface, or null if the page carries no panel. */
|
||||
export function contextForPage(pageKey) {
|
||||
const wanted = canonicalPage(pageKey) || pageKey;
|
||||
const hit = CONTEXTS.find((c) => c.pageKey && (canonicalPage(c.pageKey) || c.pageKey) === wanted);
|
||||
return hit?.contextId || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The surfaces a capability can actually be tried on, for this agent.
|
||||
*
|
||||
* The intersection of what the agent covers and what the capability declares —
|
||||
* anywhere else the runtime would decline, and offering it as a test target
|
||||
* would be offering a test guaranteed to fail for a reason that is not about
|
||||
* the capability.
|
||||
*/
|
||||
export function testTargets(agentPages = [], skillPages = []) {
|
||||
const skill = new Set((skillPages || []).map((p) => canonicalPage(p) || p));
|
||||
return (agentPages || [])
|
||||
.map((p) => canonicalPage(p) || p)
|
||||
.filter((p) => skill.has(p))
|
||||
.map((pageKey) => ({
|
||||
pageKey,
|
||||
contextId: contextForPage(pageKey),
|
||||
label: surfaceFor(pageKey)?.label || pageKey,
|
||||
}))
|
||||
.filter((t) => t.contextId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent as it *would* be with this capability attached.
|
||||
*
|
||||
* Published on purpose: an unpublished draft is refused by the runtime for a
|
||||
* reason that has nothing to do with the capability being tried, and a test
|
||||
* that always says "this agent is not published" answers the wrong question.
|
||||
*/
|
||||
function hypotheticalAgent(fields, skillId) {
|
||||
const skills = skillId && !fields.skills.includes(skillId)
|
||||
? [...fields.skills, skillId]
|
||||
: fields.skills;
|
||||
|
||||
return {
|
||||
id: fields.id || 'draft',
|
||||
name: fields.name || 'This agent',
|
||||
pages: fields.pages,
|
||||
skills,
|
||||
subagents: fields.subagents || [],
|
||||
knowledge: fields.knowledge || [],
|
||||
starters: fields.starters || [],
|
||||
reasoning: fields.reasoning,
|
||||
webSearch: fields.webSearch,
|
||||
status: 'published',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* What the runtime would do with this question, on this page, with this
|
||||
* capability added.
|
||||
*
|
||||
* `matched` is the honest headline: it is the skill that would actually answer.
|
||||
* When that is the capability under test, the test proves the capability;
|
||||
* when it is a different one, it says so rather than claiming a pass — two
|
||||
* definitions claiming the same phrase is a real condition and the reader
|
||||
* should see it here rather than discover it in production.
|
||||
*/
|
||||
export function evaluateCapability({
|
||||
fields, skillId, contextId, question, customSkills = [], entry = null,
|
||||
}) {
|
||||
const agent = hypotheticalAgent(fields, skillId);
|
||||
const registry = allSkills(customSkills);
|
||||
const disabledSkills = agentScopedDisabled(agent, registry, []);
|
||||
|
||||
const covers = agentCovers(agent, contextId);
|
||||
const reachable = covers ? skillsForContext(contextId, disabledSkills, customSkills) : [];
|
||||
const offered = reachable.some((s) => s.id === skillId);
|
||||
|
||||
/**
|
||||
* There are two ways a question reaches a skill, and conflating them made
|
||||
* this panel lie about its own suggestions.
|
||||
*
|
||||
* A **typed** question is routed by `matchSkill` against declared triggers. A
|
||||
* **suggestion** is not routed at all: it is a chip, it carries the capability
|
||||
* it asks for, and `send` short-circuits intent resolution for exactly that
|
||||
* reason (`capability ? { kind: 'answer' }`). So "What kinds of event are
|
||||
* there?" — a suggestion Activity Analysis declares — matches none of its
|
||||
* triggers and is still answered by it every time.
|
||||
*
|
||||
* Reporting that as "no skill claims this wording" was true of the matcher and
|
||||
* false of the product. Both paths are modelled here, and the one that applies
|
||||
* is named, so the reader is told *how* it would be answered rather than being
|
||||
* shown a warning about a question that works.
|
||||
*/
|
||||
const declared = (entry?.suggestions || []).find(
|
||||
(sug) => (sug.prompt || sug.label) === question
|
||||
);
|
||||
|
||||
const matched = covers && question && !declared
|
||||
? matchSkill(question, contextId, disabledSkills, customSkills)
|
||||
: null;
|
||||
|
||||
return {
|
||||
agent,
|
||||
disabledSkills,
|
||||
covers,
|
||||
/** Is the capability under test offered at all on this page? */
|
||||
reachable: offered,
|
||||
reachableCount: reachable.length,
|
||||
/** The skill that would answer a *typed* question, if any. */
|
||||
matched,
|
||||
/** True when the capability being tested is the one that answers. */
|
||||
claims: Boolean(offered && (declared || (matched && matched.id === skillId))),
|
||||
/** How it would be answered: its own suggestion, or a matched trigger. */
|
||||
route: declared ? 'suggestion' : matched ? 'trigger' : null,
|
||||
/** The capability a suggestion asks for — passed to `send` as a chip would. */
|
||||
capability: declared?.capability || null,
|
||||
tools: covers ? toolsForContext(contextId, disabledSkills, customSkills) : [],
|
||||
pageLabel: ASSISTANT_CONTEXTS[contextId]?.page || contextId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* What `send({ scope })` needs to run this turn against the target page.
|
||||
*
|
||||
* Deliberately the same `disabledSkills` the evaluation used, so the answer the
|
||||
* reader reads is produced under exactly the conditions the diagnostics above
|
||||
* described.
|
||||
*/
|
||||
export const scopeFor = (evaluation, contextId) => ({
|
||||
contextId,
|
||||
disabledSkills: evaluation.disabledSkills,
|
||||
agent: evaluation.agent,
|
||||
});
|
||||
|
||||
/**
|
||||
* Questions worth trying, taken from the capability itself.
|
||||
*
|
||||
* A definition's `owliver.suggestions` are the questions its author wrote it to
|
||||
* answer, so they are the fairest test of it — and they keep the test from
|
||||
* being a blank box the reader has to guess at. A definition with none falls
|
||||
* back to its own name, which is what its default trigger matches.
|
||||
*/
|
||||
export function testQuestions(entry) {
|
||||
const declared = (entry?.suggestions || []).map((s) => s.prompt || s.label).filter(Boolean);
|
||||
if (declared.length) return declared.slice(0, 3);
|
||||
return entry?.name ? [entry.name] : [];
|
||||
}
|
||||
431
src/lib/skills/catalog.js
Normal file
431
src/lib/skills/catalog.js
Normal file
@@ -0,0 +1,431 @@
|
||||
import { aiAgentSkills, allSkills, getSkillsForPage, skillsWithFacet } from './registry';
|
||||
import { canonicalPage, placementLabel, sectionTypeLabel, surfaceFor } from './surfaces';
|
||||
import { describeTool } from './tools';
|
||||
|
||||
/**
|
||||
* The skill catalog — the registry, read as something a person browses.
|
||||
*
|
||||
* There is **no second list of skills here**. Every entry is a definition
|
||||
* `allSkills` already returned, and every field on it is read off that
|
||||
* definition: what it can be asked for comes from its `owliver.capabilities`,
|
||||
* what it can do comes from its `actions`, and where it applies comes from its
|
||||
* `pages`. A catalog that carried its own copy of any of that would eventually
|
||||
* offer an agent a skill the runtime does not have — which is the exact failure
|
||||
* `AddSkillsModal` was written to avoid, and this keeps.
|
||||
*
|
||||
* What it adds is *grouping*. A definition may declare `category:` and eleven of
|
||||
* them do; the rest are placed by what they demonstrably are, never by their id.
|
||||
* That rule matters: the registry is explicit that nothing downstream may test a
|
||||
* page or a skill by name, so a definition authored tomorrow has to land in a
|
||||
* group without this file being edited. It does — every step below reads a
|
||||
* declaration.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The groups, in the order a reader meets them.
|
||||
*
|
||||
* Deliberately Krow's own domains rather than a generic "Core / Development /
|
||||
* Data" taxonomy: this workspace hires and rosters people, and a category
|
||||
* called Development would be a heading with nothing under it.
|
||||
*
|
||||
* A definition may still declare a `category:` outside this list — the registry
|
||||
* keeps that field free text on purpose — and `catalogGroups` surfaces it beside
|
||||
* these rather than dropping it.
|
||||
*/
|
||||
export const SKILL_GROUPS = [
|
||||
{
|
||||
id: 'analytics',
|
||||
label: 'Analytics',
|
||||
blurb: 'The workspace as figures — trends, coverage and the headline picture.',
|
||||
},
|
||||
{
|
||||
id: 'hiring',
|
||||
label: 'Hiring',
|
||||
blurb: 'The pipeline: open roles, applicants, and who has already been hired.',
|
||||
},
|
||||
{
|
||||
id: 'workforce',
|
||||
label: 'Workforce',
|
||||
blurb: 'The people already on the roster — attendance, hours and training.',
|
||||
},
|
||||
{
|
||||
id: 'operations',
|
||||
label: 'Operations',
|
||||
blurb: 'What is happening now, and what is going wrong.',
|
||||
},
|
||||
{
|
||||
id: 'authoring',
|
||||
label: 'Authoring',
|
||||
blurb: 'Skills that create a record from the conversation rather than reading one.',
|
||||
},
|
||||
];
|
||||
|
||||
const GROUP_BY_ID = new Map(SKILL_GROUPS.map((g) => [g.id, g]));
|
||||
|
||||
/**
|
||||
* The group a surface belongs to.
|
||||
*
|
||||
* Keyed on the closed surface vocabulary rather than on skill ids, so this is a
|
||||
* statement about the product's pages — which are a fixed set — and not about
|
||||
* any particular definition. A skill attaching to a surface listed here inherits
|
||||
* its group for free.
|
||||
*/
|
||||
const GROUP_BY_SURFACE = {
|
||||
'control-center': 'analytics',
|
||||
analytics: 'analytics',
|
||||
positions: 'hiring',
|
||||
'create-position': 'hiring',
|
||||
candidates: 'hiring',
|
||||
'candidates-analysis': 'hiring',
|
||||
'hired-history': 'hiring',
|
||||
'talent-pool': 'hiring',
|
||||
activity: 'operations',
|
||||
'krow-forge': 'workforce',
|
||||
profile: 'workforce',
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether this definition *writes* rather than reads.
|
||||
*
|
||||
* Read off two declarations, both of which mean the same thing in different
|
||||
* words: a `prompt:` is a definition offering to start a piece of work, and an
|
||||
* action the tool table marks as needing approval is one that changes a record.
|
||||
* Either makes a skill an authoring skill, and neither is a name.
|
||||
*/
|
||||
function isAuthoring(skill) {
|
||||
if (skill.prompt) return true;
|
||||
return (skill.actions || []).some((name) => {
|
||||
const tool = describeTool(name);
|
||||
return Boolean(tool && (tool.mutates || tool.requiresApproval));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Which group a definition belongs to.
|
||||
*
|
||||
* Its own `category:` always wins — an author who wrote one has already
|
||||
* answered this question. Everything after that is inference, in the order of
|
||||
* how much the definition is actually saying: what it does, then where it
|
||||
* applies, then nothing.
|
||||
*/
|
||||
export function groupFor(skill) {
|
||||
const declared = String(skill.category || '').trim().toLowerCase();
|
||||
if (declared) return declared;
|
||||
|
||||
if (isAuthoring(skill)) return 'authoring';
|
||||
|
||||
for (const page of skill.pages || []) {
|
||||
const group = GROUP_BY_SURFACE[page];
|
||||
if (group) return group;
|
||||
}
|
||||
|
||||
return 'general';
|
||||
}
|
||||
|
||||
/** A group's label, whether it is one of ours or one an author invented. */
|
||||
export const groupLabel = (id) => GROUP_BY_ID.get(id)?.label
|
||||
|| String(id || '').replace(/[-_]/g, ' ').replace(/^./, (c) => c.toUpperCase());
|
||||
|
||||
/**
|
||||
* What a skill can be asked for, in the reader's words.
|
||||
*
|
||||
* The `owliver:` capabilities are the machine-readable half — `summary`,
|
||||
* `table`, `flow` — and the `## Capabilities` bullets are the sentence the
|
||||
* author wrote. Both are shown, because one says what shape an answer takes and
|
||||
* the other says what the answer is about.
|
||||
*/
|
||||
function capabilitiesOf(skill) {
|
||||
const declared = skill.owliver?.capabilities || [];
|
||||
return {
|
||||
/** Response shapes this skill offers in the panel. */
|
||||
shapes: [...declared],
|
||||
/** The author's own description of what it can do. */
|
||||
described: skill.capabilities || [],
|
||||
};
|
||||
}
|
||||
|
||||
/** The tools a skill reaches, described — never a tool it did not declare. */
|
||||
function toolsOf(skill) {
|
||||
return (skill.actions || [])
|
||||
.map((name) => describeTool(name) || { name, label: name, summary: '', readOnly: true })
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/** The surfaces a skill answers on, as the product names them. */
|
||||
function surfacesOf(skill) {
|
||||
return (skill.pages || []).map((page) => ({
|
||||
id: page,
|
||||
label: surfaceFor(page)?.label || page,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* One catalog entry: a definition, plus the readings a card and a details panel
|
||||
* need. Nothing is invented — every field traces back to the parsed skill.
|
||||
*/
|
||||
export function catalogEntry(skill) {
|
||||
const group = groupFor(skill);
|
||||
return {
|
||||
id: skill.id,
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
group,
|
||||
groupLabel: groupLabel(group),
|
||||
/** Owliver, Board, or both — from the definition's own facets. */
|
||||
type: capabilityType(skill),
|
||||
capabilities: capabilitiesOf(skill),
|
||||
tools: toolsOf(skill),
|
||||
surfaces: surfacesOf(skill),
|
||||
/** Questions this skill was written to be asked, offered as chips. */
|
||||
suggestions: (skill.owliver?.suggestions || []).map((s) => ({
|
||||
label: s.label,
|
||||
prompt: s.prompt,
|
||||
capability: s.capability ?? null,
|
||||
})),
|
||||
/** How many questions its guided flow asks, when it has one. */
|
||||
questions: (skill.conversation || []).length,
|
||||
prompt: skill.prompt || null,
|
||||
custom: Boolean(skill.custom),
|
||||
status: skill.status,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Every skill an agent may carry, as catalog entries.
|
||||
*
|
||||
* The same filter `AddSkillsModal` applied, kept exactly: assistant skills with
|
||||
* the `owliver` facet and an active status. A workforce training path is
|
||||
* something a *person* learns — offering one here would promise an agent
|
||||
* behaviour that does not exist.
|
||||
*/
|
||||
export function skillCatalog(customSkills = [], { pages = null } = {}) {
|
||||
/* `pages: null` means "the whole registry" and is what the skill library
|
||||
wants. An agent always passes its scope, and gets only what it could
|
||||
actually use — see `compatibleSkills`. */
|
||||
const source = pages
|
||||
? compatibleSkills(pages, customSkills)
|
||||
: aiAgentSkills(allSkills(customSkills));
|
||||
|
||||
return skillsWithFacet(source, 'owliver')
|
||||
.filter((s) => s.status === 'active')
|
||||
.map(catalogEntry)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/* ── Scope, and what is compatible with it ────────────────────────────────
|
||||
*
|
||||
* The fix for the catalog that offered every agent all eighteen skills.
|
||||
*
|
||||
* An agent is not a container you may put anything in. It answers on a set of
|
||||
* surfaces, a skill declares the surfaces it answers on, and the overlap is the
|
||||
* only set that can ever do anything. Offering Create Position to the Activity
|
||||
* Agent was not merely noisy — it was offering an attachment the runtime would
|
||||
* then refuse, because `skillsForContext` filters by page *before* an agent's
|
||||
* own list is ever consulted. The catalog was advertising attachments that
|
||||
* could not work.
|
||||
*
|
||||
* **One function over declared data, not eight lists.** `getSkillsForPage` is
|
||||
* the registry's own answer to "what belongs here" and is what the live
|
||||
* assistant already routes through, so the catalog and the runtime cannot
|
||||
* disagree by construction. Nothing below tests an agent by name, and a surface
|
||||
* or a skill added tomorrow is scoped correctly without this file being edited.
|
||||
*/
|
||||
|
||||
/** The surfaces an agent answers on, canonicalized so aliases resolve. */
|
||||
export const scopeOf = (pages = []) =>
|
||||
[...new Set(pages.map((p) => canonicalPage(p) || p).filter(Boolean))];
|
||||
|
||||
/**
|
||||
* Every skill compatible with a scope — the union over its surfaces.
|
||||
*
|
||||
* Union rather than intersection: an agent covering Positions and Create
|
||||
* Position may use a skill answering on either, which is exactly what the
|
||||
* runtime does when the reader is standing on one of them.
|
||||
*/
|
||||
export function compatibleSkills(pages = [], customSkills = []) {
|
||||
const seen = new Map();
|
||||
for (const page of scopeOf(pages)) {
|
||||
for (const skill of getSkillsForPage(page, { customSources: customSkills, kind: 'assistant' })) {
|
||||
if (!seen.has(skill.id)) seen.set(skill.id, skill);
|
||||
}
|
||||
}
|
||||
return [...seen.values()];
|
||||
}
|
||||
|
||||
/** Is this capability usable by an agent with this scope? */
|
||||
export const isCompatible = (skillId, pages = [], customSkills = []) =>
|
||||
compatibleSkills(pages, customSkills).some((s) => s.id === skillId);
|
||||
|
||||
/**
|
||||
* Which surfaces a capability offers itself on — Owliver, Board, or both.
|
||||
*
|
||||
* Read off the facets the parser already derives from what the definition
|
||||
* declares. There is deliberately **no new `capabilityType:` field**: a second
|
||||
* place to say the same thing is a second place for it to be wrong, and a
|
||||
* definition that gains an `owliver:` block tomorrow becomes `both` on its own.
|
||||
* One logical capability, two surfaces — never two definitions.
|
||||
*/
|
||||
export function capabilityType(skill) {
|
||||
const owliver = skill.facets?.includes('owliver');
|
||||
const board = skill.facets?.includes('ui');
|
||||
if (owliver && board) return 'both';
|
||||
return board ? 'board' : 'owliver';
|
||||
}
|
||||
|
||||
/** The type, in the words the detail panel uses. */
|
||||
export const TYPE_LABEL = { owliver: 'Owliver', board: 'Board', both: 'Owliver + Board' };
|
||||
|
||||
|
||||
/* ── Board skills ─────────────────────────────────────────────────────────
|
||||
*
|
||||
* The registry's other facet, read the same way. A Board skill draws a section
|
||||
* on a KROW page; an Owliver skill teaches the assistant what it can be asked.
|
||||
* One registry, one parser, two readings — exactly the split
|
||||
* `WorkspaceSkills` already manages, surfaced here so the agent editor can show
|
||||
* both under one Skills heading without either becoming a second system.
|
||||
*
|
||||
* **The relationship is genuinely different, and this module says so rather
|
||||
* than flattening it.** An Owliver skill is carried by an agent: `agent.skills`
|
||||
* decides what that agent may use, and `agentScopedDisabled` enforces it. A
|
||||
* Board skill is not — `SkillSurface` renders sections from the page and the
|
||||
* account's `disabledSkills`, and consults no agent at all. So a Board entry
|
||||
* carries the surfaces it draws on and whether it is switched on, and never an
|
||||
* "attached to this agent" flag, because there is nothing behind one.
|
||||
*/
|
||||
|
||||
/** The sections a Board skill declares, flattened with the page each sits on. */
|
||||
function sectionsOf(skill) {
|
||||
return Object.entries(skill.ui || {}).flatMap(([page, config]) =>
|
||||
(config.sections || []).map((section) => ({
|
||||
id: section.id,
|
||||
title: section.title || sectionTypeLabel(section.type),
|
||||
type: section.type,
|
||||
typeLabel: sectionTypeLabel(section.type),
|
||||
page,
|
||||
pageLabel: surfaceFor(page)?.label || page,
|
||||
placement: section.placement,
|
||||
placementLabel: placementLabel(section.placement),
|
||||
source: section.source || null,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
/** One Board skill, as the agent editor needs to read it. */
|
||||
export function boardEntry(skill) {
|
||||
const group = groupFor(skill);
|
||||
const sections = sectionsOf(skill);
|
||||
return {
|
||||
id: skill.id,
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
group,
|
||||
groupLabel: groupLabel(group),
|
||||
type: capabilityType(skill),
|
||||
sections,
|
||||
/** The pages this skill actually draws on, derived from its sections. */
|
||||
surfaces: [...new Map(sections.map((s) => [s.page, { id: s.page, label: s.pageLabel }])).values()],
|
||||
custom: Boolean(skill.custom),
|
||||
status: skill.status,
|
||||
};
|
||||
}
|
||||
|
||||
/** Every Board skill in the registry, as entries. */
|
||||
export function boardCatalog(customSkills = [], { pages = null } = {}) {
|
||||
const source = pages
|
||||
? compatibleSkills(pages, customSkills)
|
||||
: aiAgentSkills(allSkills(customSkills));
|
||||
|
||||
return skillsWithFacet(source, 'ui')
|
||||
.filter((s) => s.status === 'active')
|
||||
.map(boardEntry)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this Board skill draw on any surface the agent covers?
|
||||
*
|
||||
* The true relationship between an agent and a Board skill, and the only one
|
||||
* there is: they can meet on a page. An agent that answers on Positions stands
|
||||
* beside whatever Board sections Positions renders — it does not own them, and
|
||||
* it cannot switch them on.
|
||||
*/
|
||||
export const boardMeetsAgent = (entry, pages = []) => {
|
||||
if (!pages.length) return false;
|
||||
const covered = new Set(pages);
|
||||
return entry.surfaces.some((s) => covered.has(s.id));
|
||||
};
|
||||
|
||||
/** Board entries, filtered by the same query the Owliver catalog uses. */
|
||||
export function filterBoard(entries, { query = '' } = {}) {
|
||||
const q = String(query || '').trim().toLowerCase();
|
||||
if (!q) return entries;
|
||||
return entries.filter((e) => [
|
||||
e.name, e.description, e.groupLabel, e.id,
|
||||
...e.surfaces.map((s) => s.label),
|
||||
...e.sections.map((s) => `${s.title} ${s.typeLabel} ${s.placementLabel}`),
|
||||
].join(' ').toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
/**
|
||||
* The groups this catalog actually contains, in a stable order.
|
||||
*
|
||||
* Derived rather than written down, so a filter can never offer a heading that
|
||||
* matches nothing — and a definition declaring a category nobody anticipated
|
||||
* appears under it instead of vanishing into "everything else".
|
||||
*/
|
||||
export function catalogGroups(entries = []) {
|
||||
const counts = new Map();
|
||||
for (const entry of entries) {
|
||||
counts.set(entry.group, (counts.get(entry.group) || 0) + 1);
|
||||
}
|
||||
|
||||
const known = SKILL_GROUPS
|
||||
.filter((g) => counts.has(g.id))
|
||||
.map((g) => ({ ...g, count: counts.get(g.id) }));
|
||||
|
||||
const extra = [...counts.keys()]
|
||||
.filter((id) => !GROUP_BY_ID.has(id))
|
||||
.sort()
|
||||
.map((id) => ({ id, label: groupLabel(id), blurb: '', count: counts.get(id) }));
|
||||
|
||||
return [...known, ...extra];
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this entry match what was typed?
|
||||
*
|
||||
* Name, description, group, the surfaces it answers on, the capabilities it
|
||||
* offers and its id — so "attendance", "Control Center", "table" and
|
||||
* "anomaly-detection" all find something, and a reader who knows the domain
|
||||
* rather than the catalog can still search it.
|
||||
*/
|
||||
export function matchesQuery(entry, query) {
|
||||
const q = String(query || '').trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
|
||||
const haystack = [
|
||||
entry.name,
|
||||
entry.description,
|
||||
entry.groupLabel,
|
||||
entry.id,
|
||||
...entry.surfaces.map((s) => s.label),
|
||||
...entry.capabilities.shapes,
|
||||
...entry.capabilities.described,
|
||||
...entry.tools.map((t) => t.label),
|
||||
].join(' ').toLowerCase();
|
||||
|
||||
return haystack.includes(q);
|
||||
}
|
||||
|
||||
/**
|
||||
* The catalog, filtered.
|
||||
*
|
||||
* One function so the count shown beside a filter and the cards under it are
|
||||
* the same reading — two filters written separately is how a heading comes to
|
||||
* say "6 skills" above four cards.
|
||||
*/
|
||||
export function filterCatalog(entries, { query = '', group = 'all' } = {}) {
|
||||
return entries
|
||||
.filter((e) => group === 'all' || e.group === group)
|
||||
.filter((e) => matchesQuery(e, query));
|
||||
}
|
||||
@@ -735,7 +735,7 @@ export function contextIdsForSkill(skill) {
|
||||
* Nothing downstream may test a page name against a skill id. The moment a page
|
||||
* asks "is this Server Training?" the attachment has stopped being data.
|
||||
*/
|
||||
export function getSkillsForPage(pageId, { disabled = [], customSources = [], kind } = {}) {
|
||||
export function getSkillsForPage(pageId, { disabled = [], customSources = [], kind = undefined } = {}) {
|
||||
if (!pageId) return [];
|
||||
const wanted = canonicalPage(pageId) || pageId;
|
||||
return allSkills(customSources).filter(
|
||||
|
||||
@@ -250,6 +250,22 @@ for (const surface of SKILL_SURFACES) {
|
||||
}
|
||||
|
||||
/** Every name a definition may use for a surface, for error messages. */
|
||||
/**
|
||||
* The surfaces that are product *domains* rather than configuration screens.
|
||||
*
|
||||
* Read off `placements`, which is already the honest distinction: a domain
|
||||
* surface offers places for a section to sit because it holds workforce
|
||||
* records, and a configuration screen offers none because it holds none. That
|
||||
* is why Settings, Workspace and the two editors declare `placements: []`.
|
||||
*
|
||||
* The agent editor uses this so its scope picker offers the pages an agent
|
||||
* could sensibly answer *about*, instead of every route the vocabulary happens
|
||||
* to name. Derived, so a surface added tomorrow classifies itself.
|
||||
*/
|
||||
export const DOMAIN_SURFACES = SKILL_SURFACES
|
||||
.filter((s) => s.placements.length > 0)
|
||||
.map((s) => s.id);
|
||||
|
||||
export const SUPPORTED_SKILL_PAGES = SKILL_SURFACES.map((s) => s.id);
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,14 +7,18 @@ import {
|
||||
import { cn } from '@/lib/utils';
|
||||
import { agentIconFor } from '@/components/agents/icons';
|
||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||
import { usePreferences } from '@/lib/krowHooks';
|
||||
import { usePreferences, useUpdatePreferences } from '@/lib/krowHooks';
|
||||
import { reportSave } from '@/lib/skills/saveFeedback';
|
||||
import { useAgents } from '@/lib/agents/useAgents';
|
||||
import { agentTemplate } from '@/lib/agents/customAgents';
|
||||
import { agentFieldsFromSource, applyAgentFields } from '@/lib/agents/agentFields';
|
||||
import { validateAgentSource } from '@/lib/agents/registry';
|
||||
import { AgentConfigure } from '@/components/agents/AgentConfigure';
|
||||
import { AgentSkillWorkspace } from '@/components/agents/skills/AgentSkillWorkspace';
|
||||
import { AgentTestPanel } from '@/components/agents/AgentTestPanel';
|
||||
import { AgentInsightsPanel } from '@/components/agents/AgentInsightsPanel';
|
||||
import { useAssistantPanel } from '@/components/ai-assistant';
|
||||
import { boardCatalog, skillCatalog } from '@/lib/skills/catalog';
|
||||
|
||||
/**
|
||||
* One agent, configured.
|
||||
@@ -31,6 +35,44 @@ import { AgentInsightsPanel } from '@/components/agents/AgentInsightsPanel';
|
||||
|
||||
const STATUS_TONE = { published: 'success', draft: 'neutral', archived: 'warning' };
|
||||
|
||||
/**
|
||||
* What Owliver says when a skill joins or leaves an agent.
|
||||
*
|
||||
* Composed from the definition itself — its name, its own description, and the
|
||||
* questions it declares — so the sentence cannot promise a capability the skill
|
||||
* does not have. Nothing is generated: this is the workspace stating a fact in
|
||||
* the conversation, and the chips under it are the skill's own suggestions, so
|
||||
* the next step is a question that skill was written to answer.
|
||||
*/
|
||||
function skillNotice({ entry, id, agentName, attached }) {
|
||||
const name = entry?.name || id;
|
||||
const on = agentName ? `**${agentName}**` : 'this agent';
|
||||
|
||||
if (!attached) {
|
||||
return {
|
||||
text: `**${name}** has been removed from ${on}. It no longer answers here.`,
|
||||
followUp: null,
|
||||
};
|
||||
}
|
||||
|
||||
const opening = `**${name}** is now available to ${on}.`;
|
||||
const what = entry?.description ? ` ${entry.description}` : '';
|
||||
const invitation = entry?.suggestions?.length
|
||||
? ' Ask me one of these, or anything else it covers.'
|
||||
: ' Ask me about it whenever you need it.';
|
||||
|
||||
return {
|
||||
text: `${opening}${what}${invitation}`,
|
||||
followUp: entry?.suggestions?.length
|
||||
? entry.suggestions.slice(0, 3).map((s) => ({
|
||||
label: s.label,
|
||||
prompt: s.prompt,
|
||||
...(s.capability ? { capability: s.capability } : null),
|
||||
}))
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export default function AdminAgentDetail() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
@@ -53,6 +95,50 @@ export default function AdminAgentDetail() {
|
||||
const [fields, setFields] = useState(() => (baseSource ? agentFieldsFromSource(baseSource) : null));
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [view, setView] = useState('configure');
|
||||
/* The skill currently being written, so its card can say so and a second
|
||||
click cannot race the first. */
|
||||
const [pendingSkill, setPendingSkill] = useState(/** @type {string|null} */ (null));
|
||||
|
||||
/* The same catalog the workspace renders, read here so the sentence Owliver
|
||||
says about a skill comes from the definition rather than from the card. */
|
||||
const customSkills = useMemo(() => preferences.customSkills || [], [preferences.customSkills]);
|
||||
const catalog = useMemo(() => skillCatalog(customSkills), [customSkills]);
|
||||
|
||||
/* Owliver's existing panel, on this page already. Attaching a skill states a
|
||||
fact in that conversation — it never opens a second one. */
|
||||
const { announce, ask, test } = useAssistantPanel();
|
||||
|
||||
/**
|
||||
* Trying a capability, in the panel already on this page.
|
||||
*
|
||||
* `scope` runs the turn against the surface the capability actually answers
|
||||
* on — see `useConversation.send`. Nothing is written: this is the whole of
|
||||
* "test", and the agent is unchanged until Enable is pressed.
|
||||
*/
|
||||
const onTestCapability = useCallback(({ question, scope, capability, trace }) => {
|
||||
ask({ question, scope, capability, trace });
|
||||
}, [ask]);
|
||||
|
||||
/* Board skills are governed by the account's `disabledSkills` — the one list
|
||||
`SkillSurface` actually reads. Written through the same `updatePreferences`
|
||||
the Skills page uses, so there is no second persistence for it. */
|
||||
const updatePreferences = useUpdatePreferences();
|
||||
const disabledSkills = useMemo(
|
||||
() => preferences.disabledSkills || [],
|
||||
[preferences.disabledSkills]
|
||||
);
|
||||
|
||||
const onToggleBoardSkill = useCallback((skillId, enabled) => {
|
||||
const entry = boardCatalog(customSkills).find((e) => e.id === skillId);
|
||||
const next = enabled
|
||||
? disabledSkills.filter((x) => x !== skillId)
|
||||
: [...new Set([...disabledSkills, skillId])];
|
||||
|
||||
updatePreferences.mutate(
|
||||
{ disabledSkills: next },
|
||||
reportSave(`${entry?.name || skillId} ${enabled ? 'enabled' : 'disabled'}`)
|
||||
);
|
||||
}, [customSkills, disabledSkills, updatePreferences]);
|
||||
|
||||
/* Reload when the address changes, but never overwrite an edit in progress. */
|
||||
useEffect(() => {
|
||||
@@ -86,6 +172,82 @@ export default function AdminAgentDetail() {
|
||||
return result.agent;
|
||||
}, [fields, baseSource, save, creating, navigate]);
|
||||
|
||||
/**
|
||||
* Attaching and detaching a skill.
|
||||
*
|
||||
* The one write path for `fields.skills`, and deliberately not the same one
|
||||
* the rest of the form uses. Every other field is a *draft* until Save — a
|
||||
* half-typed name should never reach Owliver. A skill is not like that: the
|
||||
* whole point of the catalog is that the agent gains the capability there and
|
||||
* then, and the tree, the card and Owliver all say so immediately.
|
||||
*
|
||||
* So it is optimistic, through the existing `save`, with one honest exception
|
||||
* and one honest revert:
|
||||
*
|
||||
* - **A draft the author is still editing is left a draft.** With unsaved
|
||||
* changes on screen — or a brand-new agent with no definition yet — a
|
||||
* write here would silently persist a half-finished name along with the
|
||||
* skill. The change joins the draft instead, and the header already says
|
||||
* the agent has unsaved changes.
|
||||
* - **A refused write is put back.** The list is restored to exactly what it
|
||||
* was and the reason is shown, rather than leaving a tree claiming a skill
|
||||
* the stored definition does not carry.
|
||||
*/
|
||||
const onToggleSkill = useCallback(async (skillId) => {
|
||||
if (!fields || pendingSkill) return;
|
||||
|
||||
const attached = !fields.skills.includes(skillId);
|
||||
const previous = fields.skills;
|
||||
const nextSkills = attached
|
||||
/* Never a duplicate: the id is added only when it is absent, so clicking
|
||||
Add twice cannot list the same skill twice. */
|
||||
? [...previous, skillId]
|
||||
: previous.filter((x) => x !== skillId);
|
||||
|
||||
const nextFields = { ...fields, skills: nextSkills };
|
||||
const notice = skillNotice({
|
||||
entry: catalog.find((e) => e.id === skillId) || null,
|
||||
id: skillId,
|
||||
agentName: fields.name,
|
||||
attached,
|
||||
});
|
||||
|
||||
/* A draft stays a draft — see above. */
|
||||
if (creating || dirty || !agent) {
|
||||
setFields(nextFields);
|
||||
setDirty(true);
|
||||
announce(notice);
|
||||
return;
|
||||
}
|
||||
|
||||
setFields(nextFields);
|
||||
setPendingSkill(skillId);
|
||||
|
||||
const composed = applyAgentFields(baseSource, nextFields);
|
||||
const problem = validateAgentSource(composed);
|
||||
if (problem) {
|
||||
setFields((current) => ({ ...current, skills: previous }));
|
||||
setPendingSkill(null);
|
||||
toast.error(problem);
|
||||
return;
|
||||
}
|
||||
|
||||
const skillName = catalog.find((e) => e.id === skillId)?.name || skillId;
|
||||
const result = await save(composed, {
|
||||
message: attached ? `${skillName} attached` : `${skillName} removed`,
|
||||
});
|
||||
|
||||
setPendingSkill(null);
|
||||
|
||||
if (!result.ok) {
|
||||
setFields((current) => ({ ...current, skills: previous }));
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
announce(notice);
|
||||
}, [fields, pendingSkill, creating, dirty, agent, catalog, baseSource, save, announce]);
|
||||
|
||||
const onPublish = useCallback(async () => {
|
||||
const stored = dirty ? await persist() : agent;
|
||||
if (!stored) return;
|
||||
@@ -230,7 +392,9 @@ export default function AdminAgentDetail() {
|
||||
)}
|
||||
|
||||
<Tabs
|
||||
className="mt-3 mb-1"
|
||||
/* Four tabs do not fit a 320px screen and the tablist does not wrap,
|
||||
so the strip scrolls rather than the page. */
|
||||
className="mt-3 mb-1 -mx-0.5 flex-nowrap overflow-x-auto px-0.5 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
variant="underline"
|
||||
size="sm"
|
||||
ariaLabel="Agent views"
|
||||
@@ -238,6 +402,11 @@ export default function AdminAgentDetail() {
|
||||
onChange={setView}
|
||||
tabs={[
|
||||
{ value: 'configure', label: 'Configure' },
|
||||
/* Skills earn their own view rather than a section inside
|
||||
Configure: composing an agent is a workspace — tree, catalog and
|
||||
Owliver at once — and it cannot be that inside a column of form
|
||||
fields. */
|
||||
{ value: 'skills', label: 'Skills', count: fields.skills.length },
|
||||
{ value: 'test', label: 'Test' },
|
||||
{ value: 'insights', label: 'Insights' },
|
||||
]}
|
||||
@@ -248,15 +417,31 @@ export default function AdminAgentDetail() {
|
||||
<AgentConfigure
|
||||
fields={fields}
|
||||
agents={agents}
|
||||
customSkills={preferences.customSkills || []}
|
||||
customSkills={customSkills}
|
||||
onChange={onChange}
|
||||
onOpenSkills={() => setView('skills')}
|
||||
scopeLocked={shipped}
|
||||
/>
|
||||
)}
|
||||
{view === 'skills' && (
|
||||
<AgentSkillWorkspace
|
||||
fields={fields}
|
||||
customSkills={customSkills}
|
||||
onToggleSkill={onToggleSkill}
|
||||
disabledSkills={disabledSkills}
|
||||
onToggleBoardSkill={onToggleBoardSkill}
|
||||
onTestCapability={onTestCapability}
|
||||
test={test}
|
||||
busy={saving || Boolean(pendingSkill)}
|
||||
pendingId={pendingSkill}
|
||||
dirty={dirty || creating}
|
||||
/>
|
||||
)}
|
||||
{view === 'test' && (
|
||||
<AgentTestPanel
|
||||
fields={fields}
|
||||
dirty={dirty}
|
||||
customSkills={preferences.customSkills || []}
|
||||
customSkills={customSkills}
|
||||
/>
|
||||
)}
|
||||
{view === 'insights' && (
|
||||
|
||||
@@ -198,7 +198,7 @@ export default function AdminSkillDevelopment() {
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="sm" shape="rounded" onClick={() => navigate('/admin/workspace')}>
|
||||
<ChevronLeft aria-hidden="true" /> Workspace
|
||||
<ChevronLeft aria-hidden="true" /> Agent Registry
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" shape="rounded" onClick={() => navigate('/admin/university')}>
|
||||
Manage training
|
||||
|
||||
@@ -1,35 +1,39 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
LayoutTemplate, Sparkles,
|
||||
BookOpen, ArrowRight
|
||||
} from 'lucide-react';
|
||||
import { ArrowRight, BookOpen, Sparkles } from 'lucide-react';
|
||||
import { usePreferences } from '@/lib/krowHooks';
|
||||
import { aiAgentSkills, allSkills, skillsWithFacet } from '@/lib/skills/registry';
|
||||
import { aiAgentSkills, allSkills } from '@/lib/skills/registry';
|
||||
import { allAgents } from '@/lib/agents/registry';
|
||||
import { AdminPage } from '@/components/admin/PageShell';
|
||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Workspace & Skills — AI agent capability governance.
|
||||
* Workspace — the way in to AI configuration, and only that.
|
||||
*
|
||||
* This page governs what *Owliver* can do. It used to govern workforce training
|
||||
* as well — Training Paths and Skill Progression sat in the right-hand column —
|
||||
* and that conflated two unrelated things under one word. A "skill" here is a
|
||||
* capability the assistant gains; a "skill" in KROW Forge is something a person
|
||||
* learns, proves and is verified in. They have different owners, different
|
||||
* lifecycles and different audiences, and putting them side by side made the page
|
||||
* read as though enabling a Bartending path taught Owliver to tend bar.
|
||||
* This page used to offer three destinations side by side: Owliver Agents,
|
||||
* Owliver Skills, and Board Skills. Each was a real thing with a real page
|
||||
* behind it, and together they asked the reader a question the product should
|
||||
* never have asked — *which of these three am I supposed to open?* Nothing on
|
||||
* screen answered it, because the answer is structural: skills are configured
|
||||
* **on an agent**, and an agent is the only thing here anyone actually manages.
|
||||
*
|
||||
* Workforce training was not removed, only returned to where it belongs: the
|
||||
* definitions, the routes and the pages are untouched, and KROW Forge
|
||||
* (`/admin/university`) and Skill Development still read them. See
|
||||
* `workforceTrainingSkills` in the registry for the other half of the split.
|
||||
* So there is one destination now. Agents. Everything else is reached through
|
||||
* the agent it belongs to:
|
||||
*
|
||||
* Workspace → Agents → Configure → Skills → Owliver | Board
|
||||
*
|
||||
* Nothing was deleted to achieve that. `/admin/workspace/skills` still exists
|
||||
* and still authors definitions — it is where the agent editor's own links go —
|
||||
* it simply stopped being advertised as a peer of the thing that uses it.
|
||||
*
|
||||
* The counts are the same registry reads as before, minus the one that named an
|
||||
* implementation detail: "Skill Kinds" told a recruiter that the product has two
|
||||
* internal facets, which is true and is not their problem.
|
||||
*/
|
||||
|
||||
export default function AdminWorkspace() {
|
||||
const preferences = usePreferences();
|
||||
|
||||
/* Read through the agent registry, never by counting skills — one registry
|
||||
answers "how many agents", exactly as the skill registry answers its own. */
|
||||
const agentCount = useMemo(
|
||||
@@ -53,220 +57,112 @@ export default function AdminWorkspace() {
|
||||
const disabledSkills = preferences.disabledSkills || [];
|
||||
const activeSkills = skills.filter((s) => !disabledSkills.includes(s.id)).length;
|
||||
|
||||
/* The two kinds of agent skill — the same two lists the management page is
|
||||
split into, read here from the same filtered registry so the landing page and
|
||||
the manager can never disagree about what is registered. */
|
||||
const owliverSkills = useMemo(() => skillsWithFacet(skills, 'owliver'), [skills]);
|
||||
const boardSkills = useMemo(() => skillsWithFacet(skills, 'ui'), [skills]);
|
||||
const owliverCount = owliverSkills.length;
|
||||
const boardCount = boardSkills.length;
|
||||
const metrics = [
|
||||
{ label: 'Agents', value: agentCount },
|
||||
{ label: 'Skills', value: skills.length },
|
||||
{ label: 'Active', value: activeSkills },
|
||||
];
|
||||
|
||||
return (
|
||||
<AdminPage
|
||||
title="Workspace"
|
||||
subtitle="Govern the AI agent capabilities that extend Owliver and KROW pages."
|
||||
title="Agent Registry"
|
||||
subtitle="Manage how Owliver behaves across Krow."
|
||||
>
|
||||
<div className="space-y-6 w-full">
|
||||
{/* ── 1. Hero Banner (Full Width) ───────────────────────────────── */}
|
||||
<div className="relative overflow-hidden rounded-3xl bg-gradient-to-r from-[#0838E0] via-[#1E40AF] to-[#0284C7] p-6 sm:p-8 text-white shadow-xl w-full">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,_var(--tw-gradient-stops))] from-white/15 via-transparent to-transparent pointer-events-none" />
|
||||
<div className="relative z-10 flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||
<div className="space-y-2">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-white/20 backdrop-blur-md text-white text-[12px] font-semibold border border-white/30">
|
||||
<Sparkles className="w-3.5 h-3.5 text-sky-300" />
|
||||
AI Capability Governance
|
||||
<div className="w-full space-y-6">
|
||||
{/* ── Hero ───────────────────────────────────────────────────────
|
||||
The same banner, saying three plain numbers instead of two figures
|
||||
and a word only this codebase uses. */}
|
||||
<div className="relative w-full overflow-hidden rounded-3xl bg-gradient-to-r from-[#0838E0] via-[#1E40AF] to-[#0284C7] p-6 text-white shadow-xl sm:p-8">
|
||||
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,_var(--tw-gradient-stops))] from-white/15 via-transparent to-transparent" />
|
||||
<div className="relative z-10 flex flex-col justify-between gap-6 md:flex-row md:items-center">
|
||||
<div className="min-w-0 space-y-2">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-white/30 bg-white/20 px-3 py-1 text-[12px] font-semibold text-white backdrop-blur-md">
|
||||
<Sparkles className="h-3.5 w-3.5 text-sky-300" />
|
||||
AI Agent Registry
|
||||
</div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-white">AI Agent Capabilities</h1>
|
||||
<p className="text-[14px] text-blue-100 font-normal max-w-2xl">
|
||||
Govern what Owliver can understand, analyze and execute. Workforce training
|
||||
paths and talent progression are managed in KROW Forge.
|
||||
<h1 className="text-2xl font-bold tracking-tight text-white sm:text-3xl">
|
||||
AI Agent Registry
|
||||
</h1>
|
||||
<p className="max-w-2xl text-[14px] font-normal text-blue-100">
|
||||
Manage the agents that power Owliver across Krow. Each agent decides which
|
||||
pages it answers on and which skills it may use.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 sm:flex-nowrap sm:shrink-0">
|
||||
<div className="flex-1 sm:flex-none bg-white/10 backdrop-blur-md rounded-2xl p-4 border border-white/15 min-w-[130px] text-center">
|
||||
<p className="text-[11px] uppercase tracking-wider text-blue-200 font-semibold">Registered Skills</p>
|
||||
<p className="text-2xl font-bold text-white mt-0.5">{skills.length}</p>
|
||||
<span className="text-[10px] text-emerald-300 font-medium">{activeSkills} Active</span>
|
||||
</div>
|
||||
{/* Was "Training Paths". Replaced rather than dropped: the slot is
|
||||
useful, and the split of agent skills across the two lists is
|
||||
what this page actually governs. */}
|
||||
<div className="flex-1 sm:flex-none bg-white/10 backdrop-blur-md rounded-2xl p-4 border border-white/15 min-w-[130px] text-center">
|
||||
<p className="text-[11px] uppercase tracking-wider text-blue-200 font-semibold">Skill Kinds</p>
|
||||
<p className="text-2xl font-bold text-sky-300 mt-0.5">{owliverCount + boardCount}</p>
|
||||
<span className="text-[10px] text-blue-200">{owliverCount} Owliver · {boardCount} Board</span>
|
||||
</div>
|
||||
{metrics.map((metric) => (
|
||||
<div
|
||||
key={metric.label}
|
||||
className="min-w-[100px] flex-1 rounded-2xl border border-white/15 bg-white/10 p-4 text-center backdrop-blur-md sm:flex-none"
|
||||
>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-blue-200">
|
||||
{metric.label}
|
||||
</p>
|
||||
<p className="mt-0.5 text-2xl font-bold tabular-nums text-white">{metric.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Agents ─────────────────────────────────────────────────────
|
||||
Above the two skill columns because it is the layer over them: an
|
||||
agent decides which of these skills Owliver may use on a page. */}
|
||||
<div className="bg-white rounded-2xl border border-[#E0E4EA] p-6 shadow-xs space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-[#E0E4EA] pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<OwliverAvatar className="w-6 h-6" rounded="rounded-md" />
|
||||
<h2 className="text-[16px] font-bold text-[#101820]">Owliver Agents</h2>
|
||||
{/* ── The one destination ────────────────────────────────────────
|
||||
Skills are not a card beside this one. They are configured on an
|
||||
agent, and this is the door to the agents. */}
|
||||
<div className="space-y-4 rounded-2xl border border-[#E0E4EA] bg-white p-6 shadow-xs">
|
||||
<div className="flex items-center justify-between gap-3 border-b border-[#E0E4EA] pb-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<OwliverAvatar className="h-6 w-6" rounded="rounded-md" />
|
||||
<h2 className="truncate text-[16px] font-bold text-[#101820]">Agents</h2>
|
||||
</div>
|
||||
<span className="text-[12px] font-semibold text-[#0838E0] bg-blue-50 px-3 py-1 rounded-full border border-blue-200/60">
|
||||
<span className="shrink-0 rounded-full border border-blue-200/60 bg-blue-50 px-3 py-1 text-[12px] font-semibold text-[#0838E0]">
|
||||
{agentCount} Registered
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-[13px] text-[#687386]">
|
||||
An agent is how Owliver answers on a page: which skills it may use, what it knows, and
|
||||
how much work an answer is worth. Agents narrow what a page offers — they never widen it.
|
||||
An agent is how Owliver answers on a page: which skills it may use, what it knows,
|
||||
and how much work an answer is worth. Agents narrow what a page offers — they never
|
||||
widen it.
|
||||
</p>
|
||||
|
||||
{/* The hierarchy, said once, so the reader knows where skills live
|
||||
before they click rather than after. */}
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 rounded-xl border border-[#E0E4EA] bg-[#F8F8F8]/60 px-3.5 py-2.5 text-[12px] text-[#687386]">
|
||||
<span className="font-semibold text-[#101820]">Agents</span>
|
||||
<ArrowRight className="h-3 w-3 shrink-0" aria-hidden="true" />
|
||||
<span>Configure</span>
|
||||
<ArrowRight className="h-3 w-3 shrink-0" aria-hidden="true" />
|
||||
<span>Skills</span>
|
||||
<ArrowRight className="h-3 w-3 shrink-0" aria-hidden="true" />
|
||||
<span className="font-medium text-[#101820]">Owliver Skills</span>
|
||||
<span aria-hidden="true">/</span>
|
||||
<span className="font-medium text-[#101820]">Board Skills</span>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to="/admin/workspace/agents"
|
||||
className="w-full inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl bg-[#0838E0] hover:bg-[#062BAF] text-white text-[13px] font-semibold transition-all shadow-md shadow-blue-500/20 hover:scale-[1.01]"
|
||||
className="inline-flex w-full items-center justify-center gap-2 rounded-xl bg-[#0838E0] px-5 py-3 text-[13px] font-semibold text-white shadow-md shadow-blue-500/20 transition-all hover:scale-[1.01] hover:bg-[#062BAF]"
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
<Sparkles className="h-4 w-4" />
|
||||
Manage Agents
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* ── 2. Full Width 2-Column Grid ───────────────────────────────── */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 w-full">
|
||||
{/* ── Left Column: Owliver AI Capabilities ───────────────────── */}
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-2xl border border-[#E0E4EA] p-6 shadow-xs space-y-5">
|
||||
<div className="flex items-center justify-between border-b border-[#E0E4EA] pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<OwliverAvatar className="w-6 h-6" rounded="rounded-md" />
|
||||
<h2 className="text-[16px] font-bold text-[#101820]">Owliver Skills</h2>
|
||||
</div>
|
||||
<span className="text-[12px] font-semibold text-[#0838E0] bg-blue-50 px-3 py-1 rounded-full border border-blue-200/60">
|
||||
{owliverCount} Registered
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-[13px] text-[#687386]">
|
||||
Owliver skills teach the assistant what it can be asked on a page and how to answer —
|
||||
screening, matching and contextual analysis. They define what the AI understands, not
|
||||
what a candidate learns.
|
||||
</p>
|
||||
|
||||
{/* Skills Sample List */}
|
||||
<div className="space-y-3">
|
||||
{owliverSkills.slice(0, 4).map((s) => {
|
||||
const isActive = !disabledSkills.includes(s.id);
|
||||
return (
|
||||
<div key={s.id} className="p-3.5 rounded-xl border border-[#E0E4EA] bg-[#F8F8F8]/50 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[13px] font-bold text-[#101820]">{s.name || s.id}</p>
|
||||
{s.description && <p className="text-[12px] text-[#687386] truncate mt-0.5">{s.description}</p>}
|
||||
</div>
|
||||
<span className={cn(
|
||||
'px-2.5 py-0.5 rounded-full text-[11px] font-semibold shrink-0',
|
||||
isActive ? 'bg-emerald-50 text-[#16A765] border border-emerald-200' : 'bg-gray-100 text-gray-500'
|
||||
)}>
|
||||
{isActive ? 'Active' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* CTA Button */}
|
||||
<div className="pt-2">
|
||||
<Link
|
||||
to="/admin/workspace/skills?tab=owliver"
|
||||
className="w-full inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl bg-[#0838E0] hover:bg-[#062BAF] text-white text-[13px] font-semibold transition-all shadow-md shadow-blue-500/20 hover:scale-[1.01]"
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Manage Owliver Skills
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Right Column: Board Skills ─────────────────────────────────
|
||||
The other kind of AI agent skill. This column previously held
|
||||
Workforce Training Paths and Skill Progression Tracking, both of
|
||||
which are KROW Forge concerns — see the note at the top of the file.
|
||||
Board skills belong here because, like Owliver skills, they are
|
||||
capabilities the product gains rather than something a person
|
||||
learns. */}
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-2xl border border-[#E0E4EA] p-6 shadow-xs space-y-5">
|
||||
<div className="flex items-center justify-between border-b border-[#E0E4EA] pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<LayoutTemplate className="w-5 h-5 text-[#0838E0]" />
|
||||
<h2 className="text-[16px] font-bold text-[#101820]">Board Skills</h2>
|
||||
</div>
|
||||
<span className="text-[12px] font-semibold text-[#0838E0] bg-blue-50 px-3 py-1 rounded-full border border-blue-200/60">
|
||||
{boardCount} Registered
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-[13px] text-[#687386]">
|
||||
Board skills add dynamic sections to KROW pages, read from real page data. They
|
||||
extend what a page shows, not what a candidate learns.
|
||||
</p>
|
||||
|
||||
{/* Sample list, from the same filtered registry read as the left
|
||||
column — so the two columns can never disagree about what is
|
||||
registered. */}
|
||||
<div className="space-y-3">
|
||||
{boardSkills.slice(0, 4).map((s) => {
|
||||
const isActive = !disabledSkills.includes(s.id);
|
||||
return (
|
||||
<div key={s.id} className="p-3.5 rounded-xl border border-[#E0E4EA] bg-[#F8F8F8]/50 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[13px] font-bold text-[#101820]">{s.name || s.id}</p>
|
||||
{s.description && <p className="text-[12px] text-[#687386] truncate mt-0.5">{s.description}</p>}
|
||||
</div>
|
||||
<span className={cn(
|
||||
'px-2.5 py-0.5 rounded-full text-[11px] font-semibold shrink-0',
|
||||
isActive ? 'bg-emerald-50 text-[#16A765] border border-emerald-200' : 'bg-gray-100 text-gray-500'
|
||||
)}>
|
||||
{isActive ? 'Active' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{boardSkills.length === 0 && (
|
||||
<p className="text-[13px] text-[#687386] py-2">
|
||||
No Board skills registered yet. A Board skill declares a section, where it
|
||||
sits and what it reads.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<Link
|
||||
to="/admin/workspace/skills?tab=ui"
|
||||
className="w-full inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl border border-[#E0E4EA] bg-white hover:bg-[#F8F8F8] text-[#101820] text-[13px] font-semibold transition-all shadow-xs"
|
||||
>
|
||||
<LayoutTemplate className="w-4 h-4 text-[#0838E0]" />
|
||||
Manage Board Skills
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 3. Unified Registry Architecture Banner ───────────────────── */}
|
||||
<div className="flex items-start gap-3.5 p-5 rounded-2xl bg-blue-50/60 border border-blue-200/50 text-[#0838E0] text-[13px] leading-relaxed">
|
||||
<BookOpen className="w-5 h-5 shrink-0 text-[#0838E0] mt-0.5" />
|
||||
<div>
|
||||
{/* ── The one distinction worth keeping ──────────────────────────
|
||||
Not a third destination: a standing fact about a word that means two
|
||||
unrelated things in this product, and the reason KROW Forge is not
|
||||
on this page. */}
|
||||
<div className="flex items-start gap-3.5 rounded-2xl border border-blue-200/50 bg-blue-50/60 p-5 text-[13px] leading-relaxed text-[#0838E0]">
|
||||
<BookOpen className="mt-0.5 h-5 w-5 shrink-0 text-[#0838E0]" />
|
||||
<div className="min-w-0">
|
||||
<p className="font-bold text-[#101820]">AI skills and workforce training are different things</p>
|
||||
<p className="text-[#687386] mt-0.5">
|
||||
<p className="mt-0.5 text-[#687386]">
|
||||
This page governs <span className="font-semibold">AI agent skills</span> — what Owliver
|
||||
understands and what a Board skill draws on a page. <span className="font-semibold">Workforce
|
||||
training</span> — training paths, proving challenges, skill verification and talent
|
||||
understands, and what a Board skill draws on a page. <span className="font-semibold">Workforce
|
||||
training</span> — training paths, proving challenges, verification and talent
|
||||
progression — is what a person learns, and it is managed in{' '}
|
||||
<Link to="/admin/university" className="font-semibold underline hover:no-underline">KROW Forge</Link>.
|
||||
Both are Markdown in one registry, read through separate queries so neither appears in the
|
||||
other’s surface.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -469,7 +469,7 @@ export default function AdminWorkspaceAgents() {
|
||||
className="hidden md:inline-flex items-center gap-1.5 rounded-lg border border-border/70 bg-surface px-3 py-1.5 text-body-sm text-ink-3 transition-colors hover:border-krow-blue/40 hover:text-krow-blue hover:bg-surface-subtle"
|
||||
>
|
||||
<span>Skills managed in</span>
|
||||
<span className="font-semibold text-krow-blue">Workspace → Skills</span>
|
||||
<span className="font-semibold text-krow-blue">Agent Registry → Skills</span>
|
||||
</Link>
|
||||
|
||||
<SegmentedToggle
|
||||
|
||||
@@ -504,7 +504,7 @@ export default function WorkspaceSkills() {
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="sm" shape="rounded" onClick={() => navigate('/admin/workspace')}>
|
||||
<ChevronLeft aria-hidden="true" /> Workspace
|
||||
<ChevronLeft aria-hidden="true" /> Agent Registry
|
||||
</Button>
|
||||
{/* Account definitions live in this browser's storage and nowhere
|
||||
else. Import and export are what make that a place rather than a
|
||||
|
||||
Reference in New Issue
Block a user