update owliver skill

This commit is contained in:
2026-08-14 17:33:16 +05:30
parent cccada9bd2
commit 4fdfb90326
58 changed files with 8015 additions and 1339 deletions

View File

@@ -1,6 +1,7 @@
import * as React from 'react';
import { base44 } from '@/api/base44Client';
import { resolveAssistantContext } from './placement';
import { PageContextProvider } from './PageContext';
/**
* Window state for the Owliver panel: open/collapsed and default/expanded.
@@ -149,9 +150,12 @@ export function AssistantPanelProvider({ role, pathname, children }) {
restore: () => setExpandedState(false),
}), [context, supported, isOpen, isExpanded, width, setWidthState, setOpenState, setExpandedState]);
/* 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. */
return (
<AssistantPanelContext.Provider value={value}>
{children}
<PageContextProvider>{children}</PageContextProvider>
</AssistantPanelContext.Provider>
);
}

View File

@@ -1,6 +1,8 @@
import * as React from 'react';
import { useNavigate } from 'react-router-dom';
import { Maximize2, Minimize2, PanelRightClose, RotateCcw, Sparkles } from 'lucide-react';
import {
ArrowLeft, History, Maximize2, Minimize2, PanelRightClose, RotateCcw, Sparkles, Trash2,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Surface } from '@/components/ds/Surface';
import { IconButton } from '@/components/ds/IconButton';
@@ -13,12 +15,171 @@ import { ROLE_CATEGORIES } from '@/lib/roleCategories';
import { AddSkillDialog } from '@/components/skills/AddSkillDialog';
import { runAction } from '@/lib/skills/actions';
import { skillsForContext } from '@/lib/skills/registry';
import { owliverSuggestions } from '@/lib/skills/owliverResolver';
import { useWorkforcePaths } from '@/lib/skills/usePageSkills';
import { profileForEmail } from '@/lib/skillGraph';
import { groupByRecency } from './history';
import { usePageContext } from './PageContext';
import { useAssistantFacts, useConversation, useCurrentUserName } from './useAssistant';
import { buildIntro, buildPrompts } from './dynamic';
import { Message, ThinkingIndicator, TurnDivider } from './AssistantMessage';
import { PromptInput } from './PromptInput';
import { PromptChips } from './PromptChips';
/**
* Owliver History — the conversations that came before.
*
* A list rather than a second panel: it replaces the thread in the body while
* the header and composer stay exactly where they are, so History is a state of
* the panel rather than a place you navigate to and have to find your way out
* of. Grouped by when, because that is how people look for a conversation they
* half-remember.
*/
function HistoryView({ groups, currentId, onOpen, onForget }) {
if (!groups.length) {
return (
<div className="animate-fade-in">
<p className="font-heading text-body font-semibold text-ink-1">No conversations yet</p>
<p className="mt-1 text-body-sm leading-relaxed text-ink-3">
Ask Owliver something and it will be here afterwards. History is kept on this device.
</p>
</div>
);
}
return (
<div className="animate-fade-in space-y-4">
{groups.map((group) => (
<section key={group.id} aria-label={group.label}>
<h3 className="mb-1.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4">
{group.label}
</h3>
<ul className="space-y-1">
{group.items.map((record) => (
<li key={record.id} className="group/item relative">
<button
type="button"
onClick={() => onOpen(record)}
aria-current={record.id === currentId ? 'true' : undefined}
className={cn(
`flex w-full items-start gap-2 rounded-lg border px-2.5 py-2 pr-8 text-left transition-colors
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50`,
record.id === currentId
? 'border-krow-blue/40 bg-krow-blue-tint/60'
: 'border-transparent hover:border-border hover:bg-surface-subtle'
)}
>
<span className="min-w-0 flex-1">
<span className="block truncate text-body-sm font-medium text-ink-1">
{record.title}
</span>
<span className="mt-0.5 block truncate text-[10px] text-ink-4">
{[
record.page,
`${record.turns} question${record.turns === 1 ? '' : 's'}`,
new Date(record.updatedAt).toLocaleTimeString(undefined, {
hour: 'numeric', minute: '2-digit',
}),
].filter(Boolean).join(' · ')}
</span>
</span>
</button>
{/* Destructive, so it stays out of the way until wanted rather
than sitting beside every row waiting to be mis-clicked. */}
<button
type="button"
onClick={() => onForget(record.id)}
aria-label={`Delete conversation: ${record.title}`}
className="absolute right-1.5 top-1/2 hidden -translate-y-1/2 rounded p-1.5 text-ink-4 transition-colors
hover:text-destructive focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-krow-blue/50 group-hover/item:block group-focus-within/item:block"
>
<Trash2 className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</li>
))}
</ul>
</section>
))}
</div>
);
}
/**
* Show the Back to Home row on scroll direction, not scroll position.
*
* Hidden while reading downwards, back the instant the user scrolls up — the
* way out of a long answer should not be something you have to scroll all the
* way to the top to reach. Direction is read off the panel's own scrolling
* element (the body region below), never the window: the page behind the panel
* does not move when the thread does.
*
* The listener is passive and rAF-throttled, and `setVisible` is only ever
* called with a value that can change — React bails out on an identical one, so
* a fast scroll costs at most one render per direction change.
*
* `pinToBottom` is the same follow-the-stream scroll the panel already did,
* routed through here so a programmatic jump is not mistaken for the user
* scrolling down and does not hide the control under them.
*/
function useDirectionalNav(scrollRef, { active, resetKey }) {
const [visible, setVisible] = React.useState(true);
const lastY = React.useRef(0);
/* Ignore sub-pixel and trackpad jitter, but nothing a deliberate scroll would
produce: a real direction change clears this within one frame. */
const NOISE = 4;
const pinToBottom = React.useCallback(() => {
const el = scrollRef.current;
if (!el) return;
el.scrollTop = el.scrollHeight;
/* Adopt the new position before the scroll event lands, so the next read
sees no delta and the row keeps whatever state the user left it in. */
lastY.current = el.scrollTop;
}, [scrollRef]);
React.useEffect(() => {
const el = scrollRef.current;
/* Nothing to hide when the row is not rendered — and a fresh thread or a
newly opened panel always starts with it showing. */
setVisible(true);
if (!el || !active) return undefined;
lastY.current = el.scrollTop;
let frame = 0;
const read = () => {
frame = 0;
const y = el.scrollTop;
if (y <= 0) {
lastY.current = y;
setVisible(true);
return;
}
const delta = y - lastY.current;
/* Leave `lastY` alone below the threshold so slow scrolls accumulate
rather than being swallowed frame by frame. */
if (Math.abs(delta) < NOISE) return;
lastY.current = y;
setVisible(delta < 0);
};
const onScroll = () => {
if (!frame) frame = requestAnimationFrame(read);
};
el.addEventListener('scroll', onScroll, { passive: true });
return () => {
el.removeEventListener('scroll', onScroll);
if (frame) cancelAnimationFrame(frame);
};
}, [scrollRef, active, resetKey]);
return { visible, pinToBottom };
}
/**
* Owliver — the dashboard's contextual panel.
*
@@ -88,8 +249,50 @@ export default function KrowAssistant({
() => skillsForContext(context.id, disabledSkills, customSkills),
[context.id, disabledSkills, customSkills]
);
/**
* What a declared skill reads from.
*
* The same collections `SkillSurface` hands the resolver on the page, plus
* whatever record the page has published as open. One context, one resolver:
* the card on the page and the answer in the panel are two readings of the
* same data rather than two queries that happen to agree.
*/
const pageContext = usePageContext();
const { statesFor } = useWorkforcePaths();
const trainingPaths = React.useMemo(
() => statesFor(facts.forge?.library || [], profileForEmail(facts.profiles || [], facts.user?.email)),
[statesFor, facts.forge, facts.profiles, facts.user]
);
const { data: assignments = [] } = useAssignments();
const skillContext = React.useMemo(() => ({
...pageContext,
applications: facts.applications || [],
positions: facts.postings || [],
interviews: facts.interviews || [],
courses: facts.forge?.library || [],
workerProfiles: facts.profiles || [],
/* The same records under the workforce engine's name, and the commitments
it reads availability from — so a source that scores candidates against a
position resolves here exactly as it does on the page. */
profiles: facts.profiles || [],
assignments,
staff: facts.staff || [],
activity: facts.activity || [],
trainingPaths,
}), [pageContext, facts, assignments, trainingPaths]);
const [addSkillOpen, setAddSkillOpen] = React.useState(false);
/**
* Which face of the panel the body is showing.
*
* A state rather than a route: the header, the composer and the panel's width
* are unchanged between the two, and only the scrolling region swaps. That is
* also why there is always a way back — leaving History is a state change this
* component owns, not a browser-history entry it has to hope exists.
*/
const [view, setView] = React.useState('chat');
/**
* A skill asked for something to happen. The names come from the skill file;
* `runAction` decides what they mean and refuses anything the file did not
@@ -111,8 +314,8 @@ export default function KrowAssistant({
* for a skill whose file does not, and then nothing is written.
*/
const createJob = useCreateJobPosting();
const createPosition = React.useCallback(async (draft, skill) => {
const result = runAction('create_position', { draft, skill });
const createPosition = React.useCallback(async (draft, skill, status) => {
const result = runAction('create_position', { draft, skill, status });
if (result?.type !== 'create_position') return null;
return createJob.mutateAsync(result.data);
}, [createJob]);
@@ -125,10 +328,13 @@ export default function KrowAssistant({
* routing layer because this is where the app's data already is; routing stays
* pure and testable.
*/
const { data: assignments = [] } = useAssignments();
const workforce = React.useMemo(() => ({
positions: facts.postings || [],
currentPositionId: null,
/* The position the page has open, so "who matches this position?" resolves
against the record in front of the reader rather than asking which role
they meant. A page with nothing open publishes nothing, and the question
is answered by name or asked for, exactly as before. */
currentPositionId: pageContext.position?.id || null,
context: {
profiles: facts.profiles || [],
applications: facts.applications || [],
@@ -136,7 +342,8 @@ export default function KrowAssistant({
courses: facts.forge?.library || [],
staff: facts.staff || [],
},
}), [facts.postings, facts.profiles, facts.applications, facts.forge, facts.staff, assignments]);
}), [facts.postings, facts.profiles, facts.applications, facts.forge, facts.staff, assignments,
pageContext.position?.id]);
/**
* The workforce write, through the mutation the app already has.
@@ -169,9 +376,13 @@ export default function KrowAssistant({
[markInterviewReady]
);
const { messages, pending, error, busy, send, stop, reset } = useConversation({
const {
messages, pending, error, busy, send, stop, reset,
history, conversationId, openConversation, forgetConversation,
} = useConversation({
contextId: context.id,
facts,
pageLabel: context.page,
onNavigate: goToPage,
onAction: performAction,
onCreatePosition: createPosition,
@@ -183,6 +394,7 @@ export default function KrowAssistant({
roles,
skillCategories,
courses: facts.forge?.library || [],
skillContext,
});
/* A block inside an answer asking the next question, in place. Same entry
@@ -192,10 +404,37 @@ export default function KrowAssistant({
if (!busy) send({ question });
}, [busy, send]);
const historyGroups = React.useMemo(() => groupByRecency(history), [history]);
/**
* Home is the empty panel: greeting, page fact, suggestions.
*
* One handler for every way back — the History header button, the Back to
* Home control, and reopening after reading an old thread — so "home" cannot
* mean two slightly different states depending on how you got there. Nothing
* is lost: the thread being left is already archived.
*/
const goHome = React.useCallback(() => {
setView('chat');
reset();
}, [reset]);
const openHistoryItem = React.useCallback((record) => {
openConversation(record);
setView('chat');
}, [openConversation]);
const [input, setInput] = React.useState('');
const scrollRef = React.useRef(null);
const isEmpty = messages.length === 0 && !pending;
/* The Back to Home row only exists in the states that are not already home. */
const showBackRow = view === 'history' || messages.length > 0 || Boolean(pending);
const { visible: backVisible, pinToBottom } = useDirectionalNav(scrollRef, {
active: showBackRow,
resetKey: `${view}:${conversationId || ''}`,
});
/* Greeting and suggestions come from live data, so they recompute only when
the data or the page actually changes. */
const intro = React.useMemo(
@@ -208,19 +447,46 @@ export default function KrowAssistant({
const followUp = messages[messages.length - 1]?.followUp;
const prompts = React.useMemo(() => {
if (followUp?.length) return followUp;
/* A definition's own suggestions come first: they are the only ones written
for this workspace rather than derived from the page, and they are capped
by the resolver so a workspace with several skills attached cannot bury
the page's own. */
const declared = owliverSuggestions(context.id, disabledSkills, customSkills, pageContext);
const skillPrompts = skills
.filter((s) => s.prompt)
.map((s) => ({ label: s.prompt, prompt: s.prompt }));
return [...skillPrompts, ...buildPrompts(context.id, facts, workforce)];
}, [followUp, skills, context.id, facts, workforce]);
/* Three sources can propose the same question — a skill's declared
suggestion and the page's own derived one often word it identically —
and two chips reading "Summarize hiring activity" is both a duplicate
React key and a duplicate offer. First wins, so the definition's own
wording survives and the derived copy drops out. */
/* A declared suggestion that cannot answer yet — it reads one record and
none is selected — ranks behind the page's own offers rather than
leading with a question. The lifecycle reads correctly either way:
before a position exists the page's actions lead; once one is open or
has just been created, the readings about it come first. */
const ready = declared.filter((c) => !c.deferred);
const asking = declared.filter((c) => c.deferred);
const seen = new Set();
return [...ready, ...skillPrompts, ...buildPrompts(context.id, facts, workforce), ...asking]
.filter((chip) => {
const key = String(chip?.label ?? '').trim().toLowerCase();
if (!key || seen.has(key)) return false;
seen.add(key);
return true;
});
/* `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]);
/* Follow the newest content. Direct `scrollTop` rather than smooth scrolling:
at streaming frequency a smooth scroll never catches up and the thread
visibly lags the text. */
React.useEffect(() => {
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [messages, pending]);
pinToBottom();
}, [messages, pending, pinToBottom]);
/**
* The one entry point for every message, whatever raised it.
@@ -233,6 +499,9 @@ export default function KrowAssistant({
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 });
}, [busy, send]);
@@ -271,7 +540,7 @@ export default function KrowAssistant({
padding="none"
elevation="sm"
data-wide={expanded || undefined}
className={cn('flex flex-col overflow-hidden', className)}
className={cn('relative flex flex-col overflow-hidden', className)}
role="region"
aria-label="Owliver assistant"
>
@@ -297,7 +566,19 @@ export default function KrowAssistant({
size="sm"
onClick={() => setAddSkillOpen(true)}
/>
{messages.length > 0 && (
{/* History lives with the other window controls rather than in the
body, so the layout of the panel is unchanged whether or not
there is anything to show. It toggles: pressing it again returns
to the conversation you were reading. */}
<IconButton
icon={History}
label={view === 'history' ? 'Back to the conversation' : 'Owliver history'}
variant="ghost"
size="sm"
aria-pressed={view === 'history'}
onClick={() => setView((v) => (v === 'history' ? 'chat' : 'history'))}
/>
{messages.length > 0 && view === 'chat' && (
<IconButton icon={RotateCcw} label="New conversation" variant="ghost" size="sm" onClick={reset} />
)}
{expanded
@@ -313,14 +594,53 @@ export default function KrowAssistant({
</div>
</div>
{/* Floating directional Back to Home row — reveals on UP-scroll, hides on DOWN-scroll */}
{showBackRow && (
<div
className={cn(
`absolute top-[3.25rem] left-0 right-0 z-20 flex items-center justify-between gap-2
border-b border-border bg-white/95 dark:bg-slate-900/95 px-4 py-2 shadow-sm backdrop-blur-md
transition-all duration-200 ease-out motion-reduce:transition-none`,
backVisible
? 'translate-y-0 opacity-100 pointer-events-auto'
: '-translate-y-full opacity-0 pointer-events-none'
)}
aria-hidden={backVisible ? undefined : 'true'}
>
<button
type="button"
onClick={goHome}
tabIndex={backVisible ? undefined : -1}
className="inline-flex items-center gap-1.5 rounded text-caption font-semibold text-ink-1 dark:text-white transition-colors
hover:text-krow-blue focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50 cursor-pointer"
>
<ArrowLeft className="h-3.5 w-3.5 text-krow-blue" aria-hidden="true" />
<span>Back to Home</span>
</button>
<span className="truncate text-[11px] font-medium text-ink-3">
{view === 'history'
? `${history.length} conversation${history.length === 1 ? '' : 's'}`
: context.page}
</span>
</div>
)}
{/* ── Body: the only region that scrolls ─────────────────────────── */}
<div
ref={scrollRef}
className="min-h-0 flex-1 overflow-y-auto px-4 py-3.5"
className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-4 py-3.5"
aria-live="polite"
aria-relevant="additions text"
>
{isEmpty ? (
{view === 'history' ? (
<HistoryView
groups={historyGroups}
currentId={conversationId}
onOpen={openHistoryItem}
onForget={forgetConversation}
/>
) : isEmpty ? (
/* Greeting and the single most relevant fact about this page. Top
aligned rather than centred: it is the first thing in a
conversation, not a splash screen, so it belongs where the first
@@ -373,7 +693,7 @@ export default function KrowAssistant({
Follow-ups are never capped: when Owliver has asked a question, its
chips *are* the answers, and hiding three of the six roles would make
the flow look broken. */}
{!busy && (
{!busy && view === 'chat' && (
<PromptChips
prompts={prompts}
onSelect={runPrompt}

View File

@@ -0,0 +1,154 @@
import * as React from 'react';
/**
* What the page is currently looking at, published to the panel beside it.
*
* A skill's data source declares what it needs — a position, a candidate, the
* position being specified — and the page is the only thing that knows which
* one is open. Without this, Owliver could read the collections but not the
* *selection*: it would answer about the workspace when it was asked about the
* role in the drawer.
*
* The channel is deliberately narrow. A page publishes records it already has;
* it does not publish components, callbacks or view state, and nothing here
* reaches back into the page. That keeps the coupling one-way — a page that
* publishes nothing simply has no selection, and every skill whose source needs
* one says so instead of guessing.
*
* usePublishPageContext({ position }); // in the page
* const { position } = usePageContext(); // in the panel
*
* Kept out of `AssistantPanelContext` on purpose: that context is the panel's
* window state, changing when someone drags a divider. This changes when the
* reader opens a record, and merging the two would re-render one for the other.
*/
const PageContext = React.createContext(null);
export function PageContextProvider({ children }) {
const [entity, setEntity] = React.useState(null);
/**
* The writes a page is willing to accept, keyed by data source.
*
* The read channel above is serialized state; this cannot be — a handler is a
* function, closing over the page's own setter. So it lives in a ref, and only
* the *set of source ids* is state. That distinction is the whole design:
* consumers re-render when a page starts or stops accepting a write, not every
* time the page re-renders and hands over a new closure.
*
* Keyed by source id rather than by an invented action name, because the
* source is already the validated binding between a definition and its data.
* A skill therefore cannot write anywhere it could not read, and a page cannot
* accept a write for a reading it does not own.
*/
const handlersRef = React.useRef({});
const [writable, setWritable] = React.useState([]);
const publishActions = React.useCallback((handlers) => {
handlersRef.current = handlers || {};
const next = Object.keys(handlersRef.current).sort();
/* Only a change in *what* is writable is worth a render. */
setWritable((prev) => (prev.join('|') === next.join('|') ? prev : next));
}, []);
/**
* Runs a page's handler for a source. Returns false when the page is not
* accepting that write, so a caller can stay read-only rather than pretending
* the change landed.
*/
const runAction = React.useCallback((source, payload) => {
const handler = handlersRef.current[source];
if (typeof handler !== 'function') return false;
handler(payload);
return true;
}, []);
const value = React.useMemo(
() => ({ entity, setEntity, publishActions, runAction, writable }),
[entity, publishActions, runAction, writable]
);
return <PageContext.Provider value={value}>{children}</PageContext.Provider>;
}
/**
* Publishes this page's current selection for as long as it is mounted.
*
* Keyed on the content rather than the object, because a page rebuilds its
* records on every render and an identity-keyed effect would publish in a loop.
* Unmounting clears the selection, so leaving a page cannot leave Owliver
* answering about a record nobody is looking at.
*/
export function usePublishPageContext(entity) {
const context = React.useContext(PageContext);
const setEntity = context?.setEntity;
/* `undefined` values would vanish from the serialized key, so a selection
being cleared has to read as an explicit null. */
const key = React.useMemo(() => {
try {
return entity ? JSON.stringify(entity) : null;
} catch {
return null;
}
}, [entity]);
React.useEffect(() => {
if (!setEntity) return undefined;
setEntity(key ? JSON.parse(key) : null);
return () => setEntity(null);
}, [key, setEntity]);
}
/**
* The records the page has published. Always an object, so a consumer can read
* `position` without checking whether anything published at all.
*/
export function usePageContext() {
return React.useContext(PageContext)?.entity || {};
}
/**
* Offers this page's writes for as long as it is mounted.
*
* Pass a memoized map of `{ [sourceId]: handler }`. Handlers should be stable —
* a functional `setState` closes over nothing, which is what lets the map be
* memoized once rather than rebuilt as the form is typed into.
*
* const applyWeights = useCallback(
* (next) => setForm((f) => ({ ...f, vetting_criteria: next })), []);
* usePublishPageActions(useMemo(
* () => ({ 'position.vetting': applyWeights }), [applyWeights]));
*
* Unpublished on unmount, so leaving a page cannot leave a control on screen
* that writes into a form nobody is looking at.
*/
export function usePublishPageActions(handlers) {
const publishActions = React.useContext(PageContext)?.publishActions;
React.useEffect(() => {
if (!publishActions) return undefined;
publishActions(handlers);
return () => publishActions(null);
}, [publishActions, handlers]);
}
/**
* How a section writes back, for one source — or `null` when nothing on this
* page is accepting that write.
*
* Returning null rather than a no-op is deliberate: a renderer checks it to
* decide whether to draw controls at all, so a section declared `editable` on a
* page that does not own the data stays an honest read-out.
*/
export function usePageAction(source) {
const context = React.useContext(PageContext);
const accepted = Boolean(source) && (context?.writable || []).includes(source);
const runAction = context?.runAction;
return React.useMemo(
() => (accepted && runAction ? (payload) => runAction(source, payload) : null),
[accepted, runAction, source]
);
}

View File

@@ -44,7 +44,10 @@ export function PromptChips({ prompts = [], onSelect, max, align = 'center', cla
>
{visible.map((prompt, i) => (
<button
key={prompt.label}
/* Position, not label. Callers de-duplicate, but a follow-up list is
built per answer and two chips can legitimately read the same —
the key has to stay unique either way. */
key={`${i}:${prompt.label}`}
ref={(el) => { chipRefs.current[i] = el; }}
type="button"
onClick={() => onSelect(prompt)}

View File

@@ -6,6 +6,10 @@ import {
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
import { ProgressBar, toneForScore } from '@/components/ds/Progress';
import { SECTION_COMPONENTS } from '@/components/skills/SkillSections';
import { useSkillDataContext } from '@/components/skills/SkillSurface';
import { resolveSkillData } from '@/lib/skills/dataResolver';
import { usePageAction } from './PageContext';
/**
* Renderers for response blocks.
@@ -441,8 +445,55 @@ const NoteBlock = React.memo(({ block }) => (
));
NoteBlock.displayName = 'NoteBlock';
/**
* A section a skill declared, drawn by the component the page uses for it.
*
* There is no chat-specific renderer for a skill's shapes, and there must not
* be: `SECTION_COMPONENTS` is the single table from a declared type to a
* component, and this block goes through it exactly as `SkillSurface` does. A
* definition that gains a new shape gains it in both places at once, and a
* shape the table does not carry renders nothing rather than something
* improvised.
*/
const SkillSectionBlock = React.memo(({ block }) => {
const section = block.section;
const Component = SECTION_COMPONENTS[section?.shape || section?.type];
/**
* A settable answer is re-read, never replayed.
*
* An ordinary answer is a record of what was true when it was given, and the
* stored blocks are exactly right for that. A section offering *controls* is
* different: it is a live view of a value the reader can still change, from
* here or on the page behind the panel. Replaying the stored copy would show
* a weight of 25% next to a form that now says 30%, and the control would
* write the stale figure back.
*
* So an editable section resolves from the current context on every render,
* through the same resolver the page's own card uses. There is one value and
* one place it lives; this is a second window onto it, not a second copy.
*/
const live = Boolean(section?.editable);
const context = useSkillDataContext(null);
const data = React.useMemo(
() => (live && section ? resolveSkillData(section, context) : block.data),
[live, section, context, block.data]
);
const apply = usePageAction(live ? section?.source : null);
if (!Component) return null;
return (
<div className="rounded-xl border border-border bg-surface-subtle/60 p-3">
<Component data={data} section={section} onApply={apply} compact />
</div>
);
});
SkillSectionBlock.displayName = 'SkillSectionBlock';
const RENDERERS = {
text: TextBlock,
skillSection: SkillSectionBlock,
heading: HeadingBlock,
kpis: KpisBlock,
status: StatusBlock,

View File

@@ -72,6 +72,21 @@ export const badges = (items) =>
/** Chronology. `items: [{ title, description?, timestamp?, tone?, current? }]` */
export const timeline = (items) => items?.length && { type: 'timeline', items };
/**
* A section a skill definition declared, drawn in the panel.
*
* The one block whose shape is not decided here: `section` is the normalized
* record the registry produced from a `.md` file, and `data` is what the shared
* resolver read for it. The renderer hands both to the same component the page
* mounts, so a flow asked for in conversation *is* the page's flow rather than
* a chat-shaped imitation of it.
*
* Both halves are plain data, so a reply containing one still serializes into
* the stored thread like every other block.
*/
export const skillSection = (section, data) =>
section && data && { type: 'skillSection', section, data };
/** A caveat. Always the last word on a claim, never the headline. */
export const note = (value) => value && { type: 'note', text: value };

View File

@@ -1,5 +1,5 @@
import {
Activity, AlertTriangle, ArrowLeftRight, Award, BellRing, Clock, Eye, Filter, Gauge,
Activity, AlertTriangle, ArrowLeftRight, Award, BellRing, Clock, Eye, FileText, Filter, Gauge,
GitBranch, Layers, Lightbulb, Lock, ScrollText, ShieldAlert, ShieldCheck, Star, Target,
TrendingUp, UserCheck, Users,
} from 'lucide-react';
@@ -962,6 +962,41 @@ const positionsNeedingAttention = (f) => {
);
};
/**
* Positions that were saved but never published.
*
* A draft is a real record with real values on it and no way for anyone to
* apply to it, which makes it the one thing on this page that is genuinely
* unfinished. Each one is offered with the control that finishes it: the same
* authoring form, told which draft it is continuing, with everything already
* entered still in place.
*
* Read from `postings` by status — no list of drafts is kept anywhere, and a
* position stops appearing here the moment it is published.
*/
const positionDrafts = (f) => {
const drafts = (f.postings || []).filter((p) => p.status === 'draft');
if (!drafts.length) {
return answer('Nothing is sitting in draft — every position in the workspace has been published or closed.');
}
return doc(
heading(`${plural(drafts.length, 'position')} still in draft`, 'Saved, but not published'),
insights(drafts.map((p) => ({
tone: 'warning',
title: p.title || 'Untitled position',
body: [
p.company,
p.location,
'Not published yet, so nobody can apply and no screening runs.',
].filter(Boolean).join(' · '),
action: { label: 'Continue to save', to: `/admin/positions/new?draft=${encodeURIComponent(p.id)}` },
}))),
note('Continuing reopens the same form with everything already entered — the vetting weights included. Publishing it there is what makes it an active position.')
);
};
/** Which role to fill first. */
const hiringPriority = (f) => {
const rows = f.byRole
@@ -1259,6 +1294,7 @@ export const respondControlCenter = (question, f) => {
export const respondPositions = (question, f) => {
const q = question.toLowerCase();
if (has(q, 'draft', 'unfinished', 'not published', 'continue')) return positionDrafts(f);
if (has(q, 'attention', 'urgent', 'problem', 'wrong', 'risk')) return positionsNeedingAttention(f);
if (has(q, 'first', 'priorit', 'which position', 'should i hire')) return hiringPriority(f);
if (has(q, 'bottleneck', 'funnel', 'drop', 'conversion')) return pipelineHealth(f);
@@ -1317,6 +1353,7 @@ export const CONTROL_CENTER_CAPABILITIES = [
];
export const POSITIONS_CAPABILITIES = [
{ id: 'position-drafts', label: 'Unfinished Drafts', icon: FileText, run: positionDrafts },
{ id: 'position-strength', label: 'Pipeline Strength', icon: TrendingUp, run: positionStrength },
{ id: 'positions-attention', label: 'Needs Attention', icon: BellRing, run: positionsNeedingAttention },
{ id: 'hiring-priority', label: 'What To Fill First', icon: Target, run: hiringPriority },
@@ -1935,3 +1972,164 @@ export const PROFILE_CAPABILITIES = [
{ id: 'profile-activity', label: 'My Recent Activity', icon: ScrollText, run: profileActivity },
{ id: 'profile-actions', label: 'What I Can Do Here', icon: Lightbulb, run: profileActions },
];
/* ── Create Position ─────────────────────────────────────────────────────
The authoring surface. Every answer here is about the role being specified,
read against what this workspace has already posted — so the guidance is the
deployment's own history rather than an industry number written into a file. */
/** The average of a numeric field across postings that state it. */
const across = (postings, read) => {
const values = postings.map(read).filter((n) => Number.isFinite(n) && n > 0);
return values.length ? Math.round(values.reduce((a, b) => a + b, 0) / values.length) : 0;
};
/**
* What the five vetting criteria mean, and how this workspace usually weights
* them.
*
* The typical column is counted from the positions already posted, so it says
* what this operator does rather than what a template suggests.
*/
const vettingWeights = (f) => {
const posted = f.postings.filter((p) => p.vetting_criteria);
const keys = ['experience', 'english', 'reliability', 'certifications', 'availability'];
const meaning = {
experience: 'Years in the role, weighted against the minimum this position asks for',
english: 'Spoken level, checked in the AI interview',
reliability: 'Attendance and completion history on previous assignments',
certifications: 'Whether the credentials this role requires are held and current',
availability: 'How much of the shift pattern the candidate can actually cover',
};
if (!posted.length) {
return doc(
heading('AI vetting weights', 'What each criterion scores'),
list(keys.map((k) => `**${CRITERIA_LABELS[k]}** — ${meaning[k]}`)),
note('The five must total 100%. No position has been posted yet, so there is no house pattern to compare against.')
);
}
const typical = keys.map((key) => ({
criterion: CRITERIA_LABELS[key],
typical: `${across(posted, (p) => Number(p.vetting_criteria?.[key]))}%`,
scores: meaning[key],
}));
return doc(
heading('AI vetting weights', `Typical across ${plural(posted.length, 'posted position')}`),
table(
[
{ key: 'criterion', label: 'Criterion' },
{ key: 'typical', label: 'Usually', align: 'right' },
{ key: 'scores', label: 'What it scores' },
],
typical
),
note('These are this workspace’s own averages, not a recommendation. The five weights on this form must total 100%.')
);
};
/**
* What comparable roles in this workspace ask for.
*
* Grouped by role category, which is the only comparison the data supports —
* pay and experience are not transferable between a chef and a security post.
*/
const positionBenchmarks = (f) => {
const posted = f.postings.filter((p) => p.role_category);
if (!posted.length) {
return doc(
text('No positions have been posted yet, so there is nothing to benchmark this one against.'),
note('Once a few roles exist I can compare pay, experience and requirements by category.')
);
}
const byCategory = new Map();
posted.forEach((p) => {
const entry = byCategory.get(p.role_category) || { category: p.role_category, list: [] };
entry.list.push(p);
byCategory.set(p.role_category, entry);
});
const rows = [...byCategory.values()]
.sort((a, b) => b.list.length - a.list.length)
.slice(0, 6)
.map(({ category, list: roles }) => ({
category,
posted: roles.length,
pay: across(roles, (p) => Number(p.pay_range_min))
? `$${across(roles, (p) => Number(p.pay_range_min))}–${across(roles, (p) => Number(p.pay_range_max))}/hr`
: '—',
experience: `${across(roles, (p) => Number(p.min_experience_years))} yrs`,
}));
return doc(
heading('What comparable roles ask for', `${plural(posted.length, 'position')} on file`),
table(
[
{ key: 'category', label: 'Category' },
{ key: 'posted', label: 'Posted', align: 'right' },
{ key: 'pay', label: 'Pay', align: 'right' },
{ key: 'experience', label: 'Min experience', align: 'right' },
],
rows
),
note('Averages across positions already posted in this workspace.')
);
};
/** The credentials this workspace actually asks for, ranked by how often. */
const positionRequirements = (f) => {
const counts = new Map();
f.postings.forEach((p) => {
(p.certifications_required || []).forEach((c) => counts.set(c, (counts.get(c) || 0) + 1));
});
if (!counts.size) {
return doc(
text('No position on file requires a certification, so there is no pattern to follow here.'),
note('Add only the credentials this role genuinely needs — every one narrows the pool.')
);
}
const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8);
return doc(
heading('Credentials this workspace asks for', `${plural(counts.size, 'certification')} in use`),
meters(ranked.map(([label, count]) => ({
label,
value: count,
max: f.postings.length || count,
}))),
note('Ranked by how many positions require them. Each requirement narrows the pool it is added to.')
);
};
/** How the form's own steps fit together, for someone new to it. */
const positionSpecSteps = () => doc(
heading('Specifying a position'),
list([
'Role category and job title — what the matching engine files this under',
'Client, requirements and pay — what the posting states',
'Certifications and required skills — what a person must hold',
'Generate Job Description with AI — writes the prose from the fields above',
'AI vetting weights — how the screening score is composed, totalling 100%',
], { ordered: true }),
note('Nothing is posted until you choose Save as Draft or Publish.')
);
export const respondCreatePosition = (question, f) => {
const q = question.toLowerCase();
if (has(q, 'weight', 'vetting', 'criteria', 'scoring', 'score')) return vettingWeights(f);
if (has(q, 'certification', 'credential', 'requirement', 'skill')) return positionRequirements(f);
if (has(q, 'pay', 'rate', 'salary', 'benchmark', 'compare', 'experience', 'typical')) return positionBenchmarks(f);
if (has(q, 'how do i', 'step', 'what do i', 'fill', 'form', 'explain')) return positionSpecSteps(f);
return positionSpecSteps(f);
};
export const CREATE_POSITION_CAPABILITIES = [
{ id: 'vetting-weights', label: 'Explain Vetting Weights', icon: Gauge, run: vettingWeights },
{ id: 'position-benchmarks', label: 'Compare With Similar Roles', icon: ArrowLeftRight, run: positionBenchmarks },
{ id: 'position-requirements', label: 'Credentials In Use', icon: ShieldCheck, run: positionRequirements },
{ id: 'position-spec-steps', label: 'How This Form Works', icon: Lightbulb, run: positionSpecSteps },
];

View File

@@ -4,11 +4,13 @@ import {
} from './capabilities/employer';
import {
ACTIVITY_CAPABILITIES, ADMIN_ANALYTICS_CAPABILITIES, ADMIN_CANDIDATE_CAPABILITIES,
CANDIDATE_LIST_CAPABILITIES, CONTROL_CENTER_CAPABILITIES, FORGE_CAPABILITIES,
CANDIDATE_LIST_CAPABILITIES, CONTROL_CENTER_CAPABILITIES, CREATE_POSITION_CAPABILITIES,
FORGE_CAPABILITIES,
HIRED_HISTORY_CAPABILITIES, POSITIONS_CAPABILITIES, PROFILE_CAPABILITIES,
TALENT_POOL_CAPABILITIES,
respondActivity, respondAdminAnalytics, respondAdminCandidates, respondCandidateList,
respondControlCenter, respondForge, respondHiredHistory, respondPositions,
respondControlCenter, respondCreatePosition, respondForge, respondHiredHistory,
respondPositions,
respondProfile, respondTalentPool,
} from './capabilities/admin';
@@ -61,6 +63,19 @@ export const ASSISTANT_CONTEXTS = {
capabilities: POSITIONS_CAPABILITIES,
respond: respondPositions,
},
/* Specifying a role is a different question from managing the ones that
exist, so Create Position is its own context rather than Positions with a
form on it. Its topics are the form's own fields. */
'admin.createPosition': {
id: 'admin.createPosition',
page: 'Create Position',
topics: ['weight', 'vetting', 'criteria', 'scoring', 'score', 'certification', 'credential',
'requirement', 'skill', 'pay', 'rate', 'salary', 'benchmark', 'compare', 'experience',
'typical', 'position', 'role', 'job description', 'form', 'field', 'step', 'how do i',
'what do i', 'explain', 'summar', 'flow'],
capabilities: CREATE_POSITION_CAPABILITIES,
respond: respondCreatePosition,
},
/* The Candidates list and Candidates Analysis are separate contexts because they
ask different questions of the same records: the list is triage — who needs a
decision — and Analysis reads the pool as a whole. */

View File

@@ -85,6 +85,19 @@ const GREETINGS = {
return `Platform is steady across ${plural(f.postings.length, 'position')} and ${plural(f.total, 'application')}.`;
},
/* The form is the page, so the greeting is about what is being specified
rather than about the board — read from what this workspace has posted. */
'admin.createPosition': (f) => {
if (!f.postings.length) {
return 'This is the first position in the workspace — the weights you set here become the house pattern.';
}
/* Every posted role is a benchmark for pay and experience; only the ones
carrying weights can be compared on those, so the two are counted
separately rather than one standing in for the other. */
const weighted = f.postings.filter((p) => p.vetting_criteria).length;
return `Specifying a new role. ${plural(f.postings.length, 'position')} on file to benchmark against${weighted ? `, ${weighted} with vetting weights set` : ''}.`;
},
'admin.positions': (f) => {
if (!f.openPositions.length) return 'No open positions yet — publish a role and I will start tracking it.';
if (f.starvedPositions.length) {
@@ -211,6 +224,7 @@ const TITLES = {
'employer.analytics': () => 'Analytics assistant',
'admin.controlCenter': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
'admin.positions': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
'admin.createPosition': () => 'Specifying a position',
'admin.candidatesList': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
'admin.candidates': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
'admin.analytics': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
@@ -231,6 +245,8 @@ const DESCRIPTIONS = {
`Reading ${plural(f.total, 'application')} across ${plural(f.postings.length, 'position')} — health, operations and what needs intervention.`,
'admin.positions': (f) =>
`Reading ${plural(f.total, 'application')} across ${plural(f.postings.length, 'position')} — hiring health, pipeline strength and what needs intervention.`,
'admin.createPosition': (f) =>
`Reading this draft against ${plural(f.postings.length, 'posted position')} — vetting weights, pay, experience and the credentials already in use.`,
'admin.candidatesList': (f) =>
`Reading ${plural(f.total, 'candidate')} across ${plural(f.postings.length, 'position')} — match quality, screening and hiring readiness.`,
'admin.candidates': (f) =>
@@ -281,6 +297,11 @@ const PLACEHOLDERS = {
'Ask Owliver which role to fill first…',
'Ask Owliver about hiring bottlenecks…',
],
'admin.createPosition': () => [
'Ask Owliver about the vetting weights…',
'Ask Owliver what comparable roles ask for…',
'Ask Owliver to explain this form…',
],
'admin.candidatesList': () => [
'Ask Owliver about candidates…',
'Ask Owliver who is interview ready…',
@@ -391,6 +412,15 @@ const PROMPTS = {
{ label: 'Identify operational risks', prompt: 'Identify operational risks and what I should fix first', capability: 'recommendations' },
],
/* Create Position asks about the draft in front of the author. Every one is
answerable from the form and the positions already posted. */
'admin.createPosition': () => [
{ label: 'Explain the vetting weights', prompt: 'Summarize these vetting weights', capability: 'vetting-weights' },
{ label: 'Compare with similar roles', prompt: 'What do comparable roles ask for?', capability: 'position-benchmarks' },
{ label: 'Credentials in use', prompt: 'Which certifications does this workspace require?', capability: 'position-requirements' },
{ label: 'How this form works', prompt: 'Explain how this form works', capability: 'position-spec-steps' },
],
/**
* Positions suggestions are the current workforce situation, phrased as
* questions.
@@ -407,6 +437,40 @@ const PROMPTS = {
'admin.positions': (f, workforce) => {
const out = [];
/**
* Two things about the position in front of the reader come first, because
* both are about what they were *just doing* rather than about the board.
*
* A draft is unfinished work, so finishing it leads. Then the role the page
* has open — the drawer, or the one just published, which the form names in
* the URL on its way here — gets the question its state actually invites:
* a role with no applicants yet is a matching question, not a pipeline one.
*
* Both name the record, so the answer resolves against that position and
* cannot be a stale reading of a different one.
*/
const drafts = (f.postings || []).filter((p) => p.status === 'draft');
if (drafts.length) {
out.push({
label: drafts.length === 1 ? `Continue the ${drafts[0].title} draft` : `${drafts.length} drafts to finish`,
prompt: 'Which positions are still in draft?',
capability: 'position-drafts',
});
}
const current = workforce?.currentPositionId
? (workforce.positions || []).find((p) => p.id === workforce.currentPositionId)
: null;
if (current && current.status !== 'draft') {
out.push({
label: `Find matching candidates for ${current.title}`,
/* The definition's own words, with the role named. It resolves to the
candidate-matching skill and reads that position — so switching the
skill off in Settings takes this answer with it, as it should. */
prompt: `Find matching candidates for ${current.title}`,
});
}
if (workforce?.positions?.length) {
const { positions, context } = workforce;
const ranked = prioritise(positions, context);

View File

@@ -0,0 +1,152 @@
/**
* Owliver's conversation history.
*
* The panel already persisted the *current* thread per page, in sessionStorage,
* so returning to Candidates found the conversation you left there. What it had
* no notion of was a conversation you had *finished*: starting a new one
* overwrote the old one, and there was no way back to anything.
*
* This is that archive, and it is deliberately the same artefact — a list of
* threads in the shape the panel already stores. Two differences, both
* intentional:
*
* - **localStorage, not session.** A history that empties when the tab closes
* is not a history. Threads outlive the session, which is what makes
* "Yesterday" and "Older" mean anything.
* - **Written continuously, not on reset.** The archive is updated as the
* conversation grows rather than when it ends, so a thread is recoverable
* even if the tab is closed mid-answer.
*
* Nothing here is a second conversation store: the live thread is still the one
* the panel renders from. This holds copies, keyed by conversation, for the one
* question the panel could not previously answer — "what did I ask before?"
*/
const KEY = 'krow_assistant:history';
/** Threads older than this stop being useful and start being clutter. */
const MAX_AGE_DAYS = 60;
/** A hard cap, so a heavy user cannot fill the origin's storage quota. */
const MAX_RECORDS = 100;
const DAY = 24 * 60 * 60 * 1000;
/** Every archived conversation, newest first. Never throws. */
export function readHistory() {
try {
const raw = localStorage.getItem(KEY);
const list = raw ? JSON.parse(raw) : [];
if (!Array.isArray(list)) return [];
const cutoff = Date.now() - MAX_AGE_DAYS * DAY;
return list
.filter((r) => r && r.id && Array.isArray(r.messages) && r.messages.length)
.filter((r) => new Date(r.updatedAt || 0).getTime() >= cutoff)
.sort((a, b) => new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0));
} catch {
/* Corrupt or unavailable storage is an empty history, not an error the
reader has to deal with. */
return [];
}
}
/** The first thing the person asked, which is what the thread was about. */
function titleFor(messages) {
const first = messages.find((m) => m.role === 'user' && m.text);
const text = String(first?.text || '').trim();
if (!text) return 'Conversation';
return text.length > 60 ? `${text.slice(0, 57)}…` : text;
}
/**
* Records this conversation, replacing its own earlier entry.
*
* 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 }) {
if (!id || !Array.isArray(messages) || !messages.length) return readHistory();
const record = {
id,
contextId,
page: page || '',
title: titleFor(messages),
turns: messages.filter((m) => m.role === 'user').length,
updatedAt: new Date().toISOString(),
messages,
};
const next = [record, ...readHistory().filter((r) => r.id !== id)].slice(0, MAX_RECORDS);
try {
localStorage.setItem(KEY, JSON.stringify(next));
} catch {
/* Quota or private mode: the live thread is unaffected, only the archive
is. Dropping the oldest half is a better outcome than losing every
future write. */
try {
localStorage.setItem(KEY, JSON.stringify(next.slice(0, Math.floor(MAX_RECORDS / 4))));
} catch {
/* Storage is unusable. History is in-memory for this session only. */
}
}
return next;
}
/** Forgets one conversation. */
export function removeConversation(id) {
const next = readHistory().filter((r) => r.id !== id);
try {
localStorage.setItem(KEY, JSON.stringify(next));
} catch {
/* Ignore — the caller re-reads either way. */
}
return next;
}
/** Forgets everything. */
export function clearHistory() {
try {
localStorage.removeItem(KEY);
} catch {
/* Ignore. */
}
return [];
}
/**
* Conversations under a heading, in the order a person thinks about time.
*
* Empty groups are dropped rather than rendered as a heading with nothing
* under it.
*/
export function groupByRecency(records, now = new Date()) {
const startOfToday = new Date(now);
startOfToday.setHours(0, 0, 0, 0);
const startOfYesterday = new Date(startOfToday.getTime() - DAY);
const startOfWeek = new Date(startOfToday.getTime() - 7 * DAY);
const groups = [
{ id: 'today', label: 'Today', items: [] },
{ id: 'yesterday', label: 'Yesterday', items: [] },
{ id: 'week', label: 'Earlier this week', items: [] },
{ id: 'older', label: 'Older', items: [] },
];
for (const record of records) {
const at = new Date(record.updatedAt || 0).getTime();
if (at >= startOfToday.getTime()) groups[0].items.push(record);
else if (at >= startOfYesterday.getTime()) groups[1].items.push(record);
else if (at >= startOfWeek.getTime()) groups[2].items.push(record);
else groups[3].items.push(record);
}
return groups.filter((g) => g.items.length);
}
/** A new conversation id. Time-ordered, so ids sort the way threads do. */
export function newConversationId() {
return `c_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
}

View File

@@ -21,6 +21,9 @@
*/
export { AssistantPanelProvider, useAssistantPanel } from './AssistantPanelContext';
export {
usePageAction, usePageContext, usePublishPageActions, usePublishPageContext,
} from './PageContext';
export { AssistantTrigger } from './AssistantTrigger';
export { AssistantPanel } from './AssistantPanel';
export { default as KrowAssistant } from './KrowAssistant';

View File

@@ -15,6 +15,11 @@ const PLACEMENT = {
admin: {
'/admin': 'admin.controlCenter',
'/admin/positions': 'admin.positions',
/* The authoring form. Owliver belongs here for the same reason it belongs
on Positions: the questions asked while specifying a role — what do these
weights score, what do comparable roles ask for — are answerable from the
page and from what this workspace has already posted. */
'/admin/positions/new': 'admin.createPosition',
'/admin/candidates': 'admin.candidatesList',
'/admin/candidates-analysis': 'admin.candidates',
'/admin/analytics': 'admin.analytics',
@@ -40,6 +45,8 @@ const PLACEMENT = {
export const EXCLUDED_ROUTES = [
// Every Employer and Talent route: the contextual panel is Admin-only.
'/', '/overview', '/positions', '/positions/new', '/positions/:id', '/candidates',
/* The Employer aliases only — the Admin `/admin/positions/new` carries the
panel, and is listed in the placement table above. */
'/hired', '/talent-pool', '/analytics', '/profile', '/apply',
'/university', '/university/:id', '/me', '/identity', '/employee',
'/owliver', '/design-system',

View File

@@ -1,7 +1,12 @@
import { doc, insights, list, note, text } from './blocks';
import { doc, heading, insights, list, note, skillSection, text } from './blocks';
import { ASSISTANT_CONTEXTS } from './contexts';
import { poolFor } from '@/lib/workforce';
import { matchSkill } from '@/lib/skills/registry';
import { owliverCapabilityLabel } from '@/lib/skills/surfaces';
import {
matchOwliverSkill, owliverSkillsForContext, presentable, resolveOwliverResponse,
responseTitle, summaryLines,
} from '@/lib/skills/owliverResolver';
import {
appliedToday, askWhichPosition, assignmentPreview, assignmentToExecute, availability,
candidateDetail, candidateMatches, candidateRoute, interviewReady, matchWorkforceIntent,
@@ -472,6 +477,96 @@ function proposeTraining(skill, question, courses, skillCategories) {
};
}
/* ── Declared skills ────────────────────────────────────────────────────── */
/**
* A skill answering from its own definition.
*
* The whole reply is composed from what the `.md` declared and what the shared
* resolver read: the title is the definition's, the capability is the shape it
* offers, the figures are the application's own records, and the follow-ups are
* the other capabilities the same definition carries. Nothing in this function
* knows which skill it is serving, and nothing may be added that does — the
* moment a skill id appears here, the definition has stopped being the source
* of truth.
*/
function declaredAnswer({ skill, capability, question, skillContext }) {
const resolved = resolveOwliverResponse({ skill, capability, question, context: skillContext });
if (!resolved) return null;
const { section, data, missing } = resolved;
const title = responseTitle(skill, section);
/* Other shapes of the same reading, offered rather than explained. A chip
per remaining capability, worded from the definition's own suggestions
where it wrote one for that shape. */
const followUp = skill.owliver.capabilities
.filter((c) => c !== capability)
.map((c) => {
const written = skill.owliver.suggestions.find((s) => s.capability === c);
return {
label: written?.label || `${skill.name} as ${owliverCapabilityLabel(c).toLowerCase()}`,
prompt: written?.prompt || `Show ${skill.name} as a ${c}`,
};
})
.slice(0, 3);
/**
* The reading needs a record the page has not supplied. Say which, and offer
* the ones there are — an answer about the wrong position would be worse
* than a question.
*/
if (missing === 'position') {
const open = (skillContext.positions || []).filter((p) => p.status === 'active').slice(0, 6);
return {
kind: 'skill',
skill,
doc: doc(
text(`**${title}** reads one position. Which should I read?`),
list(open.map((p) => p.title)),
note('Open a position, or name one and I will read it.')
),
followUp: open.slice(0, 4).map((p) => ({ label: p.title, prompt: `${question} for ${p.title}` })),
};
}
if (missing === 'candidate') {
return {
kind: 'skill',
skill,
doc: doc(
text(`**${title}** reads one candidate.`),
note('Open a candidate, or name one and I will read their record.')
),
};
}
/* A reading with nothing in it says so, in the resolver's own words. No
figure is invented to fill a shape. */
if (data.unavailable || data.empty) {
return {
kind: 'skill',
skill,
doc: doc(heading(title), text(data.emptyNote || 'There is nothing to report yet.')),
followUp,
};
}
/* A summary is prose; every other capability is the section the page would
draw, drawn here. */
const body = capability === 'summary'
? [
list(summaryLines(data)),
data.total != null ? note(`${data.total} record${data.total === 1 ? '' : 's'} in total.`) : null,
]
: [skillSection(section, presentable(data))];
return {
kind: 'skill',
skill,
doc: doc(heading(title, section.description || undefined), ...body),
followUp,
};
}
/**
* Skills that can act on this page, ahead of the page's own responder.
*
@@ -480,10 +575,76 @@ function proposeTraining(skill, question, courses, skillCategories) {
* bartender position" open a form instead of returning a pipeline report that
* happens to match the word "position".
*/
function resolveSkill({ question, contextId, disabledSkills, customSkills, roles, skillCategories, courses }) {
const skill = matchSkill(question, contextId, disabledSkills, customSkills);
function resolveSkill({
question, contextId, disabledSkills, customSkills, roles, skillCategories, courses,
skillContext = null,
}) {
/**
* One question, one skill, then one way of answering it.
*
* This used to be two independent matchers run in sequence: declared skills
* were resolved first and answered immediately, and only a question that
* matched none of them reached the trigger matcher and the coded flows below.
* That ordering was the bug. A declared definition could claim a question
* belonging to a flow it knew nothing about — "create a position" was
* answered by a candidate-matching skill — because the first matcher never
* asked whether anything else had a stronger claim.
*
* So both matchers run, and *then* the question is dispatched:
*
* 1. A skill with a coded flow owns its own triggers. Create Position and
* Forge authoring are conversations with state, not readings, and no
* declaration may take one over.
* 2. Otherwise a declared answer, if the definition earned the question.
* 3. Otherwise the definition reads itself back.
*
* The property this gives is the one that matters: a skill added tomorrow
* cannot capture a flow that already exists, whatever it declares.
*/
const declared = skillContext
? matchOwliverSkill(
question,
owliverSkillsForContext(contextId, disabledSkills, customSkills)
)
: null;
const triggered = matchSkill(question, contextId, disabledSkills, customSkills);
/**
* Which of the two named the question.
*
* A trigger is an explicit claim on a phrase, so it usually names the skill.
* Two cases where it must not, both of which were live bugs:
*
* - **A verbatim suggestion outranks a trigger.** A definition that
* published "Show hiring activity" and had the reader click it has the
* strongest claim there is. A *different* definition whose trigger merely
* contains those words — including the name every definition falls back
* to when it declares no triggers — has a weaker one, and was winning.
* - **An answer outranks a description.** A skill with no declared
* capabilities can only read its own definition back. If it takes a
* question another definition can actually answer, the reader gets a
* restatement of the question instead of the figures.
*
* Neither rule names a skill: both compare what the two definitions declared.
*/
const canAnswer = (s) => Boolean(s?.owliver?.enabled && s.owliver.capabilities.length);
/* A definition that collects answers in the chat or names an action to run is
a flow, not a reading, and owns the phrases it declared — read off what it
declares rather than from a list of ids, so a flow added later is protected
by the same rule. */
const ownsFlow = (s) => Boolean(s?.conversation?.length || s?.actions?.length);
const skill = (triggered && ownsFlow(triggered) && (!declared || !declared.exact))
? triggered
: declared && (declared.exact || !canAnswer(triggered))
? declared.skill
: triggered || declared?.skill || null;
if (!skill) return null;
/* The declared answer is only offered for the skill that actually won. */
const declaredForSkill = declared && declared.skill.id === skill.id ? declared : null;
/**
* Forge authoring. Same contract as Create Position: what the sentence
* answered is filled in, the rest is left blank, and the flow opens rather
@@ -522,9 +683,19 @@ function resolveSkill({ question, contextId, disabledSkills, customSkills, roles
return { kind: 'skill', skill, ...beginPositionFlow({ question, skill, roles }) };
}
/* A skill with no coded handler still answers, from its own definition. That
is what makes a Markdown-only skill useful on the page it names without a
line of Owliver being changed for it. */
/**
* No coded flow owns this one, so the definition answers it.
*
* A declared `owliver:` block is the richer answer — a real reading, drawn in
* the shape the author asked for — so it is tried first. A definition without
* one still reads itself back, which is what makes a Markdown-only skill
* useful on the page it names without a line of Owliver being changed for it.
*/
if (declaredForSkill) {
const answer = declaredAnswer({ ...declaredForSkill, question, skillContext });
if (answer) return answer;
}
return {
kind: 'skill',
skill,
@@ -538,13 +709,14 @@ function resolveSkill({ question, contextId, disabledSkills, customSkills, roles
export function resolveIntent({
question, contextId, disabledSkills = [], customSkills = [], roles = [], skillCategories = [],
courses = [], workforce = null,
courses = [], workforce = null, skillContext = null,
}) {
const context = ASSISTANT_CONTEXTS[contextId] ?? null;
/* 2. Current page skills — specific triggers, ahead of the general reader. */
const skill = resolveSkill({
question, contextId, disabledSkills, customSkills, roles, skillCategories, courses,
skillContext,
});
if (skill) return skill;

View File

@@ -6,12 +6,16 @@ import {
useUserActivity, useWorkerProfile, useWorkerProfiles,
} from '@/lib/krowHooks';
import { skillsForContext } from '@/lib/skills/registry';
import { suggestionsForPosition } from '@/lib/skills/owliverResolver';
import {
advancePositionFlow, createdFollowUp, positionCreatedReply, positionFailedReply,
} from '@/lib/skills/positionFlow';
import {
assignmentDone, assignmentFailed, assignmentFollowUp, interviewDone, interviewFailed,
} from '@/lib/skills/workforceFlow';
import {
newConversationId, readHistory, removeConversation, saveConversation,
} from './history';
import { buildFacts } from './insights';
import { createAssistantProvider } from './provider';
import { resolveIntent } from './routing';
@@ -121,16 +125,35 @@ async function streamDocument({ document, signal, onFrame, onDone }) {
export function useConversation({
contextId, facts, onNavigate, onAction, onCreatePosition, onAssignWorkers, onScheduleInterview,
workforce = null, disabledSkills = [], customSkills = [],
roles = [], skillCategories = [], courses = [],
roles = [], skillCategories = [], courses = [], skillContext = 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}`;
const flowKey = `${storageKey}:flow`;
const idKey = `${storageKey}:id`;
const [messages, setMessages] = React.useState([]);
const [pending, setPending] = React.useState(null); // { blocks, thinking }
const [error, setError] = React.useState(null);
const abortRef = React.useRef(null);
/* The archive, held in state so opening History does not have to re-read
storage on every render, and so a new turn moves its own thread to the top
of the list while it is on screen. */
const [history, setHistory] = React.useState(() => readHistory());
/**
* Which conversation this is.
*
* The thread already persisted per page; what it lacked was an identity, so
* every new conversation overwrote the last. This id is what an archived
* record is keyed by — kept in sessionStorage beside the thread it belongs
* to, so a reload continues the same conversation rather than forking it.
*/
const conversationRef = React.useRef(null);
/* A guided skill in progress — which question it is on and what it has
collected. Kept beside the thread rather than inside it, and persisted for
the same reason the thread is: leaving Positions and coming back should not
@@ -155,6 +178,11 @@ export function useConversation({
} catch {
setMessages([]);
}
try {
conversationRef.current = sessionStorage.getItem(idKey) || null;
} catch {
conversationRef.current = null;
}
try {
const raw = sessionStorage.getItem(flowKey);
flowRef.current = raw ? JSON.parse(raw) : null;
@@ -163,8 +191,16 @@ export function useConversation({
}
setPending(null);
setError(null);
}, [storageKey, flowKey]);
}, [storageKey, flowKey, idKey]);
/**
* Writes the thread, and records it.
*
* One function rather than two calls at each site: a thread that is persisted
* but not archived is a conversation History cannot show, and every path that
* adds a turn goes through here. The conversation is given its id on its
* first turn, so an untouched panel leaves nothing behind.
*/
const persist = React.useCallback((next) => {
setMessages(next);
try {
@@ -172,7 +208,26 @@ export function useConversation({
} catch {
// Quota or private mode — the thread stays in memory only.
}
}, [storageKey]);
if (!next.length) return;
if (!conversationRef.current) {
conversationRef.current = newConversationId();
try {
sessionStorage.setItem(idKey, conversationRef.current);
} catch {
// The id stays in memory: this conversation archives under one id for
// as long as the panel is mounted, which is the common case.
}
}
setHistory(saveConversation({
id: conversationRef.current,
contextId,
page: pageLabel,
messages: next,
}));
}, [storageKey, idKey, contextId, pageLabel]);
/* Abort any in-flight response when the context changes or we unmount. */
React.useEffect(() => () => abortRef.current?.abort(), [storageKey]);
@@ -224,7 +279,7 @@ export function useConversation({
? { kind: 'answer' }
: resolveIntent({
question: text, contextId, disabledSkills, customSkills, roles, skillCategories,
courses, workforce,
courses, workforce, skillContext,
});
}
@@ -236,14 +291,33 @@ export function useConversation({
if (intent.kind === 'flow' && intent.create) {
let created = null;
try {
created = await onCreatePosition?.(intent.create.draft, intent.skill);
created = await onCreatePosition?.(intent.create.draft, intent.skill, intent.create.status);
} catch {
created = null;
}
if (created?.id) {
/**
* What can now be asked about the position that was just made.
*
* A live position is something the page's skills can read; a draft is
* not finished being specified, so offering to analyse it would be
* answering about a record the admin has not committed to. The registry
* decides *which* skills can say something — see
* `suggestionsForPosition` — and this decides only whether it is yet
* the moment to ask them.
*/
const ready = created.status === 'active';
intent = {
...intent, flow: null, doc: positionCreatedReply(created), followUp: createdFollowUp(created),
...intent,
flow: null,
doc: positionCreatedReply(created),
followUp: [
...createdFollowUp(created),
...(ready
? suggestionsForPosition(contextId, disabledSkills, customSkills, created)
: []),
],
};
} else {
/* Keep the answers: the summary is still there to try again from. */
@@ -372,10 +446,18 @@ export function useConversation({
}, [contextId, facts, persist, onNavigate, onAction, onCreatePosition, onAssignWorkers,
onScheduleInterview,
workforce, setFlow, disabledSkills,
customSkills, roles, skillCategories, courses]);
customSkills, roles, skillCategories, courses, skillContext]);
const stop = React.useCallback(() => abortRef.current?.abort(), []);
/**
* Back to an empty panel.
*
* The thread being cleared is not discarded — `persist` has already archived
* every turn of it — so this releases the conversation rather than deleting
* it, and the next turn starts a new one. That is what makes "New
* conversation" and "Back to Home" safe: nothing is lost by leaving.
*/
const reset = React.useCallback(() => {
abortRef.current?.abort();
messagesRef.current = [];
@@ -385,12 +467,47 @@ export function useConversation({
/* A new conversation abandons a half-collected position too — the questions
it was answering are no longer on screen. */
setFlow(null);
conversationRef.current = null;
try {
sessionStorage.removeItem(storageKey);
sessionStorage.removeItem(idKey);
} catch {
// Ignore.
}
}, [storageKey, setFlow]);
}, [storageKey, idKey, setFlow]);
/**
* Reopens an archived conversation as the live thread.
*
* It resumes under its own id, so continuing an old conversation adds to that
* record instead of forking a near-duplicate beside it. Any half-collected
* skill flow is dropped: its questions belonged to the thread being left.
*/
const openConversation = React.useCallback((record) => {
if (!record?.id || !Array.isArray(record.messages)) return;
abortRef.current?.abort();
setPending(null);
setError(null);
setFlow(null);
messagesRef.current = record.messages;
setMessages(record.messages);
conversationRef.current = record.id;
try {
sessionStorage.setItem(storageKey, JSON.stringify(record.messages));
sessionStorage.setItem(idKey, record.id);
} catch {
// The reopened thread stays in memory only.
}
}, [storageKey, idKey, setFlow]);
/** Forgets one archived conversation, clearing the panel if it is open. */
const forgetConversation = React.useCallback((id) => {
setHistory(removeConversation(id));
if (conversationRef.current === id) reset();
}, [reset]);
return {
messages,
@@ -400,6 +517,12 @@ export function useConversation({
send,
stop,
reset,
/** Every archived conversation, newest first. */
history,
/** The conversation on screen, so History can mark it. */
conversationId: conversationRef.current,
openConversation,
forgetConversation,
providerId: provider.id,
};
}