diff --git a/.env.example b/.env.example index 5575a35..c9f37d5 100644 --- a/.env.example +++ b/.env.example @@ -37,5 +37,5 @@ VITE_API_BASE_URL=/api/v1 # Where the Vite dev proxy forwards /api. Only read by vite.config.js, never by -# client code. Change this if the Go API is not on its default address. -# VITE_API_PROXY_TARGET=http://127.0.0.1:8080 +# client code. +VITE_API_PROXY_TARGET=https://mcp.korwfoce.com diff --git a/.vite-preview.config.mjs b/.vite-preview.config.mjs new file mode 100644 index 0000000..55aa2c5 --- /dev/null +++ b/.vite-preview.config.mjs @@ -0,0 +1,12 @@ +// Temporary: `vite preview` with the same /api proxy the dev server uses, so +// the built bundle can be QA'd against the live Go API. Not part of the repo. +import base from './vite.config.js'; + +export default { + ...base, + preview: { + port: 5181, + host: '0.0.0.0', + proxy: base.server.proxy, + }, +}; diff --git a/src/components/agents/AddSkillsModal.jsx b/src/components/agents/AddSkillsModal.jsx deleted file mode 100644 index 6bf4890..0000000 --- a/src/components/agents/AddSkillsModal.jsx +++ /dev/null @@ -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 ( - -

- {picked.length - ? `${picked.length} skill${picked.length === 1 ? '' : 's'} selected` - : 'Nothing selected yet'} -

- - - - } - > -
- - - {categories.length > 1 && ( -
- {categories.map((id) => ( - - ))} -
- )} - -
- {visible.map((skill) => { - const chosen = picked.includes(skill.id); - return ( - - ); - })} - - {!visible.length && ( -

- {available.length - ? 'No skills match that.' - : 'Every available skill is already attached to this agent.'} -

- )} -
-
-
- ); -} - -export default AddSkillsModal; diff --git a/src/components/agents/AgentConfigure.jsx b/src/components/agents/AgentConfigure.jsx index 59b2036..1b34dcb 100644 --- a/src/components/agents/AgentConfigure.jsx +++ b/src/components/agents/AgentConfigure.jsx @@ -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 {coveredLabels.join(', ')}. Anywhere else it is shown as constrained and declines. : 'No page selected — this agent has nowhere to answer yet.'}

-
- {SUPPORTED_SKILL_PAGES.map((page) => ( - set({ - pages: fields.pages.includes(page) - ? fields.pages.filter((p) => p !== page) - : [...fields.pages, page], - })} - > - {pageLabel(page)} - - ))} -
+ {/** + * 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 ? ( +
+ {fields.pages.map((page) => ( + + {pageLabel(page)} + + ))} + {!fields.pages.length && ( + No surface registered. + )} +
+ ) : ( +
+ {/* 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) => ( + set({ + pages: fields.pages.includes(page) + ? fields.pages.filter((p) => p !== page) + : [...fields.pages, page], + })} + > + {pageLabel(page)} + + ))} +
+ )}

- 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.'}

@@ -361,12 +398,16 @@ export function AgentConfigure({ fields, agents, customSkills = [], onChange }) title="Skills" count={fields.skills.length} action={( - )} /> + {/* 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 ? ( ) : (

- 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{' '} + {' '} + to give it one.

)}

- Authored in{' '} + Attached under{' '} + + , where Owliver skills and Board skills both live. Definitions are written in the{' '} - Workspace → Skills + skill library + .

@@ -666,14 +729,6 @@ export function AgentConfigure({ fields, agents, customSkills = [], onChange }) - - set({ skills: [...fields.skills, ...ids] })} - /> ); } diff --git a/src/components/agents/skills/AgentSkillWorkspace.jsx b/src/components/agents/skills/AgentSkillWorkspace.jsx new file mode 100644 index 0000000..f55c33c --- /dev/null +++ b/src/components/agents/skills/AgentSkillWorkspace.jsx @@ -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 = ( + + ); + + return ( +
+ {dirty && ( + + Skill changes are being kept in the draft with them. Save the agent to store everything + together. + + )} + + {unknown.length > 0 && ( + + {unknown.join(', ')} — no definition with that id is registered, so it answers nothing. + Remove it from the tree, or restore the definition. + + )} + +
+ {/* 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. */} + + +
+
+ +
+ + {/* 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. */} + + + {kind === OWLIVER ? ( + + ) : ( + + )} +
+
+ + setTreeOpen(false)} className="w-full"> + Close + + )} + > + {tree} + + + { if (!next) setSelectedId(null); }} + isEnabledOn={isEnabledOn} + onToggle={onToggleSurface} + busy={busy} + fields={fields} + onRunTest={runCapabilityTest} + test={test} + /> +
+ ); +} + +export default AgentSkillWorkspace; diff --git a/src/components/agents/skills/AgentTree.jsx b/src/components/agents/skills/AgentTree.jsx new file mode 100644 index 0000000..336ba38 --- /dev/null +++ b/src/components/agents/skills/AgentTree.jsx @@ -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 ( +
  • + + + {/* 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. */} +
    +
    +
      + {children} + {count === 0 && ( +
    • {hint}
    • + )} +
    +
    +
    +
  • + ); +} + +/** + * 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 ( +
  • + + + +
  • + ); +} + +/** A leaf that is only ever read — a tool reached, a note the agent was given. */ +function StaticLeaf({ icon: Icon, label, detail = null }) { + return ( +
  • +
  • + ); +} + +/** @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 ( + + ); +} + +export default AgentTree; diff --git a/src/components/agents/skills/BoardSkillList.jsx b/src/components/agents/skills/BoardSkillList.jsx new file mode 100644 index 0000000..fae42d2 --- /dev/null +++ b/src/components/agents/skills/BoardSkillList.jsx @@ -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 ( +
  • +
    + {/* Soft Background Highlight on Hover */} +
    + +
    +
    +
    + + + + + Board Skill + +
    + +
    + + {enabled ? 'Active' : 'Disabled'} + + onToggle(entry.id, next)} + aria-label={`${entry.name} — ${enabled ? 'on' : 'off'} for this workspace`} + /> +
    +
    + +

    + {entry.name} +

    + + {entry.description && ( +

    + {entry.description} +

    + )} + + {/* What it actually draws. */} + {entry.sections.length > 0 && ( +
      + {entry.sections.map((section, i) => ( +
    • + {section.title} + · + {section.typeLabel} + · + + {section.pageLabel} + + / {section.placementLabel} +
    • + ))} +
    + )} +
    + +
    + + {entry.surfaces.some((s) => onSurfaces.has(s.id)) + ? 'Active on this agent\'s pages' + : 'Draws across workspace'} + + +
    +
    +
  • + ); +} + +/** @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 ( +
    + + 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. + + + {total > 0 && ( +
    + + +
    + )} + + {total > 0 && ( +
    +

    + {ordered.length} of {total} board skill{total === 1 ? '' : 's'} +

    + +
    + )} + + {ordered.length > 0 ? ( +
      + {ordered.map((entry) => ( + + ))} +
    + ) : ( +
    + {total > 0 ? ( + <> +
    + )} +
    + ); +} + +export default BoardSkillList; diff --git a/src/components/agents/skills/SkillCard.jsx b/src/components/agents/skills/SkillCard.jsx new file mode 100644 index 0000000..7b1711b --- /dev/null +++ b/src/components/agents/skills/SkillCard.jsx @@ -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 ( +
  • +
    +
    +
  • + ); +}); +SkillCard.displayName = 'SkillCard'; + +export default SkillCard; diff --git a/src/components/agents/skills/SkillCatalog.jsx b/src/components/agents/skills/SkillCatalog.jsx new file mode 100644 index 0000000..ba04d21 --- /dev/null +++ b/src/components/agents/skills/SkillCatalog.jsx @@ -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 ( +
    + {[{ id: 'all', label: 'All', count: total }, ...groups].map((group) => { + const active = value === group.id; + return ( + + ); + })} +
    + ); +} + +/** @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 ( +
    +
    +
    + + +
    + + +
    + +
    + {/* The count is read from the same array the cards come from, so a + heading cannot claim six above four cards. */} +

    + {entries.length} of {total} capabilit{total === 1 ? 'y' : 'ies'} for this scope + {attachedIds.size > 0 && · {attachedIds.size} enabled} +

    + + {/* Authoring stays reachable without being a destination of its own. */} + +
    + + {entries.length > 0 ? ( +
      + {entries.map((entry) => ( + + ))} +
    + ) : ( +
    +
    + )} +
    + ); +} + +export default SkillCatalog; diff --git a/src/components/agents/skills/SkillDetails.jsx b/src/components/agents/skills/SkillDetails.jsx new file mode 100644 index 0000000..576b43e --- /dev/null +++ b/src/components/agents/skills/SkillDetails.jsx @@ -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/` 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 ( +
    +

    +

    + {children} +
    + ); +} + +/** 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 ( +

    +

    + ); +} + +/** + * 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 ( +
    +
    + {entry.groupLabel} + {attached + ? Enabled for this agent + : Not enabled} + {entry.custom && Custom} +
    + +
    + +
    + + {/** + * 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') && ( + + 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. + + )} + + {described.length > 0 && ( +
    +
      + {described.map((line) => ( +
    • +
    • + ))} +
    +
    + )} + + {shapes.length > 0 && ( +
    +
    + {shapes.map((shape) => ( + + {shape} + + ))} +
    +
    + )} + + {/* 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 && ( +
    +
      + {entry.suggestions.map((s) => { + const value = s.prompt || s.label; + const picked = canTest && question === value; + return ( +
    • + +
    • + ); + })} +
    +
    + )} + +
    + {entry.surfaces.length > 0 ? ( + <> +
    + {entry.surfaces.map((s) => ( + + {s.label} + + ))} +
    +

    + A skill still only answers on the pages it names. Enabling it cannot widen that — + the agent's own surfaces narrow it further. +

    + + ) : ( +

    This skill names no page, so it answers nowhere.

    + )} +
    + + {entry.tools.length > 0 && ( +
    +
      + {entry.tools.map((tool) => ( +
    • +

      + {tool.label} + {!tool.readOnly && ( + + Writes + + )} +

      + {tool.summary && ( +

      {tool.summary}

      + )} +
    • + ))} +
    +
    + )} + + {/* Test: the button and the outcome. The question and the answer live in + the Owliver panel, which is already on this page. */} + {showTest && ( +
    +

    + {canTest + ? <>Runs in the Owliver panel on this page, against {target.label}. 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.'} +

    + + + + +
    + )} +
    + ); +} + +/** @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 ( + + + {/* The one control in this product that changes the agent's skills. + Last, under everything the decision should rest on. */} + + + )} + > + + + ); +} + +export default SkillDetails; diff --git a/src/components/agents/skills/SurfaceSelect.jsx b/src/components/agents/skills/SurfaceSelect.jsx new file mode 100644 index 0000000..f28b6ed --- /dev/null +++ b/src/components/agents/skills/SurfaceSelect.jsx @@ -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 ( +
    + {OPTIONS.map((option) => { + const available = option.supported(type); + const selected = available && value === option.id; + const Icon = option.icon; + + return ( + + ); + })} +
    + ); +} + +export default SurfaceSelect; diff --git a/src/components/agents/skills/glyphs.js b/src/components/agents/skills/glyphs.js new file mode 100644 index 0000000..25d9640 --- /dev/null +++ b/src/components/agents/skills/glyphs.js @@ -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; diff --git a/src/components/ai-assistant/AgentBadge.jsx b/src/components/ai-assistant/AgentBadge.jsx new file mode 100644 index 0000000..fb406b0 --- /dev/null +++ b/src/components/ai-assistant/AgentBadge.jsx @@ -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 ; + + return ( + + ); +} + +/** + * 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 ( + + Constrained + + ); +} + +/** @param {any} props */ +export function AgentBadge({ page }) { + const { agent, covers } = useActiveAgent(); + + return ( + <> + {agent + ? + : } + +
    +

    + Owliver +

    +
    +

    {page}

    + {agent && !covers && } +
    +
    + + ); +} + +export default AgentBadge; diff --git a/src/components/ai-assistant/AgentSwitcher.jsx b/src/components/ai-assistant/AgentSwitcher.jsx deleted file mode 100644 index fff3ab0..0000000 --- a/src/components/ai-assistant/AgentSwitcher.jsx +++ /dev/null @@ -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 ; - - return ( - - ); -} - -/** The label a constrained agent carries, in the switcher and in the header. */ -function ConstrainedTag({ className = '' }) { - return ( - - Constrained - - ); -} - -/** One row in the list. */ -function AgentRow({ agent, active, covers, onSelect }) { - return ( - - ); -} - -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 ( - <> - -
    -

    Owliver

    -

    {page}

    -
    - - ); - } - - return ( - - - - - - -
    -

    Switch agent

    -

    - Choose an agent to focus Owliver's scope on this page. -

    -
    - -
    - -
    - -
    - {matches.map((candidate) => ( - - ))} - - {!matches.length && ( -

    No agents match that.

    - )} -
    - -
    - - - -
    -
    -
    - ); -} - -export default AgentSwitcher; diff --git a/src/components/ai-assistant/AssistantPanelContext.jsx b/src/components/ai-assistant/AssistantPanelContext.jsx index 6572252..f7dfc28 100644 --- a/src/components/ai-assistant/AssistantPanelContext.jsx +++ b/src/components/ai-assistant/AssistantPanelContext.jsx @@ -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: () => {}, }; } diff --git a/src/components/ai-assistant/KrowAssistant.jsx b/src/components/ai-assistant/KrowAssistant.jsx index 360802c..e974e91 100644 --- a/src/components/ai-assistant/KrowAssistant.jsx +++ b/src/components/ai-assistant/KrowAssistant.jsx @@ -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 = ( {/* Header */}
    - {/* 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. */} - + {/* 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. */} + {/* Window controls. @@ -747,6 +798,46 @@ export default function KrowAssistant({
    )} + {/** + * 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 && ( +
    +
    + )} + {/* ── Body: the only region that scrolls ─────────────────────────── */}
    { + /** + * 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, diff --git a/src/layouts/AdminLayout.jsx b/src/layouts/AdminLayout.jsx index c91aa2a..676a34f 100644 --- a/src/layouts/AdminLayout.jsx +++ b/src/layouts/AdminLayout.jsx @@ -262,8 +262,12 @@ export default function AdminLayout() { navigate('/admin/profile#security')} className="cursor-pointer"> Security - navigate('/admin/workspace')} className="cursor-pointer"> - 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. */} + navigate('/admin/workspace/agents')} className="cursor-pointer"> + Agent Registry ( ({ + 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] : []; +} diff --git a/src/lib/skills/catalog.js b/src/lib/skills/catalog.js new file mode 100644 index 0000000..39667d2 --- /dev/null +++ b/src/lib/skills/catalog.js @@ -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)); +} diff --git a/src/lib/skills/registry.js b/src/lib/skills/registry.js index c792215..ca70acb 100644 --- a/src/lib/skills/registry.js +++ b/src/lib/skills/registry.js @@ -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( diff --git a/src/lib/skills/surfaces.js b/src/lib/skills/surfaces.js index 71f1b79..340d48a 100644 --- a/src/lib/skills/surfaces.js +++ b/src/lib/skills/surfaces.js @@ -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); /** diff --git a/src/pages/admin/AgentDetail.jsx b/src/pages/admin/AgentDetail.jsx index 7343e29..fb3260f 100644 --- a/src/pages/admin/AgentDetail.jsx +++ b/src/pages/admin/AgentDetail.jsx @@ -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() { )} setView('skills')} + scopeLocked={shipped} + /> + )} + {view === 'skills' && ( + )} {view === 'test' && ( )} {view === 'insights' && ( diff --git a/src/pages/admin/SkillDevelopment.jsx b/src/pages/admin/SkillDevelopment.jsx index 505689f..caecdf3 100644 --- a/src/pages/admin/SkillDevelopment.jsx +++ b/src/pages/admin/SkillDevelopment.jsx @@ -198,7 +198,7 @@ export default function AdminSkillDevelopment() { actions={ <> {/* Account definitions live in this browser's storage and nowhere else. Import and export are what make that a place rather than a diff --git a/vite.config.js b/vite.config.js index dd9458a..7019f91 100644 --- a/vite.config.js +++ b/vite.config.js @@ -58,10 +58,9 @@ export default defineConfig({ */ proxy: { '/api': { - target: process.env.VITE_API_PROXY_TARGET || 'http://127.0.0.1:8080', - // The API does not route on Host, and rewriting it would make the - // Origin the backend sees disagree with the one the browser sent. - changeOrigin: false, + target: process.env.VITE_API_PROXY_TARGET || 'https://mcp.korwfoce.com', + changeOrigin: true, + secure: false, }, }, },