update chatbox
This commit is contained in:
@@ -1,132 +1,47 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import { Sparkles } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { ResponseDocument } from './ResponseBlocks';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the light markup the capabilities emit — `**bold**`, bullets,
|
* The thinking state, shown between sending and the first block arriving.
|
||||||
* numbered items, `_asides_`, and ✓/⚠ check lines.
|
|
||||||
*
|
*
|
||||||
* A full markdown renderer is available in the project, but this content is
|
* Labelled as well as animated: three moving dots mean nothing to a screen
|
||||||
* produced locally in a known shape, and parsing five constructs keeps the
|
* reader, and the visually-hidden text is what tells one that a reply is coming.
|
||||||
* assistant's typography inside the design system rather than inheriting a
|
|
||||||
* library's defaults.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const BOLD = /\*\*(.+?)\*\*/g;
|
|
||||||
|
|
||||||
function inline(line, keyPrefix) {
|
|
||||||
const parts = [];
|
|
||||||
let last = 0;
|
|
||||||
let match;
|
|
||||||
BOLD.lastIndex = 0;
|
|
||||||
|
|
||||||
while ((match = BOLD.exec(line)) !== null) {
|
|
||||||
if (match.index > last) parts.push(line.slice(last, match.index));
|
|
||||||
parts.push(
|
|
||||||
<strong key={`${keyPrefix}-${match.index}`} className="font-semibold text-ink-1">
|
|
||||||
{match[1]}
|
|
||||||
</strong>
|
|
||||||
);
|
|
||||||
last = match.index + match[0].length;
|
|
||||||
}
|
|
||||||
if (last < line.length) parts.push(line.slice(last));
|
|
||||||
return parts.length ? parts : line;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RichText({ text, className }) {
|
|
||||||
const lines = React.useMemo(() => String(text).split('\n'), [text]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn('space-y-1.5 text-body-sm text-ink-2', className)}>
|
|
||||||
{lines.map((line, i) => {
|
|
||||||
if (!line.trim()) return <div key={i} className="h-1.5" aria-hidden="true" />;
|
|
||||||
|
|
||||||
/* Aside — a caveat the capabilities wrap in underscores. */
|
|
||||||
if (line.startsWith('_') && line.endsWith('_')) {
|
|
||||||
return (
|
|
||||||
<p key={i} className="border-l-2 border-border pl-2.5 text-caption italic text-ink-3 leading-relaxed">
|
|
||||||
{inline(line.slice(1, -1), i)}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Bullet. */
|
|
||||||
if (line.startsWith('• ')) {
|
|
||||||
return (
|
|
||||||
<div key={i} className="flex gap-2 leading-relaxed">
|
|
||||||
<span className="text-krow-blue shrink-0" aria-hidden="true">•</span>
|
|
||||||
<span className="min-w-0">{inline(line.slice(2), i)}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Indented continuation of a numbered item. */
|
|
||||||
if (line.startsWith(' ')) {
|
|
||||||
return (
|
|
||||||
<p key={i} className="pl-6 text-caption text-ink-3 leading-relaxed">
|
|
||||||
{inline(line.trim(), i)}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Numbered item. */
|
|
||||||
const numbered = line.match(/^(\d+)\.\s+(.*)$/);
|
|
||||||
if (numbered) {
|
|
||||||
return (
|
|
||||||
<div key={i} className="flex gap-2.5 leading-relaxed">
|
|
||||||
<span className="grid place-items-center w-4 h-4 mt-0.5 shrink-0 rounded-full bg-krow-blue-tint text-[10px] font-bold text-krow-blue">
|
|
||||||
{numbered[1]}
|
|
||||||
</span>
|
|
||||||
<span className="min-w-0">{inline(numbered[2], i)}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Health-check line. */
|
|
||||||
if (line.startsWith('✓ ') || line.startsWith('⚠ ')) {
|
|
||||||
const ok = line.startsWith('✓');
|
|
||||||
return (
|
|
||||||
<div key={i} className="flex gap-2 leading-relaxed">
|
|
||||||
<span className={cn('shrink-0 font-semibold', ok ? 'text-success' : 'text-warning')} aria-hidden="true">
|
|
||||||
{ok ? '✓' : '⚠'}
|
|
||||||
</span>
|
|
||||||
<span className="min-w-0">{inline(line.slice(2), i)}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <p key={i} className="leading-relaxed">{inline(line, i)}</p>;
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The three-dot indicator shown before the first delta arrives. */
|
|
||||||
export function ThinkingIndicator() {
|
export function ThinkingIndicator() {
|
||||||
return (
|
return (
|
||||||
<div className="inline-flex items-center gap-1 rounded-xl rounded-tl-sm border border-border bg-surface px-3 py-2.5 shadow-xs">
|
<div className="flex items-center gap-2">
|
||||||
{[0, 1, 2].map((i) => (
|
<span className="grid h-6 w-6 shrink-0 place-items-center rounded-lg bg-krow-blue-tint text-krow-blue">
|
||||||
<span
|
<Sparkles className="h-3 w-3" aria-hidden="true" />
|
||||||
key={i}
|
</span>
|
||||||
className="w-1.5 h-1.5 rounded-full bg-ink-4 animate-typing"
|
<span className="inline-flex items-center gap-1 rounded-xl rounded-tl-sm border border-border bg-surface px-2.5 py-2 shadow-xs">
|
||||||
style={{ animationDelay: `${i * 0.18}s` }}
|
{[0, 1, 2].map((i) => (
|
||||||
/>
|
<span
|
||||||
))}
|
key={i}
|
||||||
<span className="sr-only">Assistant is thinking</span>
|
className="h-1.5 w-1.5 animate-typing rounded-full bg-ink-4"
|
||||||
|
style={{ animationDelay: `${i * 0.18}s` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
<span className="sr-only">Krow AI is preparing a response</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One turn in the conversation. The user's own words sit in a filled bubble; the
|
* One turn.
|
||||||
* assistant's answer runs full width, because these answers are structured
|
*
|
||||||
* multi-paragraph reports and a chat bubble would waste a third of a 380px
|
* The user's words sit in a filled bubble; the assistant's reply runs the full
|
||||||
* column on padding.
|
* width of the panel. That asymmetry is deliberate — these replies are reports
|
||||||
|
* with tables and KPI tiles, and a chat bubble would waste a third of a 380px
|
||||||
|
* column on padding around them.
|
||||||
*/
|
*/
|
||||||
export function Message({ role, text, streaming, stopped }) {
|
export const Message = React.memo(({ role, text, blocks, streaming, stopped }) => {
|
||||||
if (role === 'user') {
|
if (role === 'user') {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-end animate-slide-up">
|
<div className="flex justify-end">
|
||||||
<div className="max-w-[85%] rounded-xl rounded-br-sm bg-krow-blue px-3 py-2 text-body-sm text-white">
|
<div className="max-w-[85%] animate-slide-up rounded-xl rounded-br-sm bg-krow-blue px-3 py-2 text-body-sm text-white">
|
||||||
{text}
|
{text}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -135,16 +50,24 @@ export function Message({ role, text, streaming, stopped }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-slide-up">
|
<div className="animate-slide-up">
|
||||||
<RichText text={text} />
|
<div className="mb-2 flex items-center gap-1.5">
|
||||||
{streaming && (
|
<span className="grid h-5 w-5 shrink-0 place-items-center rounded-md bg-krow-blue-tint text-krow-blue">
|
||||||
<span
|
<Sparkles className="h-2.5 w-2.5" aria-hidden="true" />
|
||||||
className="inline-block w-1.5 h-3.5 ml-0.5 -mb-0.5 bg-krow-blue animate-caret"
|
</span>
|
||||||
aria-hidden="true"
|
<span className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Krow AI</span>
|
||||||
/>
|
</div>
|
||||||
)}
|
|
||||||
{stopped && (
|
<ResponseDocument blocks={blocks} streaming={streaming} />
|
||||||
<p className="mt-1.5 text-caption italic text-ink-4">Stopped early.</p>
|
|
||||||
)}
|
{stopped && <p className="mt-2 text-caption italic text-ink-4">Stopped early.</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
Message.displayName = 'Message';
|
||||||
|
|
||||||
|
/** A subtle divider between turns, so a long thread stays readable. */
|
||||||
|
export function TurnDivider() {
|
||||||
|
return <div className="h-px bg-border/60" role="presentation" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { cn };
|
||||||
|
|||||||
@@ -4,9 +4,27 @@ import { cn } from '@/lib/utils';
|
|||||||
import KrowAssistant from './KrowAssistant';
|
import KrowAssistant from './KrowAssistant';
|
||||||
import { useAssistantPanel } from './AssistantPanelContext';
|
import { useAssistantPanel } from './AssistantPanelContext';
|
||||||
|
|
||||||
/** Panel width, plus the gutter separating it from the dashboard. */
|
const DEFAULT_WIDTH = 380;
|
||||||
const PANEL_WIDTH = 380;
|
const EXPANDED_WIDTH = 620;
|
||||||
|
/** Gutter separating the panel from the dashboard. */
|
||||||
const GUTTER = 24;
|
const GUTTER = 24;
|
||||||
|
/** Expanded must never dominate: the dashboard stays the primary experience. */
|
||||||
|
const MAX_VIEWPORT_SHARE = 0.42;
|
||||||
|
|
||||||
|
/** Tracks viewport width so the expanded panel can be clamped on smaller screens. */
|
||||||
|
function useViewportWidth() {
|
||||||
|
const [width, setWidth] = React.useState(() =>
|
||||||
|
typeof window === 'undefined' ? 1440 : window.innerWidth
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const onResize = () => setWidth(window.innerWidth);
|
||||||
|
window.addEventListener('resize', onResize);
|
||||||
|
return () => window.removeEventListener('resize', onResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return width;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AssistantPanel — the sliding column the layout renders.
|
* AssistantPanel — the sliding column the layout renders.
|
||||||
@@ -15,23 +33,30 @@ const GUTTER = 24;
|
|||||||
* dashboard reflow smoothly instead of being covered, and what lets closing
|
* dashboard reflow smoothly instead of being covered, and what lets closing
|
||||||
* restore the original layout exactly.
|
* restore the original layout exactly.
|
||||||
*
|
*
|
||||||
* Two things about the structure matter, and both were bugs before:
|
* Two structural details matter, and both were bugs at one point:
|
||||||
*
|
*
|
||||||
* 1. The clip lives *inside* the sticky element, not around it. An ancestor with
|
* 1. The clip lives *inside* the sticky element. An ancestor with
|
||||||
* `overflow: hidden` becomes the nearest scroll container, which silently
|
* `overflow: hidden` becomes the nearest scroll container and silently
|
||||||
* disables `position: sticky` on everything beneath it — the panel computed
|
* disables `position: sticky` beneath it.
|
||||||
* as sticky but never actually stuck.
|
* 2. The aside stretches to the shell's full height (`self-stretch`), so its
|
||||||
|
* sticky context runs the whole page rather than ending partway down and
|
||||||
|
* leaving empty space beside a long dashboard.
|
||||||
*
|
*
|
||||||
* 2. The aside must stretch to the shell's full height. As a flex item under
|
* Expanding widens the same panel in place — it is an analysis workspace inside
|
||||||
* `items-start` it collapsed to its own height, so its sticky context ended
|
* the application layout, not a fullscreen takeover, and never covers the
|
||||||
* partway down the page and the panel scrolled away, leaving empty space
|
* dashboard.
|
||||||
* beside a long dashboard.
|
|
||||||
*
|
|
||||||
* With both fixed the panel stays pinned for the whole scroll, and because the
|
|
||||||
* assistant is a fixed-height frame, only its conversation scrolls.
|
|
||||||
*/
|
*/
|
||||||
export function AssistantPanel({ stickyClassName, panelHeightClassName }) {
|
export function AssistantPanel({ stickyClassName, panelHeightClassName }) {
|
||||||
const { context, isOpen, close } = useAssistantPanel();
|
const { context, isOpen, isExpanded, close, expand, restore } = useAssistantPanel();
|
||||||
|
const viewportWidth = useViewportWidth();
|
||||||
|
|
||||||
|
// Clamped so a wide panel cannot squeeze the dashboard on a small screen.
|
||||||
|
const panelWidth = isExpanded
|
||||||
|
? Math.max(DEFAULT_WIDTH, Math.min(EXPANDED_WIDTH, Math.round(viewportWidth * MAX_VIEWPORT_SHARE)))
|
||||||
|
: DEFAULT_WIDTH;
|
||||||
|
|
||||||
|
const trackWidth = panelWidth + GUTTER;
|
||||||
|
const easing = [0.65, 0, 0.35, 1];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence initial={false}>
|
<AnimatePresence initial={false}>
|
||||||
@@ -40,34 +65,40 @@ export function AssistantPanel({ stickyClassName, panelHeightClassName }) {
|
|||||||
key="assistant"
|
key="assistant"
|
||||||
aria-label="Krow AI Assistant"
|
aria-label="Krow AI Assistant"
|
||||||
initial={{ width: 0, opacity: 0 }}
|
initial={{ width: 0, opacity: 0 }}
|
||||||
animate={{ width: PANEL_WIDTH + GUTTER, opacity: 1 }}
|
animate={{ width: trackWidth, opacity: 1 }}
|
||||||
exit={{ width: 0, opacity: 0 }}
|
exit={{ width: 0, opacity: 0 }}
|
||||||
transition={{ duration: 0.3, ease: [0.65, 0, 0.35, 1] }}
|
transition={{ duration: 0.3, ease: easing }}
|
||||||
/* No `overflow-hidden` here — see (1) above. `self-stretch` keeps the
|
/* No `overflow-hidden` here — see (1). `self-stretch` — see (2). */
|
||||||
sticky context running the full height of the shell — see (2). */
|
|
||||||
className="relative shrink-0 self-stretch"
|
className="relative shrink-0 self-stretch"
|
||||||
>
|
>
|
||||||
<div className={cn('sticky', stickyClassName)}>
|
<div className={cn('sticky', stickyClassName)}>
|
||||||
{/* The clip: contains the slide-in and the fixed panel width while
|
{/* The clip: contains the slide-in and the panel width while the
|
||||||
the track animates open, without breaking the sticky above it. */}
|
track animates, without breaking the sticky above it. */}
|
||||||
<div className="overflow-hidden" style={{ width: PANEL_WIDTH + GUTTER }}>
|
<motion.div
|
||||||
|
className="overflow-hidden"
|
||||||
|
animate={{ width: trackWidth }}
|
||||||
|
transition={{ duration: 0.3, ease: easing }}
|
||||||
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
className="pl-6"
|
className="pl-6"
|
||||||
initial={{ x: 32, opacity: 0 }}
|
initial={{ x: 32, opacity: 0 }}
|
||||||
animate={{ x: 0, opacity: 1 }}
|
animate={{ x: 0, opacity: 1 }}
|
||||||
exit={{ x: 32, opacity: 0 }}
|
exit={{ x: 32, opacity: 0 }}
|
||||||
transition={{ duration: 0.3, ease: [0.65, 0, 0.35, 1] }}
|
transition={{ duration: 0.3, ease: easing }}
|
||||||
>
|
>
|
||||||
{/* Keyed on the context so switching pages gives a clean panel
|
{/* Keyed on the context so switching pages gives a clean panel
|
||||||
and aborts any response still streaming for the last one. */}
|
and aborts any response still streaming for the last one. */}
|
||||||
<KrowAssistant
|
<KrowAssistant
|
||||||
key={context.id}
|
key={context.id}
|
||||||
context={context}
|
context={context}
|
||||||
|
expanded={isExpanded}
|
||||||
onClose={close}
|
onClose={close}
|
||||||
|
onExpand={expand}
|
||||||
|
onRestore={restore}
|
||||||
className={panelHeightClassName}
|
className={panelHeightClassName}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</motion.aside>
|
</motion.aside>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import * as React from 'react';
|
|||||||
import { resolveAssistantContext } from './placement';
|
import { resolveAssistantContext } from './placement';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open/closed state for the Krow AI Assistant.
|
* Window state for the Krow AI Assistant: open/closed and default/expanded.
|
||||||
*
|
*
|
||||||
* Lives in context rather than in the layout so the trigger can sit anywhere in
|
* Lives in context rather than in the layout so the trigger can sit anywhere in
|
||||||
* the page — beside that page's own actions — without the layout having to know
|
* the page — beside that page's own actions — without the layout having to know
|
||||||
@@ -14,23 +14,33 @@ import { resolveAssistantContext } from './placement';
|
|||||||
|
|
||||||
const AssistantPanelContext = React.createContext(null);
|
const AssistantPanelContext = React.createContext(null);
|
||||||
|
|
||||||
const STORAGE_KEY = 'krow_assistant_open';
|
const OPEN_KEY = 'krow_assistant_open';
|
||||||
|
const EXPANDED_KEY = 'krow_assistant_expanded';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the panel was open before. Kept for the session so moving between
|
* Window state is kept for the session so moving between supported pages does
|
||||||
* supported pages does not close a panel the user deliberately opened, while a
|
* not collapse a panel the user deliberately opened or expanded, while a new
|
||||||
* new session still starts clean on the dashboard.
|
* session still starts clean on the dashboard.
|
||||||
*/
|
*/
|
||||||
function readStoredOpen() {
|
function readFlag(key) {
|
||||||
try {
|
try {
|
||||||
return sessionStorage.getItem(STORAGE_KEY) === '1';
|
return sessionStorage.getItem(key) === '1';
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function writeFlag(key, value) {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(key, value ? '1' : '0');
|
||||||
|
} catch {
|
||||||
|
// Private mode — state simply resets next session.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function AssistantPanelProvider({ role, pathname, children }) {
|
export function AssistantPanelProvider({ role, pathname, children }) {
|
||||||
const [open, setOpen] = React.useState(readStoredOpen);
|
const [open, setOpen] = React.useState(() => readFlag(OPEN_KEY));
|
||||||
|
const [expanded, setExpanded] = React.useState(() => readFlag(EXPANDED_KEY));
|
||||||
|
|
||||||
const context = React.useMemo(
|
const context = React.useMemo(
|
||||||
() => resolveAssistantContext(role, pathname),
|
() => resolveAssistantContext(role, pathname),
|
||||||
@@ -38,36 +48,47 @@ export function AssistantPanelProvider({ role, pathname, children }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const supported = Boolean(context);
|
const supported = Boolean(context);
|
||||||
// A stored "open" must not force the panel onto a page that has no assistant.
|
// A stored "open" must not force the panel onto a page with no assistant.
|
||||||
const isOpen = supported && open;
|
const isOpen = supported && open;
|
||||||
|
const isExpanded = isOpen && expanded;
|
||||||
|
|
||||||
const persist = React.useCallback((next) => {
|
const setOpenState = React.useCallback((next) => {
|
||||||
setOpen(next);
|
setOpen(next);
|
||||||
try {
|
writeFlag(OPEN_KEY, next);
|
||||||
sessionStorage.setItem(STORAGE_KEY, next ? '1' : '0');
|
|
||||||
} catch {
|
|
||||||
// Private mode — the panel simply reverts to closed next session.
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/* Escape closes the panel, as it does for every other dismissible surface. */
|
const setExpandedState = React.useCallback((next) => {
|
||||||
|
setExpanded(next);
|
||||||
|
writeFlag(EXPANDED_KEY, next);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape steps back one level rather than closing outright: expanded → default
|
||||||
|
* → closed. Collapsing a workspace and dismissing it are different intentions,
|
||||||
|
* and one key press should not do both.
|
||||||
|
*/
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!isOpen) return undefined;
|
if (!isOpen) return undefined;
|
||||||
const onKey = (e) => {
|
const onKey = (e) => {
|
||||||
if (e.key === 'Escape') persist(false);
|
if (e.key !== 'Escape') return;
|
||||||
|
if (isExpanded) setExpandedState(false);
|
||||||
|
else setOpenState(false);
|
||||||
};
|
};
|
||||||
window.addEventListener('keydown', onKey);
|
window.addEventListener('keydown', onKey);
|
||||||
return () => window.removeEventListener('keydown', onKey);
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
}, [isOpen, persist]);
|
}, [isOpen, isExpanded, setExpandedState, setOpenState]);
|
||||||
|
|
||||||
const value = React.useMemo(() => ({
|
const value = React.useMemo(() => ({
|
||||||
context,
|
context,
|
||||||
supported,
|
supported,
|
||||||
isOpen,
|
isOpen,
|
||||||
open: () => persist(true),
|
isExpanded,
|
||||||
close: () => persist(false),
|
open: () => setOpenState(true),
|
||||||
toggle: () => persist(!isOpen),
|
close: () => setOpenState(false),
|
||||||
}), [context, supported, isOpen, persist]);
|
toggle: () => setOpenState(!isOpen),
|
||||||
|
expand: () => setExpandedState(true),
|
||||||
|
restore: () => setExpandedState(false),
|
||||||
|
}), [context, supported, isOpen, isExpanded, setOpenState, setExpandedState]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AssistantPanelContext.Provider value={value}>
|
<AssistantPanelContext.Provider value={value}>
|
||||||
@@ -79,7 +100,7 @@ export function AssistantPanelProvider({ role, pathname, children }) {
|
|||||||
/**
|
/**
|
||||||
* Panel state for triggers and the layout.
|
* Panel state for triggers and the layout.
|
||||||
*
|
*
|
||||||
* Returns a safe inert value outside a provider, so a page rendering an
|
* Returns an inert value outside a provider, so a page rendering an
|
||||||
* `AssistantTrigger` in isolation (a test, a storybook) does not crash.
|
* `AssistantTrigger` in isolation (a test, a storybook) does not crash.
|
||||||
*/
|
*/
|
||||||
export function useAssistantPanel() {
|
export function useAssistantPanel() {
|
||||||
@@ -87,8 +108,11 @@ export function useAssistantPanel() {
|
|||||||
context: null,
|
context: null,
|
||||||
supported: false,
|
supported: false,
|
||||||
isOpen: false,
|
isOpen: false,
|
||||||
|
isExpanded: false,
|
||||||
open: () => {},
|
open: () => {},
|
||||||
close: () => {},
|
close: () => {},
|
||||||
toggle: () => {},
|
toggle: () => {},
|
||||||
|
expand: () => {},
|
||||||
|
restore: () => {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,48 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { RotateCcw, Sparkles, X } from 'lucide-react';
|
import { Maximize2, Minimize2, RotateCcw, Sparkles, X } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Surface } from '@/components/ds/Surface';
|
import { Surface } from '@/components/ds/Surface';
|
||||||
import { IconButton } from '@/components/ds/IconButton';
|
import { IconButton } from '@/components/ds/IconButton';
|
||||||
import { Alert } from '@/components/ds/Alert';
|
import { Alert } from '@/components/ds/Alert';
|
||||||
import { KROW_LOGO_URL } from '@/assets/brand';
|
import { KROW_LOGO_URL } from '@/assets/brand';
|
||||||
import { useAssistantFacts, useConversation } from './useAssistant';
|
import { useAssistantFacts, useConversation } from './useAssistant';
|
||||||
import { Message, ThinkingIndicator } from './AssistantMessage';
|
import { buildIntro, buildPrompts } from './dynamic';
|
||||||
|
import { Message, ThinkingIndicator, TurnDivider } from './AssistantMessage';
|
||||||
import { PromptInput } from './PromptInput';
|
import { PromptInput } from './PromptInput';
|
||||||
|
import { PromptChips } from './PromptChips';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Krow AI Assistant — the dashboard's contextual assistant.
|
* Krow AI Assistant — the dashboard's contextual assistant.
|
||||||
*
|
*
|
||||||
* One component, reused on every supported page; only `context` differs.
|
* One component, reused on every supported page; only `context` differs.
|
||||||
*
|
*
|
||||||
* The layout is a fixed three-part frame — header, conversation, composer —
|
* The frame is fixed at three parts — header, body, composer — and only the body
|
||||||
* where only the conversation scrolls. That is what lets the panel stay pinned
|
* scrolls. That is what lets the panel stay pinned beside a long dashboard
|
||||||
* beside a long dashboard without ever growing the page or losing its input.
|
* without ever growing the page or scrolling its own input out of reach.
|
||||||
*
|
*
|
||||||
* The empty state is deliberately a conversational entry point rather than a
|
* Two states share that frame:
|
||||||
* menu: an icon, a question, and an input. Suggested chips fill the input rather
|
|
||||||
* than firing an action, so the user always sees the question before it is asked
|
|
||||||
* and can edit it — which is what makes this read as an assistant instead of a
|
|
||||||
* second dashboard.
|
|
||||||
*
|
*
|
||||||
* Independent of Owliver. It talks to a provider (see provider.js) which can
|
* - **Landing.** One centred block: mark, contextual label, greeting,
|
||||||
* later be pointed at an external service without changing anything here.
|
* description, input, suggestions. Deliberately light — it is an invitation,
|
||||||
|
* not a page, so nothing in it competes with the dashboard beside it.
|
||||||
|
* - **Conversation.** The thread takes the body and the composer returns to the
|
||||||
|
* bottom, because from then on the thread is what the user is reading.
|
||||||
|
*
|
||||||
|
* `expanded` widens the same panel into an analysis workspace. `data-wide` lets
|
||||||
|
* individual response blocks use the extra room (a KPI row goes to three
|
||||||
|
* columns) without any of them needing to know the panel's pixel width.
|
||||||
|
*
|
||||||
|
* Independent of Owliver. Answers come from a provider (see provider.js) that
|
||||||
|
* can later be pointed at an external service without changing anything here.
|
||||||
*/
|
*/
|
||||||
export default function KrowAssistant({ context, className, onClose }) {
|
export default function KrowAssistant({
|
||||||
|
context,
|
||||||
|
className,
|
||||||
|
expanded = false,
|
||||||
|
onClose,
|
||||||
|
onExpand,
|
||||||
|
onRestore,
|
||||||
|
}) {
|
||||||
const facts = useAssistantFacts({ needsWorkerProfile: context.needsWorkerProfile });
|
const facts = useAssistantFacts({ needsWorkerProfile: context.needsWorkerProfile });
|
||||||
const { messages, pending, error, busy, send, stop, reset } = useConversation({
|
const { messages, pending, error, busy, send, stop, reset } = useConversation({
|
||||||
contextId: context.id,
|
contextId: context.id,
|
||||||
@@ -38,23 +53,46 @@ export default function KrowAssistant({ context, className, onClose }) {
|
|||||||
const scrollRef = React.useRef(null);
|
const scrollRef = React.useRef(null);
|
||||||
const isEmpty = messages.length === 0 && !pending;
|
const isEmpty = messages.length === 0 && !pending;
|
||||||
|
|
||||||
/* Follow the newest content as it streams. */
|
/* Greeting and suggestions come from live data, so they recompute only when
|
||||||
|
the data or the page actually changes. */
|
||||||
|
const intro = React.useMemo(
|
||||||
|
() => buildIntro(context.id, facts, facts.profile?.full_name),
|
||||||
|
[context.id, facts]
|
||||||
|
);
|
||||||
|
const prompts = React.useMemo(() => buildPrompts(context.id, facts), [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
|
||||||
|
visibly lags the text. */
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
if (el) el.scrollTop = el.scrollHeight;
|
if (el) el.scrollTop = el.scrollHeight;
|
||||||
}, [messages, pending]);
|
}, [messages, pending]);
|
||||||
|
|
||||||
/**
|
const submit = React.useCallback((text) => {
|
||||||
* A chip's prompt resolves to its capability so the answer is precise, while
|
const question = String(text).trim();
|
||||||
* the same question typed by hand still resolves through keyword matching.
|
|
||||||
*/
|
|
||||||
const submit = (text) => {
|
|
||||||
const question = text.trim();
|
|
||||||
if (!question || busy) return;
|
if (!question || busy) return;
|
||||||
const chip = context.chips.find((c) => c.prompt === question);
|
send({ question });
|
||||||
send({ question, capability: chip?.capability ?? null });
|
|
||||||
setInput('');
|
setInput('');
|
||||||
};
|
}, [busy, send]);
|
||||||
|
|
||||||
|
/* A chip is already a complete question, so it runs on click. */
|
||||||
|
const runPrompt = React.useCallback((prompt) => {
|
||||||
|
if (busy) return;
|
||||||
|
setInput('');
|
||||||
|
send({ question: prompt.prompt, capability: prompt.capability });
|
||||||
|
}, [busy, send]);
|
||||||
|
|
||||||
|
const composer = (
|
||||||
|
<PromptInput
|
||||||
|
value={input}
|
||||||
|
onChange={setInput}
|
||||||
|
onSubmit={submit}
|
||||||
|
onStop={stop}
|
||||||
|
busy={busy}
|
||||||
|
placeholders={intro.placeholders}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Surface
|
<Surface
|
||||||
@@ -62,13 +100,15 @@ export default function KrowAssistant({ context, className, onClose }) {
|
|||||||
radius="xl"
|
radius="xl"
|
||||||
padding="none"
|
padding="none"
|
||||||
elevation="sm"
|
elevation="sm"
|
||||||
|
data-wide={expanded || undefined}
|
||||||
className={cn('flex flex-col overflow-hidden', className)}
|
className={cn('flex flex-col overflow-hidden', className)}
|
||||||
|
role="region"
|
||||||
aria-label="Krow AI Assistant"
|
aria-label="Krow AI Assistant"
|
||||||
>
|
>
|
||||||
{/* Header — fixed */}
|
{/* Header */}
|
||||||
<div className="flex shrink-0 items-center gap-2.5 border-b border-white/60 px-4 py-3">
|
<div className="flex shrink-0 items-center gap-2.5 border-b border-white/60 px-3.5 py-2.5">
|
||||||
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-krow-blue text-white shadow-brand">
|
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-lg bg-krow-blue text-white shadow-brand">
|
||||||
<Sparkles className="h-4 w-4" aria-hidden="true" />
|
<Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
@@ -76,92 +116,101 @@ export default function KrowAssistant({ context, className, onClose }) {
|
|||||||
<p className="truncate text-caption leading-tight text-ink-3">{context.page}</p>
|
<p className="truncate text-caption leading-tight text-ink-3">{context.page}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{messages.length > 0 && (
|
{/* Window controls */}
|
||||||
<IconButton icon={RotateCcw} label="New conversation" variant="ghost" size="sm" onClick={reset} />
|
<div className="flex items-center gap-0.5">
|
||||||
)}
|
{messages.length > 0 && (
|
||||||
{onClose && (
|
<IconButton icon={RotateCcw} label="New conversation" variant="ghost" size="sm" onClick={reset} />
|
||||||
<IconButton icon={X} label="Close assistant" variant="ghost" size="sm" onClick={onClose} />
|
)}
|
||||||
)}
|
{expanded
|
||||||
|
? onRestore && (
|
||||||
|
<IconButton icon={Minimize2} label="Restore panel width" variant="ghost" size="sm" onClick={onRestore} />
|
||||||
|
)
|
||||||
|
: onExpand && (
|
||||||
|
<IconButton icon={Maximize2} label="Expand to workspace" variant="ghost" size="sm" onClick={onExpand} />
|
||||||
|
)}
|
||||||
|
{onClose && (
|
||||||
|
<IconButton icon={X} label="Close assistant" variant="ghost" size="sm" onClick={onClose} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Conversation — the only scrolling region */}
|
{isEmpty ? (
|
||||||
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto" aria-live="polite">
|
/* ── Landing: one compact centred block ───────────────────────── */
|
||||||
{isEmpty ? (
|
<div className="flex min-h-0 flex-1 flex-col justify-center overflow-y-auto px-4 py-4">
|
||||||
/* Landing: an invitation to ask, not a list of features. */
|
{/* Capped so the block stays a tight composition when expanded. */}
|
||||||
<div className="flex h-full flex-col items-center justify-center px-6 py-8 text-center animate-fade-in">
|
<div className="mx-auto w-full max-w-sm animate-fade-in space-y-3.5">
|
||||||
<span className="relative mb-5 grid h-14 w-14 place-items-center rounded-2xl bg-white shadow-brand ring-1 ring-krow-blue/10">
|
<div className="flex flex-col items-center text-center">
|
||||||
<img src={KROW_LOGO_URL} alt="" aria-hidden="true" className="h-5 w-auto object-contain" />
|
<span className="relative mb-2.5 grid h-10 w-10 place-items-center rounded-xl bg-white shadow-brand ring-1 ring-krow-blue/10">
|
||||||
<span
|
<img src={KROW_LOGO_URL} alt="" aria-hidden="true" className="h-3.5 w-auto object-contain" />
|
||||||
aria-hidden="true"
|
<span
|
||||||
className="absolute -right-1 -top-1 grid h-5 w-5 place-items-center rounded-full bg-krow-blue text-white shadow-sm"
|
aria-hidden="true"
|
||||||
>
|
className="absolute -right-1 -top-1 grid h-4 w-4 place-items-center rounded-full bg-krow-blue text-white shadow-sm"
|
||||||
<Sparkles className="h-2.5 w-2.5" />
|
>
|
||||||
|
<Sparkles className="h-2 w-2" />
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
|
||||||
|
|
||||||
<h3 className="font-heading text-title text-ink-1">
|
<p className="text-[10px] font-semibold uppercase tracking-wide text-krow-blue">
|
||||||
What would you like to analyze today?
|
{intro.title}
|
||||||
</h3>
|
</p>
|
||||||
<p className="mt-2 max-w-[19rem] text-body-sm leading-relaxed text-ink-3">
|
<h3 className="mt-1 text-balance font-heading text-body-lg font-semibold leading-snug text-ink-1">
|
||||||
{context.tagline}
|
{intro.greeting}
|
||||||
</p>
|
</h3>
|
||||||
|
<p className="mt-1 text-caption leading-relaxed text-ink-3">{intro.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{composer}
|
||||||
|
|
||||||
|
<PromptChips prompts={prompts} onSelect={runPrompt} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<div className="space-y-4 px-4 py-4">
|
) : (
|
||||||
|
/* ── Conversation ─────────────────────────────────────────────── */
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className="min-h-0 flex-1 space-y-4 overflow-y-auto px-4 py-4"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-relevant="additions text"
|
||||||
|
>
|
||||||
{messages.map((message, i) => (
|
{messages.map((message, i) => (
|
||||||
<Message key={i} role={message.role} text={message.text} stopped={message.stopped} />
|
<React.Fragment key={i}>
|
||||||
|
{i > 0 && message.role === 'user' && <TurnDivider />}
|
||||||
|
<Message
|
||||||
|
role={message.role}
|
||||||
|
text={message.text}
|
||||||
|
blocks={message.blocks}
|
||||||
|
stopped={message.stopped}
|
||||||
|
/>
|
||||||
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{pending?.thinking && <ThinkingIndicator />}
|
{pending?.thinking && <ThinkingIndicator />}
|
||||||
{pending && !pending.thinking && <Message role="assistant" text={pending.text} streaming />}
|
{pending && !pending.thinking && (
|
||||||
|
<Message role="assistant" blocks={pending.blocks} streaming />
|
||||||
|
)}
|
||||||
|
|
||||||
{error && <Alert tone="destructive">{error}</Alert>}
|
{error && <Alert tone="destructive">{error}</Alert>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Suggested prompts + composer — fixed */}
|
<div className="shrink-0 space-y-2 border-t border-white/60 px-3 pb-2.5 pt-2.5">
|
||||||
<div className="shrink-0 space-y-2.5 border-t border-white/60 px-3 pb-3 pt-2.5">
|
{/* Capped in conversation: the vertical space belongs to the thread.
|
||||||
{!busy && (
|
Expanded fits more per line, so it can afford one more. */}
|
||||||
/* On the landing there is room to show every suggestion, so they wrap.
|
{!busy && (
|
||||||
Once a conversation is underway the space belongs to the thread, so
|
<PromptChips
|
||||||
they collapse to a single scrollable row. */
|
prompts={prompts}
|
||||||
<div
|
onSelect={runPrompt}
|
||||||
className={cn(
|
max={expanded ? 4 : 3}
|
||||||
'flex gap-1.5',
|
align="start"
|
||||||
isEmpty ? 'flex-wrap justify-center' : 'overflow-x-auto no-scrollbar'
|
/>
|
||||||
)}
|
)}
|
||||||
>
|
{composer}
|
||||||
{context.chips.map((chip) => (
|
<p className="px-1 text-[10px] leading-tight text-ink-4">
|
||||||
<button
|
Krow AI reads this page's data. Check anything you act on.
|
||||||
key={chip.label}
|
</p>
|
||||||
type="button"
|
|
||||||
/* Fills the input — never sends, never navigates. */
|
|
||||||
onClick={() => setInput(chip.prompt)}
|
|
||||||
className="shrink-0 rounded-full border border-border bg-surface px-2.5 py-1 text-caption
|
|
||||||
text-ink-3 transition-colors duration-base hover:border-krow-blue/40
|
|
||||||
hover:bg-krow-blue-tint hover:text-krow-blue focus-ring"
|
|
||||||
>
|
|
||||||
{chip.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</>
|
||||||
|
)}
|
||||||
<PromptInput
|
|
||||||
value={input}
|
|
||||||
onChange={setInput}
|
|
||||||
onSubmit={submit}
|
|
||||||
onStop={stop}
|
|
||||||
busy={busy}
|
|
||||||
placeholders={context.placeholders}
|
|
||||||
size={isEmpty ? 'large' : 'default'}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className="px-1 text-[10px] leading-tight text-ink-4">
|
|
||||||
Krow AI reads this page's data. Check anything you act on.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Surface>
|
</Surface>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
67
src/components/ai-assistant/PromptChips.jsx
Normal file
67
src/components/ai-assistant/PromptChips.jsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PromptChips — suggested prompts.
|
||||||
|
*
|
||||||
|
* Clicking a chip runs it immediately. A chip is already a complete question, so
|
||||||
|
* making the user press Enter afterwards adds a step that buys nothing.
|
||||||
|
*
|
||||||
|
* Chips always wrap onto as many lines as they need. The earlier horizontal-scroll
|
||||||
|
* variant is gone: a scroller can always leave a chip half-visible at the edge,
|
||||||
|
* and a half-visible button is a bug no amount of fade masking fixes. Wrapping
|
||||||
|
* makes clipping structurally impossible instead of merely unlikely.
|
||||||
|
*
|
||||||
|
* `max` caps how many are offered in a conversation, where vertical space belongs
|
||||||
|
* to the thread rather than to suggestions.
|
||||||
|
*
|
||||||
|
* Arrow keys move between chips, so the whole set is one tab stop.
|
||||||
|
*/
|
||||||
|
export function PromptChips({ prompts = [], onSelect, max, align = 'center', className }) {
|
||||||
|
const chipRefs = React.useRef([]);
|
||||||
|
const visible = max ? prompts.slice(0, max) : prompts;
|
||||||
|
|
||||||
|
const onKeyDown = (event, index) => {
|
||||||
|
const delta = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0;
|
||||||
|
if (!delta) return;
|
||||||
|
const next = chipRefs.current[index + delta];
|
||||||
|
if (!next) return;
|
||||||
|
event.preventDefault();
|
||||||
|
next.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!visible.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-label="Suggested prompts"
|
||||||
|
className={cn(
|
||||||
|
'flex flex-wrap gap-1.5',
|
||||||
|
align === 'center' ? 'justify-center' : 'justify-start',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{visible.map((prompt, i) => (
|
||||||
|
<button
|
||||||
|
key={prompt.label}
|
||||||
|
ref={(el) => { chipRefs.current[i] = el; }}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(prompt)}
|
||||||
|
onKeyDown={(e) => onKeyDown(e, i)}
|
||||||
|
title={prompt.prompt}
|
||||||
|
className={cn(
|
||||||
|
'rounded-full border border-border bg-surface px-2.5 py-1',
|
||||||
|
'text-caption font-medium text-ink-2 shadow-xs',
|
||||||
|
'transition-all duration-base ease-out',
|
||||||
|
'hover:-translate-y-px hover:border-krow-blue/40 hover:bg-krow-blue-tint hover:text-krow-blue hover:shadow-sm',
|
||||||
|
'active:translate-y-0 active:shadow-xs',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/60 focus-visible:ring-offset-1'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{prompt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,8 +20,6 @@ export function PromptInput({
|
|||||||
onStop,
|
onStop,
|
||||||
busy = false,
|
busy = false,
|
||||||
placeholders = ['Ask anything…'],
|
placeholders = ['Ask anything…'],
|
||||||
/** Larger treatment for the landing state, where the input is the focus. */
|
|
||||||
size = 'default',
|
|
||||||
autoFocus = false,
|
autoFocus = false,
|
||||||
className,
|
className,
|
||||||
}) {
|
}) {
|
||||||
@@ -38,16 +36,29 @@ export function PromptInput({
|
|||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
/* Rotate the examples only while the field is idle — changing the placeholder
|
/* Rotate the examples only while the field is idle — changing the placeholder
|
||||||
under someone who is mid-thought is distracting. */
|
under someone who is mid-thought is distracting. The crossfade is done with
|
||||||
|
a real element rather than the native placeholder, which cannot animate. */
|
||||||
|
const [fading, setFading] = React.useState(false);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (value || focused || placeholders.length < 2) return undefined;
|
if (value || focused || placeholders.length < 2) return undefined;
|
||||||
const timer = setInterval(
|
const timer = setInterval(() => {
|
||||||
() => setPlaceholderIndex((i) => (i + 1) % placeholders.length),
|
setFading(true);
|
||||||
3800
|
// Swap the text at the midpoint of the fade so neither string is visible
|
||||||
);
|
// while it changes.
|
||||||
|
setTimeout(() => {
|
||||||
|
setPlaceholderIndex((i) => (i + 1) % placeholders.length);
|
||||||
|
setFading(false);
|
||||||
|
}, 220);
|
||||||
|
}, 4000);
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [value, focused, placeholders.length]);
|
}, [value, focused, placeholders.length]);
|
||||||
|
|
||||||
|
/* A shorter list on a context change must not leave the index out of range. */
|
||||||
|
React.useEffect(() => {
|
||||||
|
setPlaceholderIndex(0);
|
||||||
|
}, [placeholders]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (autoFocus) textareaRef.current?.focus();
|
if (autoFocus) textareaRef.current?.focus();
|
||||||
}, [autoFocus]);
|
}, [autoFocus]);
|
||||||
@@ -64,36 +75,52 @@ export function PromptInput({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const large = size === 'large';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
onSubmit={(e) => { e.preventDefault(); submit(); }}
|
onSubmit={(e) => { e.preventDefault(); submit(); }}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative flex items-end gap-2 rounded-2xl border bg-surface transition-all duration-base ease-out',
|
'relative flex items-end gap-2 rounded-2xl border bg-surface transition-all duration-base ease-out',
|
||||||
large ? 'px-4 py-3' : 'px-3 py-2.5',
|
'px-3 py-2',
|
||||||
focused
|
focused
|
||||||
? 'border-krow-blue/50 shadow-[0_8px_28px_-8px_rgba(10,57,223,0.28)] ring-2 ring-krow-blue/15'
|
? 'border-krow-blue/50 shadow-[0_8px_28px_-8px_rgba(10,57,223,0.28)] ring-2 ring-krow-blue/15'
|
||||||
: 'border-border shadow-md hover:border-krow-blue/30',
|
: 'border-border shadow-md hover:border-krow-blue/30',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<textarea
|
<div className="relative min-w-0 flex-1">
|
||||||
ref={textareaRef}
|
<textarea
|
||||||
rows={1}
|
ref={textareaRef}
|
||||||
value={value}
|
rows={1}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
value={value}
|
||||||
onKeyDown={onKeyDown}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
onFocus={() => setFocused(true)}
|
onKeyDown={onKeyDown}
|
||||||
onBlur={() => setFocused(false)}
|
onFocus={() => setFocused(true)}
|
||||||
placeholder={placeholders[placeholderIndex]}
|
onBlur={() => setFocused(false)}
|
||||||
aria-label="Ask Krow AI"
|
disabled={busy}
|
||||||
className={cn(
|
aria-label="Ask Krow AI"
|
||||||
'flex-1 min-w-0 resize-none bg-transparent leading-relaxed text-ink-1',
|
aria-busy={busy || undefined}
|
||||||
'placeholder:text-ink-4 outline-none no-scrollbar',
|
className={cn(
|
||||||
large ? 'text-body py-1' : 'text-body-sm py-0.5'
|
'block w-full resize-none bg-transparent leading-relaxed text-ink-1',
|
||||||
|
'no-scrollbar outline-none disabled:cursor-wait disabled:text-ink-3',
|
||||||
|
'py-0.5 text-body-sm'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Animated placeholder. Hidden from assistive tech — the textarea's
|
||||||
|
aria-label is the accessible name. */}
|
||||||
|
{!value && (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none absolute inset-0 truncate text-ink-4 transition-opacity duration-base',
|
||||||
|
'py-0.5 text-body-sm',
|
||||||
|
fading ? 'opacity-0' : 'opacity-100'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{busy ? 'Thinking…' : placeholders[placeholderIndex]}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
/>
|
</div>
|
||||||
|
|
||||||
{busy ? (
|
{busy ? (
|
||||||
<button
|
<button
|
||||||
@@ -103,10 +130,10 @@ export function PromptInput({
|
|||||||
className={cn(
|
className={cn(
|
||||||
'grid shrink-0 place-items-center rounded-full border border-border bg-surface text-ink-2',
|
'grid shrink-0 place-items-center rounded-full border border-border bg-surface text-ink-2',
|
||||||
'transition-colors duration-base hover:bg-surface-sunken focus-ring',
|
'transition-colors duration-base hover:bg-surface-sunken focus-ring',
|
||||||
large ? 'w-9 h-9' : 'w-8 h-8'
|
'h-8 w-8'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Square className={large ? 'w-3.5 h-3.5' : 'w-3 h-3'} aria-hidden="true" />
|
<Square className="h-3 w-3" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
@@ -117,10 +144,10 @@ export function PromptInput({
|
|||||||
'grid shrink-0 place-items-center rounded-full bg-krow-blue text-white',
|
'grid shrink-0 place-items-center rounded-full bg-krow-blue text-white',
|
||||||
'transition-all duration-base ease-out hover:bg-krow-blue-dark',
|
'transition-all duration-base ease-out hover:bg-krow-blue-dark',
|
||||||
'disabled:bg-surface-sunken disabled:text-ink-4 focus-ring',
|
'disabled:bg-surface-sunken disabled:text-ink-4 focus-ring',
|
||||||
large ? 'w-9 h-9' : 'w-8 h-8'
|
'h-8 w-8'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ArrowUp className={large ? 'w-4 h-4' : 'w-3.5 h-3.5'} aria-hidden="true" />
|
<ArrowUp className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
409
src/components/ai-assistant/ResponseBlocks.jsx
Normal file
409
src/components/ai-assistant/ResponseBlocks.jsx
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { ArrowDownRight, ArrowUpRight, Check, TriangleAlert } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { ProgressBar, toneForScore } from '@/components/ds/Progress';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renderers for response blocks.
|
||||||
|
*
|
||||||
|
* Every block is drawn with the same tokens as the dashboard — the ink scale,
|
||||||
|
* the shared radius, the score-band colours — so a generated report looks like
|
||||||
|
* it belongs to Krow rather than to a chat library.
|
||||||
|
*
|
||||||
|
* Blocks are memoized individually: a streaming response re-renders on every
|
||||||
|
* snapshot, and settled blocks must not re-render with it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const INLINE = /(\*\*[^*]+\*\*|_[^_]+_)/g;
|
||||||
|
|
||||||
|
/** Inline `**bold**` and `_italic_`. Kept deliberately small — structure is
|
||||||
|
* carried by blocks, not by markup inside a paragraph. */
|
||||||
|
function Inline({ value }) {
|
||||||
|
const parts = React.useMemo(() => String(value).split(INLINE).filter(Boolean), [value]);
|
||||||
|
|
||||||
|
return parts.map((part, i) => {
|
||||||
|
if (part.startsWith('**') && part.endsWith('**')) {
|
||||||
|
return <strong key={i} className="font-semibold text-ink-1">{part.slice(2, -2)}</strong>;
|
||||||
|
}
|
||||||
|
if (part.startsWith('_') && part.endsWith('_')) {
|
||||||
|
return <em key={i} className="text-ink-3">{part.slice(1, -1)}</em>;
|
||||||
|
}
|
||||||
|
return part;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const TONE_TEXT = {
|
||||||
|
info: 'text-krow-blue',
|
||||||
|
success: 'text-success',
|
||||||
|
warning: 'text-warning',
|
||||||
|
risk: 'text-destructive',
|
||||||
|
neutral: 'text-ink-3',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TONE_SURFACE = {
|
||||||
|
info: 'bg-krow-blue-tint border-krow-blue/15',
|
||||||
|
success: 'bg-success-muted border-success/15',
|
||||||
|
warning: 'bg-warning-muted border-warning/20',
|
||||||
|
risk: 'bg-destructive-muted border-destructive/15',
|
||||||
|
neutral: 'bg-surface-subtle border-border',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TONE_BADGE = {
|
||||||
|
info: 'info', success: 'success', warning: 'warning', risk: 'destructive', neutral: 'neutral',
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── Individual blocks ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const TextBlock = React.memo(({ block }) => (
|
||||||
|
<p className="text-body-sm leading-relaxed text-ink-2">
|
||||||
|
<Inline value={block.text} />
|
||||||
|
</p>
|
||||||
|
));
|
||||||
|
TextBlock.displayName = 'TextBlock';
|
||||||
|
|
||||||
|
const HeadingBlock = React.memo(({ block }) => (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<h4 className="font-heading text-body-sm font-semibold text-ink-1">{block.text}</h4>
|
||||||
|
{block.sub && <p className="text-caption text-ink-3">{block.sub}</p>}
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
HeadingBlock.displayName = 'HeadingBlock';
|
||||||
|
|
||||||
|
/** Headline figures. Two columns keeps the numbers large in a 380px panel. */
|
||||||
|
const KpisBlock = React.memo(({ block }) => (
|
||||||
|
<div className="grid grid-cols-2 gap-2 [[data-wide]_&]:grid-cols-3">
|
||||||
|
{block.items.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.label}
|
||||||
|
className="rounded-xl border border-border bg-surface px-3 py-2.5 shadow-xs"
|
||||||
|
>
|
||||||
|
<p className="truncate text-[10px] uppercase tracking-wide text-ink-4">{item.label}</p>
|
||||||
|
<div className="mt-0.5 flex items-baseline gap-1.5">
|
||||||
|
<span className={cn(
|
||||||
|
'font-heading text-title font-bold tabular-nums',
|
||||||
|
item.tone ? TONE_TEXT[item.tone] : 'text-ink-1'
|
||||||
|
)}>
|
||||||
|
{item.value}
|
||||||
|
</span>
|
||||||
|
{typeof item.delta === 'number' && item.delta !== 0 && (
|
||||||
|
<span className={cn(
|
||||||
|
'inline-flex items-center text-[10px] font-semibold',
|
||||||
|
item.delta > 0 ? 'text-success' : 'text-destructive'
|
||||||
|
)}>
|
||||||
|
{item.delta > 0
|
||||||
|
? <ArrowUpRight className="h-2.5 w-2.5" aria-hidden="true" />
|
||||||
|
: <ArrowDownRight className="h-2.5 w-2.5" aria-hidden="true" />}
|
||||||
|
{Math.abs(item.delta)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{item.sub && <p className="mt-0.5 truncate text-[10px] text-ink-4">{item.sub}</p>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
KpisBlock.displayName = 'KpisBlock';
|
||||||
|
|
||||||
|
/** Pass/warn checks. The icon carries the state so colour is not the only cue. */
|
||||||
|
const StatusBlock = React.memo(({ block }) => (
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{block.items.map((item) => (
|
||||||
|
<li key={item.label} className="flex items-start gap-2">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'mt-0.5 grid h-4 w-4 shrink-0 place-items-center rounded-full',
|
||||||
|
item.ok ? 'bg-success-muted text-success' : 'bg-warning-muted text-warning'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.ok
|
||||||
|
? <Check className="h-2.5 w-2.5" aria-hidden="true" />
|
||||||
|
: <TriangleAlert className="h-2.5 w-2.5" aria-hidden="true" />}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<span className="text-body-sm font-medium text-ink-1">{item.label}</span>
|
||||||
|
{item.value && (
|
||||||
|
<span className="shrink-0 text-caption font-semibold tabular-nums text-ink-2">
|
||||||
|
{item.value}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{item.note && <p className="mt-0.5 text-caption leading-relaxed text-ink-3">{item.note}</p>}
|
||||||
|
</div>
|
||||||
|
<span className="sr-only">{item.ok ? 'Passing' : 'Needs attention'}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
));
|
||||||
|
StatusBlock.displayName = 'StatusBlock';
|
||||||
|
|
||||||
|
const MetersBlock = React.memo(({ block }) => (
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{block.items.map((item) => (
|
||||||
|
<div key={item.label} className="space-y-1">
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<span className="truncate text-caption text-ink-2">{item.label}</span>
|
||||||
|
<span className="flex shrink-0 items-baseline gap-1.5">
|
||||||
|
{item.weight != null && <span className="text-[10px] text-ink-4">{item.weight}%</span>}
|
||||||
|
<span className="text-caption font-semibold tabular-nums text-ink-1">{item.value}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ProgressBar value={item.value} max={item.max ?? 100} tone={item.tone ?? 'score'} size="sm" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
MetersBlock.displayName = 'MetersBlock';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A table. Scrolls horizontally inside its own container so a wide comparison
|
||||||
|
* never widens the panel.
|
||||||
|
*/
|
||||||
|
const TableBlock = React.memo(({ block }) => {
|
||||||
|
const cell = (value) => {
|
||||||
|
if (value == null || value === '') return <span className="text-ink-4">—</span>;
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
if (value.badge) {
|
||||||
|
return <Badge variant={TONE_BADGE[value.tone] || 'neutral'} size="sm">{value.badge}</Badge>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className={cn('tabular-nums', value.tone ? TONE_TEXT[value.tone] : undefined)}>
|
||||||
|
{value.value}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span className="tabular-nums">{value}</span>;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto rounded-xl border border-border bg-surface">
|
||||||
|
<table className="w-full border-collapse text-caption">
|
||||||
|
{block.caption && <caption className="sr-only">{block.caption}</caption>}
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border bg-surface-subtle">
|
||||||
|
{block.columns.map((column) => (
|
||||||
|
<th
|
||||||
|
key={column.key}
|
||||||
|
scope="col"
|
||||||
|
className={cn(
|
||||||
|
'whitespace-nowrap px-2.5 py-2 text-[10px] font-semibold uppercase tracking-wide text-ink-4',
|
||||||
|
column.align === 'right' ? 'text-right' : 'text-left'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{column.label}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{block.rows.map((row, i) => (
|
||||||
|
<tr key={i} className="border-b border-border last:border-0">
|
||||||
|
{block.columns.map((column, ci) => (
|
||||||
|
<td
|
||||||
|
key={column.key}
|
||||||
|
className={cn(
|
||||||
|
'px-2.5 py-2 align-middle',
|
||||||
|
column.align === 'right' ? 'text-right' : 'text-left',
|
||||||
|
ci === 0 ? 'font-medium text-ink-1' : 'text-ink-2'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{cell(row[column.key])}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
TableBlock.displayName = 'TableBlock';
|
||||||
|
|
||||||
|
/** Stage progression, widths relative to the largest stage. */
|
||||||
|
const FunnelBlock = React.memo(({ block }) => {
|
||||||
|
const max = Math.max(...block.steps.map((s) => s.count), 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{block.steps.map((step) => (
|
||||||
|
<div key={step.label}>
|
||||||
|
<div className="mb-1 flex items-baseline justify-between gap-2">
|
||||||
|
<span className="text-caption text-ink-2">{step.label}</span>
|
||||||
|
<span className="flex shrink-0 items-baseline gap-1.5">
|
||||||
|
{step.rate != null && <span className="text-[10px] text-ink-4">{step.rate}%</span>}
|
||||||
|
<span className="text-caption font-semibold tabular-nums text-ink-1">{step.count}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 overflow-hidden rounded-full bg-surface-sunken">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-krow-blue transition-all duration-slow ease-out"
|
||||||
|
style={{ width: `${Math.max((step.count / max) * 100, step.count > 0 ? 4 : 0)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
FunnelBlock.displayName = 'FunnelBlock';
|
||||||
|
|
||||||
|
const ListBlock = React.memo(({ block }) => {
|
||||||
|
const Tag = block.ordered ? 'ol' : 'ul';
|
||||||
|
return (
|
||||||
|
<Tag className="space-y-1.5">
|
||||||
|
{block.items.map((item, i) => (
|
||||||
|
<li key={i} className="flex gap-2 text-body-sm leading-relaxed text-ink-2">
|
||||||
|
{block.ordered ? (
|
||||||
|
<span className="mt-0.5 grid h-4 w-4 shrink-0 place-items-center rounded-full bg-krow-blue-tint text-[10px] font-bold text-krow-blue">
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="mt-1.5 h-1 w-1 shrink-0 rounded-full bg-krow-blue" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
<span className="min-w-0"><Inline value={item} /></span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
ListBlock.displayName = 'ListBlock';
|
||||||
|
|
||||||
|
/** Findings, each in a tinted card so severity is scannable. */
|
||||||
|
const InsightsBlock = React.memo(({ block }) => (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{block.items.map((item, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={cn('rounded-xl border px-3 py-2.5', TONE_SURFACE[item.tone] || TONE_SURFACE.neutral)}
|
||||||
|
>
|
||||||
|
<p className="text-body-sm font-semibold text-ink-1">{item.title}</p>
|
||||||
|
{item.body && (
|
||||||
|
<p className="mt-0.5 text-caption leading-relaxed text-ink-2"><Inline value={item.body} /></p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
InsightsBlock.displayName = 'InsightsBlock';
|
||||||
|
|
||||||
|
/** Recommended steps — numbered, because order is the recommendation. */
|
||||||
|
const ActionsBlock = React.memo(({ block }) => (
|
||||||
|
<ol className="space-y-2">
|
||||||
|
{block.items.map((item, i) => (
|
||||||
|
<li key={i} className="flex gap-2.5 rounded-xl border border-border bg-surface px-3 py-2.5 shadow-xs">
|
||||||
|
<span className="mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded-full bg-krow-blue text-[10px] font-bold text-white">
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-body-sm font-semibold text-ink-1">{item.title}</p>
|
||||||
|
{item.body && (
|
||||||
|
<p className="mt-0.5 text-caption leading-relaxed text-ink-2"><Inline value={item.body} /></p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
));
|
||||||
|
ActionsBlock.displayName = 'ActionsBlock';
|
||||||
|
|
||||||
|
const BadgesBlock = React.memo(({ block }) => (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{block.items.map((item) => (
|
||||||
|
<Badge key={item.label} variant={TONE_BADGE[item.tone] || 'soft'} size="lg">
|
||||||
|
{item.label}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
BadgesBlock.displayName = 'BadgesBlock';
|
||||||
|
|
||||||
|
const TimelineBlock = React.memo(({ block }) => (
|
||||||
|
<ol className="relative space-y-3">
|
||||||
|
{block.items.map((item, i) => (
|
||||||
|
<li key={i} className="flex gap-3">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'mt-1 h-2 w-2 shrink-0 rounded-full',
|
||||||
|
item.tone === 'success' ? 'bg-success'
|
||||||
|
: item.tone === 'warning' ? 'bg-warning'
|
||||||
|
: item.current ? 'bg-krow-blue' : 'bg-ink-4'
|
||||||
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{i < block.items.length - 1 && <span className="mt-1 w-px flex-1 bg-border" aria-hidden="true" />}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1 pb-1">
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<span className="text-body-sm font-medium text-ink-1">{item.title}</span>
|
||||||
|
{item.timestamp && (
|
||||||
|
<span className="shrink-0 text-[10px] text-ink-4">{item.timestamp}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{item.description && (
|
||||||
|
<p className="mt-0.5 text-caption leading-relaxed text-ink-3">{item.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
));
|
||||||
|
TimelineBlock.displayName = 'TimelineBlock';
|
||||||
|
|
||||||
|
const NoteBlock = React.memo(({ block }) => (
|
||||||
|
<p className="border-l-2 border-border pl-2.5 text-caption italic leading-relaxed text-ink-3">
|
||||||
|
<Inline value={block.text} />
|
||||||
|
</p>
|
||||||
|
));
|
||||||
|
NoteBlock.displayName = 'NoteBlock';
|
||||||
|
|
||||||
|
const RENDERERS = {
|
||||||
|
text: TextBlock,
|
||||||
|
heading: HeadingBlock,
|
||||||
|
kpis: KpisBlock,
|
||||||
|
status: StatusBlock,
|
||||||
|
meters: MetersBlock,
|
||||||
|
table: TableBlock,
|
||||||
|
funnel: FunnelBlock,
|
||||||
|
list: ListBlock,
|
||||||
|
insights: InsightsBlock,
|
||||||
|
actions: ActionsBlock,
|
||||||
|
badges: BadgesBlock,
|
||||||
|
timeline: TimelineBlock,
|
||||||
|
note: NoteBlock,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a response document.
|
||||||
|
*
|
||||||
|
* Spacing is decided here rather than by the blocks, so any combination of
|
||||||
|
* blocks has consistent rhythm — a heading hugs what follows it, everything
|
||||||
|
* else breathes.
|
||||||
|
*/
|
||||||
|
export const ResponseDocument = React.memo(({ blocks = [], streaming = false }) => (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{blocks.map((block, i) => {
|
||||||
|
const Renderer = RENDERERS[block.type];
|
||||||
|
if (!Renderer) return null;
|
||||||
|
const hugsNext = block.type === 'heading' && i < blocks.length - 1;
|
||||||
|
const isLast = i === blocks.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={i} className={cn(hugsNext && '-mb-1.5', 'animate-fade-in')}>
|
||||||
|
<Renderer block={block} />
|
||||||
|
{/* The cursor trails the final block only while text is still arriving. */}
|
||||||
|
{streaming && isLast && (block.type === 'text' || block.type === 'heading') && (
|
||||||
|
<span
|
||||||
|
className="ml-0.5 inline-block h-3.5 w-1.5 -mb-0.5 animate-caret bg-krow-blue align-middle"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
ResponseDocument.displayName = 'ResponseDocument';
|
||||||
|
|
||||||
|
export { toneForScore };
|
||||||
100
src/components/ai-assistant/blocks.js
Normal file
100
src/components/ai-assistant/blocks.js
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* Response blocks.
|
||||||
|
*
|
||||||
|
* Answers are documents, not strings. A capability decides how its information
|
||||||
|
* is best communicated — a comparison is a table, a health check is a status
|
||||||
|
* list, a funnel is a funnel — and the renderer draws it with design-system
|
||||||
|
* components. That is what turns a reply into a small interactive report rather
|
||||||
|
* than a wall of chat text.
|
||||||
|
*
|
||||||
|
* Blocks are plain data, so they serialize: a future remote service can return
|
||||||
|
* exactly this shape and nothing in the UI changes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Assembles a document, flattening nested arrays and dropping empty slots. */
|
||||||
|
export const doc = (...blocks) => ({
|
||||||
|
blocks: blocks.flat(Infinity).filter(Boolean),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A short paragraph. Supports `**bold**` and `_italic_` inline. */
|
||||||
|
export const text = (value) => value && { type: 'text', text: value };
|
||||||
|
|
||||||
|
/** A section label inside a response. */
|
||||||
|
export const heading = (value, sub) => value && { type: 'heading', text: value, sub };
|
||||||
|
|
||||||
|
/** Headline figures. `items: [{ label, value, delta?, sub?, tone? }]` */
|
||||||
|
export const kpis = (items) => items?.length && { type: 'kpis', items };
|
||||||
|
|
||||||
|
/** Pass/warn checks. `items: [{ label, ok, value?, note? }]` */
|
||||||
|
export const status = (items) => items?.length && { type: 'status', items };
|
||||||
|
|
||||||
|
/** Labelled bars. `items: [{ label, value, weight?, max?, tone? }]` */
|
||||||
|
export const meters = (items) => items?.length && { type: 'meters', items };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A comparison or record table.
|
||||||
|
* `columns: [{ key, label, align?, width? }]`, `rows: [{ ...cells }]`
|
||||||
|
* A cell may be a string, a number, or `{ value, tone, badge }`.
|
||||||
|
*/
|
||||||
|
export const table = (columns, rows, options = {}) =>
|
||||||
|
rows?.length && { type: 'table', columns, rows, ...options };
|
||||||
|
|
||||||
|
/** Stage progression. `steps: [{ label, count, rate?, lost? }]` */
|
||||||
|
export const funnel = (steps) => steps?.length && { type: 'funnel', steps };
|
||||||
|
|
||||||
|
/** Bulleted or numbered points. */
|
||||||
|
export const list = (items, options = {}) =>
|
||||||
|
items?.filter(Boolean).length && { type: 'list', items: items.filter(Boolean), ...options };
|
||||||
|
|
||||||
|
/** Findings. `items: [{ tone, title, body }]` — tone: info|success|warning|risk */
|
||||||
|
export const insights = (items) =>
|
||||||
|
items?.filter(Boolean).length && { type: 'insights', items: items.filter(Boolean) };
|
||||||
|
|
||||||
|
/** Recommended next steps. `items: [{ title, body }]` */
|
||||||
|
export const actions = (items) =>
|
||||||
|
items?.filter(Boolean).length && { type: 'actions', items: items.filter(Boolean) };
|
||||||
|
|
||||||
|
/** Inline tags. `items: [{ label, tone }]` or plain strings. */
|
||||||
|
export const badges = (items) =>
|
||||||
|
items?.filter(Boolean).length && {
|
||||||
|
type: 'badges',
|
||||||
|
items: items.filter(Boolean).map((i) => (typeof i === 'string' ? { label: i } : i)),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Chronology. `items: [{ title, description?, timestamp?, tone?, current? }]` */
|
||||||
|
export const timeline = (items) => items?.length && { type: 'timeline', items };
|
||||||
|
|
||||||
|
/** A caveat. Always the last word on a claim, never the headline. */
|
||||||
|
export const note = (value) => value && { type: 'note', text: value };
|
||||||
|
|
||||||
|
/** A single sentence answer — used by short free-text replies. */
|
||||||
|
export const answer = (value) => doc(text(value));
|
||||||
|
|
||||||
|
/* ── Streaming ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits a document into the progressive snapshots the provider streams.
|
||||||
|
*
|
||||||
|
* Text and heading blocks reveal word by word; structured blocks appear whole,
|
||||||
|
* because a half-drawn table reads as broken rather than as arriving. The result
|
||||||
|
* is a response that composes itself in a readable order.
|
||||||
|
*/
|
||||||
|
export function toSnapshots({ blocks }) {
|
||||||
|
const snapshots = [];
|
||||||
|
const settled = [];
|
||||||
|
|
||||||
|
for (const block of blocks) {
|
||||||
|
if (block.type === 'text' || block.type === 'heading') {
|
||||||
|
const tokens = String(block.text).split(/(\s+)/);
|
||||||
|
// Three words per snapshot: fast enough to feel live, coarse enough to
|
||||||
|
// avoid a reflow per character.
|
||||||
|
for (let i = 6; i < tokens.length; i += 6) {
|
||||||
|
snapshots.push([...settled, { ...block, text: tokens.slice(0, i).join('') }]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
settled.push(block);
|
||||||
|
snapshots.push([...settled]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshots.length ? snapshots : [[]];
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { AlertTriangle, Eye, Gauge, Lightbulb, ScrollText, TrendingUp } from 'lucide-react';
|
import { AlertTriangle, Eye, Gauge, Lightbulb, ScrollText, TrendingUp } from 'lucide-react';
|
||||||
import { bullets, compose, heading, numbered, plural, verb } from '../insights';
|
import { actions, answer, doc, heading, insights, kpis, note, status, table, text } from '../blocks';
|
||||||
|
import { plural, verb } from '../insights';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admin capabilities — Control Center, Candidates Analysis, Activity.
|
* Admin capabilities — Control Center, Candidates Analysis, Activity.
|
||||||
*
|
*
|
||||||
* Admin answers are platform-wide rather than pipeline-specific: data integrity,
|
* Platform-wide rather than pipeline-specific: data integrity, talent supply,
|
||||||
* talent supply, audit reconciliation.
|
* audit reconciliation.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* ── Control Center ─────────────────────────────────────────────────────── */
|
/* ── Control Center ─────────────────────────────────────────────────────── */
|
||||||
@@ -15,148 +16,184 @@ const platformHealth = (f) => {
|
|||||||
{
|
{
|
||||||
label: 'Data integrity',
|
label: 'Data integrity',
|
||||||
ok: f.applications.every((a) => a.job_posting_id),
|
ok: f.applications.every((a) => a.job_posting_id),
|
||||||
good: 'Every application is linked to a live posting.',
|
good: 'Every application links to a live posting.',
|
||||||
bad: 'Some applications reference a posting that no longer exists.',
|
bad: 'Some applications reference a posting that no longer exists.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Screening coverage',
|
label: 'Screening coverage',
|
||||||
|
value: `${f.standardizedPct}%`,
|
||||||
ok: f.standardizedPct >= 80,
|
ok: f.standardizedPct >= 80,
|
||||||
good: `${f.standardizedPct}% of applicants carry a score.`,
|
good: 'Applicants are scored consistently.',
|
||||||
bad: `Only ${f.standardizedPct}% of applicants are scored — ${plural(f.unscreened.length, 'record')} unstandardized.`,
|
bad: `${plural(f.unscreened.length, 'record')} unstandardized.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Interview integrity',
|
label: 'Interview integrity',
|
||||||
ok: f.flagged.length === 0,
|
ok: f.flagged.length === 0,
|
||||||
good: 'No interviews flagged.',
|
good: 'No interviews flagged.',
|
||||||
bad: `${plural(f.flagged.length, 'interview')} flagged below full integrity.`,
|
bad: `${plural(f.flagged.length, 'interview')} below full integrity.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Profile completeness',
|
label: 'Profile completeness',
|
||||||
|
value: `${f.profiles.length - f.unscoredProfiles.length}/${f.profiles.length}`,
|
||||||
ok: f.unscoredProfiles.length <= f.profiles.length / 2,
|
ok: f.unscoredProfiles.length <= f.profiles.length / 2,
|
||||||
good: `${f.profiles.length - f.unscoredProfiles.length} of ${f.profiles.length} talent profiles have a career score.`,
|
good: 'Most talent profiles carry a career score.',
|
||||||
bad: `${plural(f.unscoredProfiles.length, 'profile')} of ${f.profiles.length} ${verb(f.unscoredProfiles.length, 'has', 'have')} no score, so ${verb(f.unscoredProfiles.length, 'it cannot', 'they cannot')} be matched or ranked.`,
|
bad: `${plural(f.unscoredProfiles.length, 'profile')} without a score cannot be matched or ranked.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Throughput',
|
label: 'Throughput',
|
||||||
|
value: `${f.hireRate}%`,
|
||||||
ok: f.hired.length > 0,
|
ok: f.hired.length > 0,
|
||||||
good: `${plural(f.hired.length, 'hire')} closed at a ${f.hireRate}% hire rate.`,
|
good: `${plural(f.hired.length, 'hire')} closed.`,
|
||||||
bad: 'No hires closed yet, so there is no conversion baseline.',
|
bad: 'No hires closed yet, so there is no conversion baseline.',
|
||||||
},
|
},
|
||||||
];
|
].map((c) => ({ label: c.label, value: c.value, ok: c.ok, note: c.ok ? c.good : c.bad }));
|
||||||
|
|
||||||
const passing = checks.filter((c) => c.ok).length;
|
const passing = checks.filter((c) => c.ok).length;
|
||||||
|
|
||||||
return compose(
|
return doc(
|
||||||
heading(`Platform health: ${passing}/${checks.length} checks passing`),
|
heading(`Platform health: ${passing}/${checks.length} passing`),
|
||||||
'',
|
status(checks),
|
||||||
checks.map((c) => `${c.ok ? '✓' : '⚠'} **${c.label}** — ${c.ok ? c.good : c.bad}`).join('\n'),
|
|
||||||
'',
|
|
||||||
heading('Scale'),
|
heading('Scale'),
|
||||||
bullets([
|
kpis([
|
||||||
`${plural(f.postings.length, 'position')}, ${f.openPositions.length} open`,
|
{ label: 'Positions', value: f.postings.length, sub: `${f.openPositions.length} open` },
|
||||||
`${plural(f.total, 'application')}`,
|
{ label: 'Applications', value: f.total },
|
||||||
`${plural(f.interviews.length, 'AI interview')}`,
|
{ label: 'Interviews', value: f.interviews.length, sub: `${f.completedInterviews.length} scored` },
|
||||||
`${plural(f.profiles.length, 'talent profile')}, ${f.elite.length} Elite`,
|
{ label: 'Talent pool', value: f.profiles.length, sub: `${f.elite.length} Elite` },
|
||||||
`${plural(f.activity.length, 'logged event')}`,
|
|
||||||
]),
|
]),
|
||||||
'',
|
text(
|
||||||
passing === checks.length
|
passing === checks.length
|
||||||
? 'Nothing needs intervention at the platform level.'
|
? 'Nothing needs intervention at the platform level.'
|
||||||
: 'The failing checks are data-quality issues, not outages — they degrade matching quality rather than break anything.'
|
: 'The failing checks are data-quality issues, not outages — they degrade matching quality rather than break anything.'
|
||||||
|
)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const recommendations = (f) => {
|
const recommendations = (f) => {
|
||||||
const actions = [];
|
const items = [
|
||||||
|
f.unscoredProfiles.length && {
|
||||||
|
title: `Prompt ${plural(f.unscoredProfiles.length, 'worker')} to finish onboarding`,
|
||||||
|
body: 'Without a career score they are invisible to matching, so employers cannot find them even when they fit.',
|
||||||
|
},
|
||||||
|
f.unscreened.length && {
|
||||||
|
title: `${plural(f.unscreened.length, 'application')} unscored`,
|
||||||
|
body: 'Each one is a decision made without a standard.',
|
||||||
|
},
|
||||||
|
f.starvedPositions.length && {
|
||||||
|
title: `${plural(f.starvedPositions.length, 'posting')} with zero applicants`,
|
||||||
|
body: `${f.starvedPositions.map((p) => p.title).join(', ')}. Check distribution and pay bands.`,
|
||||||
|
},
|
||||||
|
f.unratedStaff.length && {
|
||||||
|
title: `${plural(f.unratedStaff.length, 'hire')} unrated`,
|
||||||
|
body: 'Employer ratings are the only verified performance signal feeding scores; without them the scores stay theoretical.',
|
||||||
|
},
|
||||||
|
f.flagged.length && {
|
||||||
|
title: `Review ${plural(f.flagged.length, 'flagged interview')}`,
|
||||||
|
body: 'Worth clearing before those candidates progress.',
|
||||||
|
},
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
if (f.unscoredProfiles.length) {
|
if (!items.length) return answer('No platform actions outstanding. Data is complete and nothing is flagged.');
|
||||||
actions.push(`**Prompt ${plural(f.unscoredProfiles.length, 'worker')} to finish onboarding.** Without a career score they are invisible to matching, so employers cannot find them even when they fit.`);
|
|
||||||
}
|
|
||||||
if (f.unscreened.length) {
|
|
||||||
actions.push(`**${plural(f.unscreened.length, 'application')} unscored.** Each one is a decision made without a standard.`);
|
|
||||||
}
|
|
||||||
if (f.starvedPositions.length) {
|
|
||||||
actions.push(`**${plural(f.starvedPositions.length, 'posting')} with zero applicants** — ${f.starvedPositions.map((p) => p.title).join(', ')}. Check distribution and pay bands.`);
|
|
||||||
}
|
|
||||||
if (f.unratedStaff.length) {
|
|
||||||
actions.push(`**${plural(f.unratedStaff.length, 'hire')} unrated.** Employer ratings are the only verified performance signal feeding scores; without them the scores stay theoretical.`);
|
|
||||||
}
|
|
||||||
if (f.flagged.length) {
|
|
||||||
actions.push(`**Review ${plural(f.flagged.length, 'flagged interview')}** before those candidates progress.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!actions.length) return 'No platform actions outstanding. Data is complete and nothing is flagged.';
|
return doc(heading('Recommended actions', 'Ordered by impact on matching quality'), actions(items));
|
||||||
|
|
||||||
return compose(heading('Recommended actions'), '', numbered(actions));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ── Candidates Analysis ────────────────────────────────────────────────── */
|
/* ── Candidates Analysis ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
const recruitmentInsights = (f) => {
|
const recruitmentInsights = (f) => {
|
||||||
const bands = {
|
const band = (min, max) => f.profiles.filter((p) => {
|
||||||
Elite: f.profiles.filter((p) => (p.krow_score || 0) >= 90).length,
|
const s = p.krow_score || 0;
|
||||||
Excellent: f.profiles.filter((p) => (p.krow_score || 0) >= 75 && (p.krow_score || 0) < 90).length,
|
return s >= min && (max == null || s < max);
|
||||||
Solid: f.profiles.filter((p) => (p.krow_score || 0) >= 60 && (p.krow_score || 0) < 75).length,
|
}).length;
|
||||||
Building: f.profiles.filter((p) => (p.krow_score || 0) > 0 && (p.krow_score || 0) < 60).length,
|
|
||||||
Unscored: f.unscoredProfiles.length,
|
const bands = [
|
||||||
};
|
{ label: 'Elite', count: band(90), tone: 'info' },
|
||||||
|
{ label: 'Excellent', count: band(75, 90), tone: 'success' },
|
||||||
|
{ label: 'Solid', count: band(60, 75) },
|
||||||
|
{ label: 'Building', count: band(1, 60), tone: 'warning' },
|
||||||
|
{ label: 'Unscored', count: f.unscoredProfiles.length, tone: 'risk' },
|
||||||
|
];
|
||||||
|
|
||||||
const verified = f.profiles.filter((p) => (p.capabilities || []).length).length;
|
const verified = f.profiles.filter((p) => (p.capabilities || []).length).length;
|
||||||
const topHeavy = bands.Elite + bands.Excellent;
|
const topHeavy = band(90) + band(75, 90);
|
||||||
|
|
||||||
return compose(
|
return doc(
|
||||||
heading('Talent supply'),
|
heading('Talent supply', `${plural(f.profiles.length, 'worker')} in the pool`),
|
||||||
'',
|
table(
|
||||||
bullets(Object.entries(bands).map(([band, count]) => `${band} — ${count}`)),
|
[{ key: 'label', label: 'Band' }, { key: 'count', label: 'Workers', align: 'right' }],
|
||||||
'',
|
bands.map((b) => ({ label: b.label, count: { value: b.count, tone: b.tone } }))
|
||||||
|
),
|
||||||
heading('Pipeline'),
|
heading('Pipeline'),
|
||||||
bullets([
|
kpis([
|
||||||
`${plural(f.total, 'applicant')} averaging ${f.avgScore}`,
|
{ label: 'Applicants', value: f.total, sub: `avg ${f.avgScore}` },
|
||||||
`${plural(f.ranked.filter((a) => a.ai_score >= 80).length, 'candidate')} at 80+`,
|
{ label: 'At 80+', value: f.ranked.filter((a) => a.ai_score >= 80).length, tone: 'success' },
|
||||||
`${plural(verified, 'worker')} with at least one verified capability`,
|
{ label: 'Verified skills', value: verified, sub: 'with evidence' },
|
||||||
`${plural(f.eventCounts.apply_job || 0, 'application event')} logged`,
|
{ label: 'Applications', value: f.eventCounts.apply_job || 0, sub: 'logged' },
|
||||||
|
]),
|
||||||
|
insights([
|
||||||
|
f.unscoredProfiles.length > topHeavy
|
||||||
|
? {
|
||||||
|
tone: 'warning',
|
||||||
|
title: 'The pool is bottom-heavy',
|
||||||
|
body: `${f.unscoredProfiles.length} unscored against ${topHeavy} at Excellent or above. Supply is not the constraint — verification is.`,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
tone: 'success',
|
||||||
|
title: 'Verification is keeping pace',
|
||||||
|
body: 'The scored portion of the pool outweighs the unscored.',
|
||||||
|
},
|
||||||
]),
|
]),
|
||||||
'',
|
|
||||||
bands.Unscored > topHeavy
|
|
||||||
? `The pool is bottom-heavy: ${bands.Unscored} unscored against ${topHeavy} at Excellent or above. Supply is not the constraint — verification is.`
|
|
||||||
: 'The scored portion outweighs the unscored, so verification is keeping pace with sign-ups.',
|
|
||||||
'',
|
|
||||||
heading('By role category'),
|
heading('By role category'),
|
||||||
bullets(
|
table(
|
||||||
|
[
|
||||||
|
{ key: 'title', label: 'Role' },
|
||||||
|
{ key: 'applied', label: 'Applied', align: 'right' },
|
||||||
|
{ key: 'qualified', label: '70+', align: 'right' },
|
||||||
|
],
|
||||||
f.byRole.slice().sort((a, b) => b.applied - a.applied)
|
f.byRole.slice().sort((a, b) => b.applied - a.applied)
|
||||||
.map((r) => `${r.title} — ${plural(r.applied, 'applicant')}, ${r.qualified} at 70+`)
|
.map((r) => ({ title: r.title, applied: r.applied, qualified: r.qualified }))
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const candidateRiskAnalysis = (f) => {
|
const candidateRiskAnalysis = (f) => {
|
||||||
const risks = [];
|
|
||||||
|
|
||||||
if (f.missingCredentials.length) {
|
|
||||||
risks.push(`**No credentials on file** — ${plural(f.missingCredentials.length, 'candidate')}. Compliance risk if placed on a role that requires certification.`);
|
|
||||||
}
|
|
||||||
if (f.narrowAvailability.length) {
|
|
||||||
risks.push(`**Narrow availability** — ${plural(f.narrowAvailability.length, 'candidate')} listed one slot or none. Most likely to fall through on the day.`);
|
|
||||||
}
|
|
||||||
if (f.flagged.length) {
|
|
||||||
risks.push(`**Interview integrity** — ${plural(f.flagged.length, 'interview')} below full integrity, usually suspiciously fast responses. Worth a human re-interview.`);
|
|
||||||
}
|
|
||||||
const noHistory = f.scored.filter((a) => !a.years_experience);
|
const noHistory = f.scored.filter((a) => !a.years_experience);
|
||||||
if (noHistory.length) {
|
|
||||||
risks.push(`**No recorded experience** — ${plural(noHistory.length, 'candidate')}. Not disqualifying, but unverifiable on paper.`);
|
const risks = [
|
||||||
}
|
f.missingCredentials.length && {
|
||||||
if (f.singleCandidateRoles.length) {
|
tone: 'risk',
|
||||||
risks.push(`**Concentration risk** — ${f.singleCandidateRoles.map((e) => e.posting.title).join(', ')} ${verb(f.singleCandidateRoles.length, 'depends', 'depend')} on a single viable candidate.`);
|
title: `${plural(f.missingCredentials.length, 'candidate')} with no credentials`,
|
||||||
|
body: 'Compliance risk if placed on a role that requires certification.',
|
||||||
|
},
|
||||||
|
f.narrowAvailability.length && {
|
||||||
|
tone: 'warning',
|
||||||
|
title: `${plural(f.narrowAvailability.length, 'candidate')} with narrow availability`,
|
||||||
|
body: 'One slot or none — most likely to fall through on the day.',
|
||||||
|
},
|
||||||
|
f.flagged.length && {
|
||||||
|
tone: 'risk',
|
||||||
|
title: `${plural(f.flagged.length, 'interview')} flagged for integrity`,
|
||||||
|
body: 'Usually suspiciously fast responses. Worth a human re-interview.',
|
||||||
|
},
|
||||||
|
noHistory.length && {
|
||||||
|
tone: 'warning',
|
||||||
|
title: `${plural(noHistory.length, 'candidate')} with no recorded experience`,
|
||||||
|
body: 'Not disqualifying, but unverifiable on paper.',
|
||||||
|
},
|
||||||
|
f.singleCandidateRoles.length && {
|
||||||
|
tone: 'warning',
|
||||||
|
title: 'Concentration risk',
|
||||||
|
body: `${f.singleCandidateRoles.map((e) => e.posting.title).join(', ')} ${verb(f.singleCandidateRoles.length, 'depends', 'depend')} on a single viable candidate.`,
|
||||||
|
},
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
if (!risks.length) {
|
||||||
|
return answer('No material risk flags: credentials, availability and interview integrity all check out across the scored pool.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!risks.length) return 'No material risk flags: credentials, availability and interview integrity all check out across the scored pool.';
|
return doc(
|
||||||
|
|
||||||
return compose(
|
|
||||||
heading(`${plural(risks.length, 'risk flag')}`),
|
heading(`${plural(risks.length, 'risk flag')}`),
|
||||||
'',
|
insights(risks),
|
||||||
numbered(risks),
|
note('Flags to check, not judgements. Each is a question for a human, not a rejection.')
|
||||||
'',
|
|
||||||
'_Flags to check, not judgements. Each is a question for a human, not a rejection._'
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -166,36 +203,45 @@ const auditSummary = (f) => {
|
|||||||
const count = (type) => f.activity.filter((e) => e.event_type === type).length;
|
const count = (type) => f.activity.filter((e) => e.event_type === type).length;
|
||||||
const hires = count('hire_candidate');
|
const hires = count('hire_candidate');
|
||||||
const screens = count('screen_candidate');
|
const screens = count('screen_candidate');
|
||||||
const creates = count('create_position');
|
|
||||||
|
|
||||||
return compose(
|
return doc(
|
||||||
heading('Audit summary'),
|
heading('Audit summary'),
|
||||||
'',
|
kpis([
|
||||||
bullets([
|
{ label: 'Total events', value: f.activity.length },
|
||||||
`${plural(f.activity.length, 'event')} total`,
|
{ label: 'Last 7 days', value: f.activity7d.length },
|
||||||
`${plural(f.activity7d.length, 'event')} in the last 7 days`,
|
{ label: 'Last 24 hours', value: f.activity24h.length, tone: f.activity24h.length ? undefined : 'warning' },
|
||||||
`${plural(f.activity24h.length, 'event')} in the last 24 hours`,
|
{ label: 'Distinct users', value: new Set(f.activity.map((e) => e.user_email)).size },
|
||||||
`${plural(new Set(f.activity.map((e) => e.user_email)).size, 'distinct user')}`,
|
|
||||||
]),
|
]),
|
||||||
'',
|
|
||||||
heading('Privileged actions'),
|
heading('Privileged actions'),
|
||||||
bullets([
|
table(
|
||||||
`${plural(creates, 'position creation')}`,
|
[{ key: 'action', label: 'Action' }, { key: 'count', label: 'Events', align: 'right' }],
|
||||||
`${plural(screens, 'screening action')}`,
|
[
|
||||||
`${plural(hires, 'hire')}`,
|
{ action: 'Position created', count: count('create_position') },
|
||||||
]),
|
{ action: 'Candidate screened', count: screens },
|
||||||
'',
|
{ action: 'Candidate hired', count: hires },
|
||||||
|
{ action: 'Interview started', count: count('start_interview') },
|
||||||
|
]
|
||||||
|
),
|
||||||
heading('Reconciliation'),
|
heading('Reconciliation'),
|
||||||
bullets([
|
status([
|
||||||
hires === f.staff.length
|
{
|
||||||
? `All ${f.staff.length} people on staff have a matching hire event.`
|
label: 'Hires match staff records',
|
||||||
: `${f.staff.length} on staff but ${hires} hire ${verb(hires, 'event', 'events')} — ${Math.abs(f.staff.length - hires)} unexplained.`,
|
ok: hires === f.staff.length,
|
||||||
screens < f.scored.length
|
value: `${hires}/${f.staff.length}`,
|
||||||
? `${f.scored.length} candidates carry scores but only ${plural(screens, 'screening event')} logged. Batch screening records one event per run, not per candidate.`
|
note: hires === f.staff.length
|
||||||
: 'Screening events reconcile with scored candidates.',
|
? 'Every person on staff has a matching hire event.'
|
||||||
|
: `${Math.abs(f.staff.length - hires)} unexplained.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Screening events match scores',
|
||||||
|
ok: screens >= f.scored.length,
|
||||||
|
value: `${screens}/${f.scored.length}`,
|
||||||
|
note: screens < f.scored.length
|
||||||
|
? 'Batch screening records one event per run, not per candidate.'
|
||||||
|
: 'Screening events reconcile with scored candidates.',
|
||||||
|
},
|
||||||
]),
|
]),
|
||||||
'',
|
note('An audit trail is only as good as its gaps. The reconciliation above is what I would check first in a real review.')
|
||||||
'_An audit trail is only as good as its gaps. The reconciliation above is what I would check first in a real review._'
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -208,32 +254,43 @@ const userActivityInsights = (f) => {
|
|||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
const ranked = Object.entries(perUser).sort((a, b) => b[1].count - a[1].count);
|
const ranked = Object.entries(perUser).sort((a, b) => b[1].count - a[1].count);
|
||||||
if (!ranked.length) return 'No activity to profile yet.';
|
if (!ranked.length) return answer('No activity to profile yet.');
|
||||||
|
|
||||||
const byRole = f.activity.reduce((acc, e) => {
|
|
||||||
const role = e.account_type || 'unknown';
|
|
||||||
acc[role] = (acc[role] || 0) + 1;
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
const [topEmail, top] = ranked[0];
|
const [topEmail, top] = ranked[0];
|
||||||
|
const share = Math.round((top.count / f.activity.length) * 100);
|
||||||
|
|
||||||
return compose(
|
return doc(
|
||||||
heading('Most active accounts'),
|
heading('Most active accounts'),
|
||||||
'',
|
table(
|
||||||
bullets(ranked.slice(0, 6).map(([email, d]) =>
|
[
|
||||||
`**${d.name || email}** (${d.role || 'unknown'}) — ${plural(d.count, 'event')} across ${plural(d.types.size, 'action type')}`
|
{ key: 'name', label: 'Account' },
|
||||||
)),
|
{ key: 'role', label: 'Role' },
|
||||||
'',
|
{ key: 'events', label: 'Events', align: 'right' },
|
||||||
heading('By role'),
|
],
|
||||||
bullets(Object.entries(byRole).sort((a, b) => b[1] - a[1])
|
ranked.slice(0, 6).map(([email, d]) => ({
|
||||||
.map(([role, c]) => `${role.charAt(0).toUpperCase() + role.slice(1)} — ${plural(c, 'event')}`)),
|
name: d.name || email,
|
||||||
'',
|
role: { badge: d.role || 'unknown', tone: d.role === 'employer' ? 'info' : 'neutral' },
|
||||||
`${top.name || topEmail} accounts for ${top.count} of ${f.activity.length} events. On a live platform, one account dominating the log is worth confirming is a person and not an integration.`,
|
events: { value: d.count, tone: d.count === top.count ? 'info' : undefined },
|
||||||
'',
|
}))
|
||||||
f.activity24h.length === 0
|
),
|
||||||
? 'No activity in the last day. Expected for a demo dataset; on a live platform it would be worth checking ingestion.'
|
insights([
|
||||||
: 'Activity is flowing normally.'
|
share >= 50
|
||||||
|
? {
|
||||||
|
tone: 'warning',
|
||||||
|
title: `One account is ${share}% of all activity`,
|
||||||
|
body: `${top.name || topEmail} accounts for ${top.count} of ${f.activity.length} events. On a live platform, that concentration is worth confirming is a person and not an integration.`,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
tone: 'success',
|
||||||
|
title: 'Activity is spread across accounts',
|
||||||
|
body: `No single account dominates the log — the busiest is ${share}%.`,
|
||||||
|
},
|
||||||
|
f.activity24h.length === 0 && {
|
||||||
|
tone: 'warning',
|
||||||
|
title: 'Nothing in the last 24 hours',
|
||||||
|
body: 'Expected for a demo dataset; on a live platform it would be worth checking ingestion.',
|
||||||
|
},
|
||||||
|
])
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -244,6 +301,7 @@ const has = (q, ...words) => words.some((w) => q.includes(w));
|
|||||||
export const respondControlCenter = (question, f) => {
|
export const respondControlCenter = (question, f) => {
|
||||||
const q = question.toLowerCase();
|
const q = question.toLowerCase();
|
||||||
if (has(q, 'recommend', 'should', 'action', 'fix', 'improve')) return recommendations(f);
|
if (has(q, 'recommend', 'should', 'action', 'fix', 'improve')) return recommendations(f);
|
||||||
|
if (has(q, 'unusual', 'anomal', 'suspicious')) return userActivityInsights(f);
|
||||||
return platformHealth(f);
|
return platformHealth(f);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -255,7 +313,7 @@ export const respondAdminCandidates = (question, f) => {
|
|||||||
|
|
||||||
export const respondActivity = (question, f) => {
|
export const respondActivity = (question, f) => {
|
||||||
const q = question.toLowerCase();
|
const q = question.toLowerCase();
|
||||||
if (has(q, 'user', 'who', 'active', 'behaviour', 'behavior', 'account')) return userActivityInsights(f);
|
if (has(q, 'user', 'who', 'active', 'behaviour', 'behavior', 'account', 'unusual')) return userActivityInsights(f);
|
||||||
return auditSummary(f);
|
return auditSummary(f);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,36 +1,47 @@
|
|||||||
import {
|
import {
|
||||||
AlertTriangle, BarChart3, ClipboardCheck, FileSpreadsheet, FileText, GitCompare,
|
AlertTriangle, BarChart3, ClipboardCheck, FileSpreadsheet, FileText, GitCompare,
|
||||||
HeartPulse, LineChart, ListChecks, MessageSquareQuote, Sparkles, Target, Layers,
|
HeartPulse, Layers, LineChart, ListChecks, MessageSquareQuote, Sparkles, Target,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { bullets, compose, heading, numbered, plural, verb } from '../insights';
|
import {
|
||||||
|
actions, answer, badges, doc, funnel, heading, insights, kpis, list, meters, note,
|
||||||
|
status, table, text,
|
||||||
|
} from '../blocks';
|
||||||
|
import { plural, verb } from '../insights';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Employer capabilities — Overview, Candidates, Analytics.
|
* Employer capabilities — Overview, Candidates, Analytics.
|
||||||
*
|
*
|
||||||
* Each capability is a pure function of the fact sheet. Keeping them pure is
|
* Each returns a block document rather than prose. The shape is chosen by what
|
||||||
* what allows the same code to run against local data today and against a
|
* the information is: a comparison is a table, a health check is a status list,
|
||||||
* remote AI service later without the UI changing.
|
* a pipeline is a funnel. Prose is reserved for the judgement a table cannot
|
||||||
|
* carry.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* ── Overview ───────────────────────────────────────────────────────────── */
|
/* ── Overview ───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
const hiringSummary = (f) => {
|
const hiringSummary = (f) => {
|
||||||
if (!f.total) return 'Nothing in the pipeline yet. Publish a position and I will start tracking it here.';
|
if (!f.total) return answer('Nothing in the pipeline yet. Publish a position and I will start tracking it.');
|
||||||
|
|
||||||
return compose(
|
return doc(
|
||||||
heading('Where hiring stands'),
|
kpis([
|
||||||
bullets([
|
{ label: 'Applicants', value: f.total, sub: `${f.openPositions.length} open roles` },
|
||||||
`${plural(f.total, 'applicant')} across ${plural(f.openPositions.length, 'open position')}`,
|
{ label: 'Screened', value: f.screened.length, sub: `${f.standardizedPct}% coverage`, tone: f.standardizedPct >= 80 ? 'success' : 'warning' },
|
||||||
`${f.screened.length} screened · ${f.unscreened.length} awaiting a score`,
|
{ label: 'Interviewing', value: f.interviewing.length },
|
||||||
`${plural(f.interviewing.length, 'candidate')} mid-interview`,
|
{ label: 'Hired', value: f.hired.length, sub: f.hiredAvgScore ? `avg ${f.hiredAvgScore}/100` : undefined, tone: 'info' },
|
||||||
`${plural(f.hired.length, 'hire')} averaging ${f.hiredAvgScore}/100`,
|
|
||||||
]),
|
]),
|
||||||
'',
|
heading('Pipeline'),
|
||||||
f.stalled.length
|
funnel(f.funnel.map((stage, i) => ({
|
||||||
? `Most urgent: ${plural(f.stalled.length, 'candidate')} scored 80+ and ${verb(f.stalled.length, 'is', 'are')} still sitting at AI Screened.`
|
label: stage.label,
|
||||||
: f.unscreened.length
|
count: stage.count,
|
||||||
? `Most urgent: ${plural(f.unscreened.length, 'applicant')} ${verb(f.unscreened.length, 'has', 'have')} never been scored.`
|
rate: i === 0 ? undefined : f.transitions[i - 1]?.rate,
|
||||||
: 'Nothing is stuck — every applicant is screened and every strong candidate has been actioned.'
|
}))),
|
||||||
|
text(
|
||||||
|
f.stalled.length
|
||||||
|
? `The first thing I would fix: ${plural(f.stalled.length, 'candidate')} scored 80+ and ${verb(f.stalled.length, 'is', 'are')} still at AI Screened.`
|
||||||
|
: f.unscreened.length
|
||||||
|
? `The first thing I would fix: ${plural(f.unscreened.length, 'applicant')} ${verb(f.unscreened.length, 'has', 'have')} never been scored.`
|
||||||
|
: 'Nothing is stuck — every applicant is screened and every strong candidate has been actioned.'
|
||||||
|
)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -41,7 +52,7 @@ const hiringHealth = (f) => {
|
|||||||
value: `${f.standardizedPct}%`,
|
value: `${f.standardizedPct}%`,
|
||||||
ok: f.standardizedPct >= 80,
|
ok: f.standardizedPct >= 80,
|
||||||
note: f.standardizedPct >= 80
|
note: f.standardizedPct >= 80
|
||||||
? 'Applicants are being scored consistently.'
|
? 'Applicants are scored consistently.'
|
||||||
: `${plural(f.unscreened.length, 'applicant')} never got a score, so those decisions are unstandardized.`,
|
: `${plural(f.unscreened.length, 'applicant')} never got a score, so those decisions are unstandardized.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -72,148 +83,201 @@ const hiringHealth = (f) => {
|
|||||||
|
|
||||||
const passing = checks.filter((c) => c.ok).length;
|
const passing = checks.filter((c) => c.ok).length;
|
||||||
|
|
||||||
return compose(
|
return doc(
|
||||||
heading(`${passing} of ${checks.length} signals healthy`),
|
heading(`${passing} of ${checks.length} signals healthy`),
|
||||||
'',
|
status(checks),
|
||||||
checks.map((c) => `${c.ok ? '✓' : '⚠'} **${c.label}** — ${c.value}\n ${c.note}`).join('\n\n')
|
note('Thresholds are event-staffing norms, not universal benchmarks.')
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const buildActions = (f) => [
|
||||||
|
f.stalled.length && {
|
||||||
|
title: 'Move your 80+ candidates',
|
||||||
|
body: `${f.stalled.slice(0, 3).map((a) => `**${a.applicant_name}** (${a.ai_score})`).join(', ')} ${verb(f.stalled.length, 'is', 'are')} screened and waiting on you.`,
|
||||||
|
},
|
||||||
|
f.unscreened.length && {
|
||||||
|
title: `Screen ${plural(f.unscreened.length, 'applicant')}`,
|
||||||
|
body: 'One pass clears the backlog and costs nothing.',
|
||||||
|
},
|
||||||
|
f.starvedPositions.length && {
|
||||||
|
title: 'Fix the postings nobody applies to',
|
||||||
|
body: `${f.starvedPositions.map((p) => p.title).join(' and ')} — usually pay range or reach, not the description.`,
|
||||||
|
},
|
||||||
|
f.unratedStaff.length && {
|
||||||
|
title: `Rate ${plural(f.unratedStaff.length, 'hire')}`,
|
||||||
|
body: 'Ratings feed KROW scores and sharpen future matching.',
|
||||||
|
},
|
||||||
|
f.elite.length && {
|
||||||
|
title: 'Reach out to the pool directly',
|
||||||
|
body: `${f.elite.map((p) => p.full_name).join(', ')} ${verb(f.elite.length, 'is', 'are')} Elite and ${verb(f.elite.length, 'has', 'have')} not applied to anything.`,
|
||||||
|
},
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
const pendingActions = (f) => {
|
const pendingActions = (f) => {
|
||||||
const actions = [];
|
const items = buildActions(f);
|
||||||
|
if (!items.length) {
|
||||||
if (f.stalled.length) {
|
return answer('Nothing pending. Everyone is screened, strong candidates are actioned, and every open role has applicants.');
|
||||||
actions.push(`**Move your 80+ candidates.** ${f.stalled.slice(0, 3).map((a) => `${a.applicant_name} (${a.ai_score})`).join(', ')} ${verb(f.stalled.length, 'is', 'are')} screened and waiting on you.`);
|
|
||||||
}
|
}
|
||||||
if (f.unscreened.length) {
|
return doc(heading('Pending actions', 'Ordered by what costs you most'), actions(items.slice(0, 4)));
|
||||||
actions.push(`**Screen the backlog.** ${plural(f.unscreened.length, 'applicant')} ${verb(f.unscreened.length, 'has', 'have')} no score. One pass clears it.`);
|
|
||||||
}
|
|
||||||
if (f.starvedPositions.length) {
|
|
||||||
actions.push(`**Fix the postings nobody applies to.** ${f.starvedPositions.map((p) => p.title).join(' and ')} — usually pay range or reach, not the description.`);
|
|
||||||
}
|
|
||||||
if (f.unratedStaff.length) {
|
|
||||||
actions.push(`**Rate your hires.** ${plural(f.unratedStaff.length, 'person', 'people')} ${verb(f.unratedStaff.length, 'has', 'have')} no rating. Ratings sharpen future matching.`);
|
|
||||||
}
|
|
||||||
if (f.elite.length) {
|
|
||||||
actions.push(`**Reach out directly.** ${f.elite.map((p) => p.full_name).join(', ')} ${verb(f.elite.length, 'is', 'are')} Elite and ${verb(f.elite.length, 'has', 'have')} not applied to anything.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!actions.length) return 'Nothing pending. Everyone is screened, strong candidates are actioned, and every open role has applicants.';
|
|
||||||
|
|
||||||
return compose(heading('Pending actions'), '', numbered(actions.slice(0, 4)));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const hiringRisks = (f) => {
|
const hiringRisks = (f) => {
|
||||||
const risks = [];
|
const risks = [
|
||||||
|
f.singleCandidateRoles.length && {
|
||||||
|
tone: 'risk',
|
||||||
|
title: 'Single-candidate roles',
|
||||||
|
body: `${f.singleCandidateRoles.map((e) => e.posting.title).join(', ')} ${verb(f.singleCandidateRoles.length, 'rests', 'rest')} on one viable candidate. If they withdraw, the role reopens from zero.`,
|
||||||
|
},
|
||||||
|
f.stalled.length && {
|
||||||
|
tone: 'warning',
|
||||||
|
title: 'Losing strong candidates to delay',
|
||||||
|
body: `${plural(f.stalled.length, 'candidate')} at 80+ screened but not moved. This is where good hires quietly disappear.`,
|
||||||
|
},
|
||||||
|
f.missingCredentials.length && {
|
||||||
|
tone: 'risk',
|
||||||
|
title: 'Compliance exposure',
|
||||||
|
body: `${plural(f.missingCredentials.length, 'scored candidate')} ${verb(f.missingCredentials.length, 'has', 'have')} no certification on file. Placing them on a role that requires one is a real liability.`,
|
||||||
|
},
|
||||||
|
f.narrowAvailability.length && {
|
||||||
|
tone: 'warning',
|
||||||
|
title: 'Availability risk',
|
||||||
|
body: `${plural(f.narrowAvailability.length, 'candidate')} listed one slot or none — most likely to fall through on the day.`,
|
||||||
|
},
|
||||||
|
f.starvedPositions.length && {
|
||||||
|
tone: 'warning',
|
||||||
|
title: 'Unfillable as posted',
|
||||||
|
body: `${plural(f.starvedPositions.length, 'open role')} with zero applicants after going live.`,
|
||||||
|
},
|
||||||
|
f.flagged.length && {
|
||||||
|
tone: 'risk',
|
||||||
|
title: 'Interview integrity',
|
||||||
|
body: `${plural(f.flagged.length, 'interview')} scored below full integrity. Worth a human re-interview before progressing.`,
|
||||||
|
},
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
if (f.singleCandidateRoles.length) {
|
if (!risks.length) {
|
||||||
risks.push(`**Single-candidate roles.** ${f.singleCandidateRoles.map((e) => e.posting.title).join(', ')} ${verb(f.singleCandidateRoles.length, 'rests', 'rest')} on one viable candidate. If they withdraw, the role reopens from zero.`);
|
return answer('No material risks: coverage is good, no role depends on a single candidate, and credentials check out.');
|
||||||
}
|
|
||||||
if (f.stalled.length) {
|
|
||||||
risks.push(`**Losing strong candidates to delay.** ${plural(f.stalled.length, 'candidate')} at 80+ ${verb(f.stalled.length, 'has', 'have')} been screened but not moved. This is where good hires quietly disappear.`);
|
|
||||||
}
|
|
||||||
if (f.missingCredentials.length) {
|
|
||||||
risks.push(`**Compliance exposure.** ${plural(f.missingCredentials.length, 'scored candidate')} ${verb(f.missingCredentials.length, 'has', 'have')} no certification on file. Placing them on a role that requires one is a real liability.`);
|
|
||||||
}
|
|
||||||
if (f.narrowAvailability.length) {
|
|
||||||
risks.push(`**Availability risk.** ${plural(f.narrowAvailability.length, 'candidate')} listed one slot or none. ${verb(f.narrowAvailability.length, 'This is the placement', 'These are the placements')} most likely to fall through on the day.`);
|
|
||||||
}
|
|
||||||
if (f.starvedPositions.length) {
|
|
||||||
risks.push(`**Unfillable as posted.** ${plural(f.starvedPositions.length, 'open role')} with zero applicants after being live.`);
|
|
||||||
}
|
|
||||||
if (f.flagged.length) {
|
|
||||||
risks.push(`**Interview integrity.** ${plural(f.flagged.length, 'interview')} scored below full integrity. Worth a human re-interview before progressing.`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!risks.length) return 'No material risks in the current pipeline: coverage is good, no role depends on a single candidate, and credentials check out.';
|
return doc(
|
||||||
|
|
||||||
return compose(
|
|
||||||
heading(`${plural(risks.length, 'risk')} to watch`),
|
heading(`${plural(risks.length, 'risk')} to watch`),
|
||||||
'',
|
insights(risks),
|
||||||
numbered(risks),
|
note('Flags to check, not conclusions. Each is a question for a human.')
|
||||||
'',
|
|
||||||
'_Flags to check, not conclusions. Each is a question for a human._'
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ── Candidates ─────────────────────────────────────────────────────────── */
|
/* ── Candidates ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
const resumeAnalysis = (f) => {
|
const DIMENSIONS = ['experience', 'english', 'reliability', 'certifications', 'availability'];
|
||||||
const subject = f.top[0];
|
|
||||||
if (!subject) return 'No scored candidates yet. Screen the pipeline and I will analyse the strongest.';
|
|
||||||
|
|
||||||
return compose(
|
const resumeAnalysis = (f) => {
|
||||||
heading(`${subject.applicant_name} — ${subject.ai_score}/100`),
|
const c = f.top[0];
|
||||||
`${subject.job_title} · ${plural(subject.years_experience, 'year')} experience · ${subject.english_level} English`,
|
if (!c) return answer('No scored candidates yet. Screen the pipeline and I will analyse the strongest.');
|
||||||
'',
|
|
||||||
subject.ai_summary || '',
|
return doc(
|
||||||
'',
|
heading(c.applicant_name, `${c.job_title} · ${plural(c.years_experience, 'year')} · ${c.english_level} English`),
|
||||||
subject.ai_strengths?.length ? compose(heading('What holds up'), bullets(subject.ai_strengths)) : '',
|
kpis([
|
||||||
'',
|
{ label: 'AI score', value: `${c.ai_score}`, tone: c.ai_score >= 80 ? 'success' : c.ai_score >= 60 ? 'info' : 'warning' },
|
||||||
subject.ai_gaps?.length ? compose(heading('What to probe'), bullets(subject.ai_gaps)) : '',
|
{ label: 'Verdict', value: c.ai_match_label || '—' },
|
||||||
'',
|
]),
|
||||||
subject.certifications?.length
|
c.ai_summary && text(c.ai_summary),
|
||||||
? `**Credentials:** ${subject.certifications.join(', ')} — verify before the first shift. A lapsed card is the most common surprise.`
|
c.certifications?.length
|
||||||
: '**No credentials on file.** Request these before scheduling.'
|
? [heading('Credentials'), badges(c.certifications.map((label) => ({ label, tone: 'success' })))]
|
||||||
|
: [heading('Credentials'), badges([{ label: 'None on file', tone: 'risk' }])],
|
||||||
|
heading('Score breakdown'),
|
||||||
|
meters(DIMENSIONS.map((d) => ({ label: d[0].toUpperCase() + d.slice(1), value: c.score_breakdown?.[d] ?? 0 }))),
|
||||||
|
c.ai_strengths?.length && [heading('Strengths'), list(c.ai_strengths)],
|
||||||
|
c.ai_gaps?.length && [heading('Probe these'), list(c.ai_gaps)],
|
||||||
|
note(
|
||||||
|
c.certifications?.length
|
||||||
|
? 'Verify credentials before the first shift — a lapsed card is the most common surprise.'
|
||||||
|
: 'Request credentials before scheduling.'
|
||||||
|
)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const candidateComparison = (f) => {
|
const candidateComparison = (f) => {
|
||||||
const pair = f.ranked.slice(0, 2);
|
const [a, b] = f.ranked;
|
||||||
if (pair.length < 2) return 'I need at least two scored candidates to compare.';
|
if (!a || !b) return answer('I need at least two scored candidates to compare.');
|
||||||
|
|
||||||
const [a, b] = pair;
|
|
||||||
const gap = a.ai_score - b.ai_score;
|
const gap = a.ai_score - b.ai_score;
|
||||||
const dims = ['experience', 'english', 'reliability', 'certifications', 'availability'];
|
const first = (n) => n.split(' ')[0];
|
||||||
|
|
||||||
const rows = dims.map((dim) => {
|
const rows = DIMENSIONS.map((d) => {
|
||||||
const av = a.score_breakdown?.[dim] ?? 0;
|
const av = a.score_breakdown?.[d] ?? 0;
|
||||||
const bv = b.score_breakdown?.[dim] ?? 0;
|
const bv = b.score_breakdown?.[d] ?? 0;
|
||||||
const lead = av === bv ? 'tied' : (av > bv ? a : b).applicant_name.split(' ')[0];
|
return {
|
||||||
return `${dim.charAt(0).toUpperCase() + dim.slice(1)} — ${av} vs ${bv} · ${lead}`;
|
dimension: d[0].toUpperCase() + d.slice(1),
|
||||||
|
a: { value: av, tone: av > bv ? 'success' : av < bv ? 'neutral' : undefined },
|
||||||
|
b: { value: bv, tone: bv > av ? 'success' : bv < av ? 'neutral' : undefined },
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const biggest = dims.reduce((best, d) =>
|
const biggest = DIMENSIONS.reduce((best, d) =>
|
||||||
Math.abs((a.score_breakdown?.[d] ?? 0) - (b.score_breakdown?.[d] ?? 0)) >
|
Math.abs((a.score_breakdown?.[d] ?? 0) - (b.score_breakdown?.[d] ?? 0)) >
|
||||||
Math.abs((a.score_breakdown?.[best] ?? 0) - (b.score_breakdown?.[best] ?? 0)) ? d : best, dims[0]);
|
Math.abs((a.score_breakdown?.[best] ?? 0) - (b.score_breakdown?.[best] ?? 0)) ? d : best, DIMENSIONS[0]);
|
||||||
|
|
||||||
return compose(
|
return doc(
|
||||||
heading(`${a.applicant_name} (${a.ai_score}) vs ${b.applicant_name} (${b.ai_score})`),
|
heading(`${a.applicant_name} vs ${b.applicant_name}`),
|
||||||
'',
|
table(
|
||||||
bullets(rows),
|
[
|
||||||
'',
|
{ key: 'dimension', label: '' },
|
||||||
gap <= 3
|
{ key: 'a', label: first(a.applicant_name), align: 'right' },
|
||||||
? `Effectively tied — ${plural(gap, 'point')} apart is inside the noise. Decide on availability and the interview, not the score.`
|
{ key: 'b', label: first(b.applicant_name), align: 'right' },
|
||||||
: `${a.applicant_name} leads by ${gap}, driven mostly by ${biggest}.`
|
],
|
||||||
|
[
|
||||||
|
{ dimension: 'Overall', a: { value: a.ai_score, tone: 'info' }, b: { value: b.ai_score, tone: 'info' } },
|
||||||
|
...rows,
|
||||||
|
],
|
||||||
|
{ caption: 'Score comparison by dimension' }
|
||||||
|
),
|
||||||
|
heading('Where each one wins'),
|
||||||
|
insights([
|
||||||
|
{
|
||||||
|
tone: 'success',
|
||||||
|
title: `${first(a.applicant_name)} leads on ${biggest}`,
|
||||||
|
body: a.ai_strengths?.[0] || `Scores ${a.score_breakdown?.[biggest] ?? 0} against ${b.score_breakdown?.[biggest] ?? 0}.`,
|
||||||
|
},
|
||||||
|
b.ai_strengths?.[0] && {
|
||||||
|
tone: 'info',
|
||||||
|
title: `${first(b.applicant_name)} brings`,
|
||||||
|
body: b.ai_strengths[0],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
text(
|
||||||
|
gap <= 3
|
||||||
|
? `Effectively tied — ${plural(gap, 'point')} apart is inside the noise. Decide on availability and the interview, not the score.`
|
||||||
|
: `${a.applicant_name} leads by ${gap} points, driven mostly by ${biggest}.`
|
||||||
|
),
|
||||||
|
note('Recommendation only. Neither has been met in person.')
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const interviewQuestions = (f) => {
|
const interviewQuestions = (f) => {
|
||||||
const subject = f.stalled[0] || f.ranked[0];
|
const c = f.stalled[0] || f.ranked[0];
|
||||||
if (!subject) return 'Screen a candidate and I will build questions around their specific gaps.';
|
if (!c) return answer('Screen a candidate and I will build questions around their specific gaps.');
|
||||||
|
|
||||||
const gaps = subject.ai_gaps || [];
|
const gaps = c.ai_gaps || [];
|
||||||
|
|
||||||
return compose(
|
return doc(
|
||||||
heading(`Questions for ${subject.applicant_name}`),
|
heading(`Questions for ${c.applicant_name}`, 'Built from their gaps, not a generic list'),
|
||||||
'Built from their gaps rather than a generic list, so the interview tests what the score could not.',
|
list([
|
||||||
'',
|
`Walk me through your busiest ${c.job_title?.toLowerCase() || 'shift'}. What happened, and what did you decide?`,
|
||||||
numbered([
|
|
||||||
`Walk me through your busiest ${subject.job_title?.toLowerCase() || 'shift'}. What happened, and what did you decide?`,
|
|
||||||
gaps[0]
|
gaps[0]
|
||||||
? `I noticed ${gaps[0].toLowerCase()}. How have you handled that in practice?`
|
? `I noticed ${gaps[0].toLowerCase()}. How have you handled that in practice?`
|
||||||
: 'Tell me about a shift that went wrong. What was your part in it?',
|
: 'Tell me about a shift that went wrong. What was your part in it?',
|
||||||
'Something breaks mid-service and your lead is unreachable. What are your next three moves?',
|
'Something breaks mid-service and your lead is unreachable. What are your next three moves?',
|
||||||
subject.certifications?.length
|
c.certifications?.length
|
||||||
? `Your ${subject.certifications[0]} is on file — when did you last apply it on a live shift?`
|
? `Your ${c.certifications[0]} is on file — when did you last apply it on a live shift?`
|
||||||
: 'Which certifications are you working toward, and why that one?',
|
: 'Which certifications are you working toward, and why that one?',
|
||||||
'What does your real availability look like over the next eight weeks?',
|
'What does your real availability look like over the next eight weeks?',
|
||||||
])
|
], { ordered: true }),
|
||||||
|
note('The scenario question is the one that separates candidates. Let them think.')
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const skillGapAnalysis = (f) => {
|
const skillGapAnalysis = (f) => {
|
||||||
if (!f.scored.length) return 'No scored candidates yet, so there are no gaps to analyse.';
|
if (!f.scored.length) return answer('No scored candidates yet, so there are no gaps to analyse.');
|
||||||
|
|
||||||
const tally = {};
|
const tally = {};
|
||||||
f.scored.forEach((a) => (a.ai_gaps || []).forEach((gap) => {
|
f.scored.forEach((a) => (a.ai_gaps || []).forEach((gap) => {
|
||||||
@@ -222,54 +286,86 @@ const skillGapAnalysis = (f) => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const ranked = Object.entries(tally).sort((a, b) => b[1] - a[1]).slice(0, 5);
|
const ranked = Object.entries(tally).sort((a, b) => b[1] - a[1]).slice(0, 5);
|
||||||
if (!ranked.length) return 'No recurring gaps across your scored candidates — the pool matches your postings well.';
|
if (!ranked.length) return answer('No recurring gaps — the pool matches your postings well.');
|
||||||
|
|
||||||
return compose(
|
return doc(
|
||||||
heading('Recurring gaps'),
|
heading('Recurring gaps', `Across ${plural(f.scored.length, 'scored candidate')}`),
|
||||||
'',
|
table(
|
||||||
bullets(ranked.map(([gap, count]) => `${gap} — ${count} of ${f.scored.length}`)),
|
[{ key: 'gap', label: 'Gap' }, { key: 'count', label: 'Candidates', align: 'right' }],
|
||||||
'',
|
ranked.map(([gap, count]) => ({
|
||||||
f.missingCredentials.length >= 2
|
gap,
|
||||||
? `${f.missingCredentials.length} candidates are short a required credential. That is usually a posting problem: if the certification is trainable, requiring it up front filters out people you could hire this week.`
|
count: { value: `${count}/${f.scored.length}`, tone: count > f.scored.length / 2 ? 'risk' : undefined },
|
||||||
: 'Gaps are spread thin, which means your requirements are well matched to who is applying.'
|
}))
|
||||||
|
),
|
||||||
|
text(
|
||||||
|
f.missingCredentials.length >= 2
|
||||||
|
? `${f.missingCredentials.length} candidates are short a required credential. That is usually a posting problem: if the certification is trainable, requiring it up front filters out people you could hire this week.`
|
||||||
|
: 'Gaps are spread thin, which means your requirements are well matched to who is applying.'
|
||||||
|
)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const hiringRecommendation = (f) => {
|
const hiringRecommendation = (f) => {
|
||||||
if (!f.scored.length) return 'Nothing scored yet, so I have no basis for a recommendation.';
|
if (!f.scored.length) return answer('Nothing scored yet, so I have no basis for a recommendation.');
|
||||||
|
|
||||||
const open = (a) => !['hired', 'rejected'].includes(a.status);
|
const open = (a) => !['hired', 'rejected'].includes(a.status);
|
||||||
const shortlist = f.ranked.filter((a) => a.ai_score >= 80 && open(a));
|
const groups = [
|
||||||
const interview = f.ranked.filter((a) => a.ai_score >= 60 && a.ai_score < 80 && open(a));
|
{ verdict: 'Shortlist', tone: 'success', people: f.ranked.filter((a) => a.ai_score >= 80 && open(a)) },
|
||||||
const pass = f.weak.filter(open);
|
{ verdict: 'Interview', tone: 'info', people: f.ranked.filter((a) => a.ai_score >= 60 && a.ai_score < 80 && open(a)) },
|
||||||
|
{ verdict: 'Likely pass', tone: 'risk', people: f.weak.filter(open) },
|
||||||
|
].filter((g) => g.people.length);
|
||||||
|
|
||||||
return compose(
|
if (!groups.length) return answer('Every scored candidate has already been actioned.');
|
||||||
|
|
||||||
|
return doc(
|
||||||
heading('Recommendation'),
|
heading('Recommendation'),
|
||||||
'',
|
table(
|
||||||
shortlist.length
|
[
|
||||||
? `**Shortlist** — ${shortlist.map((a) => `${a.applicant_name} (${a.ai_score})`).join(', ')}`
|
{ key: 'name', label: 'Candidate' },
|
||||||
: '**Shortlist** — nobody currently clears 80.',
|
{ key: 'score', label: 'Score', align: 'right' },
|
||||||
'',
|
{ key: 'verdict', label: 'Verdict', align: 'right' },
|
||||||
interview.length ? `**Interview** — ${interview.map((a) => `${a.applicant_name} (${a.ai_score})`).join(', ')}` : '',
|
],
|
||||||
'',
|
groups.flatMap((g) => g.people.map((p) => ({
|
||||||
pass.length ? `**Likely pass** — ${pass.map((a) => `${a.applicant_name} (${a.ai_score})`).join(', ')}` : '',
|
name: p.applicant_name,
|
||||||
'',
|
score: { value: p.ai_score, tone: g.tone },
|
||||||
'_A recommendation, not a decision. I score what is on file — I have not met these people._'
|
verdict: { badge: g.verdict, tone: g.tone },
|
||||||
|
})))
|
||||||
|
),
|
||||||
|
note('A recommendation, not a decision. I score what is on file — I have not met these people.')
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ── Analytics ──────────────────────────────────────────────────────────── */
|
/* ── Analytics ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
const explainCharts = (f) => compose(
|
const explainCharts = (f) => doc(
|
||||||
heading('What these charts are saying'),
|
heading('What these charts are saying'),
|
||||||
'',
|
insights([
|
||||||
`**Pipeline Stages** — ${f.total} applied, ${f.screened.length} screened, ${f.hired.length} hired. Bars are cumulative, so "AI Screened" includes everyone who moved past it.`,
|
{
|
||||||
'',
|
tone: 'info',
|
||||||
`**Candidate Scores** — ${plural(f.scored.length, 'candidate')} scored, averaging ${f.avgScore}. The 80–100 bucket holds ${f.ranked.filter((a) => a.ai_score >= 80).length}; that bucket is your real hiring pool.`,
|
title: 'Pipeline Stages',
|
||||||
'',
|
body: `${f.total} applied, ${f.screened.length} screened, ${f.hired.length} hired. Bars are cumulative, so "AI Screened" includes everyone who moved past it.`,
|
||||||
`**Interview Completion** — ${f.completedInterviews.length} of ${f.interviews.length} fully scored (${f.interviewCompletion}%). Unscored means started and abandoned.`,
|
},
|
||||||
'',
|
{
|
||||||
`**Cost Saved** — $${f.costSaved.toLocaleString()}, from ${plural(f.scored.length, 'screen')} at $45 and ${plural(f.interviews.length, 'interview')} at $80. Industry per-unit costs for doing it manually, so treat it as a floor.`
|
tone: 'info',
|
||||||
|
title: 'Candidate Scores',
|
||||||
|
body: `${plural(f.scored.length, 'candidate')} scored, averaging ${f.avgScore}. The 80–100 bucket holds ${f.ranked.filter((a) => a.ai_score >= 80).length} — that bucket is your real hiring pool.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tone: f.interviewCompletion >= 70 ? 'success' : 'warning',
|
||||||
|
title: 'Interview Completion',
|
||||||
|
body: `${f.completedInterviews.length} of ${f.interviews.length} fully scored (${f.interviewCompletion}%). Unscored means started and abandoned.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tone: 'success',
|
||||||
|
title: 'Cost Saved',
|
||||||
|
body: `$${f.costSaved.toLocaleString()} from ${plural(f.scored.length, 'screen')} at $45 and ${plural(f.interviews.length, 'interview')} at $80 — industry per-unit costs for doing it manually, so treat it as a floor.`,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
f.bottleneck && [
|
||||||
|
heading('Biggest drop-off'),
|
||||||
|
funnel(f.transitions.map((t) => ({ label: `${t.from} → ${t.to}`, count: t.rate, rate: t.rate }))),
|
||||||
|
text(`Most people are lost between **${f.bottleneck.from}** and **${f.bottleneck.to}** — only ${f.bottleneck.rate}% pass through, costing ${plural(f.bottleneck.lost, 'candidate')}.`),
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const forecastHiring = (f) => {
|
const forecastHiring = (f) => {
|
||||||
@@ -278,124 +374,150 @@ const forecastHiring = (f) => {
|
|||||||
const expected = Math.round((available.length * conversion) / 100) || (available.length >= 3 ? 1 : 0);
|
const expected = Math.round((available.length * conversion) / 100) || (available.length >= 3 ? 1 : 0);
|
||||||
const short = f.openPositions.length - expected;
|
const short = f.openPositions.length - expected;
|
||||||
|
|
||||||
return compose(
|
return doc(
|
||||||
heading('Forecast'),
|
heading('Forecast'),
|
||||||
'',
|
kpis([
|
||||||
bullets([
|
{ label: 'In play at 60+', value: available.length },
|
||||||
`${plural(available.length, 'candidate')} at 60+ still in play`,
|
{ label: 'Projected hires', value: expected, tone: short > 0 ? 'warning' : 'success' },
|
||||||
`At your ${conversion}% hire rate that projects to about ${plural(expected, 'hire')}`,
|
{ label: 'Open roles', value: f.openPositions.length },
|
||||||
f.timeToHire ? `At a ${f.timeToHire}-day average, those land within a week of you deciding` : 'No time-to-hire baseline yet',
|
{ label: 'Conversion', value: `${conversion}%` },
|
||||||
`${plural(f.openPositions.length, 'open position')} to fill`,
|
|
||||||
]),
|
]),
|
||||||
'',
|
|
||||||
short > 0
|
short > 0
|
||||||
? `That leaves you roughly ${plural(short, 'role')} short. Two ways to close it: screen the ${f.unscreened.length} unscored applicants, or source directly from the ${plural(f.profiles.length, 'profile')} in the talent pool.`
|
? insights([{
|
||||||
: 'That covers your open roles, assuming the strong candidates do not go elsewhere first.',
|
tone: 'warning',
|
||||||
'',
|
title: `Roughly ${plural(short, 'role')} short`,
|
||||||
'_Projected from your own conversion rate, not a promise — small pools move a lot._'
|
body: `Two ways to close it: screen the ${f.unscreened.length} unscored applicants, or source directly from the ${plural(f.profiles.length, 'profile')} in the talent pool.`,
|
||||||
|
}])
|
||||||
|
: insights([{
|
||||||
|
tone: 'success',
|
||||||
|
title: 'Current pool covers your open roles',
|
||||||
|
body: 'Assuming the strong candidates do not go elsewhere first.',
|
||||||
|
}]),
|
||||||
|
note('Projected from your own conversion rate, not a promise — small pools move a lot.')
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const departmentInsights = (f) => {
|
const departmentInsights = (f) => {
|
||||||
if (!f.byRole.length) return 'No open positions to break down.';
|
if (!f.byRole.length) return answer('No open positions to break down.');
|
||||||
|
|
||||||
const byCategory = {};
|
const byCategory = {};
|
||||||
f.byRole.forEach((r) => {
|
f.byRole.forEach((r) => {
|
||||||
byCategory[r.category] ||= { applied: 0, hired: 0, qualified: 0, roles: [] };
|
byCategory[r.category] ||= { applied: 0, hired: 0, qualified: 0, roles: 0 };
|
||||||
byCategory[r.category].applied += r.applied;
|
byCategory[r.category].applied += r.applied;
|
||||||
byCategory[r.category].hired += r.hired;
|
byCategory[r.category].hired += r.hired;
|
||||||
byCategory[r.category].qualified += r.qualified;
|
byCategory[r.category].qualified += r.qualified;
|
||||||
byCategory[r.category].roles.push(r.title);
|
byCategory[r.category].roles += 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
const entries = Object.entries(byCategory).sort((a, b) => b[1].applied - a[1].applied);
|
const entries = Object.entries(byCategory).sort((a, b) => b[1].applied - a[1].applied);
|
||||||
const starved = entries.filter(([, d]) => d.applied === 0);
|
const starved = entries.filter(([, d]) => d.applied === 0);
|
||||||
|
|
||||||
return compose(
|
return doc(
|
||||||
heading('By role category'),
|
heading('By role category'),
|
||||||
'',
|
table(
|
||||||
entries.map(([category, d]) =>
|
[
|
||||||
`**${category}** — ${plural(d.applied, 'applicant')}, ${d.qualified} at 70+, ${plural(d.hired, 'hire')} across ${plural(d.roles.length, 'role')}`
|
{ key: 'category', label: 'Category' },
|
||||||
).join('\n'),
|
{ key: 'applied', label: 'Applied', align: 'right' },
|
||||||
'',
|
{ key: 'qualified', label: '70+', align: 'right' },
|
||||||
starved.length
|
{ key: 'hired', label: 'Hired', align: 'right' },
|
||||||
? `${starved.map(([c]) => c).join(' and ')} ${verb(starved.length, 'is', 'are')} attracting nobody. Compare the pay range against the categories that are filling.`
|
],
|
||||||
: 'Every category has applicants, so your reach is working across the board.'
|
entries.map(([category, d]) => ({
|
||||||
|
category,
|
||||||
|
applied: { value: d.applied, tone: d.applied === 0 ? 'risk' : undefined },
|
||||||
|
qualified: d.qualified,
|
||||||
|
hired: d.hired,
|
||||||
|
}))
|
||||||
|
),
|
||||||
|
text(
|
||||||
|
starved.length
|
||||||
|
? `**${starved.map(([c]) => c).join(' and ')}** ${verb(starved.length, 'is', 'are')} attracting nobody. Compare the pay range against the categories that are filling.`
|
||||||
|
: 'Every category has applicants, so your reach is working across the board.'
|
||||||
|
)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateReports = (f) => {
|
const generateReports = (f) => doc(
|
||||||
const stamp = f.today.toISOString().slice(0, 10);
|
// Local date, not toISOString — UTC would show yesterday for anyone behind it.
|
||||||
|
heading('Hiring report', f.today.toLocaleDateString(undefined, {
|
||||||
return compose(
|
year: 'numeric', month: 'long', day: 'numeric',
|
||||||
heading(`Hiring report — ${stamp}`),
|
})),
|
||||||
'',
|
kpis([
|
||||||
heading('Volume'),
|
{ label: 'Applicants', value: f.total },
|
||||||
bullets([
|
{ label: 'Screened', value: `${f.standardizedPct}%` },
|
||||||
`${plural(f.total, 'applicant')} received`,
|
{ label: 'Hires', value: f.hired.length },
|
||||||
`${f.screened.length} screened (${f.standardizedPct}% coverage)`,
|
{ label: 'Hire rate', value: `${f.hireRate}%` },
|
||||||
`${plural(f.interviews.length, 'AI interview')} conducted, ${f.completedInterviews.length} scored`,
|
{ label: 'Avg hire score', value: f.hiredAvgScore || '—', tone: 'success' },
|
||||||
`${plural(f.hired.length, 'hire')} closed`,
|
{ label: 'Time to hire', value: f.timeToHire ? `${f.timeToHire}d` : '—' },
|
||||||
]),
|
]),
|
||||||
'',
|
heading('Funnel'),
|
||||||
heading('Quality'),
|
funnel(f.funnel.map((s, i) => ({ label: s.label, count: s.count, rate: i === 0 ? undefined : f.transitions[i - 1]?.rate }))),
|
||||||
bullets([
|
heading('By role'),
|
||||||
`Average candidate score ${f.avgScore}`,
|
table(
|
||||||
`Average hire score ${f.hiredAvgScore}`,
|
[
|
||||||
`Hire rate ${f.hireRate}%`,
|
{ key: 'title', label: 'Role' },
|
||||||
`Average time-to-hire ${f.timeToHire ? `${f.timeToHire} days` : 'no baseline'}`,
|
{ key: 'applied', label: 'Applied', align: 'right' },
|
||||||
]),
|
{ key: 'qualified', label: '70+', align: 'right' },
|
||||||
'',
|
{ key: 'hired', label: 'Hired', align: 'right' },
|
||||||
heading('Efficiency'),
|
],
|
||||||
bullets([
|
f.byRole.map((r) => ({ title: r.title, applied: r.applied, qualified: r.qualified, hired: r.hired }))
|
||||||
`$${f.costSaved.toLocaleString()} saved against manual screening and interviewing`,
|
),
|
||||||
`≈${f.hoursSaved} recruiter hours returned`,
|
heading('Efficiency'),
|
||||||
]),
|
kpis([
|
||||||
'',
|
{ label: 'Cost saved', value: `$${f.costSaved.toLocaleString()}`, tone: 'success' },
|
||||||
heading('By role'),
|
{ label: 'Recruiter hours', value: `${f.hoursSaved}h`, tone: 'success' },
|
||||||
bullets(f.byRole.map((r) => `${r.title} — ${r.applied} applied, ${r.qualified} at 70+, ${r.hired} hired`)),
|
]),
|
||||||
'',
|
heading('Headline finding'),
|
||||||
|
insights([
|
||||||
f.bottleneck
|
f.bottleneck
|
||||||
? `**Headline finding.** The ${f.bottleneck.from} → ${f.bottleneck.to} step passes only ${f.bottleneck.rate}%, losing ${plural(f.bottleneck.lost, 'candidate')}. That is the single biggest recoverable loss in the funnel.`
|
? {
|
||||||
: '**Headline finding.** The funnel is passing through evenly with no single bottleneck.',
|
tone: 'warning',
|
||||||
'',
|
title: `${f.bottleneck.from} → ${f.bottleneck.to} passes only ${f.bottleneck.rate}%`,
|
||||||
'_Copy this into your own template — export to PDF and CSV is not wired up in this build._'
|
body: `Losing ${plural(f.bottleneck.lost, 'candidate')} — the single biggest recoverable loss in the funnel.`,
|
||||||
);
|
}
|
||||||
};
|
: { tone: 'success', title: 'No single bottleneck', body: 'The funnel is passing through evenly.' },
|
||||||
|
]),
|
||||||
|
buildActions(f).length && [heading('Recommended actions'), actions(buildActions(f).slice(0, 3))],
|
||||||
|
note('Copy this into your own template — PDF and CSV export are not wired up in this build.')
|
||||||
|
);
|
||||||
|
|
||||||
/* ── Free-text responders ───────────────────────────────────────────────── */
|
/* ── Free-text responders ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
const has = (q, ...words) => words.some((w) => q.includes(w));
|
const has = (q, ...words) => words.some((w) => q.includes(w));
|
||||||
|
|
||||||
const stateOfPlay = (f) =>
|
const stateOfPlay = (f) => doc(
|
||||||
`I do not have a specific read on that. What I can see: ${plural(f.total, 'applicant')} across ${plural(f.openPositions.length, 'open position')}, ${f.screened.length} screened, ${plural(f.interviewing.length, 'candidate')} interviewing, ${plural(f.hired.length, 'hire')}. Ask about any of those and I will go deeper.`;
|
text('I do not have a specific read on that. Here is where things stand:'),
|
||||||
|
kpis([
|
||||||
|
{ label: 'Applicants', value: f.total },
|
||||||
|
{ label: 'Screened', value: f.screened.length },
|
||||||
|
{ label: 'Interviewing', value: f.interviewing.length },
|
||||||
|
{ label: 'Hired', value: f.hired.length },
|
||||||
|
]),
|
||||||
|
text('Ask me about any of those and I will go deeper.')
|
||||||
|
);
|
||||||
|
|
||||||
const bottleneckAnswer = (f) => {
|
const bottleneckAnswer = (f) => {
|
||||||
if (!f.bottleneck) return 'Not enough pipeline movement yet to locate a bottleneck.';
|
if (!f.bottleneck) return answer('Not enough pipeline movement yet to locate a bottleneck.');
|
||||||
const b = f.bottleneck;
|
const b = f.bottleneck;
|
||||||
const diagnosis = {
|
const diagnosis = {
|
||||||
'AI Screened': `${plural(f.unscreened.length, 'applicant')} ${verb(f.unscreened.length, 'has', 'have')} never been scored — the cheapest gap in the funnel to close.`,
|
'AI Screened': `${plural(f.unscreened.length, 'applicant')} ${verb(f.unscreened.length, 'has', 'have')} never been scored — the cheapest gap to close.`,
|
||||||
Shortlisted: 'Candidates are screened but not shortlisted. Either the scores are not being read, or the bar is above what the pool can deliver.',
|
Shortlisted: 'Candidates are screened but not shortlisted. Either the scores are not being read, or the bar is above what the pool can deliver.',
|
||||||
Interviewed: 'Shortlisted candidates are not reaching interview — usually scheduling friction rather than a decision.',
|
Interviewed: 'Shortlisted candidates are not reaching interview — usually scheduling friction rather than a decision.',
|
||||||
Hired: 'Interviews happen but offers are not closing. Check pay against market and how long the decision takes.',
|
Hired: 'Interviews happen but offers are not closing. Check pay against market and how long the decision takes.',
|
||||||
}[b.to];
|
}[b.to];
|
||||||
|
|
||||||
return compose(
|
return doc(
|
||||||
heading(`Bottleneck: ${b.from} → ${b.to}`),
|
heading(`Bottleneck: ${b.from} → ${b.to}`, `${b.rate}% pass through`),
|
||||||
`Only ${b.rate}% pass through, losing ${plural(b.lost, 'candidate')}.`,
|
funnel(f.transitions.map((t) => ({ label: `${t.from} → ${t.to}`, count: t.rate, rate: t.rate }))),
|
||||||
'',
|
insights([{ tone: 'warning', title: `Losing ${plural(b.lost, 'candidate')} here`, body: diagnosis }])
|
||||||
diagnosis || '',
|
|
||||||
'',
|
|
||||||
bullets(f.transitions.map((t) => `${t.from} → ${t.to}: ${t.rate}%${t.lost ? ` (−${t.lost})` : ''}`))
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const respondOverview = (question, f) => {
|
export const respondOverview = (question, f) => {
|
||||||
const q = question.toLowerCase();
|
const q = question.toLowerCase();
|
||||||
if (has(q, 'risk', 'exposure', 'worry', 'concern')) return hiringRisks(f);
|
if (has(q, 'risk', 'exposure', 'worry', 'concern')) return hiringRisks(f);
|
||||||
if (has(q, 'attention', 'urgent', 'priorit', 'pending', 'what should i', 'next')) return pendingActions(f);
|
if (has(q, 'attention', 'urgent', 'priorit', 'pending', 'what should i', 'next', 'slow')) return pendingActions(f);
|
||||||
if (has(q, 'health', 'how are we', 'how is hiring', 'doing')) return hiringHealth(f);
|
if (has(q, 'health', 'how are we', 'how is hiring', 'doing')) return hiringHealth(f);
|
||||||
if (has(q, 'summary', 'today', 'brief', 'catch me up', 'overview')) return hiringSummary(f);
|
if (has(q, 'summar', 'today', 'brief', 'catch me up', 'overview', 'morning', 'afternoon', 'evening')) return hiringSummary(f);
|
||||||
if (has(q, 'pipeline', 'funnel', 'stuck', 'bottleneck', 'drop')) return bottleneckAnswer(f);
|
if (has(q, 'pipeline', 'funnel', 'stuck', 'bottleneck', 'drop')) return bottleneckAnswer(f);
|
||||||
if (has(q, 'position', 'posting', 'role', 'opening')) return departmentInsights(f);
|
if (has(q, 'position', 'posting', 'role', 'opening')) return departmentInsights(f);
|
||||||
return stateOfPlay(f);
|
return stateOfPlay(f);
|
||||||
@@ -403,36 +525,44 @@ export const respondOverview = (question, f) => {
|
|||||||
|
|
||||||
export const respondCandidates = (question, f) => {
|
export const respondCandidates = (question, f) => {
|
||||||
const q = question.toLowerCase();
|
const q = question.toLowerCase();
|
||||||
if (has(q, 'resume', 'résumé', 'cv', 'analyse', 'analyze')) return resumeAnalysis(f);
|
if (has(q, 'compare', 'versus', ' vs ', 'between', '&')) return candidateComparison(f);
|
||||||
if (has(q, 'compare', 'versus', ' vs ', 'between')) return candidateComparison(f);
|
|
||||||
if (has(q, 'question', 'ask them', 'interview prep')) return interviewQuestions(f);
|
if (has(q, 'question', 'ask them', 'interview prep')) return interviewQuestions(f);
|
||||||
if (has(q, 'gap', 'missing', 'weakness', 'skill')) return skillGapAnalysis(f);
|
if (has(q, 'gap', 'missing', 'weakness', 'skill')) return skillGapAnalysis(f);
|
||||||
if (has(q, 'recommend', 'should i hire', 'who should', 'shortlist', 'pass', 'interview next')) return hiringRecommendation(f);
|
if (has(q, 'recommend', 'should i hire', 'who should', 'shortlist', 'pass', 'interview next')) return hiringRecommendation(f);
|
||||||
|
if (has(q, 'resume', 'résumé', 'cv', 'analyse', 'analyze', 'review')) return resumeAnalysis(f);
|
||||||
if (has(q, 'unscreened', 'not screened', 'pending')) {
|
if (has(q, 'unscreened', 'not screened', 'pending')) {
|
||||||
return f.unscreened.length
|
return f.unscreened.length
|
||||||
? compose(
|
? doc(
|
||||||
heading(`${plural(f.unscreened.length, 'applicant')} unscreened`),
|
heading(`${plural(f.unscreened.length, 'applicant')} unscreened`),
|
||||||
'',
|
table(
|
||||||
bullets(f.unscreened.slice(0, 8).map((a) => `${a.applicant_name} — ${a.job_title}`)),
|
[{ key: 'name', label: 'Candidate' }, { key: 'role', label: 'Applied for' }],
|
||||||
f.unscreened.length > 8 ? `…and ${f.unscreened.length - 8} more` : ''
|
f.unscreened.slice(0, 8).map((a) => ({ name: a.applicant_name, role: a.job_title }))
|
||||||
|
),
|
||||||
|
f.unscreened.length > 8 && text(`…and ${f.unscreened.length - 8} more.`)
|
||||||
)
|
)
|
||||||
: 'Everyone has been screened.';
|
: answer('Everyone has been screened.');
|
||||||
}
|
}
|
||||||
return stateOfPlay(f);
|
return stateOfPlay(f);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const respondAnalytics = (question, f) => {
|
export const respondAnalytics = (question, f) => {
|
||||||
const q = question.toLowerCase();
|
const q = question.toLowerCase();
|
||||||
|
if (has(q, 'forecast', 'predict', 'projection', 'will i', 'expect')) return forecastHiring(f);
|
||||||
|
if (has(q, 'report', 'export', 'executive', 'download')) return generateReports(f);
|
||||||
|
if (has(q, 'bottleneck', 'drop', 'stuck', 'lose', 'why do')) return bottleneckAnswer(f);
|
||||||
|
if (has(q, 'department', 'category', 'role', 'team', 'break down')) return departmentInsights(f);
|
||||||
if (has(q, 'explain', 'what does', 'mean', 'chart', 'graph')) return explainCharts(f);
|
if (has(q, 'explain', 'what does', 'mean', 'chart', 'graph')) return explainCharts(f);
|
||||||
if (has(q, 'forecast', 'predict', 'projection', 'will i')) return forecastHiring(f);
|
|
||||||
if (has(q, 'department', 'category', 'role', 'team')) return departmentInsights(f);
|
|
||||||
if (has(q, 'report', 'export', 'summar', 'download')) return generateReports(f);
|
|
||||||
if (has(q, 'bottleneck', 'drop', 'stuck', 'funnel', 'lose')) return bottleneckAnswer(f);
|
|
||||||
if (has(q, 'hire rate', 'conversion')) {
|
if (has(q, 'hire rate', 'conversion')) {
|
||||||
return `${f.hireRate}% — ${plural(f.hired.length, 'hire')} from ${plural(f.total, 'applicant')}. Healthy for event staffing; the norm sits between 2% and 8% because most applicants are never properly screened.`;
|
return answer(`${f.hireRate}% — ${plural(f.hired.length, 'hire')} from ${plural(f.total, 'applicant')}. Healthy for event staffing; the norm sits between 2% and 8% because most applicants are never properly screened.`);
|
||||||
}
|
}
|
||||||
if (has(q, 'cost', 'saved', 'roi', 'money')) {
|
if (has(q, 'cost', 'saved', 'roi', 'money')) {
|
||||||
return `$${f.costSaved.toLocaleString()} — ${plural(f.scored.length, 'AI screen')} at the $45 a manual screen costs, plus ${plural(f.interviews.length, 'automated interview')} at $80 each. Also about ${f.hoursSaved} recruiter hours you did not spend.`;
|
return doc(
|
||||||
|
kpis([
|
||||||
|
{ label: 'Cost saved', value: `$${f.costSaved.toLocaleString()}`, tone: 'success' },
|
||||||
|
{ label: 'Hours returned', value: `${f.hoursSaved}h`, tone: 'success' },
|
||||||
|
]),
|
||||||
|
text(`From ${plural(f.scored.length, 'AI screen')} at the $45 a manual screen costs, plus ${plural(f.interviews.length, 'automated interview')} at $80 each.`)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return stateOfPlay(f);
|
return stateOfPlay(f);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { BookOpen, Compass, FileText } from 'lucide-react';
|
import { BookOpen, Compass, FileText } from 'lucide-react';
|
||||||
import { bullets, compose, heading, numbered, plural } from '../insights';
|
import { actions, answer, badges, doc, heading, insights, kpis, meters, note, status, table, text } from '../blocks';
|
||||||
|
import { plural } from '../insights';
|
||||||
import { getScoreBand, toFICO } from '@/lib/talentHome';
|
import { getScoreBand, toFICO } from '@/lib/talentHome';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,121 +17,165 @@ const NO_PROFILE = 'I do not have your profile yet. Once it exists I can give yo
|
|||||||
|
|
||||||
const resumeReview = (f) => {
|
const resumeReview = (f) => {
|
||||||
const p = f.profile;
|
const p = f.profile;
|
||||||
if (!p) return NO_PROFILE;
|
if (!p) return answer(NO_PROFILE);
|
||||||
|
|
||||||
const missing = [];
|
const fields = [
|
||||||
if (!p.phone) missing.push('phone number');
|
{ label: 'Photo', ok: Boolean(p.selfie_url), note: 'The KROW ID replaces a résumé — a profile with a face gets opened more.' },
|
||||||
if (!p.address) missing.push('location');
|
{ label: 'Availability', ok: (p.availability || []).length > 0, note: 'Employers filter on this before they read anything else.' },
|
||||||
if (!(p.skills || []).length) missing.push('skills');
|
{ label: 'Certifications', ok: (p.certifications || []).length > 0, note: 'A Food Handler Card is the cheapest credential in this industry.' },
|
||||||
if (!(p.experience || []).length) missing.push('work history');
|
{ label: 'Skills', ok: (p.skills || []).length > 0, note: 'Without these you do not appear in skill searches.' },
|
||||||
if (!(p.certifications || []).length) missing.push('certifications');
|
{ label: 'Work history', ok: (p.experience || []).length > 0, note: 'Even one role gives employers something to anchor on.' },
|
||||||
if (!p.career_goals) missing.push('career goals');
|
{ label: 'Career goals', ok: Boolean(p.career_goals), note: 'Lets matching put you in front of the right roles, not every role.' },
|
||||||
if (!(p.availability || []).length) missing.push('availability');
|
{ label: 'Verified evidence', ok: (p.capabilities || []).length > 0, note: 'The only part of your profile an employer does not have to take on trust.' },
|
||||||
if (!p.selfie_url) missing.push('photo');
|
];
|
||||||
|
|
||||||
const present = [];
|
const complete = fields.filter((x) => x.ok).length;
|
||||||
if ((p.experience || []).length) present.push(`${plural(p.experience.length, 'role')} of work history`);
|
|
||||||
if ((p.skills || []).length) present.push(`${plural(p.skills.length, 'skill')} listed`);
|
|
||||||
if ((p.certifications || []).length) present.push(p.certifications.join(', '));
|
|
||||||
if ((p.capabilities || []).length) present.push(`${plural(p.capabilities.length, 'capability')} verified by evidence`);
|
|
||||||
|
|
||||||
return compose(
|
return doc(
|
||||||
heading(`Your profile is ${p.profile_completion || 0}% complete`),
|
heading(`Your profile is ${p.profile_completion || 0}% complete`, `${complete} of ${fields.length} things employers look for`),
|
||||||
'',
|
status(fields.map((x) => ({ label: x.label, ok: x.ok, note: x.ok ? undefined : x.note }))),
|
||||||
present.length ? compose(heading('What is working'), bullets(present)) : '',
|
(p.certifications || []).length && [
|
||||||
'',
|
heading('Credentials on file'),
|
||||||
missing.length
|
badges(p.certifications.map((label) => ({ label, tone: 'success' }))),
|
||||||
? compose(
|
],
|
||||||
heading('What is missing'),
|
text(
|
||||||
bullets(missing),
|
complete === fields.length
|
||||||
'',
|
? 'Nothing is missing. From here, more verified evidence is what moves you — not more fields.'
|
||||||
'Employers filter on availability and certifications before they read anything else. If you fix two things, fix those.'
|
: 'If you fix two things, fix availability and certifications. Those are the two employers filter on first.'
|
||||||
)
|
),
|
||||||
: compose(heading('Nothing is missing'), 'From here, more verified evidence moves you — not more fields.'),
|
note(
|
||||||
'',
|
(p.capabilities || []).length
|
||||||
(p.capabilities || []).length
|
? 'Your verified capabilities came with evidence attached, not a checkbox. That is why they carry weight.'
|
||||||
? '_Your verified capabilities are the part employers actually trust — those came with evidence attached, not a checkbox._'
|
: 'Nothing is verified yet. One completed challenge is worth more than ten claimed skills.'
|
||||||
: '_Nothing is verified yet. One completed challenge is worth more than ten claimed skills._'
|
)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const careerGuidance = (f) => {
|
const careerGuidance = (f) => {
|
||||||
const p = f.profile;
|
const p = f.profile;
|
||||||
if (!p) return NO_PROFILE;
|
if (!p) return answer(NO_PROFILE);
|
||||||
|
|
||||||
const score = scoreOf(p);
|
const score = scoreOf(p);
|
||||||
const band = getScoreBand(score);
|
const band = getScoreBand(score);
|
||||||
const target = score >= 90 ? null : score >= 75 ? 90 : score >= 60 ? 75 : 60;
|
const target = score >= 90 ? null : score >= 75 ? 90 : score >= 60 ? 75 : 60;
|
||||||
|
|
||||||
return compose(
|
return doc(
|
||||||
heading(`You are at ${toFICO(score)} — ${band.label}`),
|
heading(`${toFICO(score)} — ${band.label}`, 'Career score, on a 300–850 scale'),
|
||||||
'',
|
kpis([
|
||||||
bullets([
|
{ label: 'Reliability', value: p.reliability_score || 0, tone: (p.reliability_score || 0) >= 70 ? 'success' : 'warning' },
|
||||||
`Reliability ${p.reliability_score || 0}/100`,
|
{ label: 'Attendance', value: p.attendance_score ?? 0, tone: (p.attendance_score ?? 0) >= 90 ? 'success' : undefined },
|
||||||
`Attendance ${p.attendance_score ?? 0}/100`,
|
{ label: 'Shifts worked', value: p.shifts_completed || 0, tone: (p.shifts_completed || 0) === 0 ? 'warning' : undefined },
|
||||||
`${plural((p.completed_courses || []).length, 'course')} completed, ${plural((p.earned_badges || []).length, 'badge')} earned`,
|
{ label: 'Verified skills', value: (p.capabilities || []).length },
|
||||||
`${plural((p.capabilities || []).length, 'verified capability')}`,
|
|
||||||
`${plural(p.shifts_completed || 0, 'shift')} worked`,
|
|
||||||
]),
|
]),
|
||||||
'',
|
heading("What's driving it"),
|
||||||
target
|
meters([
|
||||||
? `**To reach ${target}:** verified work is the fastest lever. Courses move the score; verified shifts move it more, because employers weight what someone else confirmed over what you claimed.`
|
{ label: 'Reliability', value: p.reliability_score || 0, weight: 25 },
|
||||||
: '**You are in the top band.** From here it is about holding it — the score decays if shifts stop.',
|
{ label: 'Attendance', value: p.attendance_score ?? 0, weight: 20 },
|
||||||
'',
|
{ label: 'Communication', value: p.performance_score || 0, weight: 15 },
|
||||||
p.desired_position
|
{ label: 'Leadership', value: p.leadership_potential || 0, weight: 15 },
|
||||||
? `You want to be ${p.desired_position}. ${score >= 75 ? 'You are already scoring in the range employers shortlist for that.' : 'The gap to that role is verification, not ambition.'}`
|
{ label: 'Learning', value: Math.min(100, (p.completed_courses || []).length * 8 + (p.xp || 0) / 10), weight: 15 },
|
||||||
: 'You have not set a target role. Setting one lets me tell you which courses matter and which are noise.',
|
{ label: 'Problem solving', value: p.ai_interview_score || 0, weight: 10 },
|
||||||
'',
|
]),
|
||||||
(p.shifts_completed || 0) === 0
|
insights([
|
||||||
? '**One concrete step:** work a single shift. Attendance, ratings and reliability all need a first data point before anything else compounds.'
|
target
|
||||||
: ''
|
? {
|
||||||
|
tone: 'info',
|
||||||
|
title: `To reach ${target}`,
|
||||||
|
body: 'Verified work is the fastest lever. Courses move the score; verified shifts move it more, because employers weight what someone else confirmed over what you claimed.',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
tone: 'success',
|
||||||
|
title: 'You are in the top band',
|
||||||
|
body: 'From here it is about holding it — the score decays if shifts stop.',
|
||||||
|
},
|
||||||
|
(p.shifts_completed || 0) === 0 && {
|
||||||
|
tone: 'warning',
|
||||||
|
title: 'Work one shift',
|
||||||
|
body: 'Attendance, ratings and reliability all need a first data point before anything else compounds.',
|
||||||
|
},
|
||||||
|
p.desired_position
|
||||||
|
? {
|
||||||
|
tone: score >= 75 ? 'success' : 'info',
|
||||||
|
title: `Aiming for ${p.desired_position}`,
|
||||||
|
body: score >= 75
|
||||||
|
? 'You are already scoring in the range employers shortlist for that.'
|
||||||
|
: 'The gap to that role is verification, not ambition.',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
tone: 'info',
|
||||||
|
title: 'No target role set',
|
||||||
|
body: 'Setting one lets me tell you which courses matter and which are noise.',
|
||||||
|
},
|
||||||
|
])
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const learningRecommendations = (f) => {
|
const learningRecommendations = (f) => {
|
||||||
const p = f.profile;
|
const p = f.profile;
|
||||||
if (!p) return NO_PROFILE;
|
if (!p) return answer(NO_PROFILE);
|
||||||
|
|
||||||
const completedIds = new Set((p.completed_courses || []).map((c) => c.course_id));
|
const completedIds = new Set((p.completed_courses || []).map((c) => c.course_id));
|
||||||
const done = (p.completed_courses || []).length;
|
const done = (p.completed_courses || []).length;
|
||||||
|
const shifts = p.shifts_completed || 0;
|
||||||
const available = (f.courses || []).filter((c) => c.status === 'active' && !completedIds.has(c.id));
|
const available = (f.courses || []).filter((c) => c.status === 'active' && !completedIds.has(c.id));
|
||||||
|
|
||||||
if (!available.length) {
|
if (!available.length) {
|
||||||
return compose(
|
return doc(
|
||||||
heading('You have completed everything available'),
|
heading('You have completed everything available'),
|
||||||
`${plural(done, 'course')} done, ${plural((p.earned_badges || []).length, 'badge')} earned, ${p.xp || 0} XP.`,
|
kpis([
|
||||||
'',
|
{ label: 'Courses', value: done, tone: 'success' },
|
||||||
'The next lever is verified shifts — that is what employers weight most.'
|
{ label: 'Badges', value: (p.earned_badges || []).length, tone: 'success' },
|
||||||
|
{ label: 'XP', value: p.xp || 0 },
|
||||||
|
{ label: 'Verified', value: (p.capabilities || []).length },
|
||||||
|
]),
|
||||||
|
text('The next lever is verified shifts — that is what employers weight most.')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const byValue = [...available].sort((a, b) => (b.xp || 0) - (a.xp || 0));
|
const ranked = [...available].sort((a, b) => (b.xp || 0) - (a.xp || 0));
|
||||||
const shifts = p.shifts_completed || 0;
|
|
||||||
|
|
||||||
return compose(
|
return doc(
|
||||||
heading('What to learn next'),
|
heading('What to learn next', 'Ranked by what moves your score most'),
|
||||||
'',
|
table(
|
||||||
numbered(byValue.slice(0, 3).map((c) => {
|
[
|
||||||
const gate = c.unlock_requirements?.min_shifts || 0;
|
{ key: 'title', label: 'Course' },
|
||||||
const locked = gate > shifts;
|
{ key: 'minutes', label: 'Time', align: 'right' },
|
||||||
return `**${c.title}** — ${c.difficulty}, ${c.estimated_minutes} min, +${c.xp} XP\n Proves: ${c.proof_skill || c.title}.${locked ? ` Locked until ${plural(gate, 'shift')} (you have ${shifts}).` : ' Available now.'}`;
|
{ key: 'xp', label: 'XP', align: 'right' },
|
||||||
})),
|
],
|
||||||
'',
|
ranked.slice(0, 4).map((c) => {
|
||||||
|
const gate = c.unlock_requirements?.min_shifts || 0;
|
||||||
|
const locked = gate > shifts;
|
||||||
|
return {
|
||||||
|
title: c.title,
|
||||||
|
minutes: `${c.estimated_minutes}m`,
|
||||||
|
xp: locked
|
||||||
|
? { badge: `${gate} shifts`, tone: 'warning' }
|
||||||
|
: { value: `+${c.xp}`, tone: 'success' },
|
||||||
|
};
|
||||||
|
})
|
||||||
|
),
|
||||||
heading('Where you stand'),
|
heading('Where you stand'),
|
||||||
bullets([
|
kpis([
|
||||||
`${plural(done, 'course')} completed`,
|
{ label: 'Courses done', value: done },
|
||||||
`${plural((p.earned_badges || []).length, 'badge')} earned`,
|
{ label: 'Badges', value: (p.earned_badges || []).length },
|
||||||
`${p.xp || 0} XP`,
|
{ label: 'XP', value: p.xp || 0 },
|
||||||
`${plural((p.capabilities || []).length, 'capability')} verified`,
|
{ label: 'Verified skills', value: (p.capabilities || []).length },
|
||||||
]),
|
]),
|
||||||
'',
|
actions([
|
||||||
done === 0
|
{
|
||||||
? 'Start with the shortest one. The point of the first course is not the XP — it is getting one verified capability on the board.'
|
title: done === 0 ? `Start with ${ranked[ranked.length - 1].title}` : `Take ${ranked[0].title}`,
|
||||||
: done < 3
|
body: done === 0
|
||||||
? `Three completions is where the score starts moving noticeably, because education carries real weight in the formula. You are at ${done}.`
|
? 'The point of the first course is not the XP — it is getting one verified capability on the board.'
|
||||||
: 'You are past the easy gains. Advanced courses are worth double the XP and unlock supervisor-tier roles.',
|
: done < 3
|
||||||
'',
|
? `Three completions is where the score starts moving noticeably. You are at ${done}.`
|
||||||
'Each of these ends in a challenge, not a quiz — you demonstrate the skill and the evidence gets scored. That is why the badge means something to an employer.'
|
: 'You are past the easy gains. Advanced courses are worth double the XP and unlock supervisor-tier roles.',
|
||||||
|
},
|
||||||
|
shifts < 5 && {
|
||||||
|
title: `Log ${plural(5 - shifts, 'more shift')}`,
|
||||||
|
body: `Advanced challenges stay locked until 5 shifts — you have ${shifts}. That gate is deliberate: leadership evidence from someone who has never run a floor is not evidence.`,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
note('Each course ends in a challenge, not a quiz — you demonstrate the skill and the evidence gets scored.')
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -140,14 +185,16 @@ const has = (q, ...words) => words.some((w) => q.includes(w));
|
|||||||
|
|
||||||
export const respondTalent = (question, f) => {
|
export const respondTalent = (question, f) => {
|
||||||
const q = question.toLowerCase();
|
const q = question.toLowerCase();
|
||||||
if (has(q, 'resume', 'résumé', 'cv', 'profile')) return resumeReview(f);
|
if (has(q, 'resume', 'résumé', 'cv', 'profile', 'improve')) return resumeReview(f);
|
||||||
if (has(q, 'learn', 'course', 'training', 'study', 'skill', 'badge')) return learningRecommendations(f);
|
if (has(q, 'learn', 'course', 'training', 'study', 'skill', 'badge', 'path')) return learningRecommendations(f);
|
||||||
if (has(q, 'career', 'earn more', 'pay', 'score', 'guidance', 'promotion', 'next')) return careerGuidance(f);
|
if (has(q, 'career', 'earn more', 'pay', 'score', 'guidance', 'promotion', 'next', 'started')) return careerGuidance(f);
|
||||||
|
|
||||||
const p = f.profile;
|
const p = f.profile;
|
||||||
return p
|
return answer(
|
||||||
? `I can help with your career score (currently ${toFICO(scoreOf(p))}), your profile, or what to learn next. Where would you like to start?`
|
p
|
||||||
: NO_PROFILE;
|
? `I can help with your career score (currently ${toFICO(scoreOf(p))}), your profile, or what to learn next. Where would you like to start?`
|
||||||
|
: NO_PROFILE
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TALENT_CAPABILITIES = [
|
export const TALENT_CAPABILITIES = [
|
||||||
|
|||||||
@@ -11,18 +11,10 @@ import { TALENT_CAPABILITIES, respondTalent } from './capabilities/talent';
|
|||||||
/**
|
/**
|
||||||
* Page contexts for the Krow AI Assistant.
|
* Page contexts for the Krow AI Assistant.
|
||||||
*
|
*
|
||||||
* The component is identical on every page. Only what is in this file changes:
|
* Deliberately thin. What the user sees on the landing screen — the title,
|
||||||
* the supporting line, the placeholder examples, the suggested chips, the
|
* greeting, description, placeholders and suggested prompts — is computed from
|
||||||
* capabilities and the responder.
|
* live data in `dynamic.js`, not written here. A context only declares what this
|
||||||
*
|
* page's assistant can *do*: its capabilities and its free-text responder.
|
||||||
* Chips are phrased as things a person would actually type, because clicking one
|
|
||||||
* fills the input rather than firing an action. The user always sees the
|
|
||||||
* question before it is asked, so the assistant reads as a conversation rather
|
|
||||||
* than a menu of buttons.
|
|
||||||
*
|
|
||||||
* `capability` binds a chip to a specific capability, so a chip gets a precise
|
|
||||||
* answer while an equivalent typed question still resolves through the
|
|
||||||
* responder's keyword matching.
|
|
||||||
*
|
*
|
||||||
* `needsWorkerProfile` gates the one query with a side effect, so employer and
|
* `needsWorkerProfile` gates the one query with a side effect, so employer and
|
||||||
* admin sessions never touch worker-profile records.
|
* admin sessions never touch worker-profile records.
|
||||||
@@ -31,129 +23,42 @@ export const ASSISTANT_CONTEXTS = {
|
|||||||
'employer.overview': {
|
'employer.overview': {
|
||||||
id: 'employer.overview',
|
id: 'employer.overview',
|
||||||
page: 'Overview',
|
page: 'Overview',
|
||||||
tagline: 'Find insights from your hiring data, spot what needs attention, and ask questions about this page.',
|
|
||||||
placeholders: [
|
|
||||||
"Ask about today's hiring…",
|
|
||||||
'What should I do first?',
|
|
||||||
'Which roles are at risk?',
|
|
||||||
'How healthy is my pipeline?',
|
|
||||||
],
|
|
||||||
chips: [
|
|
||||||
{ label: "Today's Hiring", prompt: 'Give me a summary of hiring today', capability: 'hiring-summary' },
|
|
||||||
{ label: 'Pipeline Health', prompt: 'How healthy is my hiring pipeline?', capability: 'hiring-health' },
|
|
||||||
{ label: 'Pending Actions', prompt: 'What is pending and needs my attention?', capability: 'pending-actions' },
|
|
||||||
{ label: 'Hiring Risks', prompt: 'What risks should I know about?', capability: 'hiring-risks' },
|
|
||||||
],
|
|
||||||
capabilities: OVERVIEW_CAPABILITIES,
|
capabilities: OVERVIEW_CAPABILITIES,
|
||||||
respond: respondOverview,
|
respond: respondOverview,
|
||||||
},
|
},
|
||||||
|
|
||||||
'employer.candidates': {
|
'employer.candidates': {
|
||||||
id: 'employer.candidates',
|
id: 'employer.candidates',
|
||||||
page: 'Candidates',
|
page: 'Candidates',
|
||||||
tagline: 'Analyse résumés, compare candidates, generate interview questions, and decide who to move forward.',
|
|
||||||
placeholders: [
|
|
||||||
'Compare two candidates…',
|
|
||||||
'Who should I interview next?',
|
|
||||||
'What are this candidate’s gaps?',
|
|
||||||
'Draft interview questions…',
|
|
||||||
],
|
|
||||||
chips: [
|
|
||||||
{ label: 'Top Candidates', prompt: 'Analyse my strongest candidate', capability: 'resume-analysis' },
|
|
||||||
{ label: 'Compare Candidates', prompt: 'Compare my top two candidates', capability: 'candidate-comparison' },
|
|
||||||
{ label: 'Interview Questions', prompt: 'Generate interview questions for my top candidate', capability: 'interview-questions' },
|
|
||||||
{ label: 'Skill Gap Analysis', prompt: 'What skill gaps run across my candidates?', capability: 'skill-gap' },
|
|
||||||
{ label: 'Hiring Recommendation', prompt: 'Who should I hire, interview, or pass on?', capability: 'hiring-recommendation' },
|
|
||||||
],
|
|
||||||
capabilities: CANDIDATE_CAPABILITIES,
|
capabilities: CANDIDATE_CAPABILITIES,
|
||||||
respond: respondCandidates,
|
respond: respondCandidates,
|
||||||
},
|
},
|
||||||
|
|
||||||
'employer.analytics': {
|
'employer.analytics': {
|
||||||
id: 'employer.analytics',
|
id: 'employer.analytics',
|
||||||
page: 'Analytics',
|
page: 'Analytics',
|
||||||
tagline: 'Explain the numbers on this page, forecast what your pipeline will yield, and generate reports.',
|
|
||||||
placeholders: [
|
|
||||||
'Explain this chart…',
|
|
||||||
'Generate a hiring report…',
|
|
||||||
'Where do candidates drop off?',
|
|
||||||
'How many hires can I expect?',
|
|
||||||
],
|
|
||||||
chips: [
|
|
||||||
{ label: 'Explain Charts', prompt: 'Explain the charts on this page', capability: 'explain-charts' },
|
|
||||||
{ label: 'Forecast Hiring', prompt: 'Forecast my hiring from the current pipeline', capability: 'forecast-hiring' },
|
|
||||||
{ label: 'Department Insights', prompt: 'Break down performance by role category', capability: 'department-insights' },
|
|
||||||
{ label: 'Generate Report', prompt: 'Generate a hiring report', capability: 'generate-reports' },
|
|
||||||
],
|
|
||||||
capabilities: ANALYTICS_CAPABILITIES,
|
capabilities: ANALYTICS_CAPABILITIES,
|
||||||
respond: respondAnalytics,
|
respond: respondAnalytics,
|
||||||
},
|
},
|
||||||
|
|
||||||
'admin.controlCenter': {
|
'admin.controlCenter': {
|
||||||
id: 'admin.controlCenter',
|
id: 'admin.controlCenter',
|
||||||
page: 'Control Center',
|
page: 'Control Center',
|
||||||
tagline: 'Check platform health, reconcile the data, and see what needs intervention across every account.',
|
|
||||||
placeholders: [
|
|
||||||
'Is anything broken?',
|
|
||||||
'What should I fix first?',
|
|
||||||
'How is the platform performing?',
|
|
||||||
],
|
|
||||||
chips: [
|
|
||||||
{ label: 'Platform Health', prompt: 'Run a platform health check', capability: 'platform-health' },
|
|
||||||
{ label: 'Recommendations', prompt: 'What should I fix first?', capability: 'recommendations' },
|
|
||||||
],
|
|
||||||
capabilities: CONTROL_CENTER_CAPABILITIES,
|
capabilities: CONTROL_CENTER_CAPABILITIES,
|
||||||
respond: respondControlCenter,
|
respond: respondControlCenter,
|
||||||
},
|
},
|
||||||
|
|
||||||
'admin.candidates': {
|
'admin.candidates': {
|
||||||
id: 'admin.candidates',
|
id: 'admin.candidates',
|
||||||
page: 'Candidates Analysis',
|
page: 'Candidates Analysis',
|
||||||
tagline: 'Analyse talent supply across the platform and see where the risk sits in the candidate pool.',
|
|
||||||
placeholders: [
|
|
||||||
'How healthy is talent supply?',
|
|
||||||
'Any candidate risk flags?',
|
|
||||||
'What are the recruitment trends?',
|
|
||||||
],
|
|
||||||
chips: [
|
|
||||||
{ label: 'Recruitment Insights', prompt: 'Show me recruitment insights across the platform', capability: 'recruitment-insights' },
|
|
||||||
{ label: 'Candidate Risk', prompt: 'Analyse risk across the candidate pool', capability: 'candidate-risk' },
|
|
||||||
],
|
|
||||||
capabilities: ADMIN_CANDIDATE_CAPABILITIES,
|
capabilities: ADMIN_CANDIDATE_CAPABILITIES,
|
||||||
respond: respondAdminCandidates,
|
respond: respondAdminCandidates,
|
||||||
},
|
},
|
||||||
|
|
||||||
'admin.activity': {
|
'admin.activity': {
|
||||||
id: 'admin.activity',
|
id: 'admin.activity',
|
||||||
page: 'Activity',
|
page: 'Activity',
|
||||||
tagline: 'Summarize the audit trail, reconcile it against real records, and see how accounts are behaving.',
|
|
||||||
placeholders: [
|
|
||||||
'Summarize the audit trail…',
|
|
||||||
'Who is most active?',
|
|
||||||
'Are there any gaps in the log?',
|
|
||||||
],
|
|
||||||
chips: [
|
|
||||||
{ label: 'Audit Summary', prompt: 'Summarize the audit trail', capability: 'audit-summary' },
|
|
||||||
{ label: 'User Activity', prompt: 'Which accounts are most active?', capability: 'user-activity' },
|
|
||||||
],
|
|
||||||
capabilities: ACTIVITY_CAPABILITIES,
|
capabilities: ACTIVITY_CAPABILITIES,
|
||||||
respond: respondActivity,
|
respond: respondActivity,
|
||||||
},
|
},
|
||||||
|
|
||||||
'talent.portal': {
|
'talent.portal': {
|
||||||
id: 'talent.portal',
|
id: 'talent.portal',
|
||||||
page: 'My Portal',
|
page: 'My Portal',
|
||||||
tagline: 'Understand your score, improve your profile, and find the next thing worth learning.',
|
|
||||||
placeholders: [
|
|
||||||
'How do I earn more?',
|
|
||||||
'Review my profile…',
|
|
||||||
'What should I learn next?',
|
|
||||||
],
|
|
||||||
chips: [
|
|
||||||
{ label: 'Review My Profile', prompt: 'Review my profile and tell me what is missing', capability: 'resume-review' },
|
|
||||||
{ label: 'Career Guidance', prompt: 'How do I raise my career score?', capability: 'career-guidance' },
|
|
||||||
{ label: 'What To Learn Next', prompt: 'What should I learn next?', capability: 'learning-recommendations' },
|
|
||||||
],
|
|
||||||
capabilities: TALENT_CAPABILITIES,
|
capabilities: TALENT_CAPABILITIES,
|
||||||
respond: respondTalent,
|
respond: respondTalent,
|
||||||
needsWorkerProfile: true,
|
needsWorkerProfile: true,
|
||||||
|
|||||||
265
src/components/ai-assistant/dynamic.js
Normal file
265
src/components/ai-assistant/dynamic.js
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
import { plural, verb } from './insights';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dynamic greetings and suggested prompts.
|
||||||
|
*
|
||||||
|
* Nothing here is a fixed string chosen in advance. The greeting states what is
|
||||||
|
* actually true on the page right now, and the suggestions name real records —
|
||||||
|
* the specific candidates, the specific starved role. That is the difference
|
||||||
|
* between an assistant that looks aware of context and one that is.
|
||||||
|
*
|
||||||
|
* Everything derives from the same fact sheet the dashboard renders from, so a
|
||||||
|
* greeting can never contradict the numbers beside it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ── Greetings ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Time of day from the viewer's own clock.
|
||||||
|
*
|
||||||
|
* `getHours()` is local, so this follows the user's timezone rather than the
|
||||||
|
* server's. The late band matters: at 00:30 "morning" is wrong, so 21:00–04:59
|
||||||
|
* reads as evening.
|
||||||
|
*
|
||||||
|
* 05:00–11:59 morning
|
||||||
|
* 12:00–16:59 afternoon
|
||||||
|
* 17:00–20:59 evening
|
||||||
|
* 21:00–04:59 evening
|
||||||
|
*/
|
||||||
|
function timeOfDay(date) {
|
||||||
|
const hour = date.getHours();
|
||||||
|
if (hour >= 5 && hour < 12) return 'morning';
|
||||||
|
if (hour >= 12 && hour < 17) return 'afternoon';
|
||||||
|
return 'evening';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks the single most useful thing to say about the page.
|
||||||
|
*
|
||||||
|
* Ordered by urgency rather than by what sounds cheeriest: something stuck beats
|
||||||
|
* something healthy, because the greeting is the one line guaranteed to be read.
|
||||||
|
*/
|
||||||
|
const GREETINGS = {
|
||||||
|
'employer.overview': (f) => {
|
||||||
|
if (!f.total) return 'No applicants yet — publish a position and I will start tracking it.';
|
||||||
|
if (f.stalled.length) {
|
||||||
|
return `${plural(f.stalled.length, 'strong candidate')} ${verb(f.stalled.length, 'is', 'are')} screened and waiting on you.`;
|
||||||
|
}
|
||||||
|
if (f.unscreened.length) {
|
||||||
|
return `${plural(f.unscreened.length, 'applicant')} ${verb(f.unscreened.length, 'is', 'are')} waiting to be screened.`;
|
||||||
|
}
|
||||||
|
if (f.starvedPositions.length) {
|
||||||
|
return `${plural(f.starvedPositions.length, 'open role')} ${verb(f.starvedPositions.length, 'has', 'have')} no applicants yet.`;
|
||||||
|
}
|
||||||
|
return 'Your hiring pipeline looks healthy today.';
|
||||||
|
},
|
||||||
|
|
||||||
|
'employer.candidates': (f) => {
|
||||||
|
if (!f.total) return 'No candidates in the pipeline yet.';
|
||||||
|
if (f.unscreened.length) {
|
||||||
|
return `You have ${plural(f.unscreened.length, 'candidate')} waiting for review.`;
|
||||||
|
}
|
||||||
|
if (f.stalled.length) {
|
||||||
|
return `${plural(f.stalled.length, 'candidate')} at 80+ ${verb(f.stalled.length, 'is', 'are')} ready for a decision.`;
|
||||||
|
}
|
||||||
|
return `All ${f.total} candidates are scored and actioned.`;
|
||||||
|
},
|
||||||
|
|
||||||
|
'employer.analytics': (f) => {
|
||||||
|
if (!f.scored.length) return 'Nothing scored yet, so the charts are still empty.';
|
||||||
|
if (f.bottleneck && f.bottleneck.lost > 0) {
|
||||||
|
return `Your funnel loses most people between ${f.bottleneck.from} and ${f.bottleneck.to}.`;
|
||||||
|
}
|
||||||
|
return `${plural(f.scored.length, 'candidate')} scored, averaging ${f.avgScore}. Worth a closer look.`;
|
||||||
|
},
|
||||||
|
|
||||||
|
'admin.controlCenter': (f) => {
|
||||||
|
if (f.unscoredProfiles.length) {
|
||||||
|
return `${plural(f.unscoredProfiles.length, 'talent profile')} ${verb(f.unscoredProfiles.length, 'has', 'have')} no score and cannot be matched.`;
|
||||||
|
}
|
||||||
|
if (f.activity24h.length) {
|
||||||
|
return `${plural(f.activity24h.length, 'event')} logged in the last 24 hours.`;
|
||||||
|
}
|
||||||
|
return `Platform is steady across ${plural(f.postings.length, 'position')} and ${plural(f.total, 'application')}.`;
|
||||||
|
},
|
||||||
|
|
||||||
|
'admin.candidates': (f) => {
|
||||||
|
if (f.missingCredentials.length) {
|
||||||
|
return `${plural(f.missingCredentials.length, 'scored candidate')} ${verb(f.missingCredentials.length, 'has', 'have')} no credentials on file.`;
|
||||||
|
}
|
||||||
|
return `${plural(f.profiles.length, 'worker')} in the pool, ${f.elite.length} at Elite.`;
|
||||||
|
},
|
||||||
|
|
||||||
|
'admin.activity': (f) => {
|
||||||
|
if (!f.activity.length) return 'No activity logged yet.';
|
||||||
|
if (f.activity24h.length === 0) {
|
||||||
|
return `Quiet day — ${plural(f.activity7d.length, 'event')} this week, none in the last 24 hours.`;
|
||||||
|
}
|
||||||
|
return `${plural(f.activity24h.length, 'event')} today, ${f.activity7d.length} this week.`;
|
||||||
|
},
|
||||||
|
|
||||||
|
'talent.portal': (f) => {
|
||||||
|
const p = f.profile;
|
||||||
|
if (!p) return 'Let us get your profile started.';
|
||||||
|
const completion = p.profile_completion || 0;
|
||||||
|
if (completion >= 90) return 'Your profile is nearly complete — nice work.';
|
||||||
|
if (completion > 0) return `You are ${completion}% of the way through your profile.`;
|
||||||
|
return 'Your profile is empty. A few fields will change what employers see.';
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Title lines. First names read better than a formal greeting in a small panel. */
|
||||||
|
const TITLES = {
|
||||||
|
'employer.overview': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||||
|
'employer.candidates': () => 'Candidate intelligence',
|
||||||
|
'employer.analytics': () => 'Analytics assistant',
|
||||||
|
'admin.controlCenter': (f) => `Platform check, ${timeOfDay(f.today)}`,
|
||||||
|
'admin.candidates': () => 'Talent analysis',
|
||||||
|
'admin.activity': () => 'Activity review',
|
||||||
|
'talent.portal': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Descriptions state what this page's assistant can actually do with the data. */
|
||||||
|
const DESCRIPTIONS = {
|
||||||
|
'employer.overview': (f) =>
|
||||||
|
`Reading ${f.total} applicants across ${plural(f.openPositions.length, 'open role')}.`,
|
||||||
|
'employer.candidates': (f) => `All ${f.total} candidates and their score breakdowns.`,
|
||||||
|
'employer.analytics': () => 'Every figure on this page, explained or forecast.',
|
||||||
|
'admin.controlCenter': () => 'Health checks, data integrity and what needs intervention.',
|
||||||
|
'admin.candidates': () => 'Talent supply and risk across the candidate pool.',
|
||||||
|
'admin.activity': (f) => `${plural(f.activity.length, 'logged event')}, reconciled and profiled.`,
|
||||||
|
'talent.portal': () => 'Your score, your profile, and what to learn next.',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Placeholders phrased as things this page's user would actually type. */
|
||||||
|
const PLACEHOLDERS = {
|
||||||
|
'employer.overview': (f) => [
|
||||||
|
`Summarize ${timeOfDay(f.today)}'s hiring…`,
|
||||||
|
f.starvedPositions.length ? 'Which positions need attention?' : 'What should I do first?',
|
||||||
|
'Why did hiring slow this week?',
|
||||||
|
],
|
||||||
|
'employer.candidates': (f) => [
|
||||||
|
f.ranked.length >= 2 ? 'Compare the top candidates…' : 'Analyse my strongest candidate…',
|
||||||
|
'Which resumes need review?',
|
||||||
|
'Recommend interview questions…',
|
||||||
|
],
|
||||||
|
'employer.analytics': () => [
|
||||||
|
'Explain this chart…',
|
||||||
|
'Forecast hiring…',
|
||||||
|
'Generate an executive summary…',
|
||||||
|
],
|
||||||
|
'admin.controlCenter': () => ['Show unusual activity…', 'Summarize platform usage…'],
|
||||||
|
'admin.candidates': () => ['Where is the risk in the pool?', 'Summarize recruitment trends…'],
|
||||||
|
'admin.activity': () => ['Show unusual activity…', 'Summarize platform usage…'],
|
||||||
|
'talent.portal': () => ['Improve my profile…', 'Recommend learning paths…'],
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── Suggested prompts ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds suggestions from what is actually on the page.
|
||||||
|
*
|
||||||
|
* A suggestion that names two real candidates is a different product from one
|
||||||
|
* that says "Compare candidates" — it proves the assistant already looked.
|
||||||
|
* Each carries the capability that answers it best, so a click is precise.
|
||||||
|
*/
|
||||||
|
const PROMPTS = {
|
||||||
|
'employer.overview': (f) => [
|
||||||
|
{ label: `Summarize ${timeOfDay(f.today)}'s hiring`, prompt: `Summarize hiring as of this ${timeOfDay(f.today)}`, capability: 'hiring-summary' },
|
||||||
|
f.starvedPositions.length
|
||||||
|
? { label: 'Which positions need attention?', prompt: `Which positions need attention? ${f.starvedPositions[0].title} has no applicants`, capability: 'pending-actions' }
|
||||||
|
: { label: 'What needs my attention?', prompt: 'What needs my attention right now?', capability: 'pending-actions' },
|
||||||
|
{ label: 'Pipeline health', prompt: 'How healthy is my hiring pipeline?', capability: 'hiring-health' },
|
||||||
|
f.singleCandidateRoles.length || f.missingCredentials.length
|
||||||
|
? { label: 'Where is the risk?', prompt: 'Where is the risk in my hiring right now?', capability: 'hiring-risks' }
|
||||||
|
: { label: 'Any hiring risks?', prompt: 'Are there any hiring risks I should know about?', capability: 'hiring-risks' },
|
||||||
|
],
|
||||||
|
|
||||||
|
'employer.candidates': (f) => {
|
||||||
|
const [a, b] = f.ranked;
|
||||||
|
return [
|
||||||
|
a && b
|
||||||
|
? { label: `Compare ${a.applicant_name.split(' ')[0]} & ${b.applicant_name.split(' ')[0]}`, prompt: `Compare ${a.applicant_name} and ${b.applicant_name}`, capability: 'candidate-comparison' }
|
||||||
|
: { label: 'Compare top candidates', prompt: 'Compare my top candidates', capability: 'candidate-comparison' },
|
||||||
|
f.unscreened.length
|
||||||
|
? { label: `${f.unscreened.length} resumes need review`, prompt: 'Which resumes need review?', capability: 'resume-analysis' }
|
||||||
|
: { label: 'Analyse top résumé', prompt: 'Analyse my strongest candidate', capability: 'resume-analysis' },
|
||||||
|
a
|
||||||
|
? { label: `Questions for ${a.applicant_name.split(' ')[0]}`, prompt: `Recommend interview questions for ${a.applicant_name}`, capability: 'interview-questions' }
|
||||||
|
: null,
|
||||||
|
{ label: 'Skill gaps', prompt: 'What skill gaps run across my candidates?', capability: 'skill-gap' },
|
||||||
|
{ label: 'Who should I hire?', prompt: 'Who should I hire, interview, or pass on?', capability: 'hiring-recommendation' },
|
||||||
|
].filter(Boolean);
|
||||||
|
},
|
||||||
|
|
||||||
|
'employer.analytics': (f) => [
|
||||||
|
{ label: 'Explain this chart', prompt: 'Explain the charts on this page', capability: 'explain-charts' },
|
||||||
|
{ label: 'Forecast hiring', prompt: 'Forecast hiring from the current pipeline', capability: 'forecast-hiring' },
|
||||||
|
{ label: 'Generate executive summary', prompt: 'Generate an executive hiring report', capability: 'generate-reports' },
|
||||||
|
f.bottleneck
|
||||||
|
? {
|
||||||
|
label: `Why the drop to ${f.bottleneck.to}?`,
|
||||||
|
prompt: `Why do candidates drop off between ${f.bottleneck.from} and ${f.bottleneck.to}?`,
|
||||||
|
capability: 'explain-charts',
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
{ label: 'Break down by role', prompt: 'Break down performance by role category', capability: 'department-insights' },
|
||||||
|
].filter(Boolean),
|
||||||
|
|
||||||
|
'admin.controlCenter': (f) => [
|
||||||
|
{ label: 'Platform health', prompt: 'Run a platform health check', capability: 'platform-health' },
|
||||||
|
{ label: 'Summarize platform usage', prompt: 'Summarize platform usage', capability: 'platform-health' },
|
||||||
|
f.unscoredProfiles.length
|
||||||
|
? { label: `${f.unscoredProfiles.length} unscored profiles`, prompt: 'What should I fix first?', capability: 'recommendations' }
|
||||||
|
: { label: 'What should I fix?', prompt: 'What should I fix first?', capability: 'recommendations' },
|
||||||
|
],
|
||||||
|
|
||||||
|
'admin.candidates': (f) => [
|
||||||
|
{ label: 'Recruitment insights', prompt: 'Show recruitment insights across the platform', capability: 'recruitment-insights' },
|
||||||
|
f.missingCredentials.length
|
||||||
|
? { label: `${f.missingCredentials.length} missing credentials`, prompt: 'Analyse risk across the candidate pool', capability: 'candidate-risk' }
|
||||||
|
: { label: 'Candidate risk', prompt: 'Analyse risk across the candidate pool', capability: 'candidate-risk' },
|
||||||
|
],
|
||||||
|
|
||||||
|
'admin.activity': (f) => [
|
||||||
|
{ label: 'Audit summary', prompt: 'Summarize the audit trail', capability: 'audit-summary' },
|
||||||
|
{ label: 'Show unusual activity', prompt: 'Show me unusual account activity', capability: 'user-activity' },
|
||||||
|
f.activity7d.length
|
||||||
|
? { label: `${f.activity7d.length} events this week`, prompt: 'Summarize platform usage this week', capability: 'audit-summary' }
|
||||||
|
: null,
|
||||||
|
].filter(Boolean),
|
||||||
|
|
||||||
|
'talent.portal': (f) => {
|
||||||
|
const p = f.profile;
|
||||||
|
return [
|
||||||
|
{ label: 'Improve my profile', prompt: 'Review my profile and tell me what is missing', capability: 'resume-review' },
|
||||||
|
{ label: 'Recommend learning paths', prompt: 'What should I learn next?', capability: 'learning-recommendations' },
|
||||||
|
p && (p.krow_score || 0) > 0
|
||||||
|
? { label: 'How do I score higher?', prompt: 'How do I raise my career score?', capability: 'career-guidance' }
|
||||||
|
: { label: 'How do I get started?', prompt: 'How do I raise my career score?', capability: 'career-guidance' },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── Public API ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const firstName = (fullName) => String(fullName || '').trim().split(/\s+/)[0] || '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the landing screen needs, computed from current data.
|
||||||
|
* `userName` personalizes the greeting where a name is known.
|
||||||
|
*/
|
||||||
|
export function buildIntro(contextId, facts, userName) {
|
||||||
|
const name = firstName(userName);
|
||||||
|
return {
|
||||||
|
title: TITLES[contextId]?.(facts, name) ?? 'Krow AI',
|
||||||
|
greeting: GREETINGS[contextId]?.(facts) ?? '',
|
||||||
|
description: DESCRIPTIONS[contextId]?.(facts) ?? '',
|
||||||
|
placeholders: PLACEHOLDERS[contextId]?.(facts) ?? ['Ask anything…'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Suggested prompts for the current page and data. */
|
||||||
|
export function buildPrompts(contextId, facts) {
|
||||||
|
return PROMPTS[contextId]?.(facts) ?? [];
|
||||||
|
}
|
||||||
@@ -2,33 +2,32 @@
|
|||||||
* Provider seam for the Krow AI Assistant.
|
* Provider seam for the Krow AI Assistant.
|
||||||
*
|
*
|
||||||
* The UI never generates an answer and never knows where one came from. It calls
|
* The UI never generates an answer and never knows where one came from. It calls
|
||||||
* `provider.stream(request)` and renders the chunks. That single boundary is
|
* `provider.stream(request)` and renders the snapshots. That single boundary is
|
||||||
* what makes the backend swappable: today a local provider computes answers from
|
* what makes the backend swappable: today a local provider computes answers from
|
||||||
* dashboard data, and later an HTTP provider can call a real service without a
|
* dashboard data, and later an HTTP provider can call a real service without a
|
||||||
* single change to the component.
|
* change to the component.
|
||||||
*
|
*
|
||||||
* ── Contract ────────────────────────────────────────────────────────────────
|
* ── Contract ────────────────────────────────────────────────────────────────
|
||||||
*
|
*
|
||||||
* provider.id: string
|
* provider.id: string
|
||||||
* provider.stream(request): AsyncIterable<string> // yields text deltas
|
* provider.stream(request): AsyncIterable<Block[]>
|
||||||
|
*
|
||||||
|
* Each yielded value is the response *so far* as an array of blocks (see
|
||||||
|
* blocks.js). Snapshots rather than deltas because a response is structured:
|
||||||
|
* a table or a KPI row has no meaningful half-state, and the renderer stays a
|
||||||
|
* pure function of the latest snapshot.
|
||||||
*
|
*
|
||||||
* request = {
|
* request = {
|
||||||
* contextId: string, // e.g. 'employer.overview'
|
* contextId: string, // e.g. 'employer.overview'
|
||||||
* capability: string | null, // capability id, or null for free text
|
* capability: string | null, // capability id, or null for free text
|
||||||
* question: string, // what the user typed, or the capability label
|
* question: string, // what the user typed, or the chip's prompt
|
||||||
* facts: object, // the dashboard fact sheet (see insights.js)
|
* facts: object, // dashboard fact sheet (see insights.js)
|
||||||
* signal: AbortSignal, // aborts an in-flight response
|
* signal: AbortSignal, // aborts an in-flight response
|
||||||
* }
|
* }
|
||||||
*
|
|
||||||
* Deltas rather than a whole string is deliberate: it is the shape every
|
|
||||||
* streaming completion API returns, so the local provider and a future remote
|
|
||||||
* one are interchangeable without the UI learning a second pattern.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getContext } from './contexts';
|
import { getContext } from './contexts';
|
||||||
|
import { toSnapshots } from './blocks';
|
||||||
/** Words per delta. Chunking by word keeps reflow to once per word. */
|
|
||||||
const WORDS_PER_CHUNK = 3;
|
|
||||||
|
|
||||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
@@ -38,7 +37,7 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|||||||
* Deterministic: the same question against the same data returns the same
|
* Deterministic: the same question against the same data returns the same
|
||||||
* answer, which is what makes the assistant demonstrable and testable.
|
* answer, which is what makes the assistant demonstrable and testable.
|
||||||
*/
|
*/
|
||||||
export function createLocalProvider({ latency = 420, chunkDelay = 26 } = {}) {
|
export function createLocalProvider({ latency = 380, frameDelay = 26 } = {}) {
|
||||||
return {
|
return {
|
||||||
id: 'local',
|
id: 'local',
|
||||||
|
|
||||||
@@ -46,21 +45,20 @@ export function createLocalProvider({ latency = 420, chunkDelay = 26 } = {}) {
|
|||||||
const context = getContext(contextId);
|
const context = getContext(contextId);
|
||||||
if (!context) throw new Error(`Unknown assistant context: ${contextId}`);
|
if (!context) throw new Error(`Unknown assistant context: ${contextId}`);
|
||||||
|
|
||||||
const answer = capability
|
const document = capability
|
||||||
? context.capabilities.find((c) => c.id === capability)?.run(facts)
|
? context.capabilities.find((c) => c.id === capability)?.run(facts)
|
||||||
?? context.respond(question, facts)
|
?? context.respond(question, facts)
|
||||||
: context.respond(question, facts);
|
: context.respond(question, facts);
|
||||||
|
|
||||||
// A brief pause before the first delta, so the answer reads as considered
|
// A brief pause before the first frame, so the answer reads as considered
|
||||||
// rather than precomputed.
|
// rather than precomputed.
|
||||||
await sleep(latency);
|
await sleep(latency);
|
||||||
if (signal?.aborted) return;
|
if (signal?.aborted) return;
|
||||||
|
|
||||||
const tokens = String(answer).split(/(\s+)/);
|
for (const snapshot of toSnapshots(document)) {
|
||||||
for (let i = 0; i < tokens.length; i += WORDS_PER_CHUNK * 2) {
|
|
||||||
if (signal?.aborted) return;
|
if (signal?.aborted) return;
|
||||||
yield tokens.slice(i, i + WORDS_PER_CHUNK * 2).join('');
|
yield snapshot;
|
||||||
await sleep(chunkDelay);
|
await sleep(frameDelay);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -69,15 +67,15 @@ export function createLocalProvider({ latency = 420, chunkDelay = 26 } = {}) {
|
|||||||
/**
|
/**
|
||||||
* HTTP provider — the path to a real backend.
|
* HTTP provider — the path to a real backend.
|
||||||
*
|
*
|
||||||
* Reads Server-Sent-Event style `data:` lines from a streaming endpoint and
|
* Reads Server-Sent-Event style `data:` lines and yields a growing block array.
|
||||||
* yields the text deltas. Unused today; it exists so the shape of the
|
* A line may carry a whole block (`{ block: … }`) or a text delta
|
||||||
* integration is settled rather than guessed at later. Swapping providers is a
|
* (`{ delta: "…" }`), which is appended to a trailing text block. Unused today;
|
||||||
* one-line change in `AssistantProvider`.
|
* it exists so the shape of the integration is settled rather than guessed at.
|
||||||
*
|
*
|
||||||
* The request body sends `contextId`, `capability` and `question` — not the
|
* The request body sends `contextId`, `capability` and `question` — not the fact
|
||||||
* fact sheet. Dashboard data should be fetched server-side from the caller's own
|
* sheet. Dashboard data should be read server-side from the caller's own session
|
||||||
* session rather than posted from the browser, so the client cannot ask about
|
* rather than posted from the browser, so the client cannot ask about records it
|
||||||
* records it is not entitled to see.
|
* is not entitled to see.
|
||||||
*/
|
*/
|
||||||
export function createHttpProvider({ endpoint, headers = {} }) {
|
export function createHttpProvider({ endpoint, headers = {} }) {
|
||||||
if (!endpoint) throw new Error('createHttpProvider requires an endpoint');
|
if (!endpoint) throw new Error('createHttpProvider requires an endpoint');
|
||||||
@@ -99,28 +97,37 @@ export function createHttpProvider({ endpoint, headers = {} }) {
|
|||||||
|
|
||||||
const reader = response.body.getReader();
|
const reader = response.body.getReader();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
|
const blocks = [];
|
||||||
let buffer = '';
|
let buffer = '';
|
||||||
|
|
||||||
|
const appendDelta = (delta) => {
|
||||||
|
const last = blocks[blocks.length - 1];
|
||||||
|
if (last?.type === 'text') last.text += delta;
|
||||||
|
else blocks.push({ type: 'text', text: delta });
|
||||||
|
};
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
if (done) break;
|
if (done) break;
|
||||||
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
buffer += decoder.decode(value, { stream: true });
|
||||||
const lines = buffer.split('\n');
|
const lines = buffer.split('\n');
|
||||||
// The final element may be a partial line; keep it for the next read.
|
// The final element may be a partial line; hold it for the next read.
|
||||||
buffer = lines.pop() ?? '';
|
buffer = lines.pop() ?? '';
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (!line.startsWith('data:')) continue;
|
if (!line.startsWith('data:')) continue;
|
||||||
const payload = line.slice(5).trim();
|
const payload = line.slice(5).trim();
|
||||||
if (!payload || payload === '[DONE]') continue;
|
if (!payload || payload === '[DONE]') continue;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(payload);
|
const parsed = JSON.parse(payload);
|
||||||
if (parsed.delta) yield parsed.delta;
|
if (parsed.block) blocks.push(parsed.block);
|
||||||
|
else if (parsed.delta) appendDelta(parsed.delta);
|
||||||
} catch {
|
} catch {
|
||||||
// A non-JSON data line is treated as a raw delta.
|
appendDelta(payload);
|
||||||
yield payload;
|
|
||||||
}
|
}
|
||||||
|
yield [...blocks];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -128,10 +135,9 @@ export function createHttpProvider({ endpoint, headers = {} }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The provider the app uses.
|
* The provider the app uses. Local by default; point
|
||||||
*
|
* `VITE_ASSISTANT_ENDPOINT` at a streaming endpoint to switch, with no other
|
||||||
* Local by default. Point `VITE_ASSISTANT_ENDPOINT` at a streaming endpoint to
|
* code change.
|
||||||
* switch, with no other code change.
|
|
||||||
*/
|
*/
|
||||||
export function createAssistantProvider() {
|
export function createAssistantProvider() {
|
||||||
const endpoint = import.meta.env?.VITE_ASSISTANT_ENDPOINT;
|
const endpoint = import.meta.env?.VITE_ASSISTANT_ENDPOINT;
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export function useConversation({ contextId, facts }) {
|
|||||||
const storageKey = `krow_assistant:${contextId}`;
|
const storageKey = `krow_assistant:${contextId}`;
|
||||||
|
|
||||||
const [messages, setMessages] = React.useState([]);
|
const [messages, setMessages] = React.useState([]);
|
||||||
const [pending, setPending] = React.useState(null); // { question, text, thinking }
|
const [pending, setPending] = React.useState(null); // { blocks, thinking }
|
||||||
const [error, setError] = React.useState(null);
|
const [error, setError] = React.useState(null);
|
||||||
const abortRef = React.useRef(null);
|
const abortRef = React.useRef(null);
|
||||||
|
|
||||||
@@ -93,28 +93,26 @@ export function useConversation({ contextId, facts }) {
|
|||||||
messagesRef.current = withUser;
|
messagesRef.current = withUser;
|
||||||
persist(withUser);
|
persist(withUser);
|
||||||
setError(null);
|
setError(null);
|
||||||
setPending({ question: text, text: '', thinking: true });
|
setPending({ blocks: [], thinking: true });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let assembled = '';
|
let latest = [];
|
||||||
for await (const delta of provider.stream({
|
for await (const snapshot of provider.stream({
|
||||||
contextId, capability, question: text, facts, signal: controller.signal,
|
contextId, capability, question: text, facts, signal: controller.signal,
|
||||||
})) {
|
})) {
|
||||||
if (controller.signal.aborted) break;
|
if (controller.signal.aborted) break;
|
||||||
assembled += delta;
|
latest = snapshot;
|
||||||
setPending({ question: text, text: assembled, thinking: false });
|
setPending({ blocks: snapshot, thinking: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (controller.signal.aborted) {
|
// Keep whatever arrived before the user stopped it — discarding a
|
||||||
// Keep whatever streamed before the user stopped it — discarding a
|
// half-written answer loses what they were already reading. Stopping
|
||||||
// half-written answer loses information the user was already reading.
|
// before the first block, though, should leave no empty turn behind.
|
||||||
if (assembled) {
|
if (latest.length) {
|
||||||
const next = [...messagesRef.current, { role: 'assistant', text: assembled, stopped: true }];
|
const next = [
|
||||||
messagesRef.current = next;
|
...messagesRef.current,
|
||||||
persist(next);
|
{ role: 'assistant', blocks: latest, stopped: controller.signal.aborted || undefined },
|
||||||
}
|
];
|
||||||
} else {
|
|
||||||
const next = [...messagesRef.current, { role: 'assistant', text: assembled }];
|
|
||||||
messagesRef.current = next;
|
messagesRef.current = next;
|
||||||
persist(next);
|
persist(next);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user