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;
|
||||
196
src/components/ai-assistant/AgentContext.jsx
Normal file
196
src/components/ai-assistant/AgentContext.jsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import * as React from 'react';
|
||||
import { usePreferences } from '@/lib/krowHooks';
|
||||
import { allAgents } from '@/lib/agents/registry';
|
||||
import {
|
||||
agentCovers, nativeAgentForContext, resolveAgentForTurn, resolveDefaultAgent, resolveSelection,
|
||||
} from '@/lib/agents/runtime';
|
||||
|
||||
/**
|
||||
* Which agent is answering.
|
||||
*
|
||||
* Built on the same principles as `PageContext.jsx`, and deliberately kept
|
||||
* beside it rather than merged into it:
|
||||
*
|
||||
* - **The page is not an input to itself.** This context reads the page's
|
||||
* assistant context to decide a *default*, and never the other way round.
|
||||
* Nothing here can change which page the reader is on, which is what makes
|
||||
* "switching agent cannot change PageContext" true by construction rather
|
||||
* than by care.
|
||||
* - **Session-scoped, like the panel's own window state.** Choosing an agent
|
||||
* for the afternoon should not rewrite an account default, and a new tab
|
||||
* should open on the page's own agent.
|
||||
*
|
||||
* Resolution order: what this session chose *and that still applies here* → the
|
||||
* account's stated default, on the same condition → the page's own agent → the
|
||||
* general agent. A page therefore always opens on the agent written for it, a
|
||||
* page nobody wrote one for opens on the general agent rather than on nothing,
|
||||
* and a deliberate choice still survives navigation across the pages it covers.
|
||||
*
|
||||
* **A selection is made somewhere.** That is the correction this file carries:
|
||||
* it stores the context a choice was made on, not only the agent's id. Storing
|
||||
* the id alone meant a specialist chosen on Positions followed the reader onto
|
||||
* Settings and constrained a page nobody had chosen it for — the panel looked
|
||||
* broken and the reason was invisible. The rule itself lives in
|
||||
* `runtime.resolveSelection` as a pure function, so what applies where is
|
||||
* decided in one place and can be proved without a React tree.
|
||||
*/
|
||||
|
||||
const AgentContext = React.createContext(null);
|
||||
|
||||
const SESSION_KEY = 'krow_assistant:agent';
|
||||
|
||||
/**
|
||||
* The stored selection: `{ id, contextId }`.
|
||||
*
|
||||
* A bare string is still read, because that is what earlier sessions wrote and
|
||||
* a stored value from yesterday should not throw. It resolves as a choice made
|
||||
* nowhere, which is the honest reading of it — and the conservative one, since a
|
||||
* selection with no context applies only where it legitimately covers.
|
||||
*/
|
||||
function readSelection() {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(SESSION_KEY);
|
||||
if (!raw) return null;
|
||||
if (!raw.startsWith('{')) return { id: raw, contextId: null };
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed?.id ? { id: parsed.id, contextId: parsed.contextId ?? null } : null;
|
||||
} catch {
|
||||
/* Private mode, or a value that will not parse: the session simply always
|
||||
opens on the page's own agent. */
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeSelection(selection) {
|
||||
try {
|
||||
if (selection?.id) sessionStorage.setItem(SESSION_KEY, JSON.stringify(selection));
|
||||
else sessionStorage.removeItem(SESSION_KEY);
|
||||
} catch {
|
||||
/* Held in memory for this session only. */
|
||||
}
|
||||
}
|
||||
|
||||
export function AgentProvider({ contextId = null, children }) {
|
||||
const preferences = usePreferences();
|
||||
|
||||
/* Shipped definitions plus anything this account has authored, read through
|
||||
the one registry so the switcher and the management page cannot disagree
|
||||
about what exists. */
|
||||
const agents = React.useMemo(
|
||||
() => allAgents(preferences.customAgents || [], { customSkills: preferences.customSkills || [] }),
|
||||
[preferences.customAgents, preferences.customSkills]
|
||||
);
|
||||
|
||||
const [selection, setSelection] = React.useState(() => readSelection());
|
||||
|
||||
const select = React.useCallback((id) => {
|
||||
/* Stamped with where it was chosen. A specialist picked on a page it does
|
||||
not cover is a deliberate act and is honoured *here*; the stamp is what
|
||||
stops it from becoming a decision about every other page too. */
|
||||
const next = id ? { id, contextId } : null;
|
||||
setSelection(next);
|
||||
writeSelection(next);
|
||||
}, [contextId]);
|
||||
|
||||
/** Back to whichever agent this page resolves on its own. */
|
||||
const clearSelection = React.useCallback(() => {
|
||||
setSelection(null);
|
||||
writeSelection(null);
|
||||
}, []);
|
||||
|
||||
/* What the stored choice means on the page the reader is on now. */
|
||||
const applied = React.useMemo(
|
||||
() => resolveSelection(agents, selection, contextId),
|
||||
[agents, selection, contextId]
|
||||
);
|
||||
|
||||
/**
|
||||
* Storage housekeeping, in one place.
|
||||
*
|
||||
* Retiring a spent selection and re-stamping a live one are both writes about
|
||||
* *navigation*, not about a choice, so they belong in an effect rather than in
|
||||
* `select`. Both converge: the effect only writes when the stored value would
|
||||
* actually change.
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
if (applied.retire) {
|
||||
setSelection(null);
|
||||
writeSelection(null);
|
||||
return;
|
||||
}
|
||||
if (applied.id && selection?.contextId !== contextId) {
|
||||
const next = { id: applied.id, contextId };
|
||||
setSelection(next);
|
||||
writeSelection(next);
|
||||
}
|
||||
}, [applied, selection, contextId]);
|
||||
|
||||
const value = React.useMemo(() => {
|
||||
/**
|
||||
* What this turn asks for.
|
||||
*
|
||||
* The session's choice first, then the account default — and the account
|
||||
* default is put through the same rule, as a choice made nowhere. A stated
|
||||
* default that does not cover this page is not a decision about this page,
|
||||
* so it must not constrain it; the page resolves its own agent instead.
|
||||
*/
|
||||
const stated = applied.id
|
||||
|| resolveSelection(agents, preferences.defaultAgentId || null, contextId).id
|
||||
|| null;
|
||||
|
||||
const turn = resolveAgentForTurn(agents, stated, contextId);
|
||||
/* The page's own agent, offered as the way out of a constrained state.
|
||||
Null on a page nobody wrote one for — `fallback` is what answers there. */
|
||||
const native = nativeAgentForContext(agents, contextId);
|
||||
/* What this page opens on with nothing chosen: its own agent, or the
|
||||
general one. Never null, so no page is ever left without an agent. */
|
||||
const fallback = resolveDefaultAgent(agents, contextId);
|
||||
|
||||
return {
|
||||
agents,
|
||||
/* The agent that will answer. Never silently swapped: a reader who chose
|
||||
one *for this page* gets that one, with `covers` saying whether it
|
||||
belongs here. A choice carried in from another page is not that. */
|
||||
agent: turn.agent,
|
||||
covers: turn.covers,
|
||||
native,
|
||||
/* What this page resolves to with nothing chosen — its own agent, or the
|
||||
general one. This is what a constrained answer points at, so that the
|
||||
way out of a constrained state is named on every page rather than only
|
||||
on the pages that have a specialist. */
|
||||
defaultAgent: fallback,
|
||||
/* True while this page is simply showing the agent it resolves on its
|
||||
own, whether that is its specialist or the general agent. */
|
||||
isNative: Boolean(fallback && turn.agent && fallback.id === turn.agent.id && !applied.id),
|
||||
chosenId: applied.id,
|
||||
select,
|
||||
clearSelection,
|
||||
/* Whether a given agent belongs on this page, for the list. */
|
||||
coversPage: (candidate) => agentCovers(candidate, contextId),
|
||||
};
|
||||
}, [agents, applied, preferences.defaultAgentId, contextId, select, clearSelection]);
|
||||
|
||||
return <AgentContext.Provider value={value}>{children}</AgentContext.Provider>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The active agent.
|
||||
*
|
||||
* Returns an inert value outside a provider, so a panel rendered in isolation —
|
||||
* a test, a storybook — behaves as it did before agents existed rather than
|
||||
* crashing.
|
||||
*/
|
||||
export function useActiveAgent() {
|
||||
return React.useContext(AgentContext) ?? {
|
||||
agents: [],
|
||||
agent: null,
|
||||
covers: true,
|
||||
native: null,
|
||||
defaultAgent: null,
|
||||
isNative: false,
|
||||
chosenId: null,
|
||||
select: () => {},
|
||||
clearSelection: () => {},
|
||||
coversPage: () => false,
|
||||
};
|
||||
}
|
||||
234
src/components/ai-assistant/AgentSwitcher.jsx
Normal file
234
src/components/ai-assistant/AgentSwitcher.jsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import * as React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Check, ChevronDown, Plus, Search, SquareArrowOutUpRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { searchAgents } from '@/lib/agents/registry';
|
||||
import { agentIconFor } from '@/components/agents/icons';
|
||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||
import { useActiveAgent } from './AgentContext';
|
||||
|
||||
/**
|
||||
* The agent switcher, inside Owliver's own header.
|
||||
*
|
||||
* Designed with high-density Krow Control Tower aesthetics: clean popover
|
||||
* geometry, smooth row hover/active states, and legible status indicators.
|
||||
*/
|
||||
|
||||
/** The avatar for the primary agent; a lucide glyph for the rest. */
|
||||
function AgentGlyph({ agent, className = 'h-7 w-7' }) {
|
||||
const Icon = agentIconFor(agent?.icon);
|
||||
|
||||
if (!Icon) return <OwliverAvatar className={className} rounded="rounded-lg" />;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center justify-center rounded-lg border border-border/80 bg-surface-subtle text-krow-blue shadow-2xs',
|
||||
className
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** The label a constrained agent carries, in the switcher and in the header. */
|
||||
function ConstrainedTag({ className = '' }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center rounded-md border border-amber-500/25 bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-amber-700 dark:text-amber-300',
|
||||
className
|
||||
)}
|
||||
>
|
||||
Constrained
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** One row in the list. */
|
||||
function AgentRow({ agent, active, covers, onSelect }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
onClick={() => onSelect(agent.id)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left transition-all',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50',
|
||||
active
|
||||
? 'bg-krow-blue-tint/70 text-ink-1'
|
||||
: 'hover:bg-surface-subtle text-ink-2'
|
||||
)}
|
||||
>
|
||||
<AgentGlyph agent={agent} className="h-7 w-7 shrink-0" />
|
||||
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="min-w-0 truncate text-body-sm font-semibold text-ink-1">
|
||||
{agent.name}
|
||||
</span>
|
||||
{!covers && <ConstrainedTag />}
|
||||
</span>
|
||||
{agent.description && (
|
||||
<span className="mt-0.5 block truncate text-[11px] leading-tight text-ink-4">
|
||||
{agent.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{active && (
|
||||
<Check className="h-4 w-4 text-krow-blue shrink-0 ml-1" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentSwitcher({ page }) {
|
||||
const navigate = useNavigate();
|
||||
const { agents, agent, covers, select, coversPage } = useActiveAgent();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [query, setQuery] = React.useState('');
|
||||
const listRef = React.useRef(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) setQuery('');
|
||||
}, [open]);
|
||||
|
||||
const matches = React.useMemo(() => {
|
||||
const found = searchAgents(agents, query);
|
||||
return [...found].sort((a, b) => Number(coversPage(b)) - Number(coversPage(a)));
|
||||
}, [agents, query, coversPage]);
|
||||
|
||||
const choose = (id) => {
|
||||
select(id);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const onListKeyDown = (event) => {
|
||||
const step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
|
||||
if (!step) return;
|
||||
const rows = [...(listRef.current?.querySelectorAll('[role="option"]') ?? [])];
|
||||
if (!rows.length) return;
|
||||
event.preventDefault();
|
||||
const at = rows.indexOf(document.activeElement);
|
||||
rows[Math.max(0, Math.min(rows.length - 1, (at === -1 ? 0 : at + step)))]?.focus();
|
||||
};
|
||||
|
||||
if (!agent) {
|
||||
return (
|
||||
<>
|
||||
<OwliverAvatar className="h-7 w-7" rounded="rounded-lg" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="font-heading text-body-sm font-semibold leading-tight text-ink-1">Owliver</h2>
|
||||
<p className="truncate text-caption leading-tight text-ink-3">{page}</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Owliver agent: ${agent.name}. Switch to another agent`}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
className="group flex min-w-0 flex-1 items-center gap-2.5 rounded-lg px-1.5 py-1 text-left transition-colors hover:bg-surface-subtle focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
<AgentGlyph agent={agent} />
|
||||
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="min-w-0 truncate font-heading text-body-sm font-semibold leading-tight text-ink-1">
|
||||
Owliver
|
||||
</span>
|
||||
<ChevronDown
|
||||
className="h-3 w-3 shrink-0 text-ink-4 transition-colors group-hover:text-krow-blue"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="truncate text-caption leading-tight text-ink-3">{page}</span>
|
||||
{!covers && <ConstrainedTag />}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent align="start" sideOffset={6} className="w-[21.5rem] p-0 shadow-lg rounded-xl border border-border/80 overflow-hidden">
|
||||
<div className="border-b border-border/60 bg-surface-subtle/40 px-3.5 py-2.5">
|
||||
<p className="font-heading text-body-sm font-semibold text-ink-1">Switch agent</p>
|
||||
<p className="mt-0.5 text-[11px] text-ink-4">
|
||||
Choose an agent to focus Owliver's scope on this page.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="px-3 pt-2.5 pb-1">
|
||||
<label className="relative block">
|
||||
<span className="sr-only">Search agents</span>
|
||||
<Search
|
||||
className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-ink-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search agents..."
|
||||
className="w-full h-8 rounded-lg border border-border/70 bg-surface-subtle/40 py-1 pl-8 pr-2.5 text-body-sm text-ink-1 placeholder:text-ink-4 transition-all focus:bg-surface focus:border-krow-blue/50 focus:outline-none focus:ring-1 focus:ring-krow-blue/30"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={listRef}
|
||||
role="listbox"
|
||||
aria-label="Agents"
|
||||
onKeyDown={onListKeyDown}
|
||||
className="max-h-[16rem] space-y-0.5 overflow-y-auto px-2 py-1.5"
|
||||
>
|
||||
{matches.map((candidate) => (
|
||||
<AgentRow
|
||||
key={candidate.id}
|
||||
agent={candidate}
|
||||
active={candidate.id === agent.id}
|
||||
covers={coversPage(candidate)}
|
||||
onSelect={choose}
|
||||
/>
|
||||
))}
|
||||
|
||||
{!matches.length && (
|
||||
<p className="px-3 py-4 text-center text-body-sm text-ink-3">No agents match that.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-border/80 bg-surface-subtle/30 px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setOpen(false); navigate('/admin/workspace/agents'); }}
|
||||
className="inline-flex items-center gap-1.5 rounded px-1.5 py-0.5 text-caption font-medium text-ink-3 transition-colors hover:text-krow-blue focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
<SquareArrowOutUpRight className="h-3 w-3" aria-hidden="true" />
|
||||
Browse all
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setOpen(false); navigate('/admin/workspace/agents/new'); }}
|
||||
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-caption font-semibold text-krow-blue transition-colors hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Create agent
|
||||
</button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export default AgentSwitcher;
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import { ThumbsDown, ThumbsUp } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ResponseDocument } from './ResponseBlocks';
|
||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||
@@ -35,9 +36,58 @@ export function ThinkingIndicator() {
|
||||
* with tables and KPI tiles, and a chat bubble would waste a third of a 380px
|
||||
* column on padding around them.
|
||||
*/
|
||||
/**
|
||||
* How this conversation was rated.
|
||||
*
|
||||
* Offered on the latest answer only, because a rating belongs to the
|
||||
* conversation rather than to a paragraph of it — thumbs on every turn would
|
||||
* ask a reader to score an answer out of the exchange that produced it, and
|
||||
* each one would set the same value anyway.
|
||||
*
|
||||
* Hidden until the turn is hovered or focused, the way History's delete control
|
||||
* already is: a thread of reports should not carry two permanent buttons under
|
||||
* every answer.
|
||||
*/
|
||||
function FeedbackControls({ feedback, onFeedback }) {
|
||||
const rate = (rating) => onFeedback(feedback?.rating === rating ? null : rating);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'mt-2 flex items-center gap-0.5 transition-opacity',
|
||||
/* Stays visible once rated, so a reader can see and change their own
|
||||
answer rather than having to hunt for it again. */
|
||||
feedback ? 'opacity-100' : 'opacity-0 group-hover/turn:opacity-100 group-focus-within/turn:opacity-100'
|
||||
)}
|
||||
>
|
||||
{[
|
||||
{ rating: 'up', Icon: ThumbsUp, label: 'This answer was useful' },
|
||||
{ rating: 'down', Icon: ThumbsDown, label: 'This answer was not useful' },
|
||||
].map(({ rating, Icon, label }) => (
|
||||
<button
|
||||
key={rating}
|
||||
type="button"
|
||||
onClick={() => rate(rating)}
|
||||
aria-label={label}
|
||||
aria-pressed={feedback?.rating === rating}
|
||||
className={cn(
|
||||
`rounded p-1 transition-colors focus-visible:outline-none
|
||||
focus-visible:ring-2 focus-visible:ring-krow-blue/50`,
|
||||
feedback?.rating === rating
|
||||
? 'text-krow-blue'
|
||||
: 'text-ink-4 hover:text-ink-2'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const Message = React.memo(
|
||||
/** @param {any} props */
|
||||
({ role, text, blocks, streaming, stopped, onPrompt }) => {
|
||||
({ role, text, blocks, streaming, stopped, onPrompt, feedback = null, onFeedback = null }) => {
|
||||
if (role === 'user') {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
@@ -49,7 +99,7 @@ export const Message = React.memo(
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="animate-slide-up">
|
||||
<div className="group/turn animate-slide-up">
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
<OwliverAvatar className="h-5 w-5" rounded="rounded-full" />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Owliver</span>
|
||||
@@ -58,6 +108,12 @@ export const Message = React.memo(
|
||||
<ResponseDocument blocks={blocks} streaming={streaming} onPrompt={onPrompt} />
|
||||
|
||||
{stopped && <p className="mt-2 text-caption italic text-ink-4">Stopped early.</p>}
|
||||
|
||||
{/* Only where a handler was passed — a turn nobody is collecting feedback
|
||||
for renders exactly as it did before this existed. */}
|
||||
{onFeedback && !streaming && (
|
||||
<FeedbackControls feedback={feedback} onFeedback={onFeedback} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as React from 'react';
|
||||
import { base44 } from '@/api/base44Client';
|
||||
import { resolveAssistantContext } from './placement';
|
||||
import { PageContextProvider } from './PageContext';
|
||||
import { AgentProvider } from './AgentContext';
|
||||
|
||||
/**
|
||||
* Window state for the Owliver panel: open/collapsed and default/expanded.
|
||||
@@ -195,9 +196,16 @@ export function AssistantPanelProvider({ role, pathname, children }) {
|
||||
/* 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
|
||||
against the record the reader has open, whichever of the two is asking. */
|
||||
/* The page's own selection travels beside the window state, and the active
|
||||
agent beside both. `AgentProvider` is given the resolved context id and can
|
||||
only read it — nothing inside it can change which page the reader is on,
|
||||
which is what makes "switching agent never moves PageContext" structural
|
||||
rather than a rule to remember. */
|
||||
return (
|
||||
<AssistantPanelContext.Provider value={value}>
|
||||
<PageContextProvider>{children}</PageContextProvider>
|
||||
<PageContextProvider>
|
||||
<AgentProvider contextId={context?.id ?? null}>{children}</AgentProvider>
|
||||
</PageContextProvider>
|
||||
</AssistantPanelContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ArrowLeft, History, Maximize2, Minimize2, PanelRightClose, RotateCcw, Trash2,
|
||||
} from 'lucide-react';
|
||||
@@ -9,12 +9,12 @@ import { IconButton } from '@/components/ds/IconButton';
|
||||
import { Alert } from '@/components/ds/Alert';
|
||||
import {
|
||||
useAssignments, useAssignWorkers, useCreateJobPosting, useGenerateJobDescription,
|
||||
useMarkInterviewReady, useUpdateJobPosting,
|
||||
useMarkInterviewReady, useShiftRecords, useUpdateJobPosting,
|
||||
usePreferences, useRoleCategories,
|
||||
} from '@/lib/krowHooks';
|
||||
import { ROLE_CATEGORIES } from '@/lib/roleCategories';
|
||||
import { runAction } from '@/lib/skills/actions';
|
||||
import { skillsForContext } from '@/lib/skills/registry';
|
||||
import { allSkills, skillsForContext } from '@/lib/skills/registry';
|
||||
import { owliverSuggestions } from '@/lib/skills/owliverResolver';
|
||||
import { useWorkforcePaths } from '@/lib/skills/usePageSkills';
|
||||
import { profileForEmail } from '@/lib/skillGraph';
|
||||
@@ -23,7 +23,10 @@ import { useAssistantPanel } from './AssistantPanelContext';
|
||||
import { usePageContext } from './PageContext';
|
||||
import { useAssistantFacts, useConversation, useCurrentUserName } from './useAssistant';
|
||||
import { buildIntro, buildPrompts } from './dynamic';
|
||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||
import { agentScopedDisabled, agentStarters } from '@/lib/agents/runtime';
|
||||
import { buildOwliverContext } from '@/lib/agents/context';
|
||||
import { AgentSwitcher } from './AgentSwitcher';
|
||||
import { useActiveAgent } from './AgentContext';
|
||||
import { Message, ThinkingIndicator, TurnDivider } from './AssistantMessage';
|
||||
import { PromptInput } from './PromptInput';
|
||||
import { PromptChips } from './PromptChips';
|
||||
@@ -215,6 +218,7 @@ export default function KrowAssistant({
|
||||
const facts = useAssistantFacts();
|
||||
const userName = useCurrentUserName();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
/* A question a page has handed over — see the effect below `runPrompt`. */
|
||||
const { request: panelRequest, consumeRequest } = useAssistantPanel();
|
||||
@@ -230,10 +234,36 @@ export default function KrowAssistant({
|
||||
the app already has rather than from anything written into this component.
|
||||
A page whose skills change needs no edit here. */
|
||||
const preferences = usePreferences();
|
||||
const disabledSkills = React.useMemo(
|
||||
/* `defaultAgent` rather than `native`: on a page with no agent of its own the
|
||||
way out of a constrained state is the general agent, and naming nothing
|
||||
there would leave the reader with a decline and no next step. */
|
||||
const { agent, covers: agentCoversPage, defaultAgent } = useActiveAgent();
|
||||
|
||||
/**
|
||||
* What this page will not offer.
|
||||
*
|
||||
* The account's own switched-off skills, plus everything the active agent
|
||||
* does not carry. **This one memo is the entire agent scoping**: every
|
||||
* consumer below — the skill list, the suggestions, the conversation, the
|
||||
* tools — already takes `disabledSkills`, so none of them needed changing.
|
||||
*
|
||||
* It can only ever subtract. `getSkillsForPage` applies the page filter and
|
||||
* this list in the same expression, so an agent cannot make a skill appear on
|
||||
* a page that does not carry it, and with no agent the list is exactly the
|
||||
* account's own.
|
||||
*/
|
||||
const accountDisabled = React.useMemo(
|
||||
() => preferences.disabledSkills || [],
|
||||
[preferences.disabledSkills]
|
||||
);
|
||||
const allRegisteredSkills = React.useMemo(
|
||||
() => allSkills(preferences.customSkills || []),
|
||||
[preferences.customSkills]
|
||||
);
|
||||
const disabledSkills = React.useMemo(
|
||||
() => agentScopedDisabled(agent, allRegisteredSkills, accountDisabled),
|
||||
[agent, allRegisteredSkills, accountDisabled]
|
||||
);
|
||||
const { data: customCategories = [] } = useRoleCategories();
|
||||
const roles = React.useMemo(
|
||||
() => [...new Set([...ROLE_CATEGORIES, ...customCategories.map((c) => c.name)])],
|
||||
@@ -270,6 +300,17 @@ export default function KrowAssistant({
|
||||
[statesFor, facts.forge, facts.profiles, facts.user]
|
||||
);
|
||||
const { data: assignments = [] } = useAssignments();
|
||||
/* Shifts worked, missed and overrun — the records behind attendance and
|
||||
overtime. Read from the same cache a page would, so a card and an answer
|
||||
about the same fortnight cannot disagree. */
|
||||
const { data: shifts = [] } = useShiftRecords();
|
||||
/* Where the reader is, composed from the page's own channel — see
|
||||
`lib/agents/context.js`. Read-only: nothing here writes back to the page. */
|
||||
const owliverContext = React.useMemo(
|
||||
() => buildOwliverContext({ context, pathname: location.pathname, pageContext }),
|
||||
[context, location.pathname, pageContext]
|
||||
);
|
||||
|
||||
const skillContext = React.useMemo(() => ({
|
||||
...pageContext,
|
||||
applications: facts.applications || [],
|
||||
@@ -284,8 +325,9 @@ export default function KrowAssistant({
|
||||
assignments,
|
||||
staff: facts.staff || [],
|
||||
activity: facts.activity || [],
|
||||
shifts,
|
||||
trainingPaths,
|
||||
}), [pageContext, facts, assignments, trainingPaths]);
|
||||
}), [pageContext, facts, assignments, shifts, trainingPaths]);
|
||||
|
||||
/**
|
||||
* Which face of the panel the body is showing.
|
||||
@@ -407,6 +449,7 @@ export default function KrowAssistant({
|
||||
const {
|
||||
messages, pending, error, busy, send, stop, reset,
|
||||
history, conversationId, openConversation, forgetConversation,
|
||||
submitFeedback, feedback,
|
||||
} = useConversation({
|
||||
contextId: context.id,
|
||||
facts,
|
||||
@@ -425,6 +468,10 @@ export default function KrowAssistant({
|
||||
skillCategories,
|
||||
courses: facts.forge?.library || [],
|
||||
skillContext,
|
||||
agent,
|
||||
agentCoversPage,
|
||||
agentSuggestion: defaultAgent,
|
||||
owliverContext,
|
||||
});
|
||||
|
||||
/* A block inside an answer asking the next question, in place. Same entry
|
||||
@@ -518,7 +565,12 @@ export default function KrowAssistant({
|
||||
return `ask:${String(chip?.prompt ?? '').trim().toLowerCase()}`;
|
||||
};
|
||||
|
||||
return [...ready, ...skillPrompts, ...buildPrompts(context.id, facts, workforce), ...asking]
|
||||
/* The agent's own starters lead: they were written for this agent, and they
|
||||
pass through the same de-duplication below, so a starter worded like a
|
||||
skill suggestion still yields one chip rather than two. An agent that
|
||||
does not cover this page offers none. */
|
||||
return [...agentStarters(agent, context.id), ...ready, ...skillPrompts,
|
||||
...buildPrompts(context.id, facts, workforce), ...asking]
|
||||
.filter((chip) => {
|
||||
const label = String(chip?.label ?? '').trim().toLowerCase();
|
||||
if (!label) return false;
|
||||
@@ -530,7 +582,13 @@ export default function KrowAssistant({
|
||||
});
|
||||
/* `pageContext` decides which suggestions can answer without asking, so the
|
||||
chips re-rank when a position is opened or closed. */
|
||||
}, [followUp, skills, context.id, disabledSkills, customSkills, facts, workforce, pageContext]);
|
||||
}, [followUp, skills, context.id, disabledSkills, customSkills, facts, workforce, pageContext, agent]);
|
||||
|
||||
/* The newest assistant turn, which is the one that carries the rating. */
|
||||
const lastAnswerIndex = React.useMemo(
|
||||
() => messages.map((m) => m.role).lastIndexOf('assistant'),
|
||||
[messages]
|
||||
);
|
||||
|
||||
/* Follow the newest content. Direct `scrollTop` rather than smooth scrolling:
|
||||
at streaming frequency a smooth scroll never catches up and the thread
|
||||
@@ -614,12 +672,11 @@ export default function KrowAssistant({
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex shrink-0 items-center gap-2.5 border-b border-white/60 px-3.5 py-2.5">
|
||||
<OwliverAvatar className="h-7 w-7" rounded="rounded-lg" />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="font-heading text-body-sm font-semibold leading-tight text-ink-1">Owliver</h2>
|
||||
<p className="truncate text-caption leading-tight text-ink-3">{context.page}</p>
|
||||
</div>
|
||||
{/* The avatar and the two lines beside it, as they always were —
|
||||
"Owliver" over the page, with the pair doubling as the agent
|
||||
switcher. The header element, its geometry and the window controls
|
||||
to the right are unchanged. */}
|
||||
<AgentSwitcher page={context.page} />
|
||||
|
||||
{/* Window controls.
|
||||
|
||||
@@ -732,6 +789,14 @@ export default function KrowAssistant({
|
||||
blocks={message.blocks}
|
||||
stopped={message.stopped}
|
||||
onPrompt={askOwliver}
|
||||
/* A rating belongs to the conversation, so it is offered on
|
||||
the newest answer only — and never while one streams. */
|
||||
feedback={message.role === 'assistant' && i === lastAnswerIndex ? feedback : null}
|
||||
onFeedback={
|
||||
message.role === 'assistant' && i === lastAnswerIndex && !pending
|
||||
? submitFeedback
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
552
src/components/ai-assistant/capabilities/workspace.js
Normal file
552
src/components/ai-assistant/capabilities/workspace.js
Normal file
@@ -0,0 +1,552 @@
|
||||
import { doc, heading, list, note, text } from '../blocks';
|
||||
/* `vocabulary.js` is a leaf — it imports nothing — so naming the reasoning
|
||||
modes here costs no dependency.
|
||||
|
||||
The registries are deliberately **not** imported. `contexts.js` is reached
|
||||
from `placement.js`, which `skills/registry.js` already depends on, so
|
||||
importing a registry back into a context closes a cycle and leaves
|
||||
`PLACEMENT_ROUTES` undefined at module-evaluation time. Counting agents here
|
||||
would also duplicate what the screen behind this panel already shows. */
|
||||
import { REASONING_MODES } from '@/lib/agents/vocabulary';
|
||||
|
||||
/**
|
||||
* Owliver on the agent configuration screen.
|
||||
*
|
||||
* This page is a **workspace** page, not an operational one. It shows no
|
||||
* positions, no candidates and no shifts, and Owliver must not behave as though
|
||||
* it does. What it can genuinely answer from is the two registries — what
|
||||
* agents exist, what skills exist, what the configuration options mean — which
|
||||
* is metadata about the workspace itself rather than a reading of workforce
|
||||
* records.
|
||||
*
|
||||
* The distinction matters most when the agent being *edited* is an operational
|
||||
* one. Configuring the Analytics Agent does not put the reader on Analytics:
|
||||
* the edited agent is a record being changed, not the page they are standing
|
||||
* on. So nothing here reaches for analytics data, and no skill declares this
|
||||
* page — `skillsForContext` returns an empty list, which is the honest answer.
|
||||
*
|
||||
* Questions outside these topics are declined by the routing layer rather than
|
||||
* answered with whatever this page happens to know.
|
||||
*/
|
||||
|
||||
/** What this page's answers are actually about. */
|
||||
/**
|
||||
* What this page's answers are actually about.
|
||||
*
|
||||
* Deliberately narrow, and it was not narrow enough at first: `what is` and
|
||||
* `page` were on this list, so "What is our attendance rate?" matched and was
|
||||
* answered from a screen that holds no attendance records. A topic list on a
|
||||
* page with no operational data has to name *subjects*, never sentence
|
||||
* openings — a generic phrase turns the honest decline into a confident answer
|
||||
* about the wrong thing.
|
||||
*
|
||||
* Anything not named here is declined by `routing.js` and pointed at the page
|
||||
* that holds the records.
|
||||
*/
|
||||
export const AGENT_CONFIGURE_TOPICS = [
|
||||
'agent', 'agents', 'subagent', 'subagents',
|
||||
'skill', 'skills', 'knowledge',
|
||||
'reasoning', 'starter', 'starters', 'web search',
|
||||
'instruction', 'instructions', 'configure', 'configuration', 'setting', 'settings',
|
||||
'publish', 'published', 'draft', 'archive', 'archived', 'revert', 'shipped',
|
||||
'workspace',
|
||||
];
|
||||
|
||||
/** What this screen is, and what it can be asked. */
|
||||
function explainScreen() {
|
||||
return doc(
|
||||
heading('Configuring an agent'),
|
||||
text('An agent decides how Owliver answers on a page: which of that page\u2019s skills it may use, what it knows, and how much work an answer is worth.'),
|
||||
list([
|
||||
'It narrows what a page offers. It can never widen it.',
|
||||
'The same skill can be attached to several agents.',
|
||||
'Changes are saved to this workspace; publishing puts them into service.',
|
||||
]),
|
||||
note('The agents in this workspace are listed on the Agents page, and skills in Workspace \u2192 Skills.')
|
||||
);
|
||||
}
|
||||
|
||||
/** Where skills come from. */
|
||||
function skillOverview() {
|
||||
return doc(
|
||||
heading('Where skills come from'),
|
||||
text('Skills are authored once in **Workspace \u2192 Skills** and attached to as many agents as you like. There is one library, so a skill behaves identically wherever it is used.'),
|
||||
list([
|
||||
'Add skills \u2014 attaches from the shared library.',
|
||||
'A skill still only answers on the pages it itself declares.',
|
||||
'Attaching a skill an agent\u2019s pages do not cover changes nothing on those pages.',
|
||||
]),
|
||||
note('Removing a skill here detaches it from this agent. It stays in the library for everything else.')
|
||||
);
|
||||
}
|
||||
|
||||
/** What the configuration options mean. */
|
||||
function explainOptions() {
|
||||
return doc(
|
||||
heading('What each setting does'),
|
||||
list([
|
||||
'Pages it covers — where this agent may answer. It narrows what the page offers; it can never widen it.',
|
||||
'Skills — what it can do. Attached from the shared library.',
|
||||
'Knowledge — reference material it can quote, with the source named.',
|
||||
`Reasoning — how much work an answer is worth: ${REASONING_MODES.map((m) => m.label).join(', ')}.`,
|
||||
'Conversation starters — the questions offered as chips when this agent opens.',
|
||||
'Web search — whether it may look outside the workspace.',
|
||||
'Subagents — other agents whose skills it may also use, still bounded by the current page.',
|
||||
]),
|
||||
note('Changes are saved to this workspace. Publishing is what puts them into service.')
|
||||
);
|
||||
}
|
||||
|
||||
/** The lifecycle, in the words the buttons use. */
|
||||
function explainLifecycle() {
|
||||
return doc(
|
||||
heading('Draft, published, archived'),
|
||||
list([
|
||||
'Draft — saved, but not answering anywhere yet.',
|
||||
'Published — in service, and offered in the Owliver switcher.',
|
||||
'Archived — taken out of service, definition kept. It can be restored as a draft.',
|
||||
]),
|
||||
note('Editing an agent that ships with Krow saves your own version alongside it. The shipped definition is never altered, and reverting brings it back.')
|
||||
);
|
||||
}
|
||||
|
||||
export const AGENT_CONFIGURE_CAPABILITIES = [
|
||||
{ id: 'screen', label: 'What this screen configures', run: explainScreen },
|
||||
{ id: 'skills', label: 'Where skills come from', run: skillOverview },
|
||||
{ id: 'options', label: 'What each setting does', run: explainOptions },
|
||||
{ id: 'lifecycle', label: 'Draft, published, archived', run: explainLifecycle },
|
||||
];
|
||||
|
||||
/**
|
||||
* A free-text question on the configuration screen.
|
||||
*
|
||||
* Answers from the registries, or says plainly that this page cannot answer it.
|
||||
* The one thing it must never do is reach for operational data because the
|
||||
* agent being edited happens to be an operational agent.
|
||||
*/
|
||||
export function respondAgentConfigure(question) {
|
||||
const q = String(question || '').toLowerCase();
|
||||
|
||||
if (/\bskill/.test(q)) return skillOverview();
|
||||
if (/\b(draft|publish|archive|version|revert|shipped)/.test(q)) return explainLifecycle();
|
||||
if (/\b(reasoning|knowledge|starter|subagent|web search|instruction|setting|option|page)/.test(q)) {
|
||||
return explainOptions();
|
||||
}
|
||||
if (/\bagent/.test(q)) return explainScreen();
|
||||
|
||||
return doc(
|
||||
text('This is the agent configuration screen, so I can answer about **agents, skills and what each setting does** here.'),
|
||||
list([
|
||||
'What agents exist in this workspace',
|
||||
'What skills I can attach, and where they come from',
|
||||
'What pages, reasoning, knowledge and subagents mean',
|
||||
'What draft, published and archived do',
|
||||
]),
|
||||
note('For workforce questions — positions, candidates, attendance — open the page that holds those records and ask me there.')
|
||||
);
|
||||
}
|
||||
|
||||
/* ── The rest of the workspace, and Settings ──────────────────────────────
|
||||
*
|
||||
* Every page below is a configuration surface: it holds no positions, no
|
||||
* candidates and no shifts. That does not make Owliver useless there, and
|
||||
* treating it as though it did is the bug this section exists to correct — the
|
||||
* panel did not mount on these pages at all, on a product where Owliver had
|
||||
* answered perfectly well everywhere before specialised agents existed.
|
||||
*
|
||||
* What each page can honestly answer from is the screen itself: what it
|
||||
* configures, what the controls mean, and which page holds the records a
|
||||
* workforce question is really about. That is what these produce.
|
||||
*
|
||||
* **No skill is invented to make this work.** A skill is a capability over
|
||||
* records; none of these pages has records, so a skill here would be a fake one
|
||||
* whose only purpose was to populate a chip. What answers instead is a page
|
||||
* responder — the same mechanism every context in this table has always used,
|
||||
* including the eight operational ones.
|
||||
*/
|
||||
|
||||
/**
|
||||
* One configuration page's answers, from a description of the page.
|
||||
*
|
||||
* A factory rather than five near-identical copies. Each section carries the
|
||||
* questions it answers (`match`), the label the panel offers it under, and the
|
||||
* document itself — so the capability list shown when a question is declined
|
||||
* and the routing behind a typed question come from one declaration and cannot
|
||||
* drift apart.
|
||||
*
|
||||
* **Sections are matched in order, specific before general.** The section that
|
||||
* explains the screen as a whole is deliberately last on every page: it is the
|
||||
* one whose words appear in every other question, and put first it would answer
|
||||
* all of them. The same lesson `AGENT_CONFIGURE_TOPICS` records, one level down.
|
||||
*
|
||||
* `topics` is what `routing.js` gates on, and it names *subjects* rather than
|
||||
* sentence openings — a generic opener on a page with no operational data turns
|
||||
* an honest decline into a confident answer about the wrong thing.
|
||||
*/
|
||||
function workspacePage({ label, topics, sections, elsewhere }) {
|
||||
const capabilities = sections.map((section) => ({
|
||||
id: section.id,
|
||||
label: section.label,
|
||||
run: () => doc(
|
||||
heading(section.heading),
|
||||
...(section.text ? [text(section.text)] : []),
|
||||
...(section.bullets?.length ? [list(section.bullets)] : []),
|
||||
...(section.note ? [note(section.note)] : [])
|
||||
),
|
||||
}));
|
||||
|
||||
const byId = new Map(capabilities.map((c) => [c.id, c]));
|
||||
|
||||
/**
|
||||
* A typed question, answered from this page or offered what it can answer.
|
||||
*
|
||||
* The default is not "here is the page's report" — these pages have no report.
|
||||
* It is the offer itself: what can be asked here, and where the records live
|
||||
* for what cannot. `routing.js` has already declined anything outside
|
||||
* `topics`, so this only ever sees a question the page is plausibly about.
|
||||
*/
|
||||
const respond = (question) => {
|
||||
const q = String(question || '').toLowerCase();
|
||||
const matched = sections.find((section) => section.match.test(q));
|
||||
if (matched) return byId.get(matched.id).run();
|
||||
|
||||
return doc(
|
||||
text(`This is **${label}**, so I can answer about the screen itself — what it configures and what each control does.`),
|
||||
list(sections.map((section) => section.label)),
|
||||
note(elsewhere)
|
||||
);
|
||||
};
|
||||
|
||||
return { topics, capabilities, respond };
|
||||
}
|
||||
|
||||
/** What every configuration page says about where the records are. */
|
||||
const RECORDS_ELSEWHERE =
|
||||
'For workforce questions — positions, candidates, attendance, training — open the page that holds those records and ask me there.';
|
||||
|
||||
/* ── Settings ───────────────────────────────────────────────────────────── */
|
||||
|
||||
const SETTINGS = workspacePage({
|
||||
label: 'Settings',
|
||||
topics: [
|
||||
'setting', 'settings', 'account', 'password', 'credential', 'security', 'two-factor',
|
||||
'two factor', '2fa', 'permission', 'access', 'organization', 'organisation',
|
||||
'client', 'user', 'users', 'team', 'audit', 'notification', 'digest', 'email',
|
||||
'density', 'compact', 'preference', 'automation', 'toggle', 'configure', 'configuration',
|
||||
'workspace', 'owliver', 'assistant',
|
||||
],
|
||||
elsewhere: RECORDS_ELSEWHERE,
|
||||
sections: [
|
||||
{
|
||||
id: 'automation',
|
||||
label: 'What the automation toggles do',
|
||||
heading: 'Automation controls',
|
||||
match: /\b(automation|toggle|toggles|digest|notification|notifications|density|compact|assistant)\b/,
|
||||
bullets: [
|
||||
'Owliver Workspace Assistant — whether this panel opens by default on the pages that carry it.',
|
||||
'Compact UI Density — tighter table rows, for high-density monitoring.',
|
||||
'Daily Executive Email Digest — a scheduled summary to the account address.',
|
||||
],
|
||||
note: 'Turning the assistant off closes the panel; it never removes it. The docked control brings it back.',
|
||||
},
|
||||
{
|
||||
id: 'access',
|
||||
label: 'Permissions and security',
|
||||
heading: 'Who may do what',
|
||||
match: /\b(permission|permissions|access|secure|secured|security|password|credential|credentials|audit|2fa|two.factor|user|users|team)\b/,
|
||||
text: 'Access is configured here and recorded on Activity. The two are deliberately separate: this page states the policy, and the audit trail states what happened under it.',
|
||||
bullets: [
|
||||
'Users & Permissions — the roles this workspace grants.',
|
||||
'Account & Security — credentials and protection for your own account.',
|
||||
'Audit & System Activity Log — the record of events, read in full on Activity.',
|
||||
],
|
||||
note: 'Ask me on Activity for who did what, and when — that page holds the events.',
|
||||
},
|
||||
{
|
||||
id: 'screen',
|
||||
label: 'What this screen configures',
|
||||
heading: 'Settings',
|
||||
match: /\b(configure|configuration|configures|screen|settings|manage|set up|do here|organization|organisation|client|clients)\b/,
|
||||
text: 'Settings is the account and system side of Krow. It configures who you are and how the workspace behaves — never the workforce records themselves.',
|
||||
bullets: [
|
||||
'Account & Security — your profile details and how this account is protected.',
|
||||
'Organization — the clients, positions and people this workspace is structured around.',
|
||||
'Users & Permissions — who may do what.',
|
||||
'Audit & System Activity — the log of what has happened.',
|
||||
'Automation — the behaviours below, including whether Owliver opens by default.',
|
||||
],
|
||||
note: 'Changes here are saved to this account.',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export const SETTINGS_TOPICS = SETTINGS.topics;
|
||||
export const SETTINGS_CAPABILITIES = SETTINGS.capabilities;
|
||||
export const respondSettings = SETTINGS.respond;
|
||||
|
||||
/* ── Workspace hub ──────────────────────────────────────────────────────── */
|
||||
|
||||
const WORKSPACE = workspacePage({
|
||||
label: 'Workspace',
|
||||
topics: [
|
||||
'workspace', 'agent', 'agents', 'skill', 'skills', 'capability', 'capabilities',
|
||||
'training', 'path', 'paths', 'library', 'registry', 'owliver', 'extend', 'configure',
|
||||
'configuration', 'govern', 'development',
|
||||
],
|
||||
elsewhere: RECORDS_ELSEWHERE,
|
||||
sections: [
|
||||
{
|
||||
id: 'agents',
|
||||
label: 'What an agent is',
|
||||
heading: 'Agents',
|
||||
match: /\b(agent|agents|subagent|subagents|reasoning)\b/,
|
||||
text: 'An agent decides how Owliver answers on a page: which of that page’s skills it may use, what it knows, and how much work an answer is worth.',
|
||||
bullets: [
|
||||
'It narrows what a page offers. It can never widen it.',
|
||||
'A page written for an agent opens on that agent.',
|
||||
'A page with no agent of its own opens on the general Krow Workforce Agent.',
|
||||
],
|
||||
note: 'Agents are listed and edited in Workspace → Agents.',
|
||||
},
|
||||
{
|
||||
id: 'skills',
|
||||
label: 'Where skills come from',
|
||||
heading: 'Skills',
|
||||
match: /\b(skill|skills|library|registry|capability|capabilities)\b/,
|
||||
text: 'Skills are authored once and attached to as many agents as you like. There is one library, so a skill behaves identically wherever it is used.',
|
||||
bullets: [
|
||||
'An Owliver skill answers questions.',
|
||||
'A Board skill extends a Krow page with a section.',
|
||||
'A skill only applies on the pages it itself declares.',
|
||||
],
|
||||
note: 'Both lists live in Workspace → Skills.',
|
||||
},
|
||||
{
|
||||
id: 'screen',
|
||||
label: 'What the workspace governs',
|
||||
heading: 'Workspace',
|
||||
match: /\b(workspace|govern|governs|overview|configure|configuration|extend)\b/,
|
||||
text: 'The workspace is where Krow’s capabilities are configured: the agents that answer, the skills they carry, and the training paths the workforce is measured against.',
|
||||
bullets: [
|
||||
'Agents — who answers on which page, and how.',
|
||||
'Skills — what can be answered or drawn, authored once and shared.',
|
||||
'Skill Development — the training paths the workforce progresses along.',
|
||||
],
|
||||
note: 'Nothing configured here reads workforce records on its own. A page decides what is in reach; configuration decides how much of that reach is used.',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export const WORKSPACE_TOPICS = WORKSPACE.topics;
|
||||
export const WORKSPACE_CAPABILITIES = WORKSPACE.capabilities;
|
||||
export const respondWorkspace = WORKSPACE.respond;
|
||||
|
||||
/* ── Agents list ────────────────────────────────────────────────────────── */
|
||||
|
||||
const WORKSPACE_AGENTS = workspacePage({
|
||||
label: 'Agents',
|
||||
topics: [
|
||||
'agent', 'agents', 'subagent', 'subagents', 'skill', 'skills', 'page', 'pages',
|
||||
'publish', 'published', 'draft', 'archive', 'archived', 'revert', 'shipped',
|
||||
'configure', 'configuration', 'reasoning', 'workspace', 'owliver',
|
||||
'constrained', 'cover', 'covers', 'fallback', 'default',
|
||||
],
|
||||
elsewhere: RECORDS_ELSEWHERE,
|
||||
sections: [
|
||||
{
|
||||
id: 'coverage',
|
||||
label: 'How a page picks its agent',
|
||||
heading: 'Which agent answers where',
|
||||
match: /\b(cover|covers|covered|page|pages|default|fallback|constrained|picks|pick|answers|resolve|resolves)\b/,
|
||||
bullets: [
|
||||
'A page with an agent written for it opens on that agent.',
|
||||
'A page with none opens on the general Krow Workforce Agent.',
|
||||
'Choosing an agent that does not cover the page you are on shows it as constrained here, and it declines rather than answering.',
|
||||
],
|
||||
note: 'An agent is a lens on the page you are standing on. It is never a way to reach a different page.',
|
||||
},
|
||||
{
|
||||
id: 'lifecycle',
|
||||
label: 'Draft, published, archived',
|
||||
heading: 'Draft, published, archived',
|
||||
match: /\b(draft|publish|published|publishing|archive|archived|revert|shipped|version|lifecycle)\b/,
|
||||
bullets: [
|
||||
'Draft — saved, but not answering anywhere yet.',
|
||||
'Published — in service, and offered in the Owliver switcher.',
|
||||
'Archived — taken out of service, definition kept. It can be restored as a draft.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'list',
|
||||
label: 'What this list holds',
|
||||
heading: 'The agents in this workspace',
|
||||
match: /\b(list|lists|registered|exist|exists|agent|agents|screen|workspace)\b/,
|
||||
text: 'Every agent Krow ships, plus anything this account has authored. Opening one configures it; nothing on this screen changes how Owliver is answering right now.',
|
||||
bullets: [
|
||||
'A shipped agent can be edited — your version is saved alongside it, and reverting brings the original back.',
|
||||
'A published agent is offered in the Owliver switcher.',
|
||||
'An archived agent is out of service, and its definition is kept.',
|
||||
],
|
||||
note: 'Configuring an agent never puts you on the page that agent covers.',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export const WORKSPACE_AGENTS_TOPICS = WORKSPACE_AGENTS.topics;
|
||||
export const WORKSPACE_AGENTS_CAPABILITIES = WORKSPACE_AGENTS.capabilities;
|
||||
export const respondWorkspaceAgents = WORKSPACE_AGENTS.respond;
|
||||
|
||||
/* ── Skills list ────────────────────────────────────────────────────────── */
|
||||
|
||||
const WORKSPACE_SKILLS = workspacePage({
|
||||
label: 'Skills',
|
||||
topics: [
|
||||
'skill', 'skills', 'library', 'registry', 'owliver', 'board', 'section', 'surface',
|
||||
'placement', 'markdown', 'definition', 'upload', 'author', 'attach',
|
||||
'active', 'draft', 'archive', 'archived', 'category', 'capability', 'capabilities',
|
||||
'workspace', 'agent', 'agents', 'page', 'pages',
|
||||
],
|
||||
elsewhere: RECORDS_ELSEWHERE,
|
||||
sections: [
|
||||
{
|
||||
id: 'attaching',
|
||||
label: 'How skills reach an agent',
|
||||
heading: 'Attaching a skill',
|
||||
match: /\b(attach|attached|attaching|agent|agents|reach|reaches|scope)\b/,
|
||||
bullets: [
|
||||
'Attaching a skill lets an agent use it — on the pages the skill itself declares.',
|
||||
'Attaching a skill an agent’s pages do not cover changes nothing on those pages.',
|
||||
'Removing a skill from an agent leaves it in the library for everything else.',
|
||||
],
|
||||
note: 'Agents are configured in Workspace → Agents.',
|
||||
},
|
||||
{
|
||||
id: 'authoring',
|
||||
label: 'How a skill is written',
|
||||
heading: 'Writing a skill',
|
||||
match: /\b(author|authored|authoring|write|written|writing|markdown|definition|upload|edit|edited)\b/,
|
||||
text: 'A skill is a Markdown file: frontmatter declares what it is and which pages it applies to, and the body documents what it can do.',
|
||||
bullets: [
|
||||
'It only applies on the pages it declares.',
|
||||
'It is attached to an agent from this shared library, never copied into one.',
|
||||
'A definition naming something the product does not have is refused with a message rather than half-registered.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'lists',
|
||||
label: 'The two skill lists',
|
||||
heading: 'Owliver skills and Board skills',
|
||||
match: /\b(list|lists|two|owliver|board|kind|kinds|difference|library|registry|skill|skills)\b/,
|
||||
bullets: [
|
||||
'An Owliver skill answers questions in this panel.',
|
||||
'A Board skill extends a Krow page with a section the page renders.',
|
||||
'A definition can do both; it is filed by what it declares, never by a setting.',
|
||||
],
|
||||
note: 'One library behind both lists, so a skill behaves identically wherever it is used.',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export const WORKSPACE_SKILLS_TOPICS = WORKSPACE_SKILLS.topics;
|
||||
export const WORKSPACE_SKILLS_CAPABILITIES = WORKSPACE_SKILLS.capabilities;
|
||||
export const respondWorkspaceSkills = WORKSPACE_SKILLS.respond;
|
||||
|
||||
/* ── Skill editor ───────────────────────────────────────────────────────── */
|
||||
|
||||
const SKILL_CONFIGURE = workspacePage({
|
||||
label: 'Skill Configure',
|
||||
topics: [
|
||||
'skill', 'skills', 'definition', 'markdown', 'frontmatter', 'field', 'fields',
|
||||
'page', 'pages', 'surface', 'placement', 'section', 'source', 'sources', 'trigger',
|
||||
'triggers', 'action', 'actions', 'capability', 'capabilities', 'category', 'status',
|
||||
'active', 'draft', 'archive', 'archived', 'validate', 'validation', 'save', 'publish',
|
||||
'editor', 'configure', 'configuration', 'owliver', 'board', 'workspace',
|
||||
],
|
||||
elsewhere: RECORDS_ELSEWHERE,
|
||||
sections: [
|
||||
{
|
||||
id: 'validation',
|
||||
label: 'Why a definition is refused',
|
||||
heading: 'Validation',
|
||||
match: /\b(valid|validate|validated|validation|refuse|refused|error|errors|fail|fails|reject|rejected|wrong)\b/,
|
||||
text: 'The vocabulary is closed, so everything a definition names is checked before it is stored.',
|
||||
bullets: [
|
||||
'A page, placement or source the product does not have is refused with a message.',
|
||||
'A section that needs a record the placement does not supply is refused rather than drawing an empty card.',
|
||||
'A definition that parses but loses a field reports what it lost, instead of half-registering.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'reach',
|
||||
label: 'Where this skill will apply',
|
||||
heading: 'Where it applies',
|
||||
match: /\b(apply|applies|applied|where|reach|reaches|attach|attached|agent|agents|scope|surface|placement)\b/,
|
||||
bullets: [
|
||||
'On the pages this definition declares, and nowhere else.',
|
||||
'For an agent, only once it is attached to that agent.',
|
||||
'An agent can never use a skill to reach a page the skill does not declare.',
|
||||
],
|
||||
note: 'Attach it in Workspace → Agents.',
|
||||
},
|
||||
{
|
||||
id: 'screen',
|
||||
label: 'What this editor configures',
|
||||
heading: 'Editing a skill',
|
||||
match: /\b(editor|edit|editing|configure|configures|configuration|definition|frontmatter|field|fields|status|trigger|triggers|screen|skill|skills)\b/,
|
||||
text: 'A skill definition, as Markdown. The frontmatter declares what it is and where it applies; the body documents what it can do.',
|
||||
bullets: [
|
||||
'Pages — the surfaces this skill applies on. It never applies anywhere else.',
|
||||
'Triggers — the wordings that reach it.',
|
||||
'Capabilities — what it can answer or draw.',
|
||||
'Status — active, draft or archived.',
|
||||
],
|
||||
note: 'Nothing is executed from Markdown. A definition names things the product already has, and a name it does not have is refused.',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export const SKILL_CONFIGURE_TOPICS = SKILL_CONFIGURE.topics;
|
||||
export const SKILL_CONFIGURE_CAPABILITIES = SKILL_CONFIGURE.capabilities;
|
||||
export const respondSkillConfigure = SKILL_CONFIGURE.respond;
|
||||
|
||||
/* ── Skill Development ──────────────────────────────────────────────────── */
|
||||
|
||||
const SKILL_DEVELOPMENT = workspacePage({
|
||||
label: 'Skill Development',
|
||||
topics: [
|
||||
'training', 'path', 'paths', 'progression', 'level', 'levels', 'rung', 'ladder',
|
||||
'development', 'capability', 'capabilities', 'define', 'definition',
|
||||
'workspace', 'configure', 'configuration', 'owliver',
|
||||
],
|
||||
elsewhere: RECORDS_ELSEWHERE,
|
||||
sections: [
|
||||
{
|
||||
id: 'levels',
|
||||
label: 'How the levels work',
|
||||
heading: 'Levels',
|
||||
match: /\b(level|levels|rung|rungs|ladder|progress|progression|stage|stages)\b/,
|
||||
bullets: [
|
||||
'Levels are ordered, and each states what someone at that level can do.',
|
||||
'A path with no levels defines a name and nothing measurable.',
|
||||
'Editing a level changes the definition, never anyone’s recorded progress.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'screen',
|
||||
label: 'What a training path is',
|
||||
heading: 'Skill Development',
|
||||
match: /\b(training|path|paths|define|defines|definition|development|capability|capabilities|configure|screen)\b/,
|
||||
text: 'A training path defines a capability the workforce holds and the levels it progresses through. It is a definition, not a reading: nothing here says who currently holds what.',
|
||||
bullets: [
|
||||
'Each path names its levels, from first exposure to independent practice.',
|
||||
'A path is neither an Owliver skill nor a Board skill — it describes people, not the product.',
|
||||
'It is authored here and read wherever progression is shown.',
|
||||
],
|
||||
note: 'For who holds which capability today, ask me on Talent Pool or KROW Forge — those pages hold the records.',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export const SKILL_DEVELOPMENT_TOPICS = SKILL_DEVELOPMENT.topics;
|
||||
export const SKILL_DEVELOPMENT_CAPABILITIES = SKILL_DEVELOPMENT.capabilities;
|
||||
export const respondSkillDevelopment = SKILL_DEVELOPMENT.respond;
|
||||
@@ -13,6 +13,17 @@ import {
|
||||
respondPositions,
|
||||
respondProfile, respondTalentPool,
|
||||
} from './capabilities/admin';
|
||||
import {
|
||||
AGENT_CONFIGURE_CAPABILITIES, AGENT_CONFIGURE_TOPICS,
|
||||
SETTINGS_CAPABILITIES, SETTINGS_TOPICS,
|
||||
SKILL_CONFIGURE_CAPABILITIES, SKILL_CONFIGURE_TOPICS,
|
||||
SKILL_DEVELOPMENT_CAPABILITIES, SKILL_DEVELOPMENT_TOPICS,
|
||||
WORKSPACE_AGENTS_CAPABILITIES, WORKSPACE_AGENTS_TOPICS,
|
||||
WORKSPACE_CAPABILITIES, WORKSPACE_SKILLS_CAPABILITIES, WORKSPACE_SKILLS_TOPICS,
|
||||
WORKSPACE_TOPICS,
|
||||
respondAgentConfigure, respondSettings, respondSkillConfigure, respondSkillDevelopment,
|
||||
respondWorkspace, respondWorkspaceAgents, respondWorkspaceSkills,
|
||||
} from './capabilities/workspace';
|
||||
|
||||
/**
|
||||
* Page contexts for the Owliver dashboard panel.
|
||||
@@ -145,6 +156,87 @@ export const ASSISTANT_CONTEXTS = {
|
||||
capabilities: PROFILE_CAPABILITIES,
|
||||
respond: respondProfile,
|
||||
},
|
||||
/**
|
||||
* Agent configuration — a workspace page, not an operational one.
|
||||
*
|
||||
* It holds no workforce records, and no skill declares it, so
|
||||
* `skillsForContext` returns nothing here. That is deliberate rather than an
|
||||
* omission: configuring the Analytics Agent must not put the reader on
|
||||
* Analytics, and the surest way to guarantee that is for this page to have no
|
||||
* operational reading available at all.
|
||||
*
|
||||
* `topics` keeps it honest in the other direction — a workforce question asked
|
||||
* here is declined and pointed at the page that holds the records, rather than
|
||||
* answered from whatever this screen happens to know.
|
||||
*/
|
||||
'admin.agentConfigure': {
|
||||
id: 'admin.agentConfigure',
|
||||
page: 'Agent Configure',
|
||||
topics: AGENT_CONFIGURE_TOPICS,
|
||||
capabilities: AGENT_CONFIGURE_CAPABILITIES,
|
||||
respond: respondAgentConfigure,
|
||||
},
|
||||
/**
|
||||
* Settings, and the workspace pages behind it.
|
||||
*
|
||||
* **A page with no agent of its own is not a page without Owliver.** These
|
||||
* six carry the same panel as the eight operational pages, resolved by the
|
||||
* same placement table, answered by the same responder mechanism — and they
|
||||
* open on the general Krow Workforce Agent, because no specialist was written
|
||||
* for a configuration screen and none needs to be.
|
||||
*
|
||||
* What they do not carry is workforce records. No skill declares these
|
||||
* surfaces, so `skillsForContext` returns an empty list on every one of them,
|
||||
* and `topics` keeps the decline honest in the other direction: a question
|
||||
* about positions or attendance asked here is pointed at the page that holds
|
||||
* those records rather than answered from a configuration screen.
|
||||
*
|
||||
* The two halves are the whole design. Remove the first and Owliver is dead
|
||||
* on half the product; remove the second and it invents figures on a page
|
||||
* that has none.
|
||||
*/
|
||||
'admin.settings': {
|
||||
id: 'admin.settings',
|
||||
page: 'Settings',
|
||||
topics: SETTINGS_TOPICS,
|
||||
capabilities: SETTINGS_CAPABILITIES,
|
||||
respond: respondSettings,
|
||||
},
|
||||
'admin.workspace': {
|
||||
id: 'admin.workspace',
|
||||
page: 'Workspace',
|
||||
topics: WORKSPACE_TOPICS,
|
||||
capabilities: WORKSPACE_CAPABILITIES,
|
||||
respond: respondWorkspace,
|
||||
},
|
||||
'admin.workspaceAgents': {
|
||||
id: 'admin.workspaceAgents',
|
||||
page: 'Agents',
|
||||
topics: WORKSPACE_AGENTS_TOPICS,
|
||||
capabilities: WORKSPACE_AGENTS_CAPABILITIES,
|
||||
respond: respondWorkspaceAgents,
|
||||
},
|
||||
'admin.workspaceSkills': {
|
||||
id: 'admin.workspaceSkills',
|
||||
page: 'Skills',
|
||||
topics: WORKSPACE_SKILLS_TOPICS,
|
||||
capabilities: WORKSPACE_SKILLS_CAPABILITIES,
|
||||
respond: respondWorkspaceSkills,
|
||||
},
|
||||
'admin.skillConfigure': {
|
||||
id: 'admin.skillConfigure',
|
||||
page: 'Skill Configure',
|
||||
topics: SKILL_CONFIGURE_TOPICS,
|
||||
capabilities: SKILL_CONFIGURE_CAPABILITIES,
|
||||
respond: respondSkillConfigure,
|
||||
},
|
||||
'admin.skillDevelopment': {
|
||||
id: 'admin.skillDevelopment',
|
||||
page: 'Skill Development',
|
||||
topics: SKILL_DEVELOPMENT_TOPICS,
|
||||
capabilities: SKILL_DEVELOPMENT_CAPABILITIES,
|
||||
respond: respondSkillDevelopment,
|
||||
},
|
||||
'admin.activity': {
|
||||
id: 'admin.activity',
|
||||
page: 'Activity',
|
||||
|
||||
@@ -205,6 +205,24 @@ const GREETINGS = {
|
||||
return `${plural(f.activity24h.length, 'event')} today, ${f.activity7d.length} this week, nothing out of pattern.`;
|
||||
},
|
||||
|
||||
'admin.agentConfigure': () =>
|
||||
'Configure what this agent covers, its attached skills, reference knowledge, and reasoning behavior.',
|
||||
|
||||
/* Settings and the workspace pages. Static rather than computed, because a
|
||||
configuration screen has no records to read and a line that pretended
|
||||
otherwise would be the fabrication the whole design refuses. */
|
||||
'admin.settings': () =>
|
||||
'Your account, the organization, who may do what, and the behaviours this workspace runs on.',
|
||||
'admin.workspace': () =>
|
||||
'The agents that answer, the skills they carry, and the paths the workforce progresses along.',
|
||||
'admin.workspaceAgents': () =>
|
||||
'Every agent this workspace has, what each one covers, and which are in service.',
|
||||
'admin.workspaceSkills': () =>
|
||||
'The shared library — what Owliver can answer, and what a Krow page can draw.',
|
||||
'admin.skillConfigure': () =>
|
||||
'Declare what this skill is, which pages it applies on, and what it can do.',
|
||||
'admin.skillDevelopment': () =>
|
||||
'Define the capabilities the workforce holds, and the levels they progress through.',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -233,6 +251,13 @@ const TITLES = {
|
||||
'admin.talentPool': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.hiredHistory': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.profile': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.agentConfigure': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.settings': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.workspace': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.workspaceAgents': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.workspaceSkills': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.skillConfigure': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.skillDevelopment': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
};
|
||||
|
||||
/** Descriptions state what this page's assistant can actually do with the data. */
|
||||
@@ -262,6 +287,20 @@ const DESCRIPTIONS = {
|
||||
`Reading ${plural(f.hiring.hires.length, 'hire')} across ${plural(f.hiring.byDepartment.length, 'department')} — quality, speed and outcomes.`,
|
||||
'admin.profile': () =>
|
||||
'Reading this account — permissions, security, preferences and your own activity.',
|
||||
'admin.agentConfigure': () =>
|
||||
'Ask Owliver about agent settings, lifecycle, attaching skills, or custom instructions.',
|
||||
'admin.settings': () =>
|
||||
'Ask Owliver what this screen configures, what each control does, and where the records behind a workforce question live.',
|
||||
'admin.workspace': () =>
|
||||
'Ask Owliver what the workspace governs — agents, skills, and how a page decides which agent answers.',
|
||||
'admin.workspaceAgents': () =>
|
||||
'Ask Owliver how a page picks its agent, what constrains one, and what draft, published and archived mean.',
|
||||
'admin.workspaceSkills': () =>
|
||||
'Ask Owliver about the two skill lists, how a skill is written, and how one reaches an agent.',
|
||||
'admin.skillConfigure': () =>
|
||||
'Ask Owliver what this editor declares, why a definition is refused, and where the skill will apply.',
|
||||
'admin.skillDevelopment': () =>
|
||||
'Ask Owliver what a training path defines and how its levels work.',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -342,6 +381,41 @@ const PLACEHOLDERS = {
|
||||
'Ask Owliver what you can change here…',
|
||||
'Ask Owliver about your security settings…',
|
||||
],
|
||||
'admin.agentConfigure': () => [
|
||||
'Ask Owliver about agent configuration…',
|
||||
'Ask Owliver how skills attach…',
|
||||
'Ask Owliver about reasoning modes…',
|
||||
],
|
||||
'admin.settings': () => [
|
||||
'Ask Owliver what you can configure here…',
|
||||
'Ask Owliver what the automation toggles do…',
|
||||
'Ask Owliver about permissions and security…',
|
||||
],
|
||||
'admin.workspace': () => [
|
||||
'Ask Owliver what the workspace governs…',
|
||||
'Ask Owliver what an agent is…',
|
||||
'Ask Owliver where skills come from…',
|
||||
],
|
||||
'admin.workspaceAgents': () => [
|
||||
'Ask Owliver how a page picks its agent…',
|
||||
'Ask Owliver what constrained means…',
|
||||
'Ask Owliver about draft and published…',
|
||||
],
|
||||
'admin.workspaceSkills': () => [
|
||||
'Ask Owliver about the two skill lists…',
|
||||
'Ask Owliver how a skill is written…',
|
||||
'Ask Owliver how skills reach an agent…',
|
||||
],
|
||||
'admin.skillConfigure': () => [
|
||||
'Ask Owliver what this editor declares…',
|
||||
'Ask Owliver why a definition is refused…',
|
||||
'Ask Owliver where this skill will apply…',
|
||||
],
|
||||
'admin.skillDevelopment': () => [
|
||||
'Ask Owliver what a training path is…',
|
||||
'Ask Owliver how the levels work…',
|
||||
'Ask Owliver what this screen defines…',
|
||||
],
|
||||
};
|
||||
|
||||
/* ── Suggested prompts ──────────────────────────────────────────────────── */
|
||||
@@ -671,6 +745,52 @@ const PROMPTS = {
|
||||
{ label: 'Who is most active?', prompt: 'Which accounts are most active?', capability: 'user-activity' },
|
||||
],
|
||||
|
||||
'admin.agentConfigure': () => [
|
||||
{ label: 'Where do skills come from?', prompt: 'Where do skills come from on this screen?' },
|
||||
{ label: 'Explain reasoning modes', prompt: 'What are the reasoning modes and how do they work?' },
|
||||
{ label: 'How to write instructions?', prompt: 'How should I write effective instructions for this agent?' },
|
||||
{ label: 'How does publishing work?', prompt: 'How does agent publishing and versioning work?' },
|
||||
],
|
||||
|
||||
/**
|
||||
* Settings and the workspace pages.
|
||||
*
|
||||
* Every chip below resolves to something this page can actually answer, and
|
||||
* that is the whole rule: a suggestion is a promise, so a configuration screen
|
||||
* must not offer "Which positions are at risk?" merely to have chips. None of
|
||||
* these names a record; each one names the screen. The operational chips above
|
||||
* are computed from data because those pages have data — these are not, for
|
||||
* exactly the same reason.
|
||||
*/
|
||||
'admin.settings': () => [
|
||||
{ label: 'What can I configure here?', prompt: 'What can I configure on Settings?' },
|
||||
{ label: 'What do the automation toggles do?', prompt: 'What do the automation toggles do?' },
|
||||
{ label: 'Who can access what?', prompt: 'Who can access what, and where is it recorded?' },
|
||||
],
|
||||
'admin.workspace': () => [
|
||||
{ label: 'What does the workspace govern?', prompt: 'What does the workspace govern?' },
|
||||
{ label: 'What is an agent?', prompt: 'What is an agent, and what does it decide?' },
|
||||
{ label: 'Where do skills come from?', prompt: 'Where do skills come from?' },
|
||||
],
|
||||
'admin.workspaceAgents': () => [
|
||||
{ label: 'How does a page pick its agent?', prompt: 'How does a page pick its agent?' },
|
||||
{ label: 'What does draft mean?', prompt: 'What do draft, published and archived mean?' },
|
||||
{ label: 'What is in this list?', prompt: 'What does this list of agents hold?' },
|
||||
],
|
||||
'admin.workspaceSkills': () => [
|
||||
{ label: 'How do skills reach an agent?', prompt: 'How do skills reach an agent?' },
|
||||
{ label: 'How is a skill written?', prompt: 'How is a skill written?' },
|
||||
{ label: 'What are the two lists?', prompt: 'What is the difference between the two skill lists?' },
|
||||
],
|
||||
'admin.skillConfigure': () => [
|
||||
{ label: 'Why is a definition refused?', prompt: 'Why would a definition be refused?' },
|
||||
{ label: 'Where will this apply?', prompt: 'Where will this skill apply?' },
|
||||
{ label: 'What does this editor declare?', prompt: 'What does this editor configure?' },
|
||||
],
|
||||
'admin.skillDevelopment': () => [
|
||||
{ label: 'How do the levels work?', prompt: 'How do the levels work?' },
|
||||
{ label: 'What is a training path?', prompt: 'What is a training path?' },
|
||||
],
|
||||
};
|
||||
|
||||
/* ── Public API ─────────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -31,6 +31,42 @@ const MAX_RECORDS = 100;
|
||||
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* The shape of a stored record.
|
||||
*
|
||||
* Bumped when a record gains fields, so a reader can tell an old record from a
|
||||
* new one instead of guessing from which keys happen to be present.
|
||||
*/
|
||||
const SCHEMA = 2;
|
||||
|
||||
/**
|
||||
* An older record, brought up to date on the way out.
|
||||
*
|
||||
* A conversation held before agents existed is still a conversation somebody
|
||||
* had. It gets the new fields at their empty values and keeps everything it
|
||||
* already had — in particular `messages` is passed through untouched, so
|
||||
* nothing a reader wrote is rewritten by a schema change.
|
||||
*
|
||||
* Migrating on read rather than rewriting the store means a browser that never
|
||||
* opens History again still loses nothing, and there is no migration pass that
|
||||
* can fail halfway.
|
||||
*/
|
||||
function migrate(record) {
|
||||
if (record.schema === SCHEMA) return record;
|
||||
return {
|
||||
...record,
|
||||
schema: SCHEMA,
|
||||
/* Absent, not unknown: these conversations genuinely had no agent, no
|
||||
recorded skills and no feedback. */
|
||||
agentId: record.agentId ?? null,
|
||||
pageContext: record.pageContext ?? null,
|
||||
skillsUsed: Array.isArray(record.skillsUsed) ? record.skillsUsed : [],
|
||||
toolsUsed: Array.isArray(record.toolsUsed) ? record.toolsUsed : [],
|
||||
knowledgeUsed: Array.isArray(record.knowledgeUsed) ? record.knowledgeUsed : [],
|
||||
feedback: record.feedback ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Every archived conversation, newest first. Never throws. */
|
||||
export function readHistory() {
|
||||
try {
|
||||
@@ -42,6 +78,7 @@ export function readHistory() {
|
||||
return list
|
||||
.filter((r) => r && r.id && Array.isArray(r.messages) && r.messages.length)
|
||||
.filter((r) => new Date(r.updatedAt || 0).getTime() >= cutoff)
|
||||
.map(migrate)
|
||||
.sort((a, b) => new Date(b.updatedAt || 0).getTime() - new Date(a.updatedAt || 0).getTime());
|
||||
} catch {
|
||||
/* Corrupt or unavailable storage is an empty history, not an error the
|
||||
@@ -64,13 +101,39 @@ function titleFor(messages) {
|
||||
* Matched on `id`, so a thread being added to updates in place rather than
|
||||
* appearing once per turn.
|
||||
*/
|
||||
export function saveConversation({ id, contextId, page, messages }) {
|
||||
export function saveConversation({
|
||||
id, contextId, page, messages,
|
||||
/**
|
||||
* Who answered, where, and what it actually used.
|
||||
*
|
||||
* Recorded from what ran rather than from what was available: `skillsUsed`
|
||||
* is the skill that answered a turn, not every skill the page offered. A
|
||||
* record of what *could* have happened would make the insight figures
|
||||
* describe the registry instead of the conversation.
|
||||
*/
|
||||
agentId = null, pageContext = null,
|
||||
skillsUsed = [], toolsUsed = [], knowledgeUsed = [],
|
||||
}) {
|
||||
if (!id || !Array.isArray(messages) || !messages.length) return readHistory();
|
||||
|
||||
/* Feedback belongs to the conversation, not to the turn that triggered a
|
||||
save, so an existing rating survives the thread growing. */
|
||||
const existing = readHistory().find((r) => r.id === id) || null;
|
||||
|
||||
const record = {
|
||||
schema: SCHEMA,
|
||||
id,
|
||||
contextId,
|
||||
page: page || '',
|
||||
agentId,
|
||||
/* The *reduced* envelope — where the question was asked, never a copy of
|
||||
what was on screen. Storing selections and computed figures would write
|
||||
the dataset into localStorage a turn at a time. */
|
||||
pageContext,
|
||||
skillsUsed: [...new Set(skillsUsed.filter(Boolean))],
|
||||
toolsUsed: [...new Set(toolsUsed.filter(Boolean))],
|
||||
knowledgeUsed: [...new Set(knowledgeUsed.filter(Boolean))],
|
||||
feedback: existing?.feedback ?? null,
|
||||
title: titleFor(messages),
|
||||
turns: messages.filter((m) => m.role === 'user').length,
|
||||
updatedAt: new Date().toISOString(),
|
||||
@@ -95,6 +158,41 @@ export function saveConversation({ id, contextId, page, messages }) {
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records how a conversation was rated.
|
||||
*
|
||||
* Updates in place and never appends: rating a thread twice is a correction,
|
||||
* not two opinions. Returns the refreshed list so a caller re-renders from one
|
||||
* read rather than two.
|
||||
*/
|
||||
export function recordFeedback(id, feedback) {
|
||||
const list = readHistory();
|
||||
const at = list.findIndex((r) => r.id === id);
|
||||
if (at === -1) return list;
|
||||
|
||||
const next = [...list];
|
||||
next[at] = {
|
||||
...next[at],
|
||||
feedback: feedback
|
||||
? {
|
||||
rating: feedback.rating === 'up' ? 'up' : 'down',
|
||||
note: String(feedback.note || '').trim() || null,
|
||||
at: new Date().toISOString(),
|
||||
}
|
||||
/* Clearing is a real action — someone un-rating a thread should leave no
|
||||
rating behind rather than a neutral one. */
|
||||
: null,
|
||||
};
|
||||
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
/* The rating stays in memory for this session. */
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Forgets one conversation. */
|
||||
export function removeConversation(id) {
|
||||
const next = readHistory().filter((r) => r.id !== id);
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
import { isUnlocked } from '@/lib/provingGround';
|
||||
import { getScoreBand } from '@/lib/talentHome';
|
||||
import { PRIVILEGED_EVENTS, activitySignals } from '@/lib/activitySignals';
|
||||
|
||||
export const pct = (n, d) => (d ? Math.round((n / d) * 100) : 0);
|
||||
|
||||
@@ -26,10 +27,12 @@ const DAY_MS = 1000 * 60 * 60 * 24;
|
||||
|
||||
/**
|
||||
* Events an auditor looks at first: they change who is employed or what is being
|
||||
* hired for. Kept here so the fact sheet, the Activity page's severity column and
|
||||
* the assistant's security answers all agree on what "privileged" means.
|
||||
* hired for. Defined in `lib/activitySignals.js` alongside the detection that
|
||||
* uses it, and re-exported here because the fact sheet, the Activity page's
|
||||
* severity column and the assistant's security answers all import it from this
|
||||
* module and must keep agreeing on what "privileged" means.
|
||||
*/
|
||||
export const PRIVILEGED_EVENTS = ['hire_candidate', 'create_position'];
|
||||
export { PRIVILEGED_EVENTS };
|
||||
|
||||
/** `today` is injected rather than read from the clock so answers are stable. */
|
||||
export function buildFacts({
|
||||
@@ -119,64 +122,15 @@ export function buildFacts({
|
||||
/**
|
||||
* Out-of-pattern activity.
|
||||
*
|
||||
* Detected here rather than in the assistant so the greeting, the answer and
|
||||
* any future page section all count the same things. "Two unusual patterns"
|
||||
* has to mean the same two everywhere it is said.
|
||||
* Computed by `lib/activitySignals.js` rather than here, because it now has
|
||||
* a second reader: the `activity.signals` data source resolves it for a card
|
||||
* or an answer, and `dataResolver.js` cannot import from this directory
|
||||
* without a library reaching up into the component tree.
|
||||
*
|
||||
* Each signal is a deviation from this platform's own baseline, not a verdict:
|
||||
* on a live deployment most of them resolve to an integration or a busy
|
||||
* afternoon, and the copy that renders them says so.
|
||||
* Moved, not changed. "Two unusual patterns" has to mean the same two
|
||||
* everywhere it is said, and one function is the only way to guarantee that.
|
||||
*/
|
||||
const activitySignals = (() => {
|
||||
const privileged = activity.filter((e) => PRIVILEGED_EVENTS.includes(e.event_type));
|
||||
|
||||
const perUser = activity.reduce((acc, e) => {
|
||||
acc[e.user_email] ||= { email: e.user_email, name: e.user_name, count: 0, privileged: 0 };
|
||||
acc[e.user_email].count += 1;
|
||||
if (PRIVILEGED_EVENTS.includes(e.event_type)) acc[e.user_email].privileged += 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const accounts = Object.values(perUser).sort((a, b) => b.count - a.count);
|
||||
const busiest = accounts[0] || null;
|
||||
const busiestShare = busiest ? pct(busiest.count, activity.length) : 0;
|
||||
|
||||
/* A burst is more than three actions from one account inside one hour. */
|
||||
const perAccountHour = activity.reduce((acc, e) => {
|
||||
const key = `${e.user_email}|${String(e.created_date).slice(0, 13)}`;
|
||||
acc[key] = (acc[key] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
const bursts = Object.values(perAccountHour).filter((n) => n > 3).length;
|
||||
|
||||
const offHours = activity.filter((e) => {
|
||||
const hour = new Date(e.created_date).getHours();
|
||||
return hour < 6 || hour >= 22;
|
||||
});
|
||||
|
||||
const privilegedShare = pct(privileged.length, activity.length);
|
||||
|
||||
/* The flags, in the order they are worth reading. A count of these is what
|
||||
the greeting reports, so anything added here changes that number. */
|
||||
const flags = [
|
||||
activity.length > 0 && busiestShare >= 50 && 'concentration',
|
||||
bursts > 0 && 'burst',
|
||||
offHours.length > 0 && 'off-hours',
|
||||
activity.length > 0 && since(1).length === 0 && 'silent',
|
||||
privilegedShare > 30 && 'privileged-share',
|
||||
].filter(Boolean);
|
||||
|
||||
return {
|
||||
accounts,
|
||||
busiest,
|
||||
busiestShare,
|
||||
bursts,
|
||||
offHours,
|
||||
privileged,
|
||||
privilegedShare,
|
||||
flags,
|
||||
};
|
||||
})();
|
||||
const signals = activitySignals(activity, today);
|
||||
|
||||
/* ── KROW Forge ──────────────────────────────────────────────────────────
|
||||
Derived with the same gate (`isUnlocked`) and the same completion record the
|
||||
@@ -386,7 +340,7 @@ export function buildFacts({
|
||||
activity7d: since(7),
|
||||
activity24h: since(1),
|
||||
eventCounts,
|
||||
activitySignals,
|
||||
activitySignals: signals,
|
||||
|
||||
/* Signed-in worker, and the pages built on these records */
|
||||
profile,
|
||||
|
||||
@@ -28,6 +28,40 @@ const PLACEMENT = {
|
||||
'/admin/talent-pool': 'admin.talentPool',
|
||||
'/admin/hired': 'admin.hiredHistory',
|
||||
'/admin/profile': 'admin.profile',
|
||||
/* Agent management. Configuring an agent is a workspace task, not an
|
||||
operational page — see `admin.agentConfigure` for what that means for
|
||||
what Owliver may read here. `agents/new` is listed exactly so it is
|
||||
never read as an agent whose id is "new". */
|
||||
'/admin/workspace/agents/new': 'admin.agentConfigure',
|
||||
|
||||
/**
|
||||
* Settings and the rest of the workspace.
|
||||
*
|
||||
* These pages have no agent written for them, and for a while that was read
|
||||
* as "no Owliver here" — the panel did not mount at all, so a product that
|
||||
* answered questions on every Admin page before specialists existed
|
||||
* answered on eight of them afterwards.
|
||||
*
|
||||
* The absence of a specialist is not the absence of an assistant. Each of
|
||||
* these carries the same panel, resolved by this same table, answering from
|
||||
* the general agent. What they do *not* carry is operational data: no skill
|
||||
* declares these surfaces, so a workforce question asked here is declined
|
||||
* and pointed at the page that holds the records rather than answered from
|
||||
* a configuration screen.
|
||||
*/
|
||||
'/admin/settings': 'admin.settings',
|
||||
'/admin/workspace': 'admin.workspace',
|
||||
'/admin/workspace/agents': 'admin.workspaceAgents',
|
||||
'/admin/workspace/skills': 'admin.workspaceSkills',
|
||||
'/admin/workspace/skill-development': 'admin.skillDevelopment',
|
||||
/* The skill editor, listed exactly for the same reason `agents/new` is: so
|
||||
`skills/new` is never read as a skill whose id is "new". This is also the
|
||||
entry the page key is derived from — `pageKeyForRoute` reads the surface
|
||||
table, and this route is the one the `workspace-skill-configure` surface
|
||||
declares. The Owliver editor's addresses reach the same context through
|
||||
the pattern table below; listing them here too would overwrite that key
|
||||
with a path tail. */
|
||||
'/admin/workspace/skills/new': 'admin.skillConfigure',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -35,9 +69,10 @@ const PLACEMENT = {
|
||||
* Pages that must never carry the assistant, listed explicitly so the intent is
|
||||
* documented rather than implied by omission.
|
||||
*
|
||||
* Admin: login only. Every other surface — including Profile, where the
|
||||
* questions are about the account rather than the workforce — carries
|
||||
* the panel.
|
||||
* Admin: login only. Every other surface — including Profile and Settings,
|
||||
* where the questions are about the account rather than the workforce,
|
||||
* and the workspace pages, where they are about the configuration —
|
||||
* carries the panel.
|
||||
* Employer: every page. The contextual panel is an Admin capability.
|
||||
* Talent: every page. The talent portal has the standalone Owliver product,
|
||||
* with its own voice experience, branding and workflow.
|
||||
@@ -63,10 +98,70 @@ export const EXCLUDED_ROUTES = [
|
||||
*/
|
||||
export const PLACEMENT_ROUTES = PLACEMENT.admin;
|
||||
|
||||
/**
|
||||
* Routes whose address carries a record id.
|
||||
*
|
||||
* The table above is matched exactly, which is deliberate and stays that way:
|
||||
* `/candidates` must not leak an assistant onto `/candidates/:id`. But an agent
|
||||
* is configured at `/admin/workspace/agents/<id>`, and no exact table can list
|
||||
* an address that contains an id nobody has created yet.
|
||||
*
|
||||
* So a second, much smaller table is consulted **only after the exact lookup
|
||||
* misses**. Every one of the eight operational pages is an exact match and
|
||||
* never reaches this code, so their placement is unchanged by construction
|
||||
* rather than by care.
|
||||
*
|
||||
* `exclude` keeps a sibling literal route — `agents/new` is the same screen and
|
||||
* is listed exactly — from being read as an id.
|
||||
*/
|
||||
const PLACEMENT_PATTERNS = {
|
||||
admin: [
|
||||
{
|
||||
/* Agent configuration: one path segment after `agents/`, and not a
|
||||
nested route beneath it. */
|
||||
test: (pathname) => /^\/admin\/workspace\/agents\/[^/]+$/.test(pathname),
|
||||
contextId: 'admin.agentConfigure',
|
||||
},
|
||||
{
|
||||
/* The Owliver skill editor — `skills/owliver/new` and
|
||||
`skills/owliver/<id>`. Two segments, so it is matched before the
|
||||
one-segment pattern below and can never be read as a skill whose id is
|
||||
"owliver". */
|
||||
test: (pathname) => /^\/admin\/workspace\/skills\/owliver\/[^/]+$/.test(pathname),
|
||||
contextId: 'admin.skillConfigure',
|
||||
},
|
||||
{
|
||||
/* The Board skill editor: one segment after `skills/`, and no deeper.
|
||||
`owliver` is excluded because it is a prefix rather than a skill, and
|
||||
the product has no page at that address. */
|
||||
test: (pathname) => /^\/admin\/workspace\/skills\/(?!owliver$)[^/]+$/.test(pathname),
|
||||
contextId: 'admin.skillConfigure',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* The dynamic routes, as literal examples.
|
||||
*
|
||||
* Exported so the registry and the checks can reason about a pattern without
|
||||
* re-implementing it. These are addresses the pattern genuinely matches.
|
||||
*/
|
||||
export const PLACEMENT_PATTERN_ROUTES = {
|
||||
'/admin/workspace/agents/:id': 'admin.agentConfigure',
|
||||
'/admin/workspace/skills/:id': 'admin.skillConfigure',
|
||||
'/admin/workspace/skills/owliver/new': 'admin.skillConfigure',
|
||||
'/admin/workspace/skills/owliver/:id': 'admin.skillConfigure',
|
||||
};
|
||||
|
||||
/** Returns the context for a role and path, or `null` to render no assistant. */
|
||||
export function resolveAssistantContext(role, pathname) {
|
||||
/* Exact first, always. The eight operational pages resolve here and never
|
||||
reach the patterns below. */
|
||||
const id = PLACEMENT[role]?.[pathname];
|
||||
return id ? ASSISTANT_CONTEXTS[id] : null;
|
||||
if (id) return ASSISTANT_CONTEXTS[id] ?? null;
|
||||
|
||||
const pattern = (PLACEMENT_PATTERNS[role] || []).find((p) => p.test(pathname));
|
||||
return pattern ? ASSISTANT_CONTEXTS[pattern.contextId] ?? null : null;
|
||||
}
|
||||
|
||||
/** Every enabled role/route pair — used by the placement verification. */
|
||||
|
||||
@@ -27,7 +27,52 @@
|
||||
*/
|
||||
|
||||
import { getContext } from './contexts';
|
||||
import { toSnapshots } from './blocks';
|
||||
import { note, toSnapshots } from './blocks';
|
||||
|
||||
/**
|
||||
* An answer, at the depth the agent asked for.
|
||||
*
|
||||
* Three modes, and only two of them do anything: `balanced` is today's
|
||||
* behaviour exactly, so every existing answer and every agent that declares no
|
||||
* reasoning is unchanged.
|
||||
*
|
||||
* `webSearch` is handled here too, and handled honestly. No search provider is
|
||||
* configured in this deployment, so an agent with the flag set gets a note
|
||||
* saying so rather than an answer that quietly came from nowhere. Silently
|
||||
* ignoring the flag would be worse: the configuration would read as working.
|
||||
*/
|
||||
function shapeForDepth(document, agent, facts) {
|
||||
if (!agent || !document?.blocks?.length) return document;
|
||||
|
||||
const blocks = [...document.blocks];
|
||||
|
||||
if (agent.reasoning === 'fast') {
|
||||
/* The headline and the first supporting block. Enough to answer, without
|
||||
the breakdown someone asking a quick question did not want. */
|
||||
const trimmed = blocks.slice(0, 2);
|
||||
return { ...document, blocks: trimmed.length ? trimmed : blocks };
|
||||
}
|
||||
|
||||
if (agent.reasoning === 'deep') {
|
||||
const counted = [
|
||||
facts?.applications?.length != null && `${facts.applications.length} applications`,
|
||||
facts?.postings?.length != null && `${facts.postings.length} positions`,
|
||||
facts?.staff?.length != null && `${facts.staff.length} hires`,
|
||||
].filter(Boolean);
|
||||
if (counted.length) {
|
||||
blocks.push(note(`Read from ${counted.join(', ')} on this page.`));
|
||||
}
|
||||
}
|
||||
|
||||
if (agent.webSearch) {
|
||||
blocks.push(note(
|
||||
'Web search is enabled on this agent, but no search provider is configured '
|
||||
+ 'in this deployment, so nothing outside this workspace was consulted.'
|
||||
));
|
||||
}
|
||||
|
||||
return { ...document, blocks };
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
@@ -41,7 +86,7 @@ export function createLocalProvider({ latency = 380, frameDelay = 26 } = {}) {
|
||||
return {
|
||||
id: 'local',
|
||||
|
||||
async *stream({ contextId, capability, question, facts, signal }) {
|
||||
async *stream({ contextId, capability, question, facts, agent = null, signal }) {
|
||||
const context = getContext(contextId);
|
||||
if (!context) throw new Error(`Unknown assistant context: ${contextId}`);
|
||||
|
||||
@@ -50,12 +95,25 @@ export function createLocalProvider({ latency = 380, frameDelay = 26 } = {}) {
|
||||
?? context.respond(question, facts)
|
||||
: context.respond(question, facts);
|
||||
|
||||
/**
|
||||
* The agent's reasoning mode, as answer depth.
|
||||
*
|
||||
* A real configuration effect rather than a label: `fast` returns the
|
||||
* headline and stops, `deep` says which records the reading counted.
|
||||
* `balanced` — the default, and what an agent that declares nothing gets
|
||||
* — is untouched, so this cannot change any existing answer.
|
||||
*
|
||||
* Depth never changes *what* was read. The page decided that before the
|
||||
* provider was called; this only decides how much of it to say.
|
||||
*/
|
||||
const shaped = shapeForDepth(document, agent, facts);
|
||||
|
||||
// A brief pause before the first frame, so the answer reads as considered
|
||||
// rather than precomputed.
|
||||
await sleep(latency);
|
||||
if (signal?.aborted) return;
|
||||
|
||||
for (const snapshot of toSnapshots(document)) {
|
||||
for (const snapshot of toSnapshots(shaped)) {
|
||||
if (signal?.aborted) return;
|
||||
yield snapshot;
|
||||
await sleep(frameDelay);
|
||||
@@ -83,11 +141,15 @@ export function createHttpProvider({ endpoint, headers = {} }) {
|
||||
return {
|
||||
id: 'http',
|
||||
|
||||
async *stream({ contextId, capability, question, signal }) {
|
||||
async *stream({ contextId, capability, question, agent = null, owliverContext = null, signal }) {
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
body: JSON.stringify({ contextId, capability, question }),
|
||||
/* `agent` and `owliverContext` travel; the fact sheet still does not.
|
||||
Dashboard records are read server-side from the caller's own session,
|
||||
so the client cannot ask about records it is not entitled to see —
|
||||
and an agent cannot widen that by being named in the body. */
|
||||
body: JSON.stringify({ contextId, capability, question, agent, owliverContext }),
|
||||
signal,
|
||||
});
|
||||
|
||||
|
||||
@@ -178,6 +178,33 @@ export function outOfScopeAnswer(context) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The reply when the selected agent does not cover this page.
|
||||
*
|
||||
* Says three things, because leaving any of them out invites the reader to
|
||||
* assume something untrue: which agent is active, that the *page* is what
|
||||
* bounds the answer rather than the agent being broken, and which agent is the
|
||||
* one for here.
|
||||
*
|
||||
* It deliberately does not answer from the requested agent's own pages. An
|
||||
* agent is a lens on the page you are standing on, never a way to reach another
|
||||
* one — reaching would make selecting an agent a way around the page boundary,
|
||||
* which is the one thing the design does not permit.
|
||||
*/
|
||||
export function constrainedAgentAnswer(agent, context, suggestion) {
|
||||
const covered = (agent?.pages || []).join(', ');
|
||||
return doc(
|
||||
text(`**${agent?.name || 'That agent'}** is selected, but it does not cover **${context?.page || 'this page'}**.`),
|
||||
note(covered
|
||||
? `It works on: ${covered}.`
|
||||
: 'It covers no pages yet.'),
|
||||
text(suggestion
|
||||
? `On this page, **${suggestion.name}** is the one that answers. Switch to it, or open a page ${agent?.name || 'this agent'} covers.`
|
||||
: 'Open a page it covers, or ask something this page can answer.'),
|
||||
note('An agent narrows what can be asked here. It never widens it, so the records this page holds are the records any agent can read from it.')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a question to an action, ahead of the page responder.
|
||||
*
|
||||
@@ -185,6 +212,7 @@ export function outOfScopeAnswer(context) {
|
||||
* { kind: 'answer' } — let this page respond
|
||||
* { kind: 'navigate', destination, doc } — other page; go there
|
||||
* { kind: 'outOfScope', doc } — nothing here can answer it
|
||||
* { kind: 'constrained', doc } — the agent does not cover this page
|
||||
*/
|
||||
/* ── Workforce ──────────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -820,6 +848,16 @@ function resolveDraftAction(question, workforce, positionId = null) {
|
||||
export function resolveIntent({
|
||||
question, contextId, disabledSkills = [], customSkills = [], roles = [], skillCategories = [],
|
||||
courses = [], workforce = null, skillContext = null,
|
||||
/**
|
||||
* The active agent, and where the reader is.
|
||||
*
|
||||
* Both optional and both inert when absent, so a caller that knows nothing
|
||||
* about agents resolves exactly as it always did. `agent` does not gate the
|
||||
* skills considered here — that has already happened, in `disabledSkills`,
|
||||
* which arrives carrying the agent's scoping. What it decides is whether this
|
||||
* agent should be answering on this page at all.
|
||||
*/
|
||||
agent = null, agentCoversPage = true, agentSuggestion = null, owliverContext = null,
|
||||
/* The record the control that raised this question was built from, when there
|
||||
was one. Only the draft flow reads it; a typed question carries none and
|
||||
resolves exactly as it always did. */
|
||||
@@ -827,6 +865,18 @@ export function resolveIntent({
|
||||
}) {
|
||||
const context = ASSISTANT_CONTEXTS[contextId] ?? null;
|
||||
|
||||
/**
|
||||
* 0. The selected agent does not belong here.
|
||||
*
|
||||
* Ahead of everything, because every matcher below would otherwise answer
|
||||
* from this page while the header names an agent that does not cover it — an
|
||||
* answer attributed to the wrong lens. The page still decides what is
|
||||
* readable; this only declines to pretend the agent chose it.
|
||||
*/
|
||||
if (agent && !agentCoversPage) {
|
||||
return { kind: 'constrained', agent, doc: constrainedAgentAnswer(agent, context, agentSuggestion) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Finishing a draft, wherever the reader is standing.
|
||||
*
|
||||
@@ -846,7 +896,10 @@ export function resolveIntent({
|
||||
/* 2. Current page skills — specific triggers, ahead of the general reader. */
|
||||
const skill = resolveSkill({
|
||||
question, contextId, disabledSkills, customSkills, roles, skillCategories, courses,
|
||||
skillContext,
|
||||
/* The envelope travels beside the collections rather than replacing them:
|
||||
a resolver reads records, and the envelope says where the reader is. A
|
||||
source that needs a position still finds it exactly where it always was. */
|
||||
skillContext: owliverContext ? { ...skillContext, owliver: owliverContext } : skillContext,
|
||||
});
|
||||
if (skill) return skill;
|
||||
|
||||
|
||||
@@ -19,9 +19,11 @@ import {
|
||||
interviewDone, interviewFailed,
|
||||
} from '@/lib/skills/workforceFlow';
|
||||
import {
|
||||
newConversationId, readHistory, removeConversation, saveConversation,
|
||||
newConversationId, readHistory, recordFeedback, removeConversation, saveConversation,
|
||||
} from './history';
|
||||
import { storableContext } from '@/lib/agents/context';
|
||||
import { buildFacts } from './insights';
|
||||
import { agentRequest } from '@/lib/agents/runtime';
|
||||
import { createAssistantProvider } from './provider';
|
||||
import { resolveIntent } from './routing';
|
||||
import { toSnapshots } from './blocks';
|
||||
@@ -233,11 +235,33 @@ export function useConversation({
|
||||
onUpdatePosition, onGenerateDescription,
|
||||
workforce = null, disabledSkills = [], customSkills = [],
|
||||
roles = [], skillCategories = [], courses = [], skillContext = null,
|
||||
/**
|
||||
* The active agent and where the reader is.
|
||||
*
|
||||
* Both optional. `disabledSkills` already arrives carrying the agent's
|
||||
* scoping — the caller applies `agentScopedDisabled` before handing it over —
|
||||
* so nothing here decides what may be read. These are for attribution: which
|
||||
* agent answered, and whether it belongs on this page.
|
||||
*/
|
||||
agent = null, agentCoversPage = true, agentSuggestion = null, owliverContext = null,
|
||||
/* The page's own name, recorded with an archived thread so History can say
|
||||
where a conversation happened without resolving the context again. */
|
||||
pageLabel = '',
|
||||
}) {
|
||||
const storageKey = `krow_assistant:${contextId}`;
|
||||
/**
|
||||
* One thread per agent per page.
|
||||
*
|
||||
* Switching agent starts a new conversation rather than continuing the last
|
||||
* one under a different name: the answers in a thread were produced by a
|
||||
* particular agent's scope, and appending a differently-scoped reply to them
|
||||
* would make the thread a record of something that never happened.
|
||||
*
|
||||
* With no agent the key is exactly what it was, so an existing stored thread
|
||||
* is still found.
|
||||
*/
|
||||
const storageKey = agent?.id
|
||||
? `krow_assistant:${agent.id}:${contextId}`
|
||||
: `krow_assistant:${contextId}`;
|
||||
const flowKey = `${storageKey}:flow`;
|
||||
const idKey = `${storageKey}:id`;
|
||||
|
||||
@@ -267,6 +291,26 @@ export function useConversation({
|
||||
silently abandon a position half-described. */
|
||||
const flowRef = React.useRef(null);
|
||||
|
||||
/**
|
||||
* What this conversation actually used.
|
||||
*
|
||||
* Accumulated as turns run, never predicted from what was available. A skill
|
||||
* lands here when it *answered*; a tool when `runAction` was asked to perform
|
||||
* it. That distinction is the whole value of the record — a list of what the
|
||||
* page offered would describe the registry, and the insight figures built on
|
||||
* it would describe the registry too.
|
||||
*
|
||||
* A ref rather than state: nothing re-renders when it changes, and it is read
|
||||
* only at the moment a thread is written.
|
||||
*/
|
||||
const usedRef = React.useRef({ skills: [], tools: [], knowledge: [] });
|
||||
|
||||
const noteUsed = React.useCallback((kind, id) => {
|
||||
if (!id) return;
|
||||
const bucket = usedRef.current[kind];
|
||||
if (bucket && !bucket.includes(id)) bucket.push(id);
|
||||
}, []);
|
||||
|
||||
const setFlow = React.useCallback((flow) => {
|
||||
flowRef.current = flow || null;
|
||||
try {
|
||||
@@ -298,6 +342,9 @@ export function useConversation({
|
||||
}
|
||||
setPending(null);
|
||||
setError(null);
|
||||
/* A different page or agent is a different conversation, so what the last
|
||||
one used does not carry over. */
|
||||
usedRef.current = { skills: [], tools: [], knowledge: [] };
|
||||
}, [storageKey, flowKey, idKey]);
|
||||
|
||||
/**
|
||||
@@ -333,8 +380,14 @@ export function useConversation({
|
||||
contextId,
|
||||
page: pageLabel,
|
||||
messages: next,
|
||||
agentId: agent?.id ?? null,
|
||||
/* Where the question was asked, reduced — see `storableContext`. */
|
||||
pageContext: owliverContext ? storableContext(owliverContext) : null,
|
||||
skillsUsed: usedRef.current.skills,
|
||||
toolsUsed: usedRef.current.tools,
|
||||
knowledgeUsed: usedRef.current.knowledge,
|
||||
}));
|
||||
}, [storageKey, idKey, contextId, pageLabel]);
|
||||
}, [storageKey, idKey, contextId, pageLabel, agent, owliverContext]);
|
||||
|
||||
/* Abort any in-flight response when the context changes or we unmount. */
|
||||
React.useEffect(() => () => abortRef.current?.abort(), [storageKey]);
|
||||
@@ -387,6 +440,7 @@ export function useConversation({
|
||||
: resolveIntent({
|
||||
question: text, contextId, disabledSkills, customSkills, roles, skillCategories,
|
||||
courses, workforce, skillContext, positionId,
|
||||
agent, agentCoversPage, agentSuggestion, owliverContext,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -570,6 +624,11 @@ export function useConversation({
|
||||
collection on every turn — including the turn that starts it. */
|
||||
if ('flow' in intent) setFlow(intent.flow);
|
||||
|
||||
/* What this turn used, recorded from the resolved intent rather than from
|
||||
the page's offer. `intent.skill` is the definition that answered. */
|
||||
noteUsed('skills', intent.skill?.id);
|
||||
noteUsed('tools', intent.action?.name);
|
||||
|
||||
if (intent.kind !== 'answer') {
|
||||
try {
|
||||
await streamDocument({
|
||||
@@ -606,6 +665,10 @@ export function useConversation({
|
||||
let latest = [];
|
||||
for await (const snapshot of provider.stream({
|
||||
contextId, 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,
|
||||
owliverContext,
|
||||
})) {
|
||||
if (controller.signal.aborted) break;
|
||||
latest = snapshot;
|
||||
@@ -708,6 +771,26 @@ export function useConversation({
|
||||
if (conversationRef.current === id) reset();
|
||||
}, [reset]);
|
||||
|
||||
/**
|
||||
* Rates the conversation on screen.
|
||||
*
|
||||
* The conversation rather than the turn: a reader judging an answer is
|
||||
* judging the exchange that produced it, and a per-turn rating would ask them
|
||||
* to score a paragraph out of context. Returns false when there is nothing to
|
||||
* rate yet, so a caller can stay honest rather than pretending it landed.
|
||||
*/
|
||||
const submitFeedback = React.useCallback((rating, noteText = '') => {
|
||||
if (!conversationRef.current) return false;
|
||||
setHistory(recordFeedback(conversationRef.current, rating ? { rating, note: noteText } : null));
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
/** How the conversation on screen is currently rated, or null. */
|
||||
const feedback = React.useMemo(
|
||||
() => history.find((r) => r.id === conversationRef.current)?.feedback ?? null,
|
||||
[history]
|
||||
);
|
||||
|
||||
return {
|
||||
messages,
|
||||
pending,
|
||||
@@ -716,6 +799,8 @@ export function useConversation({
|
||||
send,
|
||||
stop,
|
||||
reset,
|
||||
submitFeedback,
|
||||
feedback,
|
||||
/** Every archived conversation, newest first. */
|
||||
history,
|
||||
/** The conversation on screen, so History can mark it. */
|
||||
|
||||
@@ -80,6 +80,14 @@ export {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
export { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
/* Radix Collapsible, already present and previously unexported. The agent
|
||||
configuration screen is a stack of long sections, and a page that opened all
|
||||
of them at once would bury the one being edited. */
|
||||
export {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
export { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Sparkles } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
useApplications, useAssignments, useCourses, useCurrentUser, useInterviews, useJobPostings,
|
||||
usePreferences, useStaff, useUserActivity, useWorkerProfiles,
|
||||
usePreferences, useShiftRecords, useStaff, useUserActivity, useWorkerProfiles,
|
||||
} from '@/lib/krowHooks';
|
||||
import { allSkills } from '@/lib/skills/registry';
|
||||
import { sectionsForPage } from '@/lib/skills/uiConfig';
|
||||
@@ -84,6 +84,9 @@ export function useSkillDataContext(context) {
|
||||
the matching engine reads availability from assignments, and without them
|
||||
everybody would look free. */
|
||||
const { data: assignments = [] } = useAssignments();
|
||||
/* Shifts worked, missed and overrun, for the attendance and overtime
|
||||
sources. Same cache the panel reads. */
|
||||
const { data: shifts = [] } = useShiftRecords();
|
||||
const { data: user } = useCurrentUser();
|
||||
const { statesFor } = useWorkforcePaths();
|
||||
|
||||
@@ -107,12 +110,13 @@ export function useSkillDataContext(context) {
|
||||
assignments,
|
||||
staff,
|
||||
activity,
|
||||
shifts,
|
||||
trainingPaths,
|
||||
/* `published` belongs here: it is what changes when the reader edits the
|
||||
form this panel is sitting beside, and leaving it out froze every
|
||||
section on the first value the page ever published. */
|
||||
}), [published, context, applications, positions, interviews, courses, workerProfiles, assignments,
|
||||
staff, activity, trainingPaths]);
|
||||
staff, activity, shifts, trainingPaths]);
|
||||
}
|
||||
|
||||
/** One declared section, resolved and drawn. */
|
||||
|
||||
Reference in New Issue
Block a user