update agents skill design
This commit is contained in:
180
src/components/agents/AddSkillsModal.jsx
Normal file
180
src/components/agents/AddSkillsModal.jsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import * as React from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button, Modal, SearchInput } from '@/components/ds';
|
||||
import { allSkills, skillsWithFacet } from '@/lib/skills/registry';
|
||||
import { surfaceFor } from '@/lib/skills/surfaces';
|
||||
|
||||
/**
|
||||
* Attaching skills to an agent.
|
||||
*
|
||||
* Reads **the one skill registry** — the same `allSkills` the Skills page and
|
||||
* Owliver itself read. There is deliberately no separate list for agents: a
|
||||
* second one would drift, and an agent would end up offering a skill the
|
||||
* runtime does not have.
|
||||
*
|
||||
* Categories are derived rather than written down. A skill's own `category`
|
||||
* field when it declares one, and the pages it attaches to otherwise, so a
|
||||
* filter can never offer a grouping that matches nothing — and a skill added
|
||||
* tomorrow appears under its own category without this file being edited.
|
||||
*/
|
||||
|
||||
const ALL = 'all';
|
||||
|
||||
/** The groupings the registry actually contains, in a stable order. */
|
||||
function categoriesFor(skills) {
|
||||
const named = new Set();
|
||||
for (const skill of skills) {
|
||||
if (skill.category) named.add(skill.category);
|
||||
}
|
||||
return [ALL, ...[...named].sort()];
|
||||
}
|
||||
|
||||
/** What this skill contributes, in the terms the reader is choosing between. */
|
||||
function skillSummary(skill) {
|
||||
const pages = skill.pages.map((p) => surfaceFor(p)?.label || p);
|
||||
const capabilities = skill.owliver?.capabilities || [];
|
||||
return [
|
||||
pages.length ? pages.join(', ') : 'No pages',
|
||||
capabilities.length
|
||||
? `${capabilities.length} capabilit${capabilities.length === 1 ? 'y' : 'ies'}`
|
||||
: (skill.actions || []).length ? `${skill.actions.length} action${skill.actions.length === 1 ? '' : 's'}` : null,
|
||||
].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
/** @param {any} props */
|
||||
export function AddSkillsModal({ open, onOpenChange, attached = [], customSkills = [], onAdd }) {
|
||||
const [query, setQuery] = React.useState('');
|
||||
const [category, setCategory] = React.useState(ALL);
|
||||
const [picked, setPicked] = React.useState([]);
|
||||
|
||||
/* A fresh sheet each time, so a previous selection is not still ticked. */
|
||||
React.useEffect(() => {
|
||||
if (open) { setQuery(''); setCategory(ALL); setPicked([]); }
|
||||
}, [open]);
|
||||
|
||||
/* Owliver skills only. A workforce training path is something a person
|
||||
learns, not something an agent can be asked to do, and offering one here
|
||||
would promise behaviour that does not exist. */
|
||||
const available = React.useMemo(
|
||||
() => skillsWithFacet(allSkills(customSkills), 'owliver')
|
||||
.filter((s) => s.status === 'active' && !attached.includes(s.id)),
|
||||
[customSkills, attached]
|
||||
);
|
||||
|
||||
const categories = React.useMemo(() => categoriesFor(available), [available]);
|
||||
|
||||
const visible = React.useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return available
|
||||
.filter((s) => category === ALL || s.category === category)
|
||||
.filter((s) => !q
|
||||
|| s.name.toLowerCase().includes(q)
|
||||
|| s.description.toLowerCase().includes(q)
|
||||
|| s.id.includes(q));
|
||||
}, [available, category, query]);
|
||||
|
||||
const toggle = (id) =>
|
||||
setPicked((current) => (current.includes(id)
|
||||
? current.filter((x) => x !== id)
|
||||
: [...current, id]));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Add skills"
|
||||
description="Select skills to extend what this agent can do. A skill still only answers on the pages it names."
|
||||
size="lg"
|
||||
footer={
|
||||
<>
|
||||
<p className="mr-auto text-body-sm text-ink-3">
|
||||
{picked.length
|
||||
? `${picked.length} skill${picked.length === 1 ? '' : 's'} selected`
|
||||
: 'Nothing selected yet'}
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||
<Button disabled={!picked.length} onClick={() => { onAdd(picked); onOpenChange(false); }}>
|
||||
Add
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<SearchInput value={query} onChange={setQuery} placeholder="Search skills..." debounce={0} />
|
||||
|
||||
{categories.length > 1 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{categories.map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setCategory(id)}
|
||||
className={cn(
|
||||
`rounded-full border px-2.5 py-1 text-caption font-medium capitalize transition-colors
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50`,
|
||||
category === id
|
||||
? 'border-krow-blue/40 bg-krow-blue-tint text-krow-blue'
|
||||
: 'border-border bg-surface text-ink-3 hover:border-krow-blue/30 hover:text-ink-2'
|
||||
)}
|
||||
>
|
||||
{id === ALL ? 'All skills' : id}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-h-[22rem] space-y-1.5 overflow-y-auto pr-0.5">
|
||||
{visible.map((skill) => {
|
||||
const chosen = picked.includes(skill.id);
|
||||
return (
|
||||
<button
|
||||
key={skill.id}
|
||||
type="button"
|
||||
onClick={() => toggle(skill.id)}
|
||||
aria-pressed={chosen}
|
||||
className={cn(
|
||||
`flex w-full items-start gap-2.5 rounded-xl border px-3 py-2.5 text-left transition-colors
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50`,
|
||||
chosen
|
||||
? 'border-krow-blue/50 bg-krow-blue-tint/50'
|
||||
: 'border-border bg-surface hover:border-krow-blue/30 hover:bg-surface-subtle'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded border',
|
||||
chosen ? 'border-krow-blue bg-krow-blue text-white' : 'border-border bg-surface'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{chosen && <Check className="h-3 w-3" />}
|
||||
</span>
|
||||
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-body-sm font-medium text-ink-1">{skill.name}</span>
|
||||
{skill.description && (
|
||||
<span className="mt-0.5 block text-caption leading-snug text-ink-3">
|
||||
{skill.description}
|
||||
</span>
|
||||
)}
|
||||
<span className="mt-1 block truncate text-[10px] text-ink-4">{skillSummary(skill)}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{!visible.length && (
|
||||
<p className="px-1 py-6 text-center text-body-sm text-ink-3">
|
||||
{available.length
|
||||
? 'No skills match that.'
|
||||
: 'Every available skill is already attached to this agent.'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddSkillsModal;
|
||||
516
src/components/agents/AgentCanvas.jsx
Normal file
516
src/components/agents/AgentCanvas.jsx
Normal file
@@ -0,0 +1,516 @@
|
||||
import * as React from 'react';
|
||||
import { ChevronRight, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { agentIconFor } from './icons';
|
||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||
|
||||
/**
|
||||
* The Agent Configure workspace, as parts.
|
||||
*
|
||||
* The screen this builds is an *authoring* surface, and it is the only one in
|
||||
* the Admin console that is. The eight operational pages report; this one is
|
||||
* written in. That is the whole reason it is allowed to look different from
|
||||
* them, and the difference is composition rather than colour: every token here
|
||||
* — `border-border`, `surface-subtle`, `krow-blue-tint`, the ink scale, the
|
||||
* duration and easing variables — is the same Control Tower vocabulary the rest
|
||||
* of the console uses.
|
||||
*
|
||||
* Three ideas carry the layout:
|
||||
*
|
||||
* 1. **One document, not a stack of cards.** Four equal cards make four
|
||||
* unrelated settings groups. One surface divided by hairlines makes a thing
|
||||
* being edited, which is what an agent definition is.
|
||||
*
|
||||
* 2. **A spine on the left.** The rail lists what an agent is made of —
|
||||
* Identity, Instructions, Capabilities, Behavior, and what is inside each —
|
||||
* with its current value beside it. It is the tree reading of the
|
||||
* configuration, and it is what lets someone understand the page before
|
||||
* reading a single field. It is navigation, never a second copy of state.
|
||||
*
|
||||
* 3. **Values on the surface.** A row states what it currently holds while
|
||||
* closed. Nothing has to be opened to be read.
|
||||
*
|
||||
* Motion is CSS only. `prefers-reduced-motion` is handled once, globally, in
|
||||
* `index.css`, so a component that animates with transitions inherits that and
|
||||
* a component that animates in JavaScript would not.
|
||||
*/
|
||||
|
||||
/* ── The surface ─────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The workspace: rail beside document, both inside one border.
|
||||
*
|
||||
* The rail only becomes a column when there is genuinely room for one. Owliver
|
||||
* holds a ~380px track on the right of every Admin page, so below `xl` the
|
||||
* remaining width belongs to the editor and the rail becomes a strip above it.
|
||||
*/
|
||||
export function Workspace({ rail, children, className }) {
|
||||
return (
|
||||
<div className={cn('w-full rounded-2xl border border-border bg-surface shadow-xs overflow-hidden', className)}>
|
||||
<div className="border-b border-border/80 bg-surface-subtle/40 px-4 py-3 sm:px-6">
|
||||
{rail}
|
||||
</div>
|
||||
<div className="min-w-0 w-full divide-y divide-border/60">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Motion ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Open and close, in height.
|
||||
*
|
||||
* `grid-template-rows: 0fr → 1fr` is the one way to transition to an unknown
|
||||
* height without measuring it in JavaScript. Content stays mounted, which is
|
||||
* what keeps a section addressable by the rail while it is closed.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function Collapse({ open, children, className }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid transition-[grid-template-rows] duration-slow ease-out',
|
||||
open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* `inert` rather than `aria-hidden`: the content stays mounted so a
|
||||
closed section is still addressable, and a mounted control that cannot
|
||||
be seen must not be reachable by Tab either. `aria-hidden` alone would
|
||||
hide it from a screen reader while leaving it in the tab order, which
|
||||
is the worse of both. */}
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden transition-opacity duration-base ease-out',
|
||||
open ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
inert={open ? undefined : true}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── The rail ────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The spine.
|
||||
*
|
||||
* Provides clear section navigation and leaf anchor shortcuts across the
|
||||
* configuration workspace.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function Rail({ sections, activeId, onSelect }) {
|
||||
const activeSection = sections.find((s) => s.id === activeId);
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="Agent configuration"
|
||||
className="flex flex-wrap items-center justify-between gap-2.5"
|
||||
>
|
||||
{/* Section anchor pills */}
|
||||
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2">
|
||||
{sections.map((section) => {
|
||||
const active = section.id === activeId;
|
||||
const Icon = section.icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={section.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(section.id)}
|
||||
aria-current={active ? 'true' : undefined}
|
||||
className={cn(
|
||||
`inline-flex items-center gap-2 rounded-lg px-3 py-1.5 text-body-sm font-medium
|
||||
transition-all duration-base focus-visible:outline-none focus-visible:ring-2
|
||||
focus-visible:ring-krow-blue/40`,
|
||||
active
|
||||
? 'bg-krow-blue text-white shadow-xs'
|
||||
: 'border border-border/80 bg-surface text-ink-3 hover:bg-surface-sunken hover:text-ink-1'
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn('h-3.5 w-3.5 shrink-0 transition-colors', active ? 'text-white' : 'text-ink-4')}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{section.label}</span>
|
||||
{section.meta && (
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-full px-1.5 py-0.5 text-caption tabular-nums',
|
||||
active ? 'bg-white/20 text-white' : 'bg-surface-sunken text-ink-4'
|
||||
)}
|
||||
>
|
||||
{section.meta}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Sub-item quick jump leaves */}
|
||||
{activeSection?.items?.length > 0 && (
|
||||
<div className="hidden items-center gap-1.5 md:flex">
|
||||
<span className="text-caption font-medium uppercase tracking-wider text-ink-4 mr-1">Jump to:</span>
|
||||
{activeSection.items.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(activeSection.id, item.id)}
|
||||
className={`group inline-flex items-center gap-1 rounded-md border border-border/60 bg-surface
|
||||
px-2 py-1 text-caption font-medium text-ink-3 transition-colors duration-base
|
||||
hover:border-krow-blue/40 hover:bg-surface-sunken hover:text-ink-1
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/40`}
|
||||
>
|
||||
<span className="min-w-0 truncate">{item.label}</span>
|
||||
{item.meta !== undefined && item.meta !== null && item.meta !== '' && (
|
||||
<span className="tabular-nums text-ink-4">({item.meta})</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── The document ────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* One section of the document.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function DocSection({
|
||||
id, icon: Icon, title, description, meta, open, onToggle, emphasis = false, last = false,
|
||||
children,
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
id={id}
|
||||
aria-labelledby={`${id}-title`}
|
||||
className={cn('scroll-mt-24 w-full', !last && 'border-b border-border/60')}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls={`${id}-body`}
|
||||
onClick={onToggle}
|
||||
className={`group flex w-full items-start gap-3 px-5 py-3.5 text-left transition-colors
|
||||
duration-base hover:bg-surface-subtle/50 focus-visible:outline-none
|
||||
focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-krow-blue/40
|
||||
sm:px-6`}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-lg transition-colors duration-base',
|
||||
emphasis
|
||||
? 'bg-krow-blue text-white'
|
||||
: open
|
||||
? 'bg-krow-blue-tint text-krow-blue'
|
||||
: 'bg-surface-sunken text-ink-4 group-hover:text-ink-2'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||
<span
|
||||
id={`${id}-title`}
|
||||
className={cn(
|
||||
'font-heading font-semibold tracking-tight text-ink-1',
|
||||
emphasis ? 'text-body font-bold' : 'text-body font-semibold'
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
{meta !== undefined && meta !== null && meta !== '' && (
|
||||
<span className="text-caption tabular-nums text-ink-4 font-normal">({meta})</span>
|
||||
)}
|
||||
</span>
|
||||
{description && (
|
||||
<span className="mt-0.5 block text-caption leading-relaxed text-ink-3">
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'mt-1 h-3.5 w-3.5 shrink-0 text-ink-4 transition-transform duration-base ease-out',
|
||||
open && 'rotate-90'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<Collapse open={open}>
|
||||
<div id={`${id}-body`} className="px-5 pb-5 pt-1 sm:px-6 w-full">
|
||||
{children}
|
||||
</div>
|
||||
</Collapse>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** A labelled field in the document body. */
|
||||
/** @param {any} props */
|
||||
export function DocField({ id, label, hint, children, className }) {
|
||||
const reactId = React.useId();
|
||||
const controlId = id ? `${id}-control` : reactId;
|
||||
|
||||
return (
|
||||
<div id={id} className={cn('scroll-mt-28 space-y-1', className)}>
|
||||
<label
|
||||
htmlFor={controlId}
|
||||
className="block text-body-sm font-medium text-ink-2"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
{React.isValidElement(children)
|
||||
? React.cloneElement(children, { id: children.props.id || controlId })
|
||||
: children}
|
||||
{hint && <p className="text-caption leading-relaxed text-ink-4">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The head of a group inside a section — Pages, Skills, Knowledge.
|
||||
*
|
||||
* One notch quieter than a section title and one louder than a row, which is
|
||||
* exactly the level of the thing it names.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function GroupHead({ id, icon: Icon, title, count, action, className }) {
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className={cn('flex scroll-mt-28 items-center gap-2.5 pt-3 pb-1.5', className)}
|
||||
>
|
||||
{Icon && <Icon className="h-3.5 w-3.5 shrink-0 text-ink-4" aria-hidden="true" />}
|
||||
<h4 className="text-body-sm font-semibold text-ink-1">{title}</h4>
|
||||
{count !== undefined && count !== null && (
|
||||
<span className="rounded-full bg-surface-sunken px-1.5 py-px text-caption font-medium tabular-nums text-ink-3">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
{action && <div className="ml-auto">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A thing attached to the agent — a skill, a subagent — with a way to detach it.
|
||||
*
|
||||
* A row, not a card. Five attached skills as five cards is five borders
|
||||
* competing with the section that holds them; as rows they read as a list,
|
||||
* which is what they are.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function ItemRow({ icon: Icon, title, detail, warning, onRemove, removeLabel }) {
|
||||
return (
|
||||
<li
|
||||
className={`group/item -mx-2 flex items-start gap-2.5 rounded-lg px-2 py-2
|
||||
transition-colors duration-base hover:bg-surface-subtle`}
|
||||
>
|
||||
{Icon && (
|
||||
<Icon className="mt-0.5 h-3.5 w-3.5 shrink-0 text-krow-blue" aria-hidden="true" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-body-sm font-medium text-ink-1">{title}</span>
|
||||
{detail && (
|
||||
<span className="mt-0.5 block text-caption leading-snug text-ink-3">{detail}</span>
|
||||
)}
|
||||
{warning && <span className="mt-0.5 block text-caption text-warning">{warning}</span>}
|
||||
</span>
|
||||
{onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
aria-label={removeLabel}
|
||||
className={`shrink-0 rounded-md p-1 text-ink-4 opacity-0 transition-all duration-base
|
||||
hover:bg-destructive-muted hover:text-destructive focus-visible:opacity-100
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/40
|
||||
group-hover/item:opacity-100`}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A behaviour setting: what it is, what it is set to, and the way to change it.
|
||||
*
|
||||
* Two shapes, one row. A setting whose control fits on the row carries it
|
||||
* there — a switch, a segmented choice — and is not a button, because wrapping
|
||||
* a switch in a button makes one press mean two things. A setting that needs
|
||||
* space gets a chevron and opens underneath.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function SettingRow({
|
||||
id, icon: Icon, title, value, description, control, children, open, onToggle, last = false,
|
||||
}) {
|
||||
const expandable = Boolean(children);
|
||||
|
||||
const body = (
|
||||
<>
|
||||
{Icon && <Icon className="mt-0.5 h-4 w-4 shrink-0 text-ink-4" aria-hidden="true" />}
|
||||
<span className="min-w-[11rem] flex-1 basis-[14rem]">
|
||||
<span className="flex flex-wrap items-baseline gap-x-2">
|
||||
<span className="text-body-sm font-medium text-ink-1">{title}</span>
|
||||
{value !== undefined && value !== null && value !== '' && (
|
||||
<span className="text-caption tabular-nums text-ink-3">{value}</span>
|
||||
)}
|
||||
</span>
|
||||
{description && (
|
||||
<span className="mt-0.5 block text-caption leading-snug text-ink-4">{description}</span>
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div id={id} className={cn('scroll-mt-28', !last && 'border-b border-border/60')}>
|
||||
{expandable ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={onToggle}
|
||||
className={`-mx-2 flex w-[calc(100%+1rem)] items-start gap-3 rounded-lg px-2 py-2.5 text-left
|
||||
transition-colors duration-base hover:bg-surface-subtle
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/40`}
|
||||
>
|
||||
{body}
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'mt-0.5 h-3.5 w-3.5 shrink-0 text-ink-4 transition-transform duration-base ease-out',
|
||||
open && 'rotate-90'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 py-2.5">
|
||||
{body}
|
||||
{control && <div className="ml-auto shrink-0">{control}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expandable && (
|
||||
<Collapse open={open}>
|
||||
<div className="pb-3.5 pt-0.5">{children}</div>
|
||||
</Collapse>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A page an agent covers, or one it could. */
|
||||
/** @param {any} props */
|
||||
export function ScopeChip({ active, children, ...props }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-caption font-medium
|
||||
transition-all duration-base ease-out focus-visible:outline-none focus-visible:ring-2
|
||||
focus-visible:ring-krow-blue/40`,
|
||||
active
|
||||
? 'border-krow-blue/40 bg-krow-blue-tint text-krow-blue'
|
||||
: 'border-border bg-surface text-ink-3 hover:border-krow-blue/30 hover:text-ink-1'
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'h-1.5 w-1.5 rounded-full transition-colors duration-base',
|
||||
active ? 'bg-krow-blue' : 'bg-border'
|
||||
)}
|
||||
/>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Identity ────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** The icons an agent may wear, as a picker. */
|
||||
/** @param {any} props */
|
||||
export function IconPicker({ icons, value, onChange }) {
|
||||
return (
|
||||
<div className="grid grid-cols-5 gap-1.5">
|
||||
{icons.map((icon) => {
|
||||
const Icon = agentIconFor(icon);
|
||||
const active = value === icon;
|
||||
const name = icon.replace(/-/g, ' ');
|
||||
|
||||
return (
|
||||
<button
|
||||
key={icon}
|
||||
type="button"
|
||||
onClick={() => onChange(icon)}
|
||||
title={name}
|
||||
aria-label={name}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
`flex h-9 items-center justify-center rounded-lg border transition-all duration-base
|
||||
ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/40`,
|
||||
active
|
||||
? 'scale-105 border-krow-blue bg-krow-blue-tint text-krow-blue shadow-xs'
|
||||
: 'border-border bg-surface text-ink-4 hover:-translate-y-px hover:border-krow-blue/40 hover:text-ink-2'
|
||||
)}
|
||||
>
|
||||
{Icon
|
||||
? <Icon className="h-4 w-4" />
|
||||
: <OwliverAvatar className="h-4 w-4" rounded="rounded" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* How this agent will appear where people actually meet it.
|
||||
*
|
||||
* Identity is the one section whose fields have a visible consequence
|
||||
* elsewhere, so the consequence is shown rather than described. It renders the
|
||||
* same three things the switcher renders — icon, name, description — and
|
||||
* nothing it does not.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function AgentPreview({ name, description, icon }) {
|
||||
const Icon = agentIconFor(icon);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-surface-subtle p-3">
|
||||
<p className="text-overline uppercase text-ink-4">In Owliver</p>
|
||||
<div className="mt-2 flex items-start gap-2.5 rounded-lg border border-border bg-surface p-2.5 shadow-xs">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border border-border bg-surface-subtle"
|
||||
>
|
||||
{Icon
|
||||
? <Icon className="h-3.5 w-3.5 text-krow-blue" />
|
||||
: <OwliverAvatar className="h-4 w-4" rounded="rounded" />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-body-sm font-semibold text-ink-1">
|
||||
{name || 'New agent'}
|
||||
</span>
|
||||
<span className="mt-0.5 line-clamp-2 block text-caption leading-snug text-ink-3">
|
||||
{description || 'No description yet.'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
681
src/components/agents/AgentConfigure.jsx
Normal file
681
src/components/agents/AgentConfigure.jsx
Normal file
@@ -0,0 +1,681 @@
|
||||
import * as React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
BookOpen, Boxes, Brain, FileText, Globe, IdCard, Layers, LayoutGrid, MessageSquare, Plus,
|
||||
SlidersHorizontal, Trash2, X, Zap,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Alert, Button, Field, Input, SegmentedToggle, Select, SelectContent, SelectItem, SelectTrigger,
|
||||
SelectValue, Switch, Textarea,
|
||||
} from '@/components/ds';
|
||||
import { allSkills } from '@/lib/skills/registry';
|
||||
import { surfaceFor, SUPPORTED_SKILL_PAGES } 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.
|
||||
*
|
||||
* Four sections of one document — Identity, Instructions, Capabilities,
|
||||
* Behavior — with a rail beside them that lists the same four and what is
|
||||
* inside each. The rail is the page's table of contents and its scroll
|
||||
* position; it is never a second source of state, which is why every value it
|
||||
* shows is read from `fields` at render rather than tracked.
|
||||
*
|
||||
* Every control writes a *field*. The caller composes frontmatter from those
|
||||
* fields at save time, which is what keeps Markdown the storage format while a
|
||||
* recruiter never meets it.
|
||||
*/
|
||||
|
||||
const SECTION_IDS = ['identity', 'instructions', 'capabilities', 'behavior'];
|
||||
|
||||
/**
|
||||
* Which section the reader is in.
|
||||
*
|
||||
* Observed rather than set on click, so scrolling the document moves the rail
|
||||
* too — a table of contents that only updates when you use it is a menu, not a
|
||||
* position.
|
||||
*
|
||||
* Read from the sections' own top edges against a marker just below the sticky
|
||||
* header, rather than from intersection ratios. A ratio-based spy cannot
|
||||
* resolve the case this document actually has: Capabilities is long enough that
|
||||
* it is still crossing the reading band when Behavior arrives, so "the first
|
||||
* one intersecting" stays on Capabilities for the rest of the page. The last
|
||||
* section gets the same exemption for the same reason in reverse — the document
|
||||
* runs out of scroll before its top reaches the marker, so at the end of the
|
||||
* page it is simply what is being read.
|
||||
*/
|
||||
function useActiveSection(ids) {
|
||||
const [active, setActive] = React.useState(ids[0]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined') return undefined;
|
||||
|
||||
let frame = 0;
|
||||
|
||||
const measure = () => {
|
||||
frame = 0;
|
||||
const marker = 140;
|
||||
|
||||
let current = ids[0];
|
||||
for (const id of ids) {
|
||||
const top = document.getElementById(id)?.getBoundingClientRect().top;
|
||||
if (top !== undefined && top <= marker) current = id;
|
||||
}
|
||||
|
||||
/* Only when the document genuinely scrolls. With every section closed it
|
||||
fits on one screen, and "the end of the page" is then also its start —
|
||||
which would light the last section while the reader is looking at the
|
||||
first. */
|
||||
const doc = document.documentElement;
|
||||
const scrolls = doc.scrollHeight > window.innerHeight + 8;
|
||||
const atBottom = window.innerHeight + window.scrollY >= doc.scrollHeight - 4;
|
||||
if (scrolls && atBottom) current = ids[ids.length - 1];
|
||||
|
||||
setActive(current);
|
||||
};
|
||||
|
||||
const schedule = () => {
|
||||
if (!frame) frame = requestAnimationFrame(measure);
|
||||
};
|
||||
|
||||
measure();
|
||||
window.addEventListener('scroll', schedule, { passive: true });
|
||||
window.addEventListener('resize', schedule);
|
||||
return () => {
|
||||
if (frame) cancelAnimationFrame(frame);
|
||||
window.removeEventListener('scroll', schedule);
|
||||
window.removeEventListener('resize', schedule);
|
||||
};
|
||||
}, [ids]);
|
||||
|
||||
return [active, setActive];
|
||||
}
|
||||
|
||||
/** @param {any} props */
|
||||
export function AgentConfigure({ fields, agents, customSkills = [], onChange }) {
|
||||
const [addingSkills, setAddingSkills] = React.useState(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. */
|
||||
const [open, setOpen] = React.useState(
|
||||
() => Object.fromEntries(SECTION_IDS.map((id) => [id, true]))
|
||||
);
|
||||
const [setting, setSetting] = React.useState(/** @type {string | null} */ (null));
|
||||
|
||||
const [active, setActive] = useActiveSection(SECTION_IDS);
|
||||
|
||||
const set = (patch) => onChange({ ...fields, ...patch });
|
||||
|
||||
const instructionWords = React.useMemo(
|
||||
() => (fields.instructions.trim() ? fields.instructions.trim().split(/\s+/).length : 0),
|
||||
[fields.instructions]
|
||||
);
|
||||
|
||||
const registry = React.useMemo(() => allSkills(customSkills), [customSkills]);
|
||||
const skillById = React.useMemo(() => new Map(registry.map((s) => [s.id, s])), [registry]);
|
||||
|
||||
const availableSubagents = React.useMemo(
|
||||
() => agents.filter((a) => a.id !== fields.id && a.status === 'published'),
|
||||
[agents, fields.id]
|
||||
);
|
||||
|
||||
const reasoning = REASONING_MODES.find((m) => m.id === fields.reasoning);
|
||||
const pageLabel = (p) => surfaceFor(p)?.label || p;
|
||||
|
||||
const coveredLabels = fields.pages.map(pageLabel);
|
||||
const starterCount = fields.starters.filter((s) => s.label.trim()).length;
|
||||
|
||||
/**
|
||||
* Going somewhere.
|
||||
*
|
||||
* A closed section is still addressable — the rail lists what is inside it,
|
||||
* and following one of those leaves has to arrive at the field rather than at
|
||||
* a header that happens to be shut. Opening first, scrolling on the next
|
||||
* frame, so the target has a height by the time it is scrolled to.
|
||||
*/
|
||||
const goTo = React.useCallback((sectionId, itemId) => {
|
||||
setOpen((current) => (current[sectionId] ? current : { ...current, [sectionId]: true }));
|
||||
if (itemId && itemId.startsWith('behavior-')) setSetting(itemId);
|
||||
setActive(sectionId);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const target = document.getElementById(itemId || sectionId)
|
||||
|| document.getElementById(sectionId);
|
||||
target?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
});
|
||||
});
|
||||
}, [setActive]);
|
||||
|
||||
const toggleSection = (id) => setOpen((current) => ({ ...current, [id]: !current[id] }));
|
||||
const toggleSetting = (id) => setSetting((current) => (current === id ? null : id));
|
||||
|
||||
/* The rail's model. Derived from the same fields the document renders, so a
|
||||
count on the rail cannot disagree with the list under it. */
|
||||
const sections = [
|
||||
{
|
||||
id: 'identity',
|
||||
icon: IdCard,
|
||||
label: 'Identity',
|
||||
meta: '',
|
||||
items: [
|
||||
{ id: 'identity-name', label: 'Name' },
|
||||
{ id: 'identity-description', label: 'Description' },
|
||||
{ id: 'identity-icon', label: 'Icon' },
|
||||
{ id: 'identity-trigger', label: 'When to use' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'instructions',
|
||||
icon: FileText,
|
||||
label: 'Instructions',
|
||||
meta: instructionWords ? `${instructionWords}w` : '',
|
||||
items: [{ id: 'instructions-editor', label: 'Editor' }],
|
||||
},
|
||||
{
|
||||
id: 'capabilities',
|
||||
icon: Boxes,
|
||||
label: 'Capabilities',
|
||||
meta: `${fields.pages.length + fields.skills.length + fields.knowledge.length}`,
|
||||
items: [
|
||||
{ id: 'capabilities-pages', label: 'Pages', meta: fields.pages.length },
|
||||
{ id: 'capabilities-skills', label: 'Skills', meta: fields.skills.length },
|
||||
{ id: 'capabilities-knowledge', label: 'Knowledge', meta: fields.knowledge.length },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'behavior',
|
||||
icon: SlidersHorizontal,
|
||||
label: 'Behavior',
|
||||
meta: reasoning?.label || '',
|
||||
items: [
|
||||
{ id: 'behavior-reasoning', label: 'Reasoning', meta: reasoning?.label },
|
||||
{ id: 'behavior-starters', label: 'Starters', meta: starterCount },
|
||||
{ id: 'behavior-web', label: 'Web search', meta: fields.webSearch ? 'On' : 'Off' },
|
||||
{ id: 'behavior-subagents', label: 'Subagents', meta: fields.subagents.length },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Workspace rail={<Rail sections={sections} activeId={active} onSelect={goTo} />}>
|
||||
{/* ── Identity ───────────────────────────────────────────────────── */}
|
||||
<DocSection
|
||||
id="identity"
|
||||
icon={IdCard}
|
||||
title="Identity"
|
||||
description="How this agent is presented to people using Owliver."
|
||||
open={open.identity}
|
||||
onToggle={() => toggleSection('identity')}
|
||||
>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-4 items-start">
|
||||
<div className="lg:col-span-7 xl:col-span-7 space-y-3">
|
||||
<DocField id="identity-name" label="Name">
|
||||
<Input
|
||||
value={fields.name}
|
||||
placeholder="e.g. Positions Agent"
|
||||
onChange={(e) => set({ name: e.target.value })}
|
||||
className="w-full"
|
||||
/>
|
||||
</DocField>
|
||||
|
||||
<DocField
|
||||
id="identity-description"
|
||||
label="Description"
|
||||
hint="One line, shown under the name when choosing an agent."
|
||||
>
|
||||
<Input
|
||||
value={fields.description}
|
||||
placeholder="e.g. Open roles — what they need and which are at risk."
|
||||
onChange={(e) => set({ description: e.target.value })}
|
||||
className="w-full"
|
||||
/>
|
||||
</DocField>
|
||||
|
||||
<DocField
|
||||
id="identity-trigger"
|
||||
label="When to use it"
|
||||
hint="Helps a reader pick the right agent. Never matched against questions."
|
||||
>
|
||||
<Textarea
|
||||
value={fields.trigger}
|
||||
rows={3}
|
||||
placeholder="e.g. Use on Positions, for open roles and applicant flow."
|
||||
onChange={(e) => set({ trigger: e.target.value })}
|
||||
className="w-full resize-y"
|
||||
/>
|
||||
</DocField>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-5 xl:col-span-5 space-y-3">
|
||||
<AgentPreview
|
||||
name={fields.name}
|
||||
description={fields.description}
|
||||
icon={fields.icon}
|
||||
/>
|
||||
|
||||
<div id="identity-icon" className="scroll-mt-28 rounded-xl border border-border bg-surface-subtle/50 p-3">
|
||||
<p className="mb-1.5 text-body-sm font-medium text-ink-2">Icon</p>
|
||||
<IconPicker
|
||||
icons={AGENT_ICONS}
|
||||
value={fields.icon}
|
||||
onChange={(icon) => set({ icon })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DocSection>
|
||||
|
||||
{/* ── Instructions ───────────────────────────────────────────────── */}
|
||||
<DocSection
|
||||
id="instructions"
|
||||
icon={FileText}
|
||||
title="Instructions"
|
||||
description="How this agent should answer, what it is responsible for, and what it should say when it cannot help."
|
||||
meta={`${instructionWords} word${instructionWords === 1 ? '' : 's'}`}
|
||||
open={open.instructions}
|
||||
onToggle={() => toggleSection('instructions')}
|
||||
emphasis
|
||||
>
|
||||
<div
|
||||
id="instructions-editor"
|
||||
className={`w-full scroll-mt-28 overflow-hidden rounded-xl border border-border
|
||||
bg-surface transition-all duration-base ease-out
|
||||
hover:border-border focus-within:border-krow-blue/50
|
||||
focus-within:ring-2 focus-within:ring-krow-blue/15`}
|
||||
>
|
||||
<Textarea
|
||||
value={fields.instructions}
|
||||
rows={8}
|
||||
placeholder={'Answer from the records this page holds.\n\nState a figure only where a skill has read it. When a reading needs a position and none is open, ask which rather than choosing one.'}
|
||||
onChange={(e) => set({ instructions: e.target.value })}
|
||||
className={`min-h-[11rem] resize-y rounded-none border-0 bg-transparent px-4 py-3
|
||||
text-body-md leading-relaxed focus-visible:ring-0`}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-3 border-t border-border bg-surface-subtle px-4 py-2">
|
||||
<span className="text-caption tabular-nums text-ink-4">
|
||||
{instructionWords} word{instructionWords === 1 ? '' : 's'}
|
||||
</span>
|
||||
<span className="text-caption text-ink-4">Saved with the agent definition</span>
|
||||
</div>
|
||||
</div>
|
||||
</DocSection>
|
||||
|
||||
{/* ── Capabilities ───────────────────────────────────────────────── */}
|
||||
<DocSection
|
||||
id="capabilities"
|
||||
icon={Boxes}
|
||||
title="Capabilities"
|
||||
description="What this agent can reach and use when it answers."
|
||||
meta={`${fields.pages.length} page${fields.pages.length === 1 ? '' : 's'} · ${fields.skills.length} skill${fields.skills.length === 1 ? '' : 's'} · ${fields.knowledge.length} knowledge`}
|
||||
open={open.capabilities}
|
||||
onToggle={() => toggleSection('capabilities')}
|
||||
>
|
||||
<div className="divide-y divide-border/60">
|
||||
{/* Pages ── the boundary. */}
|
||||
<div className="pb-4">
|
||||
<GroupHead
|
||||
id="capabilities-pages"
|
||||
icon={LayoutGrid}
|
||||
title="Pages it covers"
|
||||
count={fields.pages.length}
|
||||
/>
|
||||
<p className="pb-2 text-caption leading-relaxed text-ink-3">
|
||||
{coveredLabels.length
|
||||
? <>Answers on <span className="font-medium text-ink-1">{coveredLabels.join(', ')}</span>. Anywhere else it is shown as constrained and declines.</>
|
||||
: 'No page selected — this agent has nowhere to answer yet.'}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{SUPPORTED_SKILL_PAGES.map((page) => (
|
||||
<ScopeChip
|
||||
key={page}
|
||||
active={fields.pages.includes(page)}
|
||||
onClick={() => set({
|
||||
pages: fields.pages.includes(page)
|
||||
? fields.pages.filter((p) => p !== page)
|
||||
: [...fields.pages, page],
|
||||
})}
|
||||
>
|
||||
{pageLabel(page)}
|
||||
</ScopeChip>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-caption leading-relaxed text-ink-4">
|
||||
A page decides which skills exist there. An agent chooses among them — it can
|
||||
narrow that list, never widen it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Skills ── what it can do. */}
|
||||
<div className="py-4">
|
||||
<GroupHead
|
||||
id="capabilities-skills"
|
||||
icon={Zap}
|
||||
title="Skills"
|
||||
count={fields.skills.length}
|
||||
action={(
|
||||
<Button variant="outline" size="sm" onClick={() => setAddingSkills(true)}>
|
||||
<Plus className="h-3.5 w-3.5" /> Add skills
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
{fields.skills.length > 0 ? (
|
||||
<ul className="divide-y divide-border">
|
||||
{fields.skills.map((id) => {
|
||||
const skill = skillById.get(id);
|
||||
return (
|
||||
<ItemRow
|
||||
key={id}
|
||||
icon={Zap}
|
||||
title={skill?.name || id}
|
||||
detail={skill?.description}
|
||||
warning={skill ? null : 'No skill with this id is registered.'}
|
||||
removeLabel={`Remove ${skill?.name || id}`}
|
||||
onRemove={() => set({ skills: fields.skills.filter((x) => x !== id) })}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-body-sm text-ink-3">
|
||||
No skills attached. This agent can still answer from the page's own reader.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="mt-2 text-caption text-ink-4">
|
||||
Authored in{' '}
|
||||
<Link
|
||||
to="/admin/workspace/skills"
|
||||
className="font-medium text-krow-blue underline-offset-2 hover:underline"
|
||||
>
|
||||
Workspace → Skills
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Knowledge ── what it has been told. */}
|
||||
<div className="pt-4">
|
||||
<GroupHead
|
||||
id="capabilities-knowledge"
|
||||
icon={BookOpen}
|
||||
title="Knowledge"
|
||||
count={fields.knowledge.length}
|
||||
action={(
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => set({
|
||||
knowledge: [
|
||||
...fields.knowledge,
|
||||
{ id: '', label: '', kind: 'note', body: '', url: '' },
|
||||
],
|
||||
})}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" /> Add knowledge
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
{fields.knowledge.length ? (
|
||||
<div className="space-y-2.5">
|
||||
{fields.knowledge.map((entry, i) => (
|
||||
<div
|
||||
key={entry.id || i}
|
||||
className="animate-fade-in space-y-2.5 rounded-xl border border-border bg-surface-subtle p-3"
|
||||
>
|
||||
<div className="grid gap-2.5 sm:grid-cols-[minmax(0,1fr)_9rem]">
|
||||
<Field label="Title">
|
||||
<Input
|
||||
value={entry.label}
|
||||
placeholder="e.g. Overtime policy"
|
||||
onChange={(e) => {
|
||||
const next = [...fields.knowledge];
|
||||
next[i] = { ...entry, label: e.target.value };
|
||||
set({ knowledge: next });
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Kind">
|
||||
<Select
|
||||
value={entry.kind}
|
||||
onValueChange={(kind) => {
|
||||
const next = [...fields.knowledge];
|
||||
next[i] = { ...entry, kind };
|
||||
set({ knowledge: next });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{KNOWLEDGE_KINDS.map((k) => (
|
||||
<SelectItem key={k} value={k} className="capitalize">
|
||||
{k.replace('-', ' ')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{entry.kind === 'link' ? (
|
||||
<Field label="Link">
|
||||
<Input
|
||||
value={entry.url}
|
||||
placeholder="https://…"
|
||||
onChange={(e) => {
|
||||
const next = [...fields.knowledge];
|
||||
next[i] = { ...entry, url: e.target.value };
|
||||
set({ knowledge: next });
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
) : (
|
||||
<Field
|
||||
label="What it says"
|
||||
hint="Quoted back with its source named. Nothing is invented from it."
|
||||
>
|
||||
<Textarea
|
||||
value={entry.body}
|
||||
rows={3}
|
||||
onChange={(e) => {
|
||||
const next = [...fields.knowledge];
|
||||
next[i] = { ...entry, body: e.target.value };
|
||||
set({ knowledge: next });
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => set({
|
||||
knowledge: fields.knowledge.filter((_, x) => x !== i),
|
||||
})}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" /> Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-body-sm text-ink-3">
|
||||
No knowledge attached. Nothing is invented — this agent answers from skills only.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DocSection>
|
||||
|
||||
{/* ── Behavior ───────────────────────────────────────────────────── */}
|
||||
<DocSection
|
||||
id="behavior"
|
||||
icon={SlidersHorizontal}
|
||||
title="Behavior"
|
||||
description="How this agent works when it answers."
|
||||
meta={reasoning?.label}
|
||||
open={open.behavior}
|
||||
onToggle={() => toggleSection('behavior')}
|
||||
last
|
||||
>
|
||||
<div>
|
||||
<SettingRow
|
||||
id="behavior-reasoning"
|
||||
icon={Brain}
|
||||
title="Reasoning"
|
||||
description={reasoning?.summary || 'How much work an answer is worth.'}
|
||||
control={(
|
||||
<SegmentedToggle
|
||||
size="sm"
|
||||
ariaLabel="Reasoning depth"
|
||||
value={fields.reasoning}
|
||||
onChange={(id) => set({ reasoning: id })}
|
||||
options={REASONING_MODES.map((m) => ({ value: m.id, label: m.label }))}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
id="behavior-starters"
|
||||
icon={MessageSquare}
|
||||
title="Conversation starters"
|
||||
value={starterCount ? `${starterCount} configured` : 'None'}
|
||||
description="Questions offered as chips when this agent opens."
|
||||
open={setting === 'behavior-starters'}
|
||||
onToggle={() => toggleSetting('behavior-starters')}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{fields.starters.map((starter, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={starter.label}
|
||||
placeholder="e.g. Which roles are at risk?"
|
||||
className="max-w-[30rem]"
|
||||
onChange={(e) => {
|
||||
const next = [...fields.starters];
|
||||
next[i] = { label: e.target.value, prompt: e.target.value };
|
||||
set({ starters: next });
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Remove starter"
|
||||
onClick={() => set({ starters: fields.starters.filter((_, x) => x !== i) })}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => set({ starters: [...fields.starters, { label: '', prompt: '' }] })}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" /> Add starter
|
||||
</Button>
|
||||
</div>
|
||||
</SettingRow>
|
||||
|
||||
<div id="behavior-web" className="scroll-mt-28">
|
||||
<SettingRow
|
||||
icon={Globe}
|
||||
title="Web search"
|
||||
value={fields.webSearch ? 'On' : 'Off'}
|
||||
description="Whether this agent may look outside the workspace."
|
||||
control={(
|
||||
<Switch
|
||||
checked={fields.webSearch}
|
||||
onCheckedChange={(webSearch) => set({ webSearch })}
|
||||
aria-label="Allow web search"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Collapse open={fields.webSearch}>
|
||||
<div className="pb-4">
|
||||
<Alert tone="warning" title="No search provider is configured">
|
||||
This deployment has no web search connected, so answers say plainly that
|
||||
nothing outside the workspace was consulted.
|
||||
</Alert>
|
||||
</div>
|
||||
</Collapse>
|
||||
</div>
|
||||
|
||||
<SettingRow
|
||||
id="behavior-subagents"
|
||||
icon={Layers}
|
||||
title="Subagents"
|
||||
value={fields.subagents.length ? `${fields.subagents.length} configured` : 'None'}
|
||||
description="Other agents whose skills this one may also use. Most need none."
|
||||
open={setting === 'behavior-subagents'}
|
||||
onToggle={() => toggleSetting('behavior-subagents')}
|
||||
last
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{fields.subagents.length > 0 && (
|
||||
<ul className="divide-y divide-border">
|
||||
{fields.subagents.map((id) => {
|
||||
const sub = agents.find((a) => a.id === id);
|
||||
return (
|
||||
<ItemRow
|
||||
key={id}
|
||||
icon={Layers}
|
||||
title={sub?.name || id}
|
||||
detail={sub?.description}
|
||||
warning={sub ? null : 'No agent with this id is registered.'}
|
||||
removeLabel={`Remove ${sub?.name || id}`}
|
||||
onRemove={() => set({
|
||||
subagents: fields.subagents.filter((x) => x !== id),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<Select
|
||||
value=""
|
||||
onValueChange={(id) => !fields.subagents.includes(id)
|
||||
&& set({ subagents: [...fields.subagents, id] })}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-72">
|
||||
<SelectValue placeholder="Add a subagent..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableSubagents
|
||||
.filter((a) => !fields.subagents.includes(a.id))
|
||||
.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<p className="text-caption leading-relaxed text-ink-4">
|
||||
A subagent's skills are still bounded by the current page — borrowing them
|
||||
cannot reach data this page does not hold.
|
||||
</p>
|
||||
</div>
|
||||
</SettingRow>
|
||||
</div>
|
||||
</DocSection>
|
||||
</Workspace>
|
||||
|
||||
<AddSkillsModal
|
||||
open={addingSkills}
|
||||
onOpenChange={setAddingSkills}
|
||||
attached={fields.skills}
|
||||
customSkills={customSkills}
|
||||
onAdd={(ids) => set({ skills: [...fields.skills, ...ids] })}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default AgentConfigure;
|
||||
238
src/components/agents/AgentInsightsPanel.jsx
Normal file
238
src/components/agents/AgentInsightsPanel.jsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Activity,
|
||||
BarChart3,
|
||||
Clock,
|
||||
HelpCircle,
|
||||
MessageSquare,
|
||||
ThumbsDown,
|
||||
ThumbsUp,
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
import { Badge, EmptyState } from '@/components/ds';
|
||||
import { readHistory } from '@/components/ai-assistant/history';
|
||||
import { conversationStats, reviewRow } from '@/lib/agents/conversationInsights';
|
||||
import { allSkills } from '@/lib/skills/registry';
|
||||
import { usePreferences } from '@/lib/krowHooks';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* What this agent has actually been asked.
|
||||
*
|
||||
* Real-time usage analytics computed directly from the local telemetry log.
|
||||
*/
|
||||
|
||||
/** @param {any} props */
|
||||
function MetricCard({ icon: Icon, label, value, detail, highlight }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-border bg-surface p-5 shadow-xs space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-caption font-medium text-ink-4 uppercase tracking-wider">{label}</span>
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-surface-subtle text-ink-3">
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="font-heading text-title-xl font-bold tabular-nums text-ink-1 sm:text-3xl">
|
||||
{value}
|
||||
</span>
|
||||
{highlight && (
|
||||
<span className="text-caption font-semibold text-krow-blue">{highlight}</span>
|
||||
)}
|
||||
</div>
|
||||
{detail && <p className="text-caption text-ink-4">{detail}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {any} props */
|
||||
export function AgentInsightsPanel({ agentId, agentName }) {
|
||||
const preferences = usePreferences();
|
||||
|
||||
const [records] = React.useState(() => readHistory());
|
||||
|
||||
const stats = React.useMemo(
|
||||
() => conversationStats(records, { agentId }),
|
||||
[records, agentId]
|
||||
);
|
||||
|
||||
const skillName = React.useMemo(() => {
|
||||
const byId = new Map(allSkills(preferences.customSkills || []).map((s) => [s.id, s.name]));
|
||||
return (id) => byId.get(id) || id;
|
||||
}, [preferences.customSkills]);
|
||||
|
||||
const conversations = React.useMemo(
|
||||
() => records
|
||||
.filter((r) => (agentId ? r.agentId === agentId : true))
|
||||
.slice(0, 8)
|
||||
.map(reviewRow),
|
||||
[records, agentId]
|
||||
);
|
||||
|
||||
if (stats.empty) {
|
||||
return (
|
||||
<div className="w-full rounded-2xl border border-border bg-surface p-12 shadow-xs text-center">
|
||||
<EmptyState
|
||||
icon={MessageSquare}
|
||||
title="No conversations with this agent yet"
|
||||
description={`Insights are counted from conversations held on this device. Ask ${agentName || 'this agent'} something in Owliver and the figures appear here.`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4">
|
||||
{/* ── KPI Metric Cards ───────────────────────────────────────────── */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<MetricCard
|
||||
icon={MessageSquare}
|
||||
label="Total Conversations"
|
||||
value={stats.total}
|
||||
detail={`Active across ${stats.activeDays} day${stats.activeDays === 1 ? '' : 's'}`}
|
||||
/>
|
||||
<MetricCard
|
||||
icon={HelpCircle}
|
||||
label="Questions Asked"
|
||||
value={stats.turns}
|
||||
detail={`${stats.averageTurns} per session average`}
|
||||
/>
|
||||
<MetricCard
|
||||
icon={Activity}
|
||||
label="Surfaces Utilized"
|
||||
value={stats.pages}
|
||||
detail="Operational pages touched"
|
||||
/>
|
||||
<MetricCard
|
||||
icon={ThumbsUp}
|
||||
label="Feedback Rating"
|
||||
value={stats.feedback.score === null ? '—' : `${stats.feedback.score}%`}
|
||||
detail={
|
||||
stats.feedback.score === null
|
||||
? 'No ratings submitted'
|
||||
: `${stats.feedback.up} helpful · ${stats.feedback.down} unhelpful`
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── 2-Column Ranking Breakdowns ────────────────────────────────── */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{/* Most Used Skills */}
|
||||
<div className="rounded-2xl border border-border bg-surface p-5 shadow-xs space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="h-4 w-4 text-krow-blue" />
|
||||
<h4 className="font-heading text-body font-semibold text-ink-1">Most Triggered Skills</h4>
|
||||
</div>
|
||||
|
||||
{stats.bySkill.length ? (
|
||||
<div className="space-y-2">
|
||||
{stats.bySkill.slice(0, 5).map((row) => (
|
||||
<div
|
||||
key={row.id}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-border/60 bg-surface-subtle/30 px-3 py-2 text-body-sm"
|
||||
>
|
||||
<span className="truncate font-medium text-ink-2">{skillName(row.id)}</span>
|
||||
<Badge variant="neutral" size="sm" className="tabular-nums">
|
||||
{row.count} query{row.count === 1 ? '' : 'ies'}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-border/80 bg-surface-subtle/50 p-4 text-body-sm text-ink-3">
|
||||
No skill executions logged yet. Conversations were handled directly by native page readers.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions Executed */}
|
||||
<div className="rounded-2xl border border-border bg-surface p-5 shadow-xs space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Wrench className="h-4 w-4 text-krow-blue" />
|
||||
<h4 className="font-heading text-body font-semibold text-ink-1">Actions & Tool Usage</h4>
|
||||
</div>
|
||||
|
||||
{stats.byTool.length ? (
|
||||
<div className="space-y-2">
|
||||
{stats.byTool.slice(0, 5).map((row) => (
|
||||
<div
|
||||
key={row.id}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-border/60 bg-surface-subtle/30 px-3 py-2 text-body-sm"
|
||||
>
|
||||
<span className="truncate font-medium text-ink-2">{row.id.replace(/_/g, ' ')}</span>
|
||||
<Badge variant="neutral" size="sm" className="tabular-nums">
|
||||
{row.count} run{row.count === 1 ? '' : 's'}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-border/80 bg-surface-subtle/50 p-4 text-body-sm text-ink-3">
|
||||
No action tool executions recorded in current audit window.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Recent Conversations Table ─────────────────────────────────── */}
|
||||
<div className="rounded-2xl border border-border bg-surface shadow-xs overflow-hidden">
|
||||
<div className="border-b border-border/80 bg-surface-subtle/40 px-5 py-4 sm:px-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-krow-blue" />
|
||||
<h4 className="font-heading text-body font-semibold text-ink-1">
|
||||
Recent Conversation Logs
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-border/60">
|
||||
{conversations.map((row) => (
|
||||
<div
|
||||
key={row.id}
|
||||
className="flex flex-wrap items-center justify-between gap-3 px-5 py-3.5 sm:px-6 hover:bg-surface-subtle/30 transition-colors"
|
||||
>
|
||||
<div className="min-w-0 flex-1 space-y-0.5">
|
||||
<p className="truncate text-body-sm font-medium text-ink-1">
|
||||
{row.title || 'General consultation'}
|
||||
</p>
|
||||
<p className="text-caption text-ink-4">
|
||||
Surface:{' '}
|
||||
<span className="font-medium text-ink-3">{row.page || 'Direct'}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<span className="text-caption tabular-nums text-ink-4">
|
||||
{row.turns} turn{row.turns === 1 ? '' : 's'}
|
||||
</span>
|
||||
|
||||
{row.feedback && (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold',
|
||||
row.feedback.rating === 'up'
|
||||
? 'bg-emerald-50 text-emerald-700 border border-emerald-200 dark:bg-emerald-950/40 dark:text-emerald-300 dark:border-emerald-800'
|
||||
: 'bg-amber-50 text-amber-700 border border-amber-200 dark:bg-amber-950/40 dark:text-amber-300 dark:border-amber-800'
|
||||
)}
|
||||
>
|
||||
{row.feedback.rating === 'up' ? (
|
||||
<>
|
||||
<ThumbsUp className="h-2.5 w-2.5" /> Helpful
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ThumbsDown className="h-2.5 w-2.5" /> Unhelpful
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AgentInsightsPanel;
|
||||
347
src/components/agents/AgentTestPanel.jsx
Normal file
347
src/components/agents/AgentTestPanel.jsx
Normal file
@@ -0,0 +1,347 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
AlertCircle,
|
||||
BookOpen,
|
||||
CheckCircle2,
|
||||
Cpu,
|
||||
Layers,
|
||||
Play,
|
||||
Search,
|
||||
Sparkles,
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Badge,
|
||||
Input,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ds';
|
||||
import { allSkills, matchSkill, pageKeyForContext, skillsForContext } from '@/lib/skills/registry';
|
||||
import { PLACEMENT_ROUTES } from '@/components/ai-assistant/placement';
|
||||
import { ASSISTANT_CONTEXTS } from '@/components/ai-assistant/contexts';
|
||||
import { agentCovers, agentScopedDisabled, classifyQuestion } from '@/lib/agents/runtime';
|
||||
import { toolsForContext } from '@/lib/skills/tools';
|
||||
import { retrieveKnowledge } from '@/lib/agents/knowledge';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* What this agent would do, on a page, before it is published.
|
||||
*
|
||||
* Runs the live runtime classifier, skill matcher, tool resolver, and
|
||||
* knowledge retriever to display comprehensive diagnostics.
|
||||
*/
|
||||
|
||||
const CONTEXT_OPTIONS = Object.entries(PLACEMENT_ROUTES).map(([route, contextId]) => ({
|
||||
contextId,
|
||||
route,
|
||||
label: ASSISTANT_CONTEXTS[contextId]?.page || contextId,
|
||||
}));
|
||||
|
||||
const CLASSIFICATION_COPY = {
|
||||
structured: 'Reads workforce records through an attached skill.',
|
||||
knowledge: 'Answers directly from reference knowledge.',
|
||||
combined: 'Requires both structured records and knowledge retrieval.',
|
||||
};
|
||||
|
||||
/** @param {any} props */
|
||||
export function AgentTestPanel({ fields, dirty, customSkills = [] }) {
|
||||
const [contextId, setContextId] = React.useState(
|
||||
() => CONTEXT_OPTIONS.find((o) => fields.pages.includes(pageKeyForContext(o.contextId)))?.contextId
|
||||
|| CONTEXT_OPTIONS[0].contextId
|
||||
);
|
||||
const [question, setQuestion] = React.useState('What needs my attention?');
|
||||
|
||||
/* The draft as the runtime sees it */
|
||||
const draftAgent = React.useMemo(() => ({
|
||||
id: fields.id || 'draft',
|
||||
name: fields.name || 'This agent',
|
||||
pages: fields.pages,
|
||||
skills: fields.skills,
|
||||
subagents: fields.subagents,
|
||||
knowledge: fields.knowledge,
|
||||
starters: fields.starters,
|
||||
reasoning: fields.reasoning,
|
||||
webSearch: fields.webSearch,
|
||||
status: 'published',
|
||||
}), [fields]);
|
||||
|
||||
const registry = React.useMemo(() => allSkills(customSkills), [customSkills]);
|
||||
|
||||
const result = React.useMemo(() => {
|
||||
const covers = agentCovers(draftAgent, contextId);
|
||||
const disabled = agentScopedDisabled(draftAgent, registry, []);
|
||||
const pageOnly = skillsForContext(contextId, [], customSkills);
|
||||
const scoped = covers ? skillsForContext(contextId, disabled, customSkills) : [];
|
||||
const matched = covers ? matchSkill(question, contextId, disabled, customSkills) : null;
|
||||
|
||||
return {
|
||||
covers,
|
||||
pageOnly,
|
||||
scoped,
|
||||
matched,
|
||||
tools: covers ? toolsForContext(contextId, disabled, customSkills) : [],
|
||||
knowledge: retrieveKnowledge({ agent: draftAgent, contextId, question }),
|
||||
classification: classifyQuestion({ question }),
|
||||
};
|
||||
}, [draftAgent, contextId, question, registry, customSkills]);
|
||||
|
||||
const activePageLabel = ASSISTANT_CONTEXTS[contextId]?.page || 'Current Page';
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4">
|
||||
{/* ── Test Workbench Card ────────────────────────────────────────── */}
|
||||
<div className="w-full rounded-2xl border border-border bg-surface shadow-xs overflow-hidden">
|
||||
<div className="border-b border-border/80 bg-surface-subtle/40 px-5 py-4 sm:px-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-krow-blue-tint text-krow-blue">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="font-heading text-body font-semibold text-ink-1">
|
||||
Simulation & Scope Diagnostics
|
||||
</h3>
|
||||
<p className="text-caption text-ink-4">
|
||||
Evaluate real-time intent routing and skill activation without publishing changes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dirty && (
|
||||
<Badge variant="warning" size="sm" className="gap-1.5">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-warning animate-pulse" />
|
||||
Testing unsaved draft
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-5 sm:p-6 space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-12 items-end">
|
||||
<div className="md:col-span-4 space-y-1.5">
|
||||
<label className="block text-body-sm font-medium text-ink-2">
|
||||
Contextual Page
|
||||
</label>
|
||||
<Select value={contextId} onValueChange={setContextId}>
|
||||
<SelectTrigger className="w-full bg-surface">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CONTEXT_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.contextId} value={o.contextId}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-8 space-y-1.5">
|
||||
<label className="block text-body-sm font-medium text-ink-2">
|
||||
Simulated User Query
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-ink-4" />
|
||||
<Input
|
||||
value={question}
|
||||
onChange={(e) => setQuestion(e.target.value)}
|
||||
placeholder="e.g. Which roles are at risk?"
|
||||
className="pl-9 w-full bg-surface"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{fields.starters.filter((s) => s.label).length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1 border-t border-border/50">
|
||||
<span className="text-caption font-medium uppercase tracking-wider text-ink-4 mr-1">
|
||||
Suggested Starters:
|
||||
</span>
|
||||
{fields.starters.filter((s) => s.label).map((s, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => setQuestion(s.prompt || s.label)}
|
||||
className={cn(
|
||||
`inline-flex items-center gap-1.5 rounded-lg border border-border/80 bg-surface px-2.5 py-1
|
||||
text-caption font-medium text-ink-2 transition-all duration-base
|
||||
hover:border-krow-blue/40 hover:bg-krow-blue-tint/30 hover:text-krow-blue
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/40`,
|
||||
question === (s.prompt || s.label) && 'border-krow-blue bg-krow-blue-tint text-krow-blue'
|
||||
)}
|
||||
>
|
||||
<Play className="h-3 w-3 shrink-0" aria-hidden="true" />
|
||||
<span>{s.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Active Status Banner ───────────────────────────────────────── */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-start gap-3.5 rounded-2xl border p-4 sm:p-5 transition-colors',
|
||||
result.covers
|
||||
? 'border-emerald-200 bg-emerald-50/70 text-emerald-950 dark:border-emerald-900/40 dark:bg-emerald-950/20 dark:text-emerald-100'
|
||||
: 'border-amber-200 bg-amber-50/70 text-amber-950 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-100'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'flex h-6 w-6 shrink-0 items-center justify-center rounded-full mt-0.5',
|
||||
result.covers
|
||||
? 'bg-emerald-600 text-white dark:bg-emerald-500'
|
||||
: 'bg-amber-600 text-white dark:bg-amber-500'
|
||||
)}
|
||||
>
|
||||
{result.covers ? <CheckCircle2 className="h-4 w-4" /> : <AlertCircle className="h-4 w-4" />}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="font-heading text-body font-semibold">
|
||||
{result.covers ? `Active on ${activePageLabel}` : `Constrained on ${activePageLabel}`}
|
||||
</h4>
|
||||
<p className="mt-0.5 text-body-sm leading-relaxed opacity-90">
|
||||
{result.covers
|
||||
? result.scoped.length
|
||||
? `${result.scoped.length} of ${result.pageOnly.length} operational skills are active and ready on this surface.`
|
||||
: `This page provides ${result.pageOnly.length} skills. With none attached, this agent will answer using the page's standard reader.`
|
||||
: `This agent has not been assigned to ${activePageLabel}. When asked on this page, it will politely decline or defer to the workspace reader.`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 4-Quadrant Diagnostic Grid ─────────────────────────────────── */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{/* Quadrant 1: Intent & Skill Routing */}
|
||||
<div className="rounded-2xl border border-border bg-surface p-5 shadow-xs space-y-3">
|
||||
<div className="flex items-center gap-2 text-ink-1">
|
||||
<Cpu className="h-4 w-4 text-krow-blue shrink-0" />
|
||||
<h4 className="font-heading text-body font-semibold">Intent & Skill Routing</h4>
|
||||
</div>
|
||||
<p className="text-caption text-ink-3">
|
||||
{CLASSIFICATION_COPY[result.classification] || CLASSIFICATION_COPY.structured}
|
||||
</p>
|
||||
|
||||
{result.matched ? (
|
||||
<div className="rounded-xl border border-krow-blue/40 bg-krow-blue-tint/40 p-3 space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-body-sm font-semibold text-krow-blue">
|
||||
{result.matched.name}
|
||||
</span>
|
||||
<Badge variant="info" size="sm">Matched</Badge>
|
||||
</div>
|
||||
<p className="text-caption text-ink-3 leading-relaxed">
|
||||
{result.matched.description}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-border/80 bg-surface-subtle/50 p-3 text-body-sm text-ink-3">
|
||||
No specialized skill claimed this exact wording. The native page reader will process the query.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quadrant 2: Reachable Skills */}
|
||||
<div className="rounded-2xl border border-border bg-surface p-5 shadow-xs space-y-3">
|
||||
<div className="flex items-center justify-between gap-2 text-ink-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="h-4 w-4 text-krow-blue shrink-0" />
|
||||
<h4 className="font-heading text-body font-semibold">Reachable Page Skills</h4>
|
||||
</div>
|
||||
<span className="rounded-full bg-surface-sunken px-2 py-0.5 text-caption font-semibold tabular-nums text-ink-3">
|
||||
{result.scoped.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{result.scoped.length ? (
|
||||
<div className="space-y-1.5 max-h-48 overflow-y-auto pr-1">
|
||||
{result.scoped.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className="flex items-center justify-between gap-2 rounded-lg border border-border/60 bg-surface-subtle/30 px-3 py-2 text-body-sm"
|
||||
>
|
||||
<span className="truncate font-medium text-ink-2">{s.name}</span>
|
||||
<span className="text-caption text-ink-4 tabular-nums">Ready</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-border/80 bg-surface-subtle/50 p-3 text-body-sm text-ink-3">
|
||||
No attached skills apply to {activePageLabel}.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quadrant 3: Execution Capabilities */}
|
||||
<div className="rounded-2xl border border-border bg-surface p-5 shadow-xs space-y-3">
|
||||
<div className="flex items-center justify-between gap-2 text-ink-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Wrench className="h-4 w-4 text-krow-blue shrink-0" />
|
||||
<h4 className="font-heading text-body font-semibold">Execution Actions & Tools</h4>
|
||||
</div>
|
||||
<span className="rounded-full bg-surface-sunken px-2 py-0.5 text-caption font-semibold tabular-nums text-ink-3">
|
||||
{result.tools.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{result.tools.length ? (
|
||||
<div className="space-y-1.5 max-h-48 overflow-y-auto pr-1">
|
||||
{result.tools.map((tool) => (
|
||||
<div
|
||||
key={tool.name}
|
||||
className="flex items-center justify-between gap-2 rounded-lg border border-border/60 bg-surface-subtle/30 px-3 py-2 text-body-sm"
|
||||
>
|
||||
<span className="truncate font-medium text-ink-2">{tool.label}</span>
|
||||
{tool.requiresApproval ? (
|
||||
<Badge variant="warning" size="sm">Confirmation required</Badge>
|
||||
) : (
|
||||
<Badge variant="neutral" size="sm">Read-only</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-border/80 bg-surface-subtle/50 p-3 text-body-sm text-ink-3">
|
||||
Read-only operations. No active mutations or system write actions.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quadrant 4: Referenced Knowledge */}
|
||||
<div className="rounded-2xl border border-border bg-surface p-5 shadow-xs space-y-3">
|
||||
<div className="flex items-center gap-2 text-ink-1">
|
||||
<BookOpen className="h-4 w-4 text-krow-blue shrink-0" />
|
||||
<h4 className="font-heading text-body font-semibold">Referenced Knowledge</h4>
|
||||
</div>
|
||||
|
||||
{result.knowledge.passages.length ? (
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto pr-1">
|
||||
{result.knowledge.passages.map((p) => (
|
||||
<div
|
||||
key={p.chunkId}
|
||||
className="rounded-lg border border-border/70 bg-surface-subtle/50 p-2.5 text-body-sm"
|
||||
>
|
||||
<span className="block font-medium text-ink-1">{p.label}</span>
|
||||
<span className="mt-0.5 line-clamp-2 block text-caption text-ink-3 leading-relaxed">
|
||||
{p.text}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-border/80 bg-surface-subtle/50 p-3 text-body-sm text-ink-3">
|
||||
{result.knowledge.note || 'No custom reference knowledge attached to this agent.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AgentTestPanel;
|
||||
30
src/components/agents/icons.js
Normal file
30
src/components/agents/icons.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
Activity, BarChart3, Briefcase, GraduationCap, Layers, Shield, Sparkles, UserCheck, Users,
|
||||
} from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Agent icon id → component.
|
||||
*
|
||||
* A hand-written table, for the same reason `SECTION_COMPONENTS` is one: a
|
||||
* definition names a key, and only a key written down here resolves to
|
||||
* anything. There is no path from a Markdown file to a component nobody chose.
|
||||
*
|
||||
* `owliver` is deliberately absent — it is not a lucide glyph but the product's
|
||||
* own avatar, and the switcher draws it directly. Returning `null` for it is
|
||||
* the signal to do that, which keeps the primary agent looking like Owliver
|
||||
* rather than like a generic icon.
|
||||
*/
|
||||
const ICONS = {
|
||||
sparkles: Sparkles,
|
||||
briefcase: Briefcase,
|
||||
users: Users,
|
||||
'user-check': UserCheck,
|
||||
layers: Layers,
|
||||
'graduation-cap': GraduationCap,
|
||||
'bar-chart': BarChart3,
|
||||
activity: Activity,
|
||||
shield: Shield,
|
||||
};
|
||||
|
||||
/** The component for an icon id, or null when the avatar should be drawn. */
|
||||
export const agentIconFor = (id) => ICONS[String(id || '').trim()] ?? null;
|
||||
Reference in New Issue
Block a user