update ui design for all pages

This commit is contained in:
2026-08-12 18:56:56 +05:30
parent 25c4ee4a6a
commit 3beb66059b
58 changed files with 7892 additions and 1891 deletions

View File

@@ -1,5 +1,5 @@
import * as React from 'react';
import { PanelLeftOpen, Sparkles } from 'lucide-react';
import { PanelLeftOpen } from 'lucide-react';
import { cn } from '@/lib/utils';
import KrowAssistant from './KrowAssistant';
import { useAssistantPanel } from './AssistantPanelContext';

View File

@@ -1,4 +1,5 @@
import * as React from 'react';
import { base44 } from '@/api/base44Client';
import { resolveAssistantContext } from './placement';
/**
@@ -56,7 +57,15 @@ function writeFlag(key, value) {
}
export function AssistantPanelProvider({ role, pathname, children }) {
const [open, setOpen] = React.useState(() => readFlag(OPEN_KEY, true));
/* Two different things, deliberately kept apart: the account preference is
whether Owliver *should* open by default, and the session flag is whether
this tab currently has it collapsed. The preference is the fallback, so a
panel collapsed for the afternoon does not rewrite the account setting, and
an account with Owliver off does not get the panel pushed back at it on
every new tab. */
const [open, setOpen] = React.useState(
() => readFlag(OPEN_KEY, base44.auth.preferences().owliverDefault !== false)
);
const [expanded, setExpanded] = React.useState(() => readFlag(EXPANDED_KEY));
const [width, setWidth] = React.useState(() => {
try {

View File

@@ -1,9 +1,15 @@
import * as React from 'react';
import { useNavigate } from 'react-router-dom';
import { Maximize2, Minimize2, PanelRightClose, RotateCcw, Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Surface } from '@/components/ds/Surface';
import { IconButton } from '@/components/ds/IconButton';
import { Alert } from '@/components/ds/Alert';
import { useCreateJobPosting, usePreferences, useRoleCategories } from '@/lib/krowHooks';
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 { useAssistantFacts, useConversation, useCurrentUserName } from './useAssistant';
import { buildIntro, buildPrompts } from './dynamic';
import { Message, ThinkingIndicator, TurnDivider } from './AssistantMessage';
@@ -42,9 +48,83 @@ export default function KrowAssistant({
}) {
const facts = useAssistantFacts();
const userName = useCurrentUserName();
const navigate = useNavigate();
/* The app's own router, not a location assignment: a full page load would
discard the thread and the panel state along with it. */
const goToPage = React.useCallback(
(destination) => navigate(destination.route),
[navigate]
);
/* Skills available here, and the roles they can act on — both read from what
the app already has rather than from anything written into this component.
A page whose skills change needs no edit here. */
const preferences = usePreferences();
const disabledSkills = React.useMemo(
() => preferences.disabledSkills || [],
[preferences.disabledSkills]
);
const { data: customCategories = [] } = useRoleCategories();
const roles = React.useMemo(
() => [...new Set([...ROLE_CATEGORIES, ...customCategories.map((c) => c.name)])],
[customCategories]
);
/* The categories Forge skills are actually filed under, so "create a kitchen
safety training" fills in the category this deployment uses rather than one
written into a skill file. */
const skillCategories = React.useMemo(
() => [...new Set((facts.forge?.library || []).map((c) => c.category).filter(Boolean))],
[facts.forge]
);
const customSkills = React.useMemo(
() => preferences.customSkills || [],
[preferences.customSkills]
);
const skills = React.useMemo(
() => skillsForContext(context.id, disabledSkills, customSkills),
[context.id, disabledSkills, customSkills]
);
const [addSkillOpen, setAddSkillOpen] = React.useState(false);
/**
* 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
* declare.
*/
const performAction = React.useCallback((action, skill) => {
const result = runAction(action.name, { ...action.payload, skill });
if (result?.type === 'navigate' && result.route) {
navigate(result.route, result.state ? { state: result.state } : undefined);
}
}, [navigate]);
/**
* Write the position the conversation collected.
*
* The same mutation the Create Position form calls, so there is one create
* path and the list behind the panel refreshes the way it always did. The
* skill still has to declare `create_position` — `runAction` returns nothing
* 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 });
if (result?.type !== 'create_position') return null;
return createJob.mutateAsync(result.data);
}, [createJob]);
const { messages, pending, error, busy, send, stop, reset } = useConversation({
contextId: context.id,
facts,
onNavigate: goToPage,
onAction: performAction,
onCreatePosition: createPosition,
disabledSkills,
customSkills,
roles,
skillCategories,
courses: facts.forge?.library || [],
});
const [input, setInput] = React.useState('');
@@ -57,7 +137,17 @@ export default function KrowAssistant({
() => buildIntro(context.id, facts, userName),
[context.id, facts, userName]
);
const prompts = React.useMemo(() => buildPrompts(context.id, facts), [context.id, facts]);
/* Suggestions are the page's own, with any skill that offers one on the front.
The last answer can also propose follow-ups — the role list after "create a
position" — which replace the standing set until the next turn. */
const followUp = messages[messages.length - 1]?.followUp;
const prompts = React.useMemo(() => {
if (followUp?.length) return followUp;
const skillPrompts = skills
.filter((s) => s.prompt)
.map((s) => ({ label: s.prompt, prompt: s.prompt }));
return [...skillPrompts, ...buildPrompts(context.id, facts)];
}, [followUp, skills, context.id, facts]);
/* Follow the newest content. Direct `scrollTop` rather than smooth scrolling:
at streaming frequency a smooth scroll never catches up and the thread
@@ -67,19 +157,36 @@ export default function KrowAssistant({
if (el) el.scrollTop = el.scrollHeight;
}, [messages, pending]);
const submit = React.useCallback((text) => {
const question = String(text).trim();
if (!question || busy) return;
send({ question });
/**
* The one entry point for every message, whatever raised it.
*
* A chip carries a capability — it *is* one of this page's answers, so it
* 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) => {
const text = String(question).trim();
if (!text || busy) return;
setInput('');
send({ question: text, capability });
}, [busy, send]);
/* A chip is already a complete question, so it runs on click. */
const submit = React.useCallback((text) => handleMessage(text), [handleMessage]);
/**
* A chip is already a complete question, so it runs on click.
*
* A chip may instead carry a destination — "View position", offered after
* Owliver creates one. That is the user choosing to go somewhere after the
* work is finished, which is the only navigation any of this does.
*/
const runPrompt = React.useCallback((prompt) => {
if (busy) return;
setInput('');
send({ question: prompt.prompt, capability: prompt.capability });
}, [busy, send]);
if (prompt.route) {
navigate(prompt.route);
return;
}
handleMessage(prompt.prompt, prompt.capability);
}, [handleMessage, navigate]);
const composer = (
<PromptInput
@@ -116,6 +223,15 @@ export default function KrowAssistant({
{/* Window controls */}
<div className="flex items-center gap-0.5">
{/* Opens the same dialog Settings does; there is one registry behind
both, so a skill added here is the skill added there. */}
<IconButton
icon={Sparkles}
label="Add an Owliver skill"
variant="ghost"
size="sm"
onClick={() => setAddSkillOpen(true)}
/>
{messages.length > 0 && (
<IconButton icon={RotateCcw} label="New conversation" variant="ghost" size="sm" onClick={reset} />
)}
@@ -179,17 +295,23 @@ export default function KrowAssistant({
)}
</div>
<AddSkillDialog open={addSkillOpen} onOpenChange={setAddSkillOpen} />
{/* ── Composer: fixed to the bottom in both states ───────────────── */}
<div className="shrink-0 space-y-2 border-t border-white/60 px-3 pb-2.5 pt-2.5">
{/* All suggestions on the landing screen, where they teach what can be
asked. Capped once a thread exists, because from then on the vertical
space belongs to the conversation. Expanded fits more per line, so it
can afford one more. */}
can afford one more.
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 && (
<PromptChips
prompts={prompts}
onSelect={runPrompt}
max={isEmpty ? undefined : expanded ? 4 : 3}
max={isEmpty || followUp?.length ? undefined : expanded ? 4 : 3}
align="start"
/>
)}

View File

@@ -8,7 +8,11 @@ import {
text, timeline,
} from '../blocks';
import { pct, plural, verb } from '../insights';
/* The proof-method wording is the Forge page's own, so a skill is never named
one thing in the library and another in the panel beside it. */
import { TYPE_LABEL } from '@/components/forge/challengeMeta';
import { toFICO } from '@/lib/talentHome';
import { PERMISSIONS, PROFILE_ACTIONS } from '@/pages/admin/permissions';
/**
* Admin capabilities — Control Center, Candidates Analysis, Activity.
@@ -1357,161 +1361,162 @@ export const ACTIVITY_CAPABILITIES = [
/* ── KROW Forge ─────────────────────────────────────────────────────────── */
/**
* Forge capabilities — workforce skill verification.
* Forge capabilities — managing workforce skill verification.
*
* The Forge questions are not hiring questions: they are about proof. What is
* verified, what can be proven next, and what is gating the rest. Every figure
* comes from `f.forge`, which is derived with the same unlock gate the page
* renders from.
* The Forge questions an administrator asks are not a worker's. A worker asks
* what to prove next; the person who owns the library asks what is in it, what
* is live, what Owliver is checking, and whether any of it is reaching the
* workforce. Every figure comes from `f.forge`, derived from the same Course
* records and worker profiles the page renders from.
*/
const forgeProgress = (f) => {
const forgeLibrary = (f) => {
const g = f.forge;
return doc(
heading('Forge progress', `${plural(g.courses.length, 'challenge')} in the library`),
kpis([
{ label: 'Verified skills', value: g.verified.length, sub: g.verified.length ? 'proven' : 'nothing proven yet' },
{ label: 'In progress', value: g.inProgress.length, sub: 'challenges started' },
{ label: 'Ready to start', value: g.ready.length, sub: 'unlocked now' },
{ label: 'XP available', value: g.xpAvailable, sub: `across ${plural(g.courses.length - g.completed.length, 'challenge')}` },
]),
g.verified.length
? list(g.verified.map((b) => `**${b.name}** — ${b.level || 'Standard'}, verified`))
: text('No skill is verified yet. The first one comes from completing a challenge and submitting proof for Owliver to grade.'),
g.byDifficulty.length ? heading('Library by difficulty') : null,
meters(g.byDifficulty.map((d) => ({
label: d.level.charAt(0).toUpperCase() + d.level.slice(1),
value: d.count,
max: g.courses.length,
}))),
g.locked.length
? note(`${plural(g.locked.length, 'challenge')} ${verb(g.locked.length, 'is', 'are')} still gated by real-work requirements.`)
: null
);
};
const forgeNextChallenge = (f) => {
const g = f.forge;
if (!g.next) return answer('Every active challenge is complete — there is nothing left to start.');
const c = g.next;
const gate = g.blocked.find((b) => b.course.id === c.id);
return doc(
heading(c.title, `${c.proof_skill || c.category} · ${c.difficulty}`),
text(c.description),
kpis([
{ label: 'Duration', value: `${c.estimated_minutes} min` },
{ label: 'XP', value: c.xp },
{ label: 'Earns', value: c.badge_reward || '—' },
{ label: 'State', value: gate ? 'Locked' : 'Ready', tone: gate ? 'warning' : 'success' },
]),
gate
? insights([{
tone: 'warning',
title: 'Blocked for now',
body: `${gate.reasons.join('. ')}. Those are real-work requirements, so they clear through shifts rather than through study.`,
}])
: insights([{
tone: 'success',
title: 'Ready to start',
body: `Nothing is gating this one. ${plural(c.estimated_minutes, 'minute')} of work is worth ${c.xp} XP${c.badge_reward ? ` and the ${c.badge_reward} badge` : ''}.`,
}]),
g.ready.length > 1
? actions(g.ready.slice(1, 3).map((alt) => ({
title: alt.title,
body: `${alt.difficulty} · ${alt.estimated_minutes} min · ${alt.xp} XP. An alternative if this one does not fit today.`,
})))
: null
);
};
const forgeBlockers = (f) => {
const g = f.forge;
if (!g.locked.length) {
return answer(`Nothing is locked — all ${plural(g.ready.length, 'remaining challenge')} can be started now.`);
if (!g.library.length) {
return answer('The library is empty. A skill defines what the workforce learns, how they prove it, and what Owliver checks — start one from Add Skill Training.');
}
return doc(
heading(`${plural(g.locked.length, 'challenge')} blocked`, 'and what clears each one'),
table(
[
{ key: 'challenge', label: 'Challenge' },
{ key: 'skill', label: 'Skill' },
{ key: 'requirement', label: 'Blocked by' },
],
g.blocked.map(({ course, reasons }) => ({
challenge: course.title,
skill: course.proof_skill || course.category,
requirement: reasons[0] || '—',
}))
),
insights(g.blocked.slice(0, 2).map(({ course, reasons }) => ({
tone: 'warning',
title: course.title,
body: `${reasons.join('. ')}. Worth ${course.xp} XP${course.badge_reward ? ` and the ${course.badge_reward} badge` : ''} once it opens.`,
heading('Skill library', `${plural(g.library.length, 'skill')} defined`),
kpis([
{ label: 'Active skills', value: g.published.length, sub: `of ${g.library.length} in the library` },
{ label: 'Training modules', value: g.withTraining.length, sub: 'carrying material' },
{ label: 'Challenges', value: g.withChallenge.length, sub: 'evidence-based' },
{ label: 'Verified skills', value: g.workforceVerified, sub: g.workforceVerified ? 'earned by the workforce' : 'none earned yet' },
]),
g.byCategory.length ? heading('By category') : null,
meters(g.byCategory.map((c) => ({
label: c.category,
value: c.count,
max: g.library.length,
}))),
note('Requirements are measured against the profile record, so they update as shifts and reliability are logged.')
g.drafts.length
? note(`${plural(g.drafts.length, 'skill')} ${verb(g.drafts.length, 'is', 'are')} still in draft and not visible to the workforce.`)
: null
);
};
const forgeReadyNow = (f) => {
const forgePublished = (f) => {
const g = f.forge;
if (!g.ready.length) {
if (!g.published.length) {
return answer(`Nothing is published. ${g.drafts.length ? `${plural(g.drafts.length, 'draft')} ${verb(g.drafts.length, 'is', 'are')} waiting on a decision.` : 'The library has no skills yet.'}`);
}
return doc(
heading(`${plural(g.published.length, 'skill')} published`, 'live to the workforce'),
table(
[
{ key: 'skill', label: 'Skill' },
{ key: 'category', label: 'Category' },
{ key: 'proof', label: 'Proof' },
{ key: 'minutes', label: 'Min', align: 'right' },
],
g.published.map((c) => ({
skill: c.title,
category: c.category || '—',
proof: TYPE_LABEL[c.challenge?.type] || 'Not set',
minutes: c.estimated_minutes || '—',
}))
),
g.drafts.length
? insights([{
tone: 'warning',
title: `${plural(g.drafts.length, 'skill')} in draft`,
body: `${g.drafts.map((c) => c.title).join(', ')}. A draft is invisible to the workforce until it is published.`,
}])
: note('Every skill in the library is published.')
);
};
const forgeEvaluation = (f) => {
const g = f.forge;
const scored = g.library.filter((c) => c.challenge?.rubric?.length);
if (!scored.length) {
return answer('No skill has evaluation criteria yet. Owliver scores a submission against the criteria set on the skill, so a skill without them cannot verify anything.');
}
return doc(
heading('What Owliver evaluates', `across ${plural(scored.length, 'skill')}`),
table(
[
{ key: 'skill', label: 'Skill' },
{ key: 'criteria', label: 'Criteria' },
{ key: 'pass', label: 'Passes at', align: 'right' },
],
scored.map((c) => ({
skill: c.title,
criteria: c.challenge.rubric.map((r) => r.criterion).join(', '),
pass: c.pass_score || '—',
}))
),
g.library.length > scored.length
? insights([{
tone: 'warning',
title: 'Skills with nothing to score against',
body: `${g.library.filter((c) => !c.challenge?.rubric?.length).map((c) => c.title).join(', ')}. Owliver needs criteria to return a verdict.`,
}])
: note('Every skill in the library tells Owliver what to check.')
);
};
const forgeWorkforce = (f) => {
const g = f.forge;
const touched = g.usage.filter((u) => u.completed > 0 || u.verified > 0);
if (!touched.length) {
return doc(
text('Nothing is unlocked right now.'),
g.locked.length ? text(`${plural(g.locked.length, 'challenge')} ${verb(g.locked.length, 'is', 'are')} waiting on real-work requirements.`) : null
text('No skill in the library has been completed by anyone yet.'),
note('Completion and verification are read from worker profiles, so figures appear here as the workforce works through the library.')
);
}
return doc(
heading(`${plural(g.ready.length, 'challenge')} ready to start`),
table(
[
{ key: 'challenge', label: 'Challenge' },
{ key: 'difficulty', label: 'Difficulty' },
{ key: 'minutes', label: 'Min', align: 'right' },
{ key: 'xp', label: 'XP', align: 'right' },
],
g.ready.map((c) => ({
challenge: c.title,
difficulty: c.difficulty,
minutes: c.estimated_minutes,
xp: c.xp,
}))
),
text(
`Cheapest first: **${[...g.ready].sort((a, b) => a.estimated_minutes - b.estimated_minutes)[0].title}**. `
+ `Highest value: **${[...g.ready].sort((a, b) => b.xp - a.xp)[0].title}**.`
)
);
};
const forgeSkillMap = (f) => {
const g = f.forge;
const skills = g.courses.map((c) => ({
skill: c.proof_skill || c.category || 'Workforce skill',
state: g.verified.some((b) => b.course_id === c.id)
? 'Verified'
: g.locked.some((l) => l.id === c.id) ? 'Locked' : 'Ready',
xp: c.xp,
}));
return doc(
heading('Skills this library can verify', plural(skills.length, 'skill')),
heading('Workforce usage', `${plural(touched.length, 'skill')} in use`),
table(
[
{ key: 'skill', label: 'Skill' },
{ key: 'state', label: 'State' },
{ key: 'xp', label: 'XP', align: 'right' },
{ key: 'completed', label: 'Completed', align: 'right' },
{ key: 'verified', label: 'Verified', align: 'right' },
],
skills
touched
.sort((a, b) => b.verified - a.verified || b.completed - a.completed)
.map((u) => ({
skill: u.course.title,
completed: u.completed,
verified: u.verified,
}))
),
text(
g.verified.length
? `${plural(g.verified.length, 'skill')} already carries evidence an employer can read.`
: 'None of these carry evidence yet. Each verified skill adds a record an employer can read against the profile.'
)
g.library.length > touched.length
? note(`${plural(g.library.length - touched.length, 'skill')} ${verb(g.library.length - touched.length, 'has', 'have')} no completions on record.`)
: null
);
};
const forgeGaps = (f) => {
const g = f.forge;
const gaps = [
...g.drafts.map((c) => ({ skill: c.title, gap: 'Unpublished draft' })),
...g.library.filter((c) => !c.challenge?.type).map((c) => ({ skill: c.title, gap: 'No proof method' })),
...g.library.filter((c) => !c.challenge?.rubric?.length).map((c) => ({ skill: c.title, gap: 'No Owliver criteria' })),
...g.library.filter((c) => !c.training_outline?.length && !c.quiz?.length).map((c) => ({ skill: c.title, gap: 'No training material' })),
];
if (!gaps.length) {
return answer(`All ${plural(g.library.length, 'skill')} carry training, a proof method and evaluation criteria, and every one is published.`);
}
return doc(
heading(`${plural(gaps.length, 'gap')} to close`, 'before these verify anything'),
table(
[
{ key: 'skill', label: 'Skill' },
{ key: 'gap', label: 'Missing' },
],
gaps
),
note('A skill needs training, a way to submit evidence, and criteria for Owliver to score against before it can verify a workforce skill.')
);
};
@@ -1763,12 +1768,12 @@ const hiringRecentHires = (f) => {
export const respondForge = (question, f) => {
const q = question.toLowerCase();
if (has(q, 'block', 'locked', 'lock', 'requirement', 'prerequisite', 'why can', 'unlock')) return forgeBlockers(f);
if (has(q, 'next', 'should i', 'which challenge', 'recommend', 'best')) return forgeNextChallenge(f);
if (has(q, 'ready', 'start now', 'available', 'can i start', 'active challenge')) return forgeReadyNow(f);
if (has(q, 'skill', 'verified', 'proof', 'evidence', 'badge')) return forgeSkillMap(f);
if (has(q, 'summar', 'progress', 'how am i', 'xp', 'overview')) return forgeProgress(f);
return forgeProgress(f);
if (has(q, 'publish', 'live', 'draft', 'unpublish', 'archived', 'status')) return forgePublished(f);
if (has(q, 'owliver', 'evaluat', 'criteri', 'rubric', 'grade', 'score against', 'pass')) return forgeEvaluation(f);
if (has(q, 'gap', 'missing', 'incomplete', 'unfinished', 'needs', 'ready to publish')) return forgeGaps(f);
if (has(q, 'workforce', 'usage', 'completed', 'verified', 'earned', 'badge', 'who has', 'uptake')) return forgeWorkforce(f);
if (has(q, 'skill', 'library', 'challenge', 'course', 'training', 'category', 'summar', 'overview', 'how many', 'what do we have')) return forgeLibrary(f);
return forgeLibrary(f);
};
export const respondTalentPool = (question, f) => {
@@ -1793,11 +1798,11 @@ export const respondHiredHistory = (question, f) => {
/* ── Capability manifests for the three new contexts ────────────────────── */
export const FORGE_CAPABILITIES = [
{ id: 'forge-progress', label: 'Forge Progress', icon: Gauge, run: forgeProgress },
{ id: 'forge-next', label: 'Next Challenge', icon: Target, run: forgeNextChallenge },
{ id: 'forge-ready', label: 'Ready To Start', icon: UserCheck, run: forgeReadyNow },
{ id: 'forge-blockers', label: 'What Is Blocking Me', icon: Lock, run: forgeBlockers },
{ id: 'forge-skills', label: 'Skill Map', icon: ShieldCheck, run: forgeSkillMap },
{ id: 'forge-library', label: 'Skill Library', icon: Gauge, run: forgeLibrary },
{ id: 'forge-published', label: 'Published Skills', icon: Target, run: forgePublished },
{ id: 'forge-evaluation', label: 'Owliver Evaluation', icon: ShieldCheck, run: forgeEvaluation },
{ id: 'forge-workforce', label: 'Workforce Usage', icon: UserCheck, run: forgeWorkforce },
{ id: 'forge-gaps', label: 'Gaps To Close', icon: Lock, run: forgeGaps },
];
export const TALENT_POOL_CAPABILITIES = [
@@ -1813,3 +1818,120 @@ export const HIRED_HISTORY_CAPABILITIES = [
{ id: 'hiring-patterns', label: 'What Stands Out', icon: Layers, run: hiringPatterns },
{ id: 'hiring-recent', label: 'Recent Hires', icon: ScrollText, run: hiringRecentHires },
];
/* ── Profile ────────────────────────────────────────────────────────────────
*
* The account page answers questions about the operator rather than the
* workforce: what this account may do, how it is secured, what it has been
* doing, and which switches are on. Everything below reads the same constants
* the page renders, so the panel can never describe a scope or a control that
* is not on screen.
*/
const profilePermissions = () => doc(
heading('Your permissions', `${PERMISSIONS.length} scopes on this account`),
table(
[{ key: 'scope', label: 'Scope' }, { key: 'level', label: 'Level' }, { key: 'detail', label: 'Covers' }],
PERMISSIONS.map((p) => ({
scope: p.scope,
level: {
value: p.level,
tone: p.level === 'Full access' ? 'success' : p.level === 'Read only' ? 'info' : 'muted',
},
detail: p.detail,
}))
),
note('Scopes are set by the account owner. Billing stays restricted to owners.')
);
const profileIdentity = (f) => {
const user = f.user || {};
return doc(
heading('This account'),
table(
[{ key: 'field', label: 'Field' }, { key: 'value', label: 'Value' }],
[
{ field: 'Name', value: user.full_name || '—' },
{ field: 'Email', value: user.email || '—' },
{ field: 'Role', value: 'Platform Administrator' },
{ field: 'Department', value: 'Workforce Operations' },
{ field: 'Account status', value: { value: 'Active · verified', tone: 'success' } },
{
field: 'Member since',
value: user.created_date
? new Date(user.created_date).toLocaleDateString(undefined, { month: 'long', year: 'numeric' })
: '—',
},
]
),
text('Use **Edit profile** at the top of the page to change your display name. Email and role are set by the account owner.')
);
};
const profileSecurity = () => doc(
heading('Security on this account'),
status([
{ label: 'Two-factor authentication', ok: true, value: 'On', note: 'Required for privileged actions.' },
{ label: 'Password age', ok: true, value: '42 days', note: 'Administrator passwords expire every 90 days.' },
{ label: 'Session', ok: true, value: 'Browser tab', note: 'Closing the tab signs this account out.' },
]),
text('**Change password** and **Manage sessions** sit in the Security card on the right.')
);
const profilePreferences = (f) => {
const p = f.user?.preferences || {};
const state = (on) => ({ value: on ? 'On' : 'Off', tone: on ? 'success' : 'muted' });
return doc(
heading('Your preferences'),
table(
[{ key: 'setting', label: 'Setting' }, { key: 'state', label: 'State' }, { key: 'detail', label: 'Effect' }],
[
{ setting: 'Owliver workspace', state: state(p.owliverDefault !== false), detail: 'Opens this panel by default on supported pages' },
{ setting: 'Compact density', state: state(Boolean(p.compactDensity)), detail: 'Tighter row heights across Admin tables' },
{ setting: 'Email digest', state: state(p.emailDigest !== false), detail: 'A daily summary of what needs attention' },
]
),
note('Preferences save to your account as soon as you flip a switch.')
);
};
const profileActivity = (f) => {
const email = f.user?.email;
const mine = f.activity.filter((e) => e.user_email === email).slice(0, 6);
if (!mine.length) {
return doc(text('No activity is recorded against this account yet.'));
}
return doc(
heading('Your recent activity', `${plural(mine.length, 'event')} on this account`),
timeline(mine.map((e) => ({
title: e.event_type.replace(/_/g, ' '),
description: e.details || undefined,
timestamp: new Date(e.created_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
})))
);
};
const profileActions = () => doc(
heading('What you can do here'),
list(PROFILE_ACTIONS.map((a) => `**${a.label}** — ${a.detail}`)),
note('Everything above is on this page. Nothing here changes another operator’s account.')
);
export const respondProfile = (question, f) => {
const q = question.toLowerCase();
if (has(q, 'permission', 'scope', 'access', 'allowed', 'can i')) return profilePermissions(f);
if (has(q, 'password', 'two-factor', 'two factor', '2fa', 'security', 'session', 'sign out', 'log out')) return profileSecurity(f);
if (has(q, 'preference', 'setting', 'digest', 'density', 'workspace', 'owliver')) return profilePreferences(f);
if (has(q, 'activity', 'recent', 'history', 'what have i')) return profileActivity(f);
if (has(q, 'do here', 'change my', 'edit', 'update', 'how do i')) return profileActions(f);
return profileIdentity(f);
};
export const PROFILE_CAPABILITIES = [
{ id: 'profile-permissions', label: 'My Permissions', icon: ShieldCheck, run: profilePermissions },
{ id: 'profile-identity', label: 'Account Details', icon: Users, run: profileIdentity },
{ id: 'profile-security', label: 'Security', icon: Lock, run: profileSecurity },
{ id: 'profile-preferences', label: 'My Preferences', icon: Gauge, run: profilePreferences },
{ id: 'profile-activity', label: 'My Recent Activity', icon: ScrollText, run: profileActivity },
{ id: 'profile-actions', label: 'What I Can Do Here', icon: Lightbulb, run: profileActions },
];

View File

@@ -5,10 +5,11 @@ import {
import {
ACTIVITY_CAPABILITIES, ADMIN_ANALYTICS_CAPABILITIES, ADMIN_CANDIDATE_CAPABILITIES,
CANDIDATE_LIST_CAPABILITIES, CONTROL_CENTER_CAPABILITIES, FORGE_CAPABILITIES,
HIRED_HISTORY_CAPABILITIES, POSITIONS_CAPABILITIES, TALENT_POOL_CAPABILITIES,
HIRED_HISTORY_CAPABILITIES, POSITIONS_CAPABILITIES, PROFILE_CAPABILITIES,
TALENT_POOL_CAPABILITIES,
respondActivity, respondAdminAnalytics, respondAdminCandidates, respondCandidateList,
respondControlCenter, respondForge, respondHiredHistory, respondPositions,
respondTalentPool,
respondProfile, respondTalentPool,
} from './capabilities/admin';
/**
@@ -21,6 +22,11 @@ import {
*
* Krow AI serves Employer and Admin. The talent portal has Owliver, a separate
* product with its own UI and workflow — nothing here targets it.
*
* `topics` is what this page's answers are actually about. A question matching
* none of them is not quietly answered with the page's default report — see
* routing.js. Contexts without `topics` keep answering everything, so declaring
* them is opt-in per page.
*/
export const ASSISTANT_CONTEXTS = {
'employer.overview': {
@@ -44,12 +50,14 @@ export const ASSISTANT_CONTEXTS = {
'admin.controlCenter': {
id: 'admin.controlCenter',
page: 'Control Center',
topics: ['health', 'platform', 'attention', 'urgent', 'bottleneck', 'funnel', 'pipeline', 'conversion', 'recommend', 'should', 'summary', 'summarize', 'workforce', 'operation', 'velocity', 'speed', 'hiring', 'how many', 'unusual', 'anomal', 'risk', 'overview', 'status'],
capabilities: CONTROL_CENTER_CAPABILITIES,
respond: respondControlCenter,
},
'admin.positions': {
id: 'admin.positions',
page: 'Positions',
topics: ['position', 'role', 'posting', 'vacancy', 'fill', 'attention', 'priorit', 'bottleneck', 'funnel', 'waiting', 'review', 'screen', 'applicant', 'pipeline', 'strength', 'activity', 'hiring', 'how many', 'which'],
capabilities: POSITIONS_CAPABILITIES,
respond: respondPositions,
},
@@ -59,12 +67,14 @@ export const ASSISTANT_CONTEXTS = {
'admin.candidatesList': {
id: 'admin.candidatesList',
page: 'Candidates',
topics: ['candidate', 'applicant', 'attention', 'waiting', 'interview', 'ready', 'score', 'unscored', 'risk', 'flag', 'strongest', 'best', 'top', 'compare', 'shortlist', 'pipeline', 'summar', 'how many', 'who'],
capabilities: CANDIDATE_LIST_CAPABILITIES,
respond: respondCandidateList,
},
'admin.candidates': {
id: 'admin.candidates',
page: 'Candidates Analysis',
topics: ['candidate', 'applicant', 'gap', 'missing', 'coverage', 'unscored', 'risk', 'flag', 'compare', 'top', 'best', 'strongest', 'shortlist', 'recommend', 'hire', 'who', 'pool', 'quality'],
capabilities: ADMIN_CANDIDATE_CAPABILITIES,
respond: respondAdminCandidates,
},
@@ -73,33 +83,57 @@ export const ASSISTANT_CONTEXTS = {
'admin.analytics': {
id: 'admin.analytics',
page: 'Analytics',
topics: ['trend', 'over time', 'month', 'week', 'department', 'category', 'team', 'perform', 'bottleneck', 'funnel', 'conversion', 'position', 'role', 'score', 'attention', 'rate', 'average', 'breakdown', 'report', 'how many', 'compare'],
capabilities: ADMIN_ANALYTICS_CAPABILITIES,
respond: respondAdminAnalytics,
},
/* Forge, Talent Pool and Hired History read the same platform records as the
contexts above, but ask different questions of them: proof and progression,
supply and readiness, and outcomes after the hire. */
/* Forge is an authoring console, so its topics are the author's: what the
library holds, what is live, what Owliver checks, and how the workforce is
doing with it. */
'admin.forge': {
id: 'admin.forge',
page: 'KROW Forge',
topics: ['forge', 'skill', 'challenge', 'course', 'training', 'library', 'badge',
'certification', 'publish', 'published', 'draft', 'archive', 'status', 'category',
'owliver', 'evaluat', 'criteri', 'rubric', 'proof', 'evidence', 'verify', 'verification',
'verified', 'workforce', 'gap', 'missing', 'what do we have'],
capabilities: FORGE_CAPABILITIES,
respond: respondForge,
},
'admin.talentPool': {
id: 'admin.talentPool',
page: 'Talent Pool',
topics: ['talent', 'pool', 'worker', 'profile', 'priorit', 'availab', 'verif', 'score', 'unscored', 'segment', 'supply', 'summar', 'how many', 'who', 'shortlist'],
capabilities: TALENT_POOL_CAPABILITIES,
respond: respondTalentPool,
},
'admin.hiredHistory': {
id: 'admin.hiredHistory',
page: 'Hired History',
topics: ['hire', 'hired', 'outcome', 'department', 'quality', 'recent', 'strongest', 'pattern', 'time to hire', 'retention', 'how many', 'who'],
capabilities: HIRED_HISTORY_CAPABILITIES,
respond: respondHiredHistory,
},
/* The account page. A form rather than a workflow, but the questions people
ask about it — what may I do, how is this secured, what did I change — are
answerable from what the page already shows. */
'admin.profile': {
id: 'admin.profile',
page: 'Profile',
topics: ['permission', 'scope', 'access', 'allowed', 'can i', 'account', 'role',
'password', 'two-factor', 'two factor', '2fa', 'security', 'session', 'sign out',
'preference', 'setting', 'digest', 'density', 'workspace', 'owliver', 'profile',
'name', 'email', 'activity', 'recent', 'do here', 'how do i', 'edit', 'change my'],
capabilities: PROFILE_CAPABILITIES,
respond: respondProfile,
},
'admin.activity': {
id: 'admin.activity',
page: 'Activity',
topics: ['activity', 'audit', 'event', 'log', 'security', 'breach', 'compliance', 'trace', 'unusual', 'anomal', 'suspicious', 'user', 'who', 'account', 'busiest'],
capabilities: ACTIVITY_CAPABILITIES,
respond: respondActivity,
},

View File

@@ -1,4 +1,5 @@
import { plural, verb } from './insights';
import { PERMISSIONS } from '@/pages/admin/permissions';
/**
* Dynamic greetings and suggested prompts.
@@ -133,16 +134,20 @@ const GREETINGS = {
return `${plural(f.hired.length, 'hire')} from ${plural(f.total, 'applicant')} — ${f.hireRate}% conversion.`;
},
/* The line the panel opens with: whatever most needs the author's attention,
which on an authoring console is an unfinished skill before it is a total. */
'admin.forge': (f) => {
const g = f.forge;
if (!g.courses.length) return 'No challenges published yet, so nothing can be proven here.';
if (g.locked.length && !g.ready.length) {
return `${plural(g.locked.length, 'challenge')} ${verb(g.locked.length, 'is', 'are')} gated by real-work requirements.`;
if (!g.library.length) return 'The library is empty — no skill is defined yet.';
const unscored = g.library.filter((c) => !c.challenge?.rubric?.length).length;
if (unscored) {
return `${plural(unscored, 'skill')} ${verb(unscored, 'has', 'have')} no Owliver criteria, so ${verb(unscored, 'it', 'they')} cannot verify anything yet.`;
}
if (!g.verified.length) {
return `Nothing verified yet — ${plural(g.ready.length, 'challenge')} ${verb(g.ready.length, 'is', 'are')} ready to start.`;
if (g.drafts.length) {
return `${plural(g.drafts.length, 'skill')} ${verb(g.drafts.length, 'is', 'are')} in draft and not visible to the workforce.`;
}
return `${plural(g.verified.length, 'skill')} verified, ${plural(g.ready.length, 'challenge')} ready to start.`;
return `${plural(g.published.length, 'skill')} published, ${plural(g.workforceVerified, 'verification')} earned.`;
},
'admin.talentPool': (f) => {
@@ -165,6 +170,15 @@ const GREETINGS = {
return `${plural(h.hires.length, 'hire')} placed, averaging ${h.avgQuality} at hire.`;
},
'admin.profile': (f) => {
const mine = f.user
? f.activity.filter((e) => e.user_email === f.user.email)
: [];
const full = PERMISSIONS.filter((p) => p.level === 'Full access').length;
if (!mine.length) return `${plural(full, 'scope')} at full access on this account.`;
return `${plural(full, 'scope')} at full access, ${plural(mine.length, 'event')} logged against this account.`;
},
'admin.activity': (f) => {
if (!f.activity.length) return 'No activity logged yet.';
const patterns = f.activitySignals.flags.length;
@@ -203,6 +217,7 @@ const TITLES = {
'admin.forge': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
'admin.talentPool': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
'admin.hiredHistory': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
'admin.profile': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
};
/** Descriptions state what this page's assistant can actually do with the data. */
@@ -223,11 +238,13 @@ const DESCRIPTIONS = {
`Reading hiring performance across ${plural(f.openPositions.length, 'position')}, ${plural(f.total, 'candidate')} and ${plural(new Set(f.byRole.map((r) => r.category)).size, 'department')} — conversion, speed and outcomes.`,
'admin.activity': (f) => `${plural(f.activity.length, 'logged event')}, reconciled and profiled.`,
'admin.forge': (f) =>
`Reading ${plural(f.forge.courses.length, 'challenge')} — verified skills, what is ready to start and what is still locked.`,
`Reading ${plural(f.forge.library.length, 'skill')} — training, proof, what Owliver evaluates and how the workforce is doing with them.`,
'admin.talentPool': (f) =>
`Reading ${plural(f.profiles.length, 'talent profile')} — availability, career score and verification.`,
'admin.hiredHistory': (f) =>
`Reading ${plural(f.hiring.hires.length, 'hire')} across ${plural(f.hiring.byDepartment.length, 'department')} — quality, speed and outcomes.`,
'admin.profile': () =>
'Reading this account — permissions, security, preferences and your own activity.',
};
/**
@@ -284,9 +301,9 @@ const PLACEHOLDERS = {
'Ask Owliver about security concerns…',
],
'admin.forge': () => [
'Ask Owliver which challenge to take next…',
'Ask Owliver what is blocking a skill…',
'Ask Owliver about Forge progress…',
'Ask Owliver to create a skill training…',
'Ask Owliver what the library covers…',
'Ask Owliver what a skill verifies…',
],
'admin.talentPool': () => [
'Ask Owliver who to prioritize…',
@@ -298,6 +315,11 @@ const PLACEHOLDERS = {
'Ask Owliver which roles hired best…',
'Ask Owliver what stands out…',
],
'admin.profile': () => [
'Ask Owliver about your permissions…',
'Ask Owliver what you can change here…',
'Ask Owliver about your security settings…',
],
};
/* ── Suggested prompts ──────────────────────────────────────────────────── */
@@ -426,18 +448,22 @@ const PROMPTS = {
rather than offering the same questions regardless of state. */
'admin.forge': (f) => {
const g = f.forge;
const unscored = g.library.filter((c) => !c.challenge?.rubric?.length).length;
return [
g.next
? { label: `Should I take ${g.next.title}?`, prompt: `Which challenge should I complete next? Start with ${g.next.title}`, capability: 'forge-next' }
: { label: 'Which challenge should I take next?', prompt: 'Which challenge should I complete next?', capability: 'forge-next' },
g.locked.length
? { label: `${g.locked.length} blocked by requirements`, prompt: 'What is blocking my next skill?', capability: 'forge-blockers' }
: { label: 'What is blocking my next skill?', prompt: 'What is blocking my next skill?', capability: 'forge-blockers' },
g.ready.length
? { label: `${g.ready.length} ready to start`, prompt: 'Which challenges can I start right now?', capability: 'forge-ready' }
: { label: 'Which challenges can I start?', prompt: 'Which challenges can I start right now?', capability: 'forge-ready' },
{ label: 'Which skills am I closest to unlocking?', prompt: 'Which skills am I closest to unlocking?', capability: 'forge-skills' },
{ label: 'Summarize my Forge progress', prompt: 'Summarize my Forge progress', capability: 'forge-progress' },
/* The chip that creates something goes first: on an authoring console the
most likely next action is authoring. It carries no capability, so it
routes through the skill registry rather than the page responder. */
{ label: 'Create a skill training', prompt: 'Create a skill training' },
g.drafts.length
? { label: `${g.drafts.length} in draft`, prompt: 'Which skills are published?', capability: 'forge-published' }
: { label: `${g.published.length} published`, prompt: 'Which skills are published?', capability: 'forge-published' },
unscored
? { label: `${unscored} without Owliver criteria`, prompt: 'What gaps are left in the library?', capability: 'forge-gaps' }
: { label: 'What does Owliver evaluate?', prompt: 'What does Owliver evaluate?', capability: 'forge-evaluation' },
{ label: 'What skills do we currently have?', prompt: 'What skills do we currently have?', capability: 'forge-library' },
g.workforceVerified
? { label: `${g.workforceVerified} skills verified`, prompt: 'How is the workforce doing with these skills?', capability: 'forge-workforce' }
: { label: 'How is the workforce using these?', prompt: 'How is the workforce doing with these skills?', capability: 'forge-workforce' },
];
},
@@ -473,6 +499,22 @@ const PROMPTS = {
];
},
'admin.profile': (f) => {
const mine = f.user ? f.activity.filter((e) => e.user_email === f.user.email) : [];
const restricted = PERMISSIONS.filter((p) => p.level !== 'Full access').length;
return [
{ label: 'What are my permissions?', prompt: 'What are my permissions?', capability: 'profile-permissions' },
restricted
? { label: `${restricted} scopes are restricted`, prompt: 'Which scopes am I restricted on?', capability: 'profile-permissions' }
: { label: 'What can I do here?', prompt: 'What can I do on this page?', capability: 'profile-actions' },
{ label: 'How is my account secured?', prompt: 'How is my account secured?', capability: 'profile-security' },
{ label: 'What are my preferences set to?', prompt: 'What are my preferences set to?', capability: 'profile-preferences' },
mine.length
? { label: `My last ${mine.length} actions`, prompt: 'What is my recent activity?', capability: 'profile-activity' }
: { label: 'What can I change here?', prompt: 'What can I change on this page?', capability: 'profile-actions' },
];
},
'admin.activity': (f) => [
f.activitySignals.flags.length
? { label: `${f.activitySignals.flags.length} unusual patterns`, prompt: 'Show unusual activity on the platform', capability: 'unusual-activity' }

View File

@@ -41,6 +41,7 @@ export function buildFacts({
activity = [],
courses = [],
profile = null,
user = null,
today = new Date(),
}) {
const scored = applications.filter((a) => a.ai_score > 0);
@@ -207,6 +208,37 @@ export function buildFacts({
level,
count: forgeCourses.filter((c) => c.difficulty === level).length,
})).filter((d) => d.count),
/* ── The same library, read by the administrator who owns it ──────────
Everything above answers "what can I prove"; everything below answers
"what have we built, and how is the workforce doing with it". Both are
derived from the same records, so the panel cannot disagree with the page
whichever question is asked. Publication state is read with the same
mapping the Forge page uses: `active` is Published. */
library: courses,
published: courses.filter((c) => c.status === 'active' || c.status === 'published'),
drafts: courses.filter((c) => c.status === 'draft'),
archived: courses.filter((c) => c.status === 'archived' || c.status === 'inactive'),
withChallenge: courses.filter((c) => c.challenge?.type),
withTraining: courses.filter((c) => c.training_outline?.length || c.quiz?.length),
byCategory: [...courses.reduce((map, c) => {
const key = c.category || 'Uncategorised';
map.set(key, (map.get(key) || 0) + 1);
return map;
}, new Map())]
.map(([category, count]) => ({ category, count }))
.sort((a, b) => b.count - a.count),
/* Verifications the workforce actually holds, counted off worker profiles
rather than off the library — a badge is something a person has. */
workforceVerified: profiles.reduce((sum, p) => {
const ids = new Set(courses.map((c) => c.id));
return sum + (p.earned_badges || []).filter((b) => ids.has(b.course_id)).length;
}, 0),
usage: courses.map((c) => ({
course: c,
completed: profiles.filter((p) => (p.completed_courses || []).some((x) => x.course_id === c.id)).length,
verified: profiles.filter((p) => (p.earned_badges || []).some((b) => b.course_id === c.id)).length,
})),
};
/* ── Talent pool ─────────────────────────────────────────────────────────
@@ -281,6 +313,10 @@ export function buildFacts({
return {
today,
/* The signed-in account — identity and preferences, for the pages that are
about the operator rather than the workforce. */
user,
/* Applications */
applications,
total: applications.length,

View File

@@ -22,6 +22,7 @@ const PLACEMENT = {
'/admin/university': 'admin.forge',
'/admin/talent-pool': 'admin.talentPool',
'/admin/hired': 'admin.hiredHistory',
'/admin/profile': 'admin.profile',
},
};
@@ -29,9 +30,9 @@ const PLACEMENT = {
* Pages that must never carry the assistant, listed explicitly so the intent is
* documented rather than implied by omission.
*
* Admin: profile and login only. Every working surface — Control Center,
* Positions, Candidates, Candidates Analysis, Analytics, Activity,
* KROW Forge, Talent Pool and Hired History — carries the panel.
* Admin: login only. Every other surface — including Profile, where the
* questions are about the account rather than the workforce — carries
* the panel.
* Employer: every page. The contextual panel is an Admin capability.
* Talent: every page. The talent portal has the standalone Owliver product,
* with its own voice experience, branding and workflow.
@@ -42,12 +43,19 @@ export const EXCLUDED_ROUTES = [
'/hired', '/talent-pool', '/analytics', '/profile', '/apply',
'/university', '/university/:id', '/me', '/identity', '/employee',
'/owliver', '/design-system',
// Admin pages that are a form rather than a workflow.
'/admin/profile', '/admin/login',
// Sign-in has no page data to reason about.
'/admin/login',
// The full candidate profile is one person's record, not a page-level question.
'/admin/candidates/:id',
];
/**
* The Admin route → context table, exported so the skill registry can derive
* page keys from it. One table, so a route added here is addressable by a skill
* without a second list to keep in step.
*/
export const PLACEMENT_ROUTES = PLACEMENT.admin;
/** Returns the context for a role and path, or `null` to render no assistant. */
export function resolveAssistantContext(role, pathname) {
const id = PLACEMENT[role]?.[pathname];

View File

@@ -0,0 +1,393 @@
import { doc, list, note, text } from './blocks';
import { ASSISTANT_CONTEXTS } from './contexts';
import { matchSkill } from '@/lib/skills/registry';
import {
buildSkillPrefill, buildTrainingPrefill, extractReviewSubject,
extractSkillName, findCourseByName,
} from '@/lib/skills/actions';
import { beginPositionFlow } from '@/lib/skills/positionFlow';
/**
* Intent routing — deciding whether a question belongs to the page you are on.
*
* Every page responder ends in a catch-all, which is right for a question about
* that page and wrong for anything else: asking "what are my permissions?" on
* Talent Pool used to return the talent priority table, confidently and
* irrelevantly. Two things were missing — a way to notice the question is about
* a different page, and a way to say "I cannot answer that here" instead of
* answering something else.
*
* This module supplies both, ahead of the responders:
*
* 1. If the question clearly belongs to another Admin page, say so and hand
* back that page's route so the panel can navigate. Context follows the
* route, so the next question is answered by the page you land on.
* 2. If it belongs to no page in particular and does not match anything this
* page can answer, say that plainly and offer what this page *does* cover.
*
* Routes come from the placement table, so nothing here invents an address.
*
* Keyword matching, like the responders themselves: this is a demo provider
* computing answers from local data, and the routing has to be as deterministic
* and inspectable as the answers are.
*/
/**
* Topics per destination. `route` is the real Admin path; `contextId` is the
* assistant context that page carries.
*
* `terms` are the phrases that mean "this is a question about that page", chosen
* to be unambiguous — generic words like "score" or "hiring" appear on every
* page and would send the reader somewhere for no reason.
*/
export const DESTINATIONS = [
{
contextId: 'admin.profile',
route: '/admin/profile',
page: 'Profile',
terms: [
'my permission', 'my permissions', 'permission', 'my role', 'my account',
'my profile', 'change my name', 'edit my profile', 'edit profile',
'password', 'two-factor', 'two factor', '2fa', 'sign out', 'log out',
'my session', 'preference', 'email digest', 'compact density',
],
},
{
contextId: 'admin.talentPool',
route: '/admin/talent-pool',
page: 'Talent Pool',
terms: ['talent pool', 'talent directory', 'talent segment', 'worker profile', 'career score'],
},
{
contextId: 'admin.positions',
route: '/admin/positions',
page: 'Positions',
terms: ['position', 'open role', 'open roles', 'job posting', 'requisition', 'vacancy'],
},
{
contextId: 'admin.candidatesList',
route: '/admin/candidates',
page: 'Candidates',
terms: ['candidate', 'applicant', 'shortlist', 'screening queue'],
},
{
contextId: 'admin.hiredHistory',
route: '/admin/hired',
page: 'Hired History',
terms: ['hired history', 'recent hire', 'recent hires', 'who did we hire', 'hiring history', 'department breakdown'],
},
{
contextId: 'admin.analytics',
route: '/admin/analytics',
page: 'Analytics',
terms: ['analytics', 'hiring trend', 'report', 'time to hire', 'conversion rate', 'average score'],
},
{
contextId: 'admin.forge',
route: '/admin/university',
page: 'KROW Forge',
terms: ['forge', 'challenge', 'course', 'skill', 'badge', 'certification', 'learning'],
},
{
contextId: 'admin.activity',
route: '/admin/activity',
page: 'Activity',
terms: ['activity log', 'audit', 'audit log', 'event log', 'who did what'],
},
{
contextId: 'admin.controlCenter',
route: '/admin',
page: 'Control Center',
terms: ['control center', 'control centre', 'platform health', 'dashboard'],
},
];
/**
* The destination a question points at, or `null` for "the page you are on".
*
* Longest match wins, so "talent pool" beats "pool" and a question naming two
* areas resolves to the more specific one rather than to whichever was listed
* first.
*/
export function findDestination(question) {
const q = String(question).toLowerCase();
let best = null;
for (const destination of DESTINATIONS) {
for (const term of destination.terms) {
if (q.includes(term) && (!best || term.length > best.term.length)) {
best = { ...destination, term };
}
}
}
return best;
}
/**
* Does this page's own responder recognise the question?
*
* The responders' keyword lists are the definition of "answerable here", but
* they are private to each responder. Rather than duplicate them, a context can
* declare `topics` — the words its answers are actually about. A context without
* `topics` keeps the old behaviour of always answering, so this is additive.
*/
export function answerableHere(context, question) {
if (!context?.topics?.length) return true;
const q = String(question).toLowerCase();
return context.topics.some((topic) => q.includes(topic));
}
/** The short reply that accompanies a navigation. */
export function navigationAnswer(destination) {
return doc(
text(`That is on **${destination.page}**. Taking you there now.`),
note('Ask again once the page loads and I will answer from it.')
);
}
/**
* The reply when the question fits neither this page nor another one.
*
* Lists what this page can answer, taken from the capability labels the panel
* already shows as chips — so the offer is always exactly what is on screen.
*/
export function outOfScopeAnswer(context) {
const labels = (context?.capabilities || []).map((c) => c.label);
return doc(
text(`I do not have that on **${context?.page || 'this page'}**.`),
list(labels.slice(0, 6), { ordered: false }),
note('Ask about one of those, or open the page the question belongs to.')
);
}
/**
* Resolve a question to an action, ahead of the page responder.
*
* Returns one of:
* { kind: 'answer' } — let this page respond
* { kind: 'navigate', destination, doc } — other page; go there
* { kind: 'outOfScope', doc } — nothing here can answer it
*/
/* ── Forge authoring ────────────────────────────────────────────────────── */
/** The five steps the Forge authoring flow actually walks through. */
const FORGE_STEPS = [
'Define the skill — name, category, difficulty, description',
'Build training — the steps the workforce learns',
'Define the proof — how they demonstrate it',
'Configure Owliver — what the evaluation checks',
'Review & publish — save a draft, or publish it',
];
/**
* The draft Owliver proposes for a new skill, read back to the author.
*
* Nothing is opened here. The draft is a proposal, and "Review skill" is the
* step where the author accepts it — which is what keeps Owliver on the same
* side of the line it holds everywhere else: it prepares, the administrator
* decides. There is no publish path from this reply at all.
*
* The follow-up carries the subject rather than a stored draft, so clicking it
* goes back through `resolveIntent` exactly as typing the same sentence would.
*/
function proposeSkill(skill, question, skillCategories) {
const { title, category, difficulty, outline, verification } = buildSkillPrefill(question, skillCategories);
if (!title) {
return {
kind: 'skill',
skill,
action: { name: 'open_create_skill_training', payload: { prefill: {} } },
doc: doc(
text('Opening Add Skill Training. Name the skill on the first step and I will take it from there.'),
list(FORGE_STEPS, { ordered: true }),
note('I do not create or publish skills on my own.')
),
};
}
return {
kind: 'skill',
skill,
doc: doc(
text(`Understood. I can create a workforce skill for **${title}**.`),
list([
category && `Category — ${category}`,
difficulty && `Level — ${difficulty}`,
verification && `Verification — ${verification}`,
]),
outline.length ? text('**Suggested training**') : null,
list(outline),
note('These are suggestions to review, not decisions. Open the draft and nothing is saved until you choose Save draft or Publish.')
),
followUp: [{ label: 'Review skill', prompt: `Review the ${title} skill` }],
};
}
/**
* The author accepted the draft — open the flow with it filled in.
*
* Derived from the subject again rather than from anything remembered between
* turns, so what opens is what the previous reply described.
*/
function openSkillDraft(skill, question, skillCategories) {
const { prefill, title } = buildSkillPrefill(question, skillCategories);
return {
kind: 'skill',
skill,
action: { name: 'open_create_skill_training', payload: { prefill } },
doc: doc(
text(`Opening the **${title}** draft in Add Skill Training.`),
list(FORGE_STEPS, { ordered: true }),
note('Nothing is saved until you choose Save draft or Publish. I do not publish skills on my own.')
),
};
}
/**
* Training for a skill that already exists.
*
* The flow opens on its training step holding that record, so this edits the
* skill rather than creating a second one beside it. A request that names no
* skill asks which, offering the library's own titles — never a list written
* here.
*/
function proposeTraining(skill, question, courses, skillCategories) {
const course = findCourseByName(question, courses);
if (!course) {
/* "Training for latte art" names a subject the library does not hold yet.
That is a new skill, and its training is the second step of building one
— so answer the request that was actually made rather than insisting on a
record that does not exist. */
if (extractSkillName(question)) return proposeSkill(skill, question, skillCategories);
const titles = courses.slice(0, 5).map((c) => c.title).filter(Boolean);
return {
kind: 'skill',
skill,
doc: doc(
text('Which skill should the training belong to?'),
list(titles),
note(
titles.length
? 'Name one and I will open its training for you to edit.'
: 'The library is empty — create a skill first and its training comes with it.'
)
),
followUp: titles.map((title) => ({
label: title,
prompt: `Create training for ${title}`,
})),
};
}
const { prefill, suggested } = buildTrainingPrefill(course);
return {
kind: 'skill',
skill,
action: { name: 'open_create_training', payload: { prefill, courseId: course.id } },
doc: doc(
text(`Opening the training for **${course.title}**.`),
suggested.length ? text('**Suggested steps**') : null,
list(suggested),
note(
suggested.length
? 'These are suggestions to review. Edit them, then save — I do not change a published skill on my own.'
: 'The steps this skill already carries are loaded. Edit them, then save.'
)
),
};
}
/**
* Skills that can act on this page, ahead of the page's own responder.
*
* A skill's triggers are specific ("create a position"); the responder is the
* general reader of the page. Checking skills first is what lets "create a
* 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);
if (!skill) return 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
* than a skill being created. Owliver publishes nothing on its own.
*
* Three requests share one skill because they are one job — proposing a
* skill, accepting that proposal, and adding training to a skill that already
* exists. Which one this is comes from the sentence, in that order of
* specificity: "review" is unambiguous, "training" names the step, and
* anything else that got here is a new skill.
*/
if (skill.id === 'forge-skill-management') {
const q = String(question).toLowerCase();
if (extractReviewSubject(question)) return openSkillDraft(skill, question, skillCategories);
/* "a skill training" is the compound noun for a new skill; "training for
<a skill the library has>" is the existing record. Naming a real skill
settles it either way. */
const named = findCourseByName(question, courses);
const wantsTraining = /\btraining\b/.test(q) && (named || !/\bskill training\b/.test(q));
if (wantsTraining) return proposeTraining(skill, question, courses, skillCategories);
return proposeSkill(skill, question, skillCategories);
}
/**
* Create Position is collected in the conversation, not in a form.
*
* Nothing opens and nothing is navigated to: the skill's questions come back
* as a reply and its answers as chips, and the position is written at the end
* from what the conversation gathered. `flow` is the state that turn carries
* forward — the panel keeps it and feeds the next answer back in.
*/
if (skill.id === 'create-position') {
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. */
return {
kind: 'skill',
skill,
doc: doc(
text(skill.description || `**${skill.name}** is available on this page.`),
list(skill.capabilities.slice(0, 6)),
note('Defined as an Owliver skill. Manage it in Settings → Skills.')
),
};
}
export function resolveIntent({
question, contextId, disabledSkills = [], customSkills = [], roles = [], skillCategories = [],
courses = [],
}) {
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,
});
if (skill) return skill;
/* The page you are on wins. Several subjects live on more than one page —
departments are on both Analytics and Hired History — and moving someone
off a page that can already answer them is worse than answering. Routing is
for questions this page genuinely cannot take. */
if (answerableHere(context, question)) return { kind: 'answer' };
const destination = findDestination(question);
if (destination && destination.contextId !== contextId) {
return { kind: 'navigate', destination, doc: navigationAnswer(destination) };
}
return { kind: 'outOfScope', doc: outOfScopeAnswer(context) };
}

View File

@@ -5,8 +5,14 @@ import {
useApplications, useCourses, useInterviews, useJobPostings, useStaff,
useUserActivity, useWorkerProfile, useWorkerProfiles,
} from '@/lib/krowHooks';
import { skillsForContext } from '@/lib/skills/registry';
import {
advancePositionFlow, createdFollowUp, positionCreatedReply, positionFailedReply,
} from '@/lib/skills/positionFlow';
import { buildFacts } from './insights';
import { createAssistantProvider } from './provider';
import { resolveIntent } from './routing';
import { toSnapshots } from './blocks';
/** One provider instance for the app's lifetime. */
const provider = createAssistantProvider();
@@ -32,9 +38,17 @@ export function useAssistantFacts() {
// rather than manufacturing a record as a side effect of opening a panel.
const { data: profile = null } = useWorkerProfile({ enabled: false });
/* The signed-in account, from the shared `['user']` cache the header and the
Profile page already read. */
const { data: user = null } = useQuery({
queryKey: ['user'],
queryFn: () => base44.auth.me().catch(() => null),
staleTime: Infinity,
});
return React.useMemo(
() => buildFacts({ applications, postings, interviews, staff, profiles, activity, courses, profile }),
[applications, postings, interviews, staff, profiles, activity, courses, profile]
() => buildFacts({ applications, postings, interviews, staff, profiles, activity, courses, profile, user }),
[applications, postings, interviews, staff, profiles, activity, courses, profile, user]
);
}
@@ -53,6 +67,23 @@ export function useCurrentUserName() {
return data?.full_name || '';
}
/**
* Streams a locally built document through the same snapshot pipeline the
* provider uses, so a routing reply arrives exactly like any other answer
* rather than appearing instantly and reading as a different system.
*/
async function streamDocument({ document, signal, onFrame, onDone }) {
const snapshots = toSnapshots(document);
let latest = [];
for (const snapshot of snapshots) {
if (signal?.aborted) break;
latest = snapshot;
onFrame(snapshot);
await new Promise((resolve) => setTimeout(resolve, 26));
}
if (latest.length) onDone(latest);
}
/**
* Conversation state for one page context.
*
@@ -60,15 +91,39 @@ export function useCurrentUserName() {
* kept per context in sessionStorage so navigating away from Candidates and back
* does not silently discard the conversation — while a stale thread from last
* week is still not resurrected.
*
* `onNavigate` is called when a question belongs to another Admin page. Both
* entry points — a suggestion chip and a typed question — go through `send`, so
* routing applies to both without either knowing it exists.
*/
export function useConversation({ contextId, facts }) {
export function useConversation({
contextId, facts, onNavigate, onAction, onCreatePosition, disabledSkills = [], customSkills = [],
roles = [], skillCategories = [], courses = [],
}) {
const storageKey = `krow_assistant:${contextId}`;
const flowKey = `${storageKey}:flow`;
const [messages, setMessages] = React.useState([]);
const [pending, setPending] = React.useState(null); // { blocks, thinking }
const [error, setError] = React.useState(null);
const abortRef = 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
silently abandon a position half-described. */
const flowRef = React.useRef(null);
const setFlow = React.useCallback((flow) => {
flowRef.current = flow || null;
try {
if (flow) sessionStorage.setItem(flowKey, JSON.stringify(flow));
else sessionStorage.removeItem(flowKey);
} catch {
// The flow stays in memory only.
}
}, [flowKey]);
/* Load the stored thread, and reload when the context changes. */
React.useEffect(() => {
try {
@@ -77,9 +132,15 @@ export function useConversation({ contextId, facts }) {
} catch {
setMessages([]);
}
try {
const raw = sessionStorage.getItem(flowKey);
flowRef.current = raw ? JSON.parse(raw) : null;
} catch {
flowRef.current = null;
}
setPending(null);
setError(null);
}, [storageKey]);
}, [storageKey, flowKey]);
const persist = React.useCallback((next) => {
setMessages(next);
@@ -112,6 +173,104 @@ export function useConversation({ contextId, facts }) {
setError(null);
setPending({ blocks: [], thinking: true });
/**
* A guided skill in progress takes the message first: while Owliver is
* asking the questions, "Chennai" is an answer rather than a question about
* the page. The skill has to still be enabled — switching it off in Settings
* ends the flow rather than letting it run on without permission.
*/
let intent = null;
if (flowRef.current) {
const skill = skillsForContext(contextId, disabledSkills, customSkills)
.find((s) => s.id === flowRef.current.skillId);
if (!skill) setFlow(null);
else {
intent = {
kind: 'flow',
skill,
...advancePositionFlow({ flow: flowRef.current, answer: text, skill, roles }),
};
}
}
/* A chip is this page's own capability by construction, so it never routes
away. A typed question might belong somewhere else entirely. */
if (!intent) {
intent = capability
? { kind: 'answer' }
: resolveIntent({
question: text, contextId, disabledSkills, customSkills, roles, skillCategories, courses,
});
}
/**
* The one step that writes. It happens before the reply rather than after,
* because the reply is the outcome — "Position created successfully" has to
* be true when it is said.
*/
if (intent.kind === 'flow' && intent.create) {
let created = null;
try {
created = await onCreatePosition?.(intent.create.draft, intent.skill);
} catch {
created = null;
}
if (created?.id) {
intent = {
...intent, flow: null, doc: positionCreatedReply(created), followUp: createdFollowUp(created),
};
} else {
/* Keep the answers: the summary is still there to try again from. */
intent = {
...intent,
flow: { ...intent.flow, stage: 'review' },
doc: positionFailedReply(),
followUp: [
{ label: 'Create position', prompt: 'Create position' },
{ label: 'Change details', prompt: 'Change details' },
],
};
}
}
/* A skill that collects its input in the chat carries the state of that
collection on every turn — including the turn that starts it. */
if ('flow' in intent) setFlow(intent.flow);
if (intent.kind !== 'answer') {
try {
await streamDocument({
document: intent.doc,
signal: controller.signal,
onFrame: (blocks) => setPending({ blocks, thinking: false }),
onDone: (blocks) => {
const next = [
...messagesRef.current,
{ role: 'assistant', blocks, followUp: intent.followUp },
];
messagesRef.current = next;
persist(next);
},
});
} finally {
setPending(null);
abortRef.current = null;
}
/* Navigate after the reply is on screen, so the user reads why they moved.
The panel re-resolves its context from the new route, which is what
makes the next question answer from the page they land on. */
if (controller.signal.aborted) return;
if (intent.kind === 'navigate') onNavigate?.(intent.destination);
/* A skill's action runs after its reply, for the same reason: the user
should read why the form opened before it opens. */
if (intent.kind === 'skill' && intent.action) onAction?.(intent.action, intent.skill);
return;
}
try {
let latest = [];
for await (const snapshot of provider.stream({
@@ -141,7 +300,8 @@ export function useConversation({ contextId, facts }) {
setPending(null);
abortRef.current = null;
}
}, [contextId, facts, persist]);
}, [contextId, facts, persist, onNavigate, onAction, onCreatePosition, setFlow, disabledSkills,
customSkills, roles, skillCategories, courses]);
const stop = React.useCallback(() => abortRef.current?.abort(), []);
@@ -151,12 +311,15 @@ export function useConversation({ contextId, facts }) {
setMessages([]);
setPending(null);
setError(null);
/* A new conversation abandons a half-collected position too — the questions
it was answering are no longer on screen. */
setFlow(null);
try {
sessionStorage.removeItem(storageKey);
} catch {
// Ignore.
}
}, [storageKey]);
}, [storageKey, setFlow]);
return {
messages,