update agents skill design
This commit is contained in:
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. */
|
||||
|
||||
Reference in New Issue
Block a user