update owliver agent
This commit is contained in:
83
src/components/ai-assistant/AgentBadge.jsx
Normal file
83
src/components/ai-assistant/AgentBadge.jsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { agentIconFor } from '@/components/agents/icons';
|
||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||
import { useActiveAgent } from './AgentContext';
|
||||
|
||||
/**
|
||||
* Who is answering, in Owliver's own header.
|
||||
*
|
||||
* This was an agent *switcher*: the header opened a popover with a search box,
|
||||
* every registered agent, Browse all and Create agent. That made the panel a
|
||||
* second place to decide which agent answers, competing with the one place that
|
||||
* actually configures agents — the Agent Editor — and letting a reader put
|
||||
* Owliver into a state nothing on the page explained.
|
||||
*
|
||||
* It is now a label. The agent is whatever `AgentContext` resolves for the page
|
||||
* being read, which is the same resolution the runtime already uses to answer,
|
||||
* so the header cannot disagree with the reply underneath it. Switching belongs
|
||||
* to Agent Registry → Agents → Configure.
|
||||
*
|
||||
* Deliberately **not** a button, and deliberately no chevron: a disclosure
|
||||
* affordance that discloses nothing is worse than none. Nothing here is
|
||||
* clickable, so nothing here promises a menu.
|
||||
*/
|
||||
|
||||
/** The avatar for the primary agent; a lucide glyph for the rest. */
|
||||
function AgentGlyph({ agent, className = 'h-7 w-7' }) {
|
||||
const Icon = agentIconFor(agent?.icon);
|
||||
|
||||
if (!Icon) return <OwliverAvatar className={className} rounded="rounded-lg" />;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center justify-center rounded-lg border border-border/80 bg-surface-subtle text-krow-blue shadow-2xs',
|
||||
className
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The mark a constrained agent carries.
|
||||
*
|
||||
* Kept: it is a fact about the answer that follows — this agent does not cover
|
||||
* this page and will say so — not a control, and losing it would make a
|
||||
* constrained reply arrive with no warning.
|
||||
*/
|
||||
function ConstrainedTag() {
|
||||
return (
|
||||
<span className="inline-flex shrink-0 items-center rounded-md border border-amber-500/25 bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-amber-700 dark:text-amber-300">
|
||||
Constrained
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {any} props */
|
||||
export function AgentBadge({ page }) {
|
||||
const { agent, covers } = useActiveAgent();
|
||||
|
||||
return (
|
||||
<>
|
||||
{agent
|
||||
? <AgentGlyph agent={agent} />
|
||||
: <OwliverAvatar className="h-7 w-7" rounded="rounded-lg" />}
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="truncate font-heading text-body-sm font-semibold leading-tight text-ink-1">
|
||||
Owliver
|
||||
</h2>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<p className="truncate text-caption leading-tight text-ink-3">{page}</p>
|
||||
{agent && !covers && <ConstrainedTag />}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default AgentBadge;
|
||||
@@ -1,234 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Check, ChevronDown, Plus, Search, SquareArrowOutUpRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { searchAgents } from '@/lib/agents/registry';
|
||||
import { agentIconFor } from '@/components/agents/icons';
|
||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||
import { useActiveAgent } from './AgentContext';
|
||||
|
||||
/**
|
||||
* The agent switcher, inside Owliver's own header.
|
||||
*
|
||||
* Designed with high-density Krow Control Tower aesthetics: clean popover
|
||||
* geometry, smooth row hover/active states, and legible status indicators.
|
||||
*/
|
||||
|
||||
/** The avatar for the primary agent; a lucide glyph for the rest. */
|
||||
function AgentGlyph({ agent, className = 'h-7 w-7' }) {
|
||||
const Icon = agentIconFor(agent?.icon);
|
||||
|
||||
if (!Icon) return <OwliverAvatar className={className} rounded="rounded-lg" />;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center justify-center rounded-lg border border-border/80 bg-surface-subtle text-krow-blue shadow-2xs',
|
||||
className
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** The label a constrained agent carries, in the switcher and in the header. */
|
||||
function ConstrainedTag({ className = '' }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center rounded-md border border-amber-500/25 bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-amber-700 dark:text-amber-300',
|
||||
className
|
||||
)}
|
||||
>
|
||||
Constrained
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** One row in the list. */
|
||||
function AgentRow({ agent, active, covers, onSelect }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
onClick={() => onSelect(agent.id)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left transition-all',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50',
|
||||
active
|
||||
? 'bg-krow-blue-tint/70 text-ink-1'
|
||||
: 'hover:bg-surface-subtle text-ink-2'
|
||||
)}
|
||||
>
|
||||
<AgentGlyph agent={agent} className="h-7 w-7 shrink-0" />
|
||||
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="min-w-0 truncate text-body-sm font-semibold text-ink-1">
|
||||
{agent.name}
|
||||
</span>
|
||||
{!covers && <ConstrainedTag />}
|
||||
</span>
|
||||
{agent.description && (
|
||||
<span className="mt-0.5 block truncate text-[11px] leading-tight text-ink-4">
|
||||
{agent.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{active && (
|
||||
<Check className="h-4 w-4 text-krow-blue shrink-0 ml-1" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentSwitcher({ page }) {
|
||||
const navigate = useNavigate();
|
||||
const { agents, agent, covers, select, coversPage } = useActiveAgent();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [query, setQuery] = React.useState('');
|
||||
const listRef = React.useRef(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) setQuery('');
|
||||
}, [open]);
|
||||
|
||||
const matches = React.useMemo(() => {
|
||||
const found = searchAgents(agents, query);
|
||||
return [...found].sort((a, b) => Number(coversPage(b)) - Number(coversPage(a)));
|
||||
}, [agents, query, coversPage]);
|
||||
|
||||
const choose = (id) => {
|
||||
select(id);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const onListKeyDown = (event) => {
|
||||
const step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
|
||||
if (!step) return;
|
||||
const rows = [...(listRef.current?.querySelectorAll('[role="option"]') ?? [])];
|
||||
if (!rows.length) return;
|
||||
event.preventDefault();
|
||||
const at = rows.indexOf(document.activeElement);
|
||||
rows[Math.max(0, Math.min(rows.length - 1, (at === -1 ? 0 : at + step)))]?.focus();
|
||||
};
|
||||
|
||||
if (!agent) {
|
||||
return (
|
||||
<>
|
||||
<OwliverAvatar className="h-7 w-7" rounded="rounded-lg" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="font-heading text-body-sm font-semibold leading-tight text-ink-1">Owliver</h2>
|
||||
<p className="truncate text-caption leading-tight text-ink-3">{page}</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Owliver agent: ${agent.name}. Switch to another agent`}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
className="group flex min-w-0 flex-1 items-center gap-2.5 rounded-lg px-1.5 py-1 text-left transition-colors hover:bg-surface-subtle focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
<AgentGlyph agent={agent} />
|
||||
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="min-w-0 truncate font-heading text-body-sm font-semibold leading-tight text-ink-1">
|
||||
Owliver
|
||||
</span>
|
||||
<ChevronDown
|
||||
className="h-3 w-3 shrink-0 text-ink-4 transition-colors group-hover:text-krow-blue"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="truncate text-caption leading-tight text-ink-3">{page}</span>
|
||||
{!covers && <ConstrainedTag />}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent align="start" sideOffset={6} className="w-[21.5rem] p-0 shadow-lg rounded-xl border border-border/80 overflow-hidden">
|
||||
<div className="border-b border-border/60 bg-surface-subtle/40 px-3.5 py-2.5">
|
||||
<p className="font-heading text-body-sm font-semibold text-ink-1">Switch agent</p>
|
||||
<p className="mt-0.5 text-[11px] text-ink-4">
|
||||
Choose an agent to focus Owliver's scope on this page.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="px-3 pt-2.5 pb-1">
|
||||
<label className="relative block">
|
||||
<span className="sr-only">Search agents</span>
|
||||
<Search
|
||||
className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-ink-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search agents..."
|
||||
className="w-full h-8 rounded-lg border border-border/70 bg-surface-subtle/40 py-1 pl-8 pr-2.5 text-body-sm text-ink-1 placeholder:text-ink-4 transition-all focus:bg-surface focus:border-krow-blue/50 focus:outline-none focus:ring-1 focus:ring-krow-blue/30"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={listRef}
|
||||
role="listbox"
|
||||
aria-label="Agents"
|
||||
onKeyDown={onListKeyDown}
|
||||
className="max-h-[16rem] space-y-0.5 overflow-y-auto px-2 py-1.5"
|
||||
>
|
||||
{matches.map((candidate) => (
|
||||
<AgentRow
|
||||
key={candidate.id}
|
||||
agent={candidate}
|
||||
active={candidate.id === agent.id}
|
||||
covers={coversPage(candidate)}
|
||||
onSelect={choose}
|
||||
/>
|
||||
))}
|
||||
|
||||
{!matches.length && (
|
||||
<p className="px-3 py-4 text-center text-body-sm text-ink-3">No agents match that.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-border/80 bg-surface-subtle/30 px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setOpen(false); navigate('/admin/workspace/agents'); }}
|
||||
className="inline-flex items-center gap-1.5 rounded px-1.5 py-0.5 text-caption font-medium text-ink-3 transition-colors hover:text-krow-blue focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
<SquareArrowOutUpRight className="h-3 w-3" aria-hidden="true" />
|
||||
Browse all
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setOpen(false); navigate('/admin/workspace/agents/new'); }}
|
||||
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-caption font-semibold text-krow-blue transition-colors hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Create agent
|
||||
</button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export default AgentSwitcher;
|
||||
@@ -125,6 +125,36 @@ export function AssistantPanelProvider({ role, pathname, children }) {
|
||||
const [request, setRequest] = React.useState(null);
|
||||
const requestSeq = React.useRef(0);
|
||||
|
||||
/**
|
||||
* Something the workspace has *told* Owliver, waiting to be said.
|
||||
*
|
||||
* `ask` above is the page speaking as the reader — it puts a question in the
|
||||
* composer's place and a turn runs. This is the other half, and it is a
|
||||
* different act: attaching a skill to an agent is not a question, and staging
|
||||
* it as one would put words in the reader's mouth and then answer them.
|
||||
*
|
||||
* So a notice appends a single assistant turn stating what changed and what
|
||||
* can now be asked. It is the same conversation, the same panel and the same
|
||||
* thread — deliberately not a second chat, a toast queue or a system-message
|
||||
* lane, because the point of Owliver knowing about a skill is that the next
|
||||
* thing the reader types can use it.
|
||||
*
|
||||
* Held as state for the same reason a request is: the panel is unmounted
|
||||
* while collapsed, and the notice must still be here when it mounts.
|
||||
*/
|
||||
const [notice, setNotice] = React.useState(null);
|
||||
const noticeSeq = React.useRef(0);
|
||||
|
||||
/**
|
||||
* A capability test in flight: `{ id, trace, state, error }`.
|
||||
*
|
||||
* `trace` is what the agent editor was testing — the skill, the agent and the
|
||||
* surface — so the panel can say so and the drawer can show the outcome. It
|
||||
* is presentation only: nothing in the runtime reads it, and it is never
|
||||
* persisted with the thread.
|
||||
*/
|
||||
const [test, setTest] = React.useState(null);
|
||||
|
||||
const context = React.useMemo(
|
||||
() => resolveAssistantContext(role, pathname),
|
||||
[role, pathname]
|
||||
@@ -174,19 +204,68 @@ export function AssistantPanelProvider({ role, pathname, children }) {
|
||||
* match. Each call carries its own id, so asking the same question twice is
|
||||
* two turns rather than one ignored repeat.
|
||||
*/
|
||||
const ask = React.useCallback(({ question, positionId = null }) => {
|
||||
const ask = React.useCallback(({
|
||||
question, positionId = null, scope = null, capability = null, trace = null,
|
||||
}) => {
|
||||
const text = String(question || '').trim();
|
||||
if (!text) return;
|
||||
requestSeq.current += 1;
|
||||
setRequest({ id: requestSeq.current, question: text, positionId });
|
||||
const id = requestSeq.current;
|
||||
/* `scope` runs the turn against another page's skills — the agent editor
|
||||
testing a capability. See `useConversation.send`. Ordinary callers omit
|
||||
it and the turn is about the page the reader is on. */
|
||||
setRequest({ id, question: text, positionId, scope, capability, trace });
|
||||
/**
|
||||
* A capability test is a turn somebody is *waiting on the outcome of*, and
|
||||
* the panel is the only thing that knows when a turn has finished. So a
|
||||
* traced ask opens a small status the caller can watch — queued while the
|
||||
* panel is still mounting or busy, running once the turn starts, then done
|
||||
* or error. Untraced asks set nothing and behave exactly as before.
|
||||
*/
|
||||
if (trace) setTest({ id, trace, state: 'queued', error: null });
|
||||
setOpenState(true);
|
||||
}, [setOpenState]);
|
||||
|
||||
/** The panel reporting back on a traced turn. */
|
||||
const reportTest = React.useCallback((id, state, error = null) => {
|
||||
setTest((current) => (current?.id === id ? { ...current, state, error } : current));
|
||||
}, []);
|
||||
|
||||
/** Drops the test banner — on a new ordinary question, or when it is dismissed. */
|
||||
const clearTest = React.useCallback(() => setTest(null), []);
|
||||
|
||||
/** Taken by the panel once it has actually asked it. */
|
||||
const consumeRequest = React.useCallback((id) => {
|
||||
setRequest((current) => (current?.id === id ? null : current));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* States a fact in the conversation, without asking anything.
|
||||
*
|
||||
* `followUp` are the chips offered under it — a skill's own suggestions, so
|
||||
* the next step after attaching one is a question that skill was written to
|
||||
* answer rather than a blank composer.
|
||||
*
|
||||
* Never opens the panel on its own. A reader who collapsed Owliver to get the
|
||||
* full width does not want it reopened by a checkbox, and the notice is still
|
||||
* waiting in the thread when they open it again.
|
||||
*/
|
||||
const announce = React.useCallback(({ text, followUp = null }) => {
|
||||
const body = String(text || '').trim();
|
||||
if (!body) return;
|
||||
noticeSeq.current += 1;
|
||||
setNotice({
|
||||
id: noticeSeq.current,
|
||||
text: body,
|
||||
followUp: Array.isArray(followUp) && followUp.length ? followUp : null,
|
||||
});
|
||||
}, []);
|
||||
|
||||
/** Taken by the panel once it has actually said it. */
|
||||
const consumeNotice = React.useCallback((id) => {
|
||||
setNotice((current) => (current?.id === id ? null : current));
|
||||
}, []);
|
||||
|
||||
/* Width is written on every drag frame, so persistence is debounced rather
|
||||
than hitting sessionStorage sixty times a second. */
|
||||
const setWidthState = React.useCallback((next) => {
|
||||
@@ -220,8 +299,14 @@ export function AssistantPanelProvider({ role, pathname, children }) {
|
||||
ask,
|
||||
request,
|
||||
consumeRequest,
|
||||
announce,
|
||||
notice,
|
||||
consumeNotice,
|
||||
test,
|
||||
reportTest,
|
||||
clearTest,
|
||||
}), [context, supported, isOpen, isExpanded, width, setWidthState, setOpenState, setExpandedState,
|
||||
ask, request, consumeRequest]);
|
||||
ask, request, consumeRequest, announce, notice, consumeNotice, test, reportTest, clearTest]);
|
||||
|
||||
/* The page's own selection travels beside the window state, mounted here so
|
||||
both the page and the panel are inside it — a skill's data source resolves
|
||||
@@ -265,5 +350,11 @@ export function useAssistantPanel() {
|
||||
ask: () => {},
|
||||
request: null,
|
||||
consumeRequest: () => {},
|
||||
announce: () => {},
|
||||
notice: null,
|
||||
consumeNotice: () => {},
|
||||
test: null,
|
||||
reportTest: () => {},
|
||||
clearTest: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ArrowLeft, History, Maximize2, Minimize2, PanelRightClose, RotateCcw, Trash2,
|
||||
ArrowLeft, History, Maximize2, Minimize2, PanelRightClose, RotateCcw, Trash2, X,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Surface } from '@/components/ds/Surface';
|
||||
@@ -25,7 +25,7 @@ import { useAssistantFacts, useConversation, useCurrentUserName } from './useAss
|
||||
import { buildIntro, buildPrompts } from './dynamic';
|
||||
import { agentScopedDisabled, agentStarters } from '@/lib/agents/runtime';
|
||||
import { buildOwliverContext } from '@/lib/agents/context';
|
||||
import { AgentSwitcher } from './AgentSwitcher';
|
||||
import { AgentBadge } from './AgentBadge';
|
||||
import { useActiveAgent } from './AgentContext';
|
||||
import { Message, ThinkingIndicator, TurnDivider } from './AssistantMessage';
|
||||
import { PromptInput } from './PromptInput';
|
||||
@@ -221,7 +221,11 @@ export default function KrowAssistant({
|
||||
const location = useLocation();
|
||||
|
||||
/* A question a page has handed over — see the effect below `runPrompt`. */
|
||||
const { request: panelRequest, consumeRequest } = useAssistantPanel();
|
||||
const {
|
||||
request: panelRequest, consumeRequest,
|
||||
notice: panelNotice, consumeNotice,
|
||||
test: panelTest, reportTest, clearTest,
|
||||
} = useAssistantPanel();
|
||||
|
||||
/* The app's own router, not a location assignment: a full page load would
|
||||
discard the thread and the panel state along with it. */
|
||||
@@ -447,7 +451,7 @@ export default function KrowAssistant({
|
||||
);
|
||||
|
||||
const {
|
||||
messages, pending, error, busy, send, stop, reset,
|
||||
messages, pending, error, busy, send, announce, stop, reset,
|
||||
history, conversationId, openConversation, forgetConversation,
|
||||
submitFeedback, feedback,
|
||||
} = useConversation({
|
||||
@@ -604,17 +608,18 @@ export default function KrowAssistant({
|
||||
* bypasses intent routing. A typed question carries none and is routed. Both
|
||||
* land in the same `send`, so there is a single pipeline to reason about.
|
||||
*/
|
||||
const handleMessage = React.useCallback((question, capability = null, positionId = null) => {
|
||||
const handleMessage = React.useCallback((
|
||||
question, capability = null, positionId = null, scope = null,
|
||||
) => {
|
||||
const text = String(question).trim();
|
||||
if (!text || busy) return;
|
||||
setInput('');
|
||||
/* Asking something while reading History returns to the conversation — the
|
||||
answer is about to arrive there, and leaving the list up would hide it. */
|
||||
setView('chat');
|
||||
send({ question: text, capability, positionId });
|
||||
send({ question: text, capability, positionId, scope });
|
||||
}, [busy, send]);
|
||||
|
||||
const submit = React.useCallback((text) => handleMessage(text), [handleMessage]);
|
||||
|
||||
/**
|
||||
* A chip is already a complete question, so it runs on click.
|
||||
@@ -644,9 +649,54 @@ export default function KrowAssistant({
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
if (!panelRequest || busy) return;
|
||||
handleMessage(panelRequest.question, null, panelRequest.positionId);
|
||||
handleMessage(
|
||||
panelRequest.question, panelRequest.capability ?? null,
|
||||
panelRequest.positionId, panelRequest.scope,
|
||||
);
|
||||
if (panelRequest.trace) reportTest(panelRequest.id, 'running');
|
||||
consumeRequest(panelRequest.id);
|
||||
}, [panelRequest, busy, handleMessage, consumeRequest]);
|
||||
}, [panelRequest, busy, handleMessage, consumeRequest, reportTest]);
|
||||
|
||||
/**
|
||||
* Reporting the end of a traced turn.
|
||||
*
|
||||
* The panel is the only place that knows a turn finished, and a reader who
|
||||
* pressed Test is waiting on exactly that. `busy` falling is the signal;
|
||||
* `error` decides which outcome it was. Nothing is inferred about the
|
||||
* *content* of the answer — a capability that answered is a pass, and judging
|
||||
* whether the answer was any good is the reader's job, which is the whole
|
||||
* reason the answer is shown to them rather than summarised.
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
if (panelTest?.state !== 'running' || busy) return;
|
||||
reportTest(panelTest.id, error ? 'error' : 'done', error || null);
|
||||
}, [panelTest, busy, error, reportTest]);
|
||||
|
||||
/* A question the reader types themselves ends the test banner: they have
|
||||
moved on, and leaving "Testing…" above an unrelated answer would label
|
||||
something that is no longer happening. */
|
||||
const submit = React.useCallback((text) => {
|
||||
if (panelTest) clearTest();
|
||||
handleMessage(text);
|
||||
}, [handleMessage, panelTest, clearTest]);
|
||||
|
||||
/**
|
||||
* Something the workspace told Owliver, said in the thread.
|
||||
*
|
||||
* Attaching a skill to an agent is the only source today. It is stated rather
|
||||
* than asked, so it appends a turn instead of running one — see
|
||||
* `AssistantPanelContext.announce` for why those are different acts.
|
||||
*
|
||||
* Deferred while a response streams, for the same reason a request is: a
|
||||
* notice arriving mid-answer waits for the panel rather than landing between
|
||||
* a question and its reply. Reading History is left alone — the notice is in
|
||||
* the live thread when the reader comes back to it.
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
if (!panelNotice || busy) return;
|
||||
announce({ text: panelNotice.text, followUp: panelNotice.followUp });
|
||||
consumeNotice(panelNotice.id);
|
||||
}, [panelNotice, busy, announce, consumeNotice]);
|
||||
|
||||
const composer = (
|
||||
<PromptInput
|
||||
@@ -672,11 +722,12 @@ export default function KrowAssistant({
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex shrink-0 items-center gap-2.5 border-b border-white/60 px-3.5 py-2.5">
|
||||
{/* The avatar and the two lines beside it, as they always were —
|
||||
"Owliver" over the page, with the pair doubling as the agent
|
||||
switcher. The header element, its geometry and the window controls
|
||||
to the right are unchanged. */}
|
||||
<AgentSwitcher page={context.page} />
|
||||
{/* The avatar and the two lines beside it — "Owliver" over the page,
|
||||
and who is answering. It is a label, not a control: choosing an
|
||||
agent belongs to Agent Registry → Configure, not to this header.
|
||||
The header element, its geometry and the window controls to the
|
||||
right are unchanged. */}
|
||||
<AgentBadge page={context.page} />
|
||||
|
||||
{/* Window controls.
|
||||
|
||||
@@ -747,6 +798,46 @@ export default function KrowAssistant({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/**
|
||||
* A capability test running through this panel.
|
||||
*
|
||||
* One line, in the panel's own vocabulary, so the reader knows the next
|
||||
* answer is the thing they asked for rather than the page talking to
|
||||
* itself. Deliberately outside the scrolling body: it describes the turn,
|
||||
* not a message in it, and nothing about it is persisted with the thread.
|
||||
*/}
|
||||
{panelTest && (
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-white/60 bg-krow-blue-tint/50 px-3.5 py-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
'h-1.5 w-1.5 shrink-0 rounded-full',
|
||||
panelTest.state === 'error' ? 'bg-destructive'
|
||||
: panelTest.state === 'done' ? 'bg-success'
|
||||
: 'animate-pulse bg-krow-blue'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p className="min-w-0 flex-1 truncate text-caption text-ink-2">
|
||||
<span className="font-medium">
|
||||
{panelTest.state === 'done' ? 'Tested'
|
||||
: panelTest.state === 'error' ? 'Test failed' : 'Testing'}
|
||||
</span>
|
||||
{': '}
|
||||
{panelTest.trace.skillName}
|
||||
{panelTest.trace.agentName ? ` on ${panelTest.trace.agentName}` : ''}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearTest}
|
||||
aria-label="Dismiss test indicator"
|
||||
className={`shrink-0 rounded p-0.5 text-ink-4 transition-colors hover:text-ink-2
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/40`}
|
||||
>
|
||||
<X className="h-3 w-3" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Body: the only region that scrolls ─────────────────────────── */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
|
||||
@@ -26,7 +26,7 @@ import { buildFacts } from './insights';
|
||||
import { agentRequest } from '@/lib/agents/runtime';
|
||||
import { createAssistantProvider } from './provider';
|
||||
import { resolveIntent } from './routing';
|
||||
import { toSnapshots } from './blocks';
|
||||
import { doc, text as textBlock, toSnapshots } from './blocks';
|
||||
|
||||
/** One provider instance for the app's lifetime. */
|
||||
const provider = createAssistantProvider();
|
||||
@@ -397,10 +397,36 @@ export function useConversation({
|
||||
const messagesRef = React.useRef(messages);
|
||||
messagesRef.current = messages;
|
||||
|
||||
const send = React.useCallback(async ({ question, capability = null, positionId = null }) => {
|
||||
/**
|
||||
* Asks something.
|
||||
*
|
||||
* `scope` runs this one turn against a *different* page and a different set
|
||||
* of reachable skills, and exists for exactly one caller: testing a
|
||||
* capability from the agent editor. The reader is standing on the
|
||||
* configuration screen, so the panel's own context is the workspace — asking
|
||||
* "what happened today?" there would resolve against the workspace's skills
|
||||
* and prove nothing about the Activity capability being configured.
|
||||
*
|
||||
* It is an override of *which page this question is about*, never of what may
|
||||
* be read: `resolveIntent` and the provider apply the same page rules to the
|
||||
* substituted context that they apply to a real one, so a test cannot reach a
|
||||
* record the real page would not have offered. The turn lands in the ordinary
|
||||
* thread, is persisted and archived like any other, and nothing about it is
|
||||
* simulated — it is the live pipeline pointed at another surface.
|
||||
*/
|
||||
const send = React.useCallback(async ({
|
||||
question, capability = null, positionId = null, scope = null,
|
||||
}) => {
|
||||
const text = String(question).trim();
|
||||
if (!text) return;
|
||||
|
||||
/* The page this turn is about, and what is reachable there. Defaults are
|
||||
the panel's own, so every existing caller is unchanged. */
|
||||
const turnContext = scope?.contextId || contextId;
|
||||
const turnDisabled = scope?.disabledSkills || disabledSkills;
|
||||
const turnAgent = scope?.agent || agent;
|
||||
const turnCovers = scope ? true : agentCoversPage;
|
||||
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
@@ -419,7 +445,7 @@ export function useConversation({
|
||||
*/
|
||||
let intent = null;
|
||||
if (flowRef.current) {
|
||||
const skill = skillsForContext(contextId, disabledSkills, customSkills)
|
||||
const skill = skillsForContext(turnContext, turnDisabled, customSkills)
|
||||
.find((s) => s.id === flowRef.current.skillId);
|
||||
|
||||
if (!skill) setFlow(null);
|
||||
@@ -438,9 +464,14 @@ export function useConversation({
|
||||
intent = capability
|
||||
? { kind: 'answer' }
|
||||
: resolveIntent({
|
||||
question: text, contextId, disabledSkills, customSkills, roles, skillCategories,
|
||||
question: text,
|
||||
contextId: turnContext,
|
||||
disabledSkills: turnDisabled,
|
||||
customSkills, roles, skillCategories,
|
||||
courses, workforce, skillContext, positionId,
|
||||
agent, agentCoversPage, agentSuggestion, owliverContext,
|
||||
agent: turnAgent,
|
||||
agentCoversPage: turnCovers,
|
||||
agentSuggestion, owliverContext,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -476,7 +507,7 @@ export function useConversation({
|
||||
followUp: [
|
||||
...createdFollowUp(created),
|
||||
...(ready
|
||||
? suggestionsForPosition(contextId, disabledSkills, customSkills, created)
|
||||
? suggestionsForPosition(turnContext, turnDisabled, customSkills, created)
|
||||
: []),
|
||||
],
|
||||
};
|
||||
@@ -664,10 +695,10 @@ export function useConversation({
|
||||
try {
|
||||
let latest = [];
|
||||
for await (const snapshot of provider.stream({
|
||||
contextId, capability, question: text, facts, signal: controller.signal,
|
||||
contextId: turnContext, capability, question: text, facts, signal: controller.signal,
|
||||
/* What the agent *is*, never what it may read. The page settled that
|
||||
before this call, and `agentRequest` carries no records. */
|
||||
agent: agent ? agentRequest(agent, contextId) : null,
|
||||
agent: turnAgent ? agentRequest(turnAgent, turnContext) : null,
|
||||
owliverContext,
|
||||
})) {
|
||||
if (controller.signal.aborted) break;
|
||||
@@ -703,10 +734,39 @@ export function useConversation({
|
||||
onGenerateDescription, onAssignWorkers,
|
||||
onScheduleInterview,
|
||||
workforce, setFlow, disabledSkills,
|
||||
customSkills, roles, skillCategories, courses, skillContext]);
|
||||
customSkills, roles, skillCategories, courses, skillContext,
|
||||
agent, agentCoversPage, agentSuggestion, owliverContext]);
|
||||
|
||||
const stop = React.useCallback(() => abortRef.current?.abort(), []);
|
||||
|
||||
/**
|
||||
* States something in the thread without a question having been asked.
|
||||
*
|
||||
* The workspace's own voice: "Attendance Analysis is now available to this
|
||||
* agent." It is a real turn — persisted and archived by the same `persist`
|
||||
* every answer goes through, so it survives a reload and appears in History
|
||||
* exactly where it happened, rather than being a banner that evaporates.
|
||||
*
|
||||
* Nothing is generated. The caller supplies the sentence and the chips, which
|
||||
* is what keeps this from being a second answering path: no skill runs, no
|
||||
* provider is called, and nothing here can claim a figure.
|
||||
*/
|
||||
const announce = React.useCallback(({ text: body, followUp = null }) => {
|
||||
const sentence = String(body || '').trim();
|
||||
if (!sentence) return;
|
||||
|
||||
const message = {
|
||||
role: 'assistant',
|
||||
text: sentence,
|
||||
blocks: doc(textBlock(sentence)).blocks,
|
||||
...(followUp?.length ? { followUp } : null),
|
||||
};
|
||||
|
||||
const next = [...messagesRef.current, message];
|
||||
messagesRef.current = next;
|
||||
persist(next);
|
||||
}, [persist]);
|
||||
|
||||
/**
|
||||
* Back to an empty panel.
|
||||
*
|
||||
@@ -797,6 +857,7 @@ export function useConversation({
|
||||
error,
|
||||
busy: Boolean(pending),
|
||||
send,
|
||||
announce,
|
||||
stop,
|
||||
reset,
|
||||
submitFeedback,
|
||||
|
||||
Reference in New Issue
Block a user