This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import { usePreferences } from '@/lib/krowHooks';
|
||||
import { allAgents } from '@/lib/agents/registry';
|
||||
import { AGENTS, readAgentRegistry } from '@/lib/agents/registry';
|
||||
import { sourcesFrom, useAgentDefinitions } from '@/lib/agents/agentStore';
|
||||
import {
|
||||
agentCovers, nativeAgentForContext, resolveAgentForTurn, resolveDefaultAgent, resolveSelection,
|
||||
} from '@/lib/agents/runtime';
|
||||
@@ -73,12 +74,30 @@ function writeSelection(selection) {
|
||||
export function AgentProvider({ contextId = null, children }) {
|
||||
const preferences = usePreferences();
|
||||
|
||||
/* Shipped definitions plus anything this account has authored, read through
|
||||
the one registry so the switcher and the management page cannot disagree
|
||||
about what exists. */
|
||||
/**
|
||||
* Shipped definitions plus anything this account has authored.
|
||||
*
|
||||
* Read from `agent-definitions` — the store the Agent Registry and Agent
|
||||
* Configure write to — rather than from `preferences.customAgents`, which is
|
||||
* where authored agents used to live. That move happened for the management
|
||||
* screens and this was left behind, so the panel's idea of an agent was the
|
||||
* shipped file and nothing else: an agent edited in Configure looked saved,
|
||||
* and the agent answering beside it was still the version off disk.
|
||||
*
|
||||
* It is only visible once something actually depends on an authored field.
|
||||
* Attaching a skill is that: ownership is read off `agent.skills`, and an
|
||||
* attachment made in Configure has to be the one the page sees, or the two
|
||||
* halves of the product disagree about what this agent owns.
|
||||
*/
|
||||
const definitions = useAgentDefinitions();
|
||||
const shippedIds = React.useMemo(() => new Set(AGENTS.map((a) => a.id)), []);
|
||||
const stored = React.useMemo(
|
||||
() => sourcesFrom(definitions.data || [], shippedIds),
|
||||
[definitions.data, shippedIds]
|
||||
);
|
||||
const agents = React.useMemo(
|
||||
() => allAgents(preferences.customAgents || [], { customSkills: preferences.customSkills || [] }),
|
||||
[preferences.customAgents, preferences.customSkills]
|
||||
() => readAgentRegistry(stored, { customSkills: preferences.customSkills || [] }).agents,
|
||||
[stored, preferences.customSkills]
|
||||
);
|
||||
|
||||
const [selection, setSelection] = React.useState(() => readSelection());
|
||||
|
||||
@@ -2,14 +2,14 @@ import * as React from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
ArrowLeft, History, Maximize2, Minimize2, PanelRightClose, RotateCcw, Trash2, X,
|
||||
History, Home, Maximize2, Minimize2, PanelRightClose, Trash2, X,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Surface } from '@/components/ds/Surface';
|
||||
import { IconButton } from '@/components/ds/IconButton';
|
||||
import { Alert } from '@/components/ds/Alert';
|
||||
import {
|
||||
useAssignments, useAssignWorkers, useCreateEmployeeRole, useCreateJobPosting,
|
||||
useAssignments, useAssignWorkers, useCreateWorkerWithRole, useCreateJobPosting,
|
||||
useGenerateJobDescription,
|
||||
fetchOwliverSuggestions, useMarkInterviewReady, useOwliverSuggestions, useShiftRecords,
|
||||
useUpdateJobPosting,
|
||||
@@ -17,7 +17,9 @@ import {
|
||||
} from '@/lib/krowHooks';
|
||||
import { ROLE_CATEGORIES } from '@/lib/roleCategories';
|
||||
import { runAction } from '@/lib/skills/actions';
|
||||
import { allSkills, pageKeyForContext } from '@/lib/skills/registry';
|
||||
import { useUiEditing } from '@/components/ui-tree/UiEditingProvider';
|
||||
import { allSkills, pageKeyForContext, skillsForContext } from '@/lib/skills/registry';
|
||||
import { actionSuggestions } from '@/lib/skills/tools';
|
||||
import { suggestionChips } from '@/lib/skills/serverSuggestions';
|
||||
import { useWorkforcePaths } from '@/lib/skills/usePageSkills';
|
||||
import { profileForEmail } from '@/lib/skillGraph';
|
||||
@@ -39,7 +41,7 @@ import { PromptChips } from './PromptChips';
|
||||
*
|
||||
* Identity, not emptiness: `prompts` is recomputed on every keystroke, and a
|
||||
* fresh `[]` each time would rerender the row — and anything memoized against
|
||||
* it — throughout the whole of a query that is still too short to match.
|
||||
* it — on every turn that offers nothing.
|
||||
*/
|
||||
const EMPTY_PROMPTS = [];
|
||||
|
||||
@@ -53,41 +55,6 @@ const EMPTY_PROMPTS = [];
|
||||
*/
|
||||
const EMPTY_SUGGESTIONS = [];
|
||||
|
||||
/**
|
||||
* How long a pause counts as having finished typing.
|
||||
*
|
||||
* Short enough that the chips feel like they are keeping up, long enough that a
|
||||
* word typed at speed is one request rather than eight. The endpoint is cached
|
||||
* per query, so a reader deleting back to something already asked pays nothing
|
||||
* either way.
|
||||
*/
|
||||
const SUGGEST_DEBOUNCE_MS = 180;
|
||||
|
||||
/**
|
||||
* A value, held still until it stops changing.
|
||||
*
|
||||
* Deliberately generic and local: it debounces the composer's contents and
|
||||
* nothing else, and the alternative — debouncing inside the query hook — would
|
||||
* make every other caller of that hook pay for a delay it did not ask for.
|
||||
*/
|
||||
function useDebounced(value, delay) {
|
||||
const [settled, setSettled] = React.useState(value);
|
||||
|
||||
React.useEffect(() => {
|
||||
/* An emptied composer settles immediately. Waiting would leave the previous
|
||||
query's chips under a blank input for a fifth of a second, which reads as
|
||||
the panel not having noticed. */
|
||||
if (!value) {
|
||||
setSettled(value);
|
||||
return undefined;
|
||||
}
|
||||
const timer = setTimeout(() => setSettled(value), delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delay]);
|
||||
|
||||
return settled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owliver History — the conversations that came before.
|
||||
*
|
||||
@@ -169,77 +136,98 @@ function HistoryView({ groups, currentId, onOpen, onForget }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the Back to Home row on scroll direction, not scroll position.
|
||||
* Whether the composer is offering anything, as one rule in one place.
|
||||
*
|
||||
* Hidden while reading downwards, back the instant the user scrolls up — the
|
||||
* way out of a long answer should not be something you have to scroll all the
|
||||
* way to the top to reach. Direction is read off the panel's own scrolling
|
||||
* element (the body region below), never the window: the page behind the panel
|
||||
* does not move when the thread does.
|
||||
* Extracted because it is the whole of the interaction and every clause is a
|
||||
* decision somebody could reasonably make differently:
|
||||
*
|
||||
* The listener is passive and rAF-throttled, and `setVisible` is only ever
|
||||
* called with a value that can change — React bails out on an identical one, so
|
||||
* a fast scroll costs at most one render per direction change.
|
||||
*
|
||||
* `pinToBottom` is the same follow-the-stream scroll the panel already did,
|
||||
* routed through here so a programmatic jump is not mistaken for the user
|
||||
* scrolling down and does not hide the control under them.
|
||||
* focused — an offer belongs to the thing you are about to type into. An
|
||||
* unfocused composer showing suggestions is the panel talking
|
||||
* first.
|
||||
* !busy — nothing is offered while an answer is still arriving; the next
|
||||
* question is not knowable until this one lands.
|
||||
* chat — History is a different view with a different body.
|
||||
* count — nothing to say, nothing shown, and this clause now carries what
|
||||
* a `!typed` test used to. Typing does not hide the panel by
|
||||
* rule; it changes what the panel HAS. An empty composer offers
|
||||
* follow-ups, a typed action intent offers the matching actions,
|
||||
* and arbitrary partial text matches no action and so offers
|
||||
* nothing — which is the same outcome by a more honest route,
|
||||
* and the reason "Create" can be answered while "he" cannot.
|
||||
*/
|
||||
function useDirectionalNav(scrollRef, { active, resetKey }) {
|
||||
const [visible, setVisible] = React.useState(true);
|
||||
const lastY = React.useRef(0);
|
||||
export const shouldShowSuggestions = ({ focused, busy, view, count }) => Boolean(
|
||||
focused && !busy && view === 'chat' && count > 0
|
||||
);
|
||||
|
||||
/* Ignore sub-pixel and trackpad jitter, but nothing a deliberate scroll would
|
||||
produce: a real direction change clears this within one frame. */
|
||||
const NOISE = 4;
|
||||
/**
|
||||
* The questions on offer, above the composer they belong to.
|
||||
*
|
||||
* A labelled panel rather than a bare row of chips: the label is what makes
|
||||
* three sentences read as an offer rather than as something the assistant just
|
||||
* said. It sits inside the composer's own region, above the input and below the
|
||||
* conversation, so it reads as part of the thing you are about to type into.
|
||||
*
|
||||
* Always mounted, height animated. Mounting on open would move the input the
|
||||
* instant the panel appeared and again when it left, so the caret would jump
|
||||
* under the reader's hands every time they focused the box. Animating a
|
||||
* collapsed height keeps the geometry continuous, and `pointer-events-none`
|
||||
* plus `inert`-style tab removal means the closed panel cannot be clicked or
|
||||
* tabbed into.
|
||||
*
|
||||
* `max-h` is generous enough for four wrapped questions and scrolls past that,
|
||||
* so a narrow panel on a small screen cannot push the composer off the bottom.
|
||||
*/
|
||||
function SuggestedQuestions({ prompts, open, onSelect }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden transition-all duration-200 ease-out motion-reduce:transition-none',
|
||||
open
|
||||
? 'max-h-56 translate-y-0 opacity-100'
|
||||
: 'pointer-events-none max-h-0 translate-y-1 opacity-0'
|
||||
)}
|
||||
aria-hidden={open ? undefined : 'true'}
|
||||
>
|
||||
<p className="px-1 pb-1.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4">
|
||||
Suggested questions
|
||||
</p>
|
||||
{/* The existing chip component, and the existing submit path behind it —
|
||||
`runPrompt` is the same handler a typed question goes through. */}
|
||||
<PromptChips
|
||||
prompts={prompts}
|
||||
onSelect={onSelect}
|
||||
align="start"
|
||||
focusable={open}
|
||||
className="pb-1"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const pinToBottom = React.useCallback(() => {
|
||||
/**
|
||||
* Keep the thread pinned to its newest content.
|
||||
*
|
||||
* All that survives of a floating "Back to Home" row that used to sit between
|
||||
* the header and the conversation. That row was `absolute`, so it did not take
|
||||
* part in the layout — it OVERLAID the top of the scrolling region, and the
|
||||
* first line or two of a long answer arrived underneath it. It hid on
|
||||
* down-scroll to compensate, which meant the fix for a control covering the
|
||||
* answer was to make the control disappear while you read.
|
||||
*
|
||||
* The navigation it carried now lives in the header, where the panel's other
|
||||
* controls already are and where nothing can cover the response. What is left
|
||||
* here is the scroll behaviour, which was always a separate concern that had
|
||||
* been folded in because the two happened to share a listener.
|
||||
*
|
||||
* Direct `scrollTop` rather than smooth scrolling: at streaming frequency a
|
||||
* smooth scroll never catches up and the thread visibly lags the text.
|
||||
*/
|
||||
function usePinToBottom(scrollRef) {
|
||||
return React.useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
/* Adopt the new position before the scroll event lands, so the next read
|
||||
sees no delta and the row keeps whatever state the user left it in. */
|
||||
lastY.current = el.scrollTop;
|
||||
}, [scrollRef]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
/* Nothing to hide when the row is not rendered — and a fresh thread or a
|
||||
newly opened panel always starts with it showing. */
|
||||
setVisible(true);
|
||||
if (!el || !active) return undefined;
|
||||
|
||||
lastY.current = el.scrollTop;
|
||||
let frame = 0;
|
||||
|
||||
const read = () => {
|
||||
frame = 0;
|
||||
const y = el.scrollTop;
|
||||
if (y <= 0) {
|
||||
lastY.current = y;
|
||||
setVisible(true);
|
||||
return;
|
||||
}
|
||||
const delta = y - lastY.current;
|
||||
/* Leave `lastY` alone below the threshold so slow scrolls accumulate
|
||||
rather than being swallowed frame by frame. */
|
||||
if (Math.abs(delta) < NOISE) return;
|
||||
lastY.current = y;
|
||||
setVisible(delta < 0);
|
||||
};
|
||||
|
||||
const onScroll = () => {
|
||||
if (!frame) frame = requestAnimationFrame(read);
|
||||
};
|
||||
|
||||
el.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => {
|
||||
el.removeEventListener('scroll', onScroll);
|
||||
if (frame) cancelAnimationFrame(frame);
|
||||
};
|
||||
}, [scrollRef, active, resetKey]);
|
||||
|
||||
return { visible, pinToBottom };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -449,13 +437,18 @@ export default function KrowAssistant({
|
||||
* so adding a conversation is adding an entry here rather than another prop
|
||||
* threaded through the panel.
|
||||
*/
|
||||
const createRole = useCreateEmployeeRole();
|
||||
const createRole = useCreateWorkerWithRole();
|
||||
const createEmployeeRole = React.useCallback(async (draft, skill, status) => {
|
||||
const result = runAction('create_employee_role', { draft, skill, status });
|
||||
if (result?.type !== 'create_employee_role') return null;
|
||||
return createRole.mutateAsync(result.data);
|
||||
}, [createRole]);
|
||||
|
||||
/* The page's layout session, mounted by the layout above both this panel and
|
||||
the page. Null on a surface that composes no tree, which is every page that
|
||||
has not migrated — and every branch that reads it checks first. */
|
||||
const uiEditing = useUiEditing();
|
||||
|
||||
const flowWriters = React.useMemo(() => ({
|
||||
position: createPosition,
|
||||
'employee-role': createEmployeeRole,
|
||||
@@ -516,13 +509,16 @@ export default function KrowAssistant({
|
||||
* used here — it was computed before the row existed.
|
||||
*/
|
||||
const queryClient = useQueryClient();
|
||||
const refreshSuggestions = React.useCallback(async () => {
|
||||
const refreshSuggestions = React.useCallback(async ({ query = '' } = {}) => {
|
||||
if (!owliverPage) return [];
|
||||
const fresh = await queryClient.fetchQuery({
|
||||
/* The empty query string is the untyped request — the same key
|
||||
`useOwliverSuggestions` uses when the composer is empty. */
|
||||
queryKey: ['owliverSuggestions', owliverPage, ''],
|
||||
queryFn: () => fetchOwliverSuggestions({ page: owliverPage }),
|
||||
/* An empty query is the untyped request — the same key
|
||||
`useOwliverSuggestions` uses when the composer is empty, and what a
|
||||
write wants: the page changed, so ask what matters on it now.
|
||||
A question passed in ranks the same catalogue AGAINST that question,
|
||||
which is what makes a follow-up follow from something. */
|
||||
queryKey: ['owliverSuggestions', owliverPage, query],
|
||||
queryFn: () => fetchOwliverSuggestions({ page: owliverPage, query }),
|
||||
staleTime: 0,
|
||||
});
|
||||
return suggestionChips(fresh || [], context.id);
|
||||
@@ -619,7 +615,11 @@ export default function KrowAssistant({
|
||||
onNavigate: goToPage,
|
||||
onAction: performAction,
|
||||
flowWriters,
|
||||
uiEditing,
|
||||
companies,
|
||||
/* The postings this caller can already see — the evidence behind
|
||||
role-aware certification suggestions. */
|
||||
postings: facts.postings || [],
|
||||
workers,
|
||||
onRefreshSuggestions: refreshSuggestions,
|
||||
onUpdatePosition,
|
||||
@@ -670,12 +670,12 @@ export default function KrowAssistant({
|
||||
const scrollRef = React.useRef(null);
|
||||
const isEmpty = messages.length === 0 && !pending;
|
||||
|
||||
/* The Back to Home row only exists in the states that are not already home. */
|
||||
const showBackRow = view === 'history' || messages.length > 0 || Boolean(pending);
|
||||
const { visible: backVisible, pinToBottom } = useDirectionalNav(scrollRef, {
|
||||
active: showBackRow,
|
||||
resetKey: `${view}:${conversationId || ''}`,
|
||||
});
|
||||
const pinToBottom = usePinToBottom(scrollRef);
|
||||
|
||||
/* Whether there is anything to leave. The header's own control is shown on
|
||||
exactly the states that are not already home — the same test the removed
|
||||
row used, now deciding a button rather than an overlay. */
|
||||
const canGoHome = view === 'history' || messages.length > 0 || Boolean(pending);
|
||||
|
||||
/* Greeting and suggestions come from live data, so they recompute only when
|
||||
the data or the page actually changes. */
|
||||
@@ -685,60 +685,98 @@ export default function KrowAssistant({
|
||||
);
|
||||
|
||||
/**
|
||||
* What the chip row actually shows, which is one of three separate things.
|
||||
* What the composer offers, and when.
|
||||
*
|
||||
* They are separate states, not one merged list, because they answer to
|
||||
* different owners. Follow-ups belong to the answer that raised them; the
|
||||
* suggestions belong to the server. Only one of them can be true at a time,
|
||||
* and the order below is that precedence.
|
||||
* Three rules, and they are about DIFFERENT questions — what to show, and
|
||||
* whether to show anything at all.
|
||||
*
|
||||
* 1. Follow-ups. When an answer ends by asking something, its chips *are*
|
||||
* the answers to it — the role list after "create a position". They are
|
||||
* never capped and never filtered, and they stand until the next turn or
|
||||
* until the reader starts typing something else.
|
||||
* WHAT. Before the first question, the page's own suggested questions: the
|
||||
* reader has asked nothing, so there is nothing to follow up and the useful
|
||||
* offer is the range of what this page can answer. After an answer, the
|
||||
* follow-ups that answer carried — questions this conversation has not
|
||||
* already covered, worked out in `nextSteps`. Never both: a thread that has
|
||||
* run out of new ground shows nothing rather than falling back to the
|
||||
* catalogue it has already been through.
|
||||
*
|
||||
* 2. The server's suggestions. From the moment there is something in the
|
||||
* composer, `GET /api/v1/owliver/suggestions` is asked what this page
|
||||
* can usefully answer for this query, and its reply is rendered in the
|
||||
* order it arrived. The panel does not rank, score, filter or reorder
|
||||
* it: which readings exist depends on the caller's role and on what is
|
||||
* actually in the database, and neither of those is knowable here.
|
||||
* WHEN. Only while the composer has focus and is empty. Suggestions used to
|
||||
* appear from the second character typed, which is the wrong moment twice
|
||||
* over: a reader who is typing has already decided what to ask, and two
|
||||
* characters is not enough to know what they mean. So typing hides them and
|
||||
* the reader's own text is never touched.
|
||||
*
|
||||
* 3. Nothing. An empty composer offers no chips at all. The panel used to
|
||||
* open on a dozen of them, which taught the range of what could be asked
|
||||
* by saying all of it at once and pushed the composer — the thing the
|
||||
* reader came for — under a wall of suggestions. The greeting still
|
||||
* carries the page's context; `buildIntro` reads the same fact sheet it
|
||||
* always did.
|
||||
* The typed-query branch is gone with it. The endpoint still takes a query
|
||||
* and `nextSteps` still uses it — that is what makes a follow-up follow from
|
||||
* something — but nothing asks it on a keystroke any more.
|
||||
*/
|
||||
const followUp = messages[messages.length - 1]?.followUp;
|
||||
const typed = input.trim();
|
||||
const [composerFocused, setComposerFocused] = React.useState(false);
|
||||
|
||||
/**
|
||||
* The request behind (2), debounced.
|
||||
*
|
||||
* The endpoint is cheap and cached per query, but a keystroke is not a
|
||||
* decision — a reader typing "positions" would otherwise fire nine requests
|
||||
* to see the answer to the ninth. A short delay means one request per pause,
|
||||
* and `placeholderData` in the hook keeps the previous answer on screen
|
||||
* meanwhile so the row does not empty and refill.
|
||||
*
|
||||
* Only asked while there is something in the composer. An empty one offers no
|
||||
* chips, so there would be nothing to render the answer into — and a reader
|
||||
* who starts typing has left the follow-up behind, which is why typing
|
||||
* supersedes it rather than being ranked against it.
|
||||
*/
|
||||
const debouncedQuery = useDebounced(typed, SUGGEST_DEBOUNCE_MS);
|
||||
const { data: suggested = EMPTY_SUGGESTIONS } = useOwliverSuggestions({
|
||||
/* The page's own questions, for a thread that has not started. One untyped
|
||||
request, cached by the hook, asked only while it could be shown. */
|
||||
const { data: pageSuggestions = EMPTY_SUGGESTIONS } = useOwliverSuggestions({
|
||||
page: owliverPage,
|
||||
query: debouncedQuery,
|
||||
enabled: Boolean(debouncedQuery),
|
||||
enabled: Boolean(owliverPage) && messages.length === 0,
|
||||
});
|
||||
|
||||
/**
|
||||
* What is on offer, which now depends on whether anything has been typed.
|
||||
*
|
||||
* TYPED — the actions this page can perform that the text is starting to
|
||||
* name, and nothing else. "Create" reaches "Create a position" here because
|
||||
* that is a skill on this page that declares an action; "he" reaches nothing,
|
||||
* and neither does "abc". This is the narrow case the composer was missing:
|
||||
* a reader typing an action intent had to finish the sentence unaided, while
|
||||
* a reader typing anything at all used to get the whole page catalogue.
|
||||
*
|
||||
* EMPTY — the follow-ups the last answer left, or, before a thread starts,
|
||||
* what this page can be asked. Unchanged.
|
||||
*/
|
||||
const reachableSkills = React.useMemo(
|
||||
() => skillsForContext(context.id, disabledSkills, preferences.customSkills || []),
|
||||
[context.id, disabledSkills, preferences.customSkills]
|
||||
);
|
||||
|
||||
const prompts = React.useMemo(() => {
|
||||
if (!typed) return followUp?.length ? followUp : EMPTY_PROMPTS;
|
||||
return suggestionChips(suggested, context.id);
|
||||
}, [typed, followUp, suggested, context.id]);
|
||||
if (typed) return actionSuggestions(typed, reachableSkills);
|
||||
if (messages.length) return followUp?.length ? followUp : EMPTY_PROMPTS;
|
||||
return suggestionChips(pageSuggestions, context.id);
|
||||
}, [typed, reachableSkills, messages.length, followUp, pageSuggestions, context.id]);
|
||||
|
||||
const showSuggestions = shouldShowSuggestions({
|
||||
focused: composerFocused, busy, view, count: prompts.length,
|
||||
});
|
||||
|
||||
/**
|
||||
* Focus, read at the composer rather than at the input.
|
||||
*
|
||||
* A chip lives inside the same region, so moving to one keeps the region
|
||||
* focused and the panel open long enough for the click to land — which a
|
||||
* `blur` handler on the textarea alone would not do. `relatedTarget` is what
|
||||
* makes "clicked outside" mean it: focus leaving for anywhere else in the
|
||||
* document closes the panel.
|
||||
*/
|
||||
const onComposerBlur = React.useCallback((event) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget)) setComposerFocused(false);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Asking closes the panel, and only focus reopens it.
|
||||
*
|
||||
* A blur handler alone was not enough, which a live run showed: after a
|
||||
* question was sent, focus ended up on `document.body` while `composerFocused`
|
||||
* was still true, so the panel came back on its own under the finished answer
|
||||
* with nobody's cursor in the box. The subtree re-renders while the answer
|
||||
* streams, and a focus lost that way does not always arrive as a blur this
|
||||
* handler sees.
|
||||
*
|
||||
* So submitting is treated as what it is — the reader has finished with the
|
||||
* composer for now — rather than relying on a blur that may never come. The
|
||||
* state table is unchanged: focus opens it, everything else leaves it shut.
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
if (busy) setComposerFocused(false);
|
||||
}, [busy]);
|
||||
|
||||
/* The newest assistant turn, which is the one that carries the rating. */
|
||||
const lastAnswerIndex = React.useMemo(
|
||||
@@ -891,6 +929,10 @@ export default function KrowAssistant({
|
||||
different places with different affordances. The panel does the
|
||||
first job only; the registry behind it is unchanged. */}
|
||||
<div className="flex items-center gap-0.5">
|
||||
{/* The way back to a clean panel, positioned in front of History */}
|
||||
{canGoHome && (
|
||||
<IconButton icon={Home} label="Back to home" variant="ghost" size="sm" onClick={goHome} />
|
||||
)}
|
||||
{/* History lives with the other window controls rather than in the
|
||||
body, so the layout of the panel is unchanged whether or not
|
||||
there is anything to show. It toggles: pressing it again returns
|
||||
@@ -903,9 +945,6 @@ export default function KrowAssistant({
|
||||
aria-pressed={view === 'history'}
|
||||
onClick={() => setView((v) => (v === 'history' ? 'chat' : 'history'))}
|
||||
/>
|
||||
{messages.length > 0 && view === 'chat' && (
|
||||
<IconButton icon={RotateCcw} label="New conversation" variant="ghost" size="sm" onClick={reset} />
|
||||
)}
|
||||
{expanded
|
||||
? onRestore && (
|
||||
<IconButton icon={Minimize2} label="Restore the default workspace width" variant="ghost" size="sm" onClick={onRestore} />
|
||||
@@ -919,37 +958,6 @@ export default function KrowAssistant({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating directional Back to Home row — reveals on UP-scroll, hides on DOWN-scroll */}
|
||||
{showBackRow && (
|
||||
<div
|
||||
className={cn(
|
||||
`absolute top-[3.25rem] left-0 right-0 z-20 flex items-center justify-between gap-2
|
||||
border-b border-border bg-white/95 dark:bg-slate-900/95 px-4 py-2 shadow-sm backdrop-blur-md
|
||||
transition-all duration-200 ease-out motion-reduce:transition-none`,
|
||||
backVisible
|
||||
? 'translate-y-0 opacity-100 pointer-events-auto'
|
||||
: '-translate-y-full opacity-0 pointer-events-none'
|
||||
)}
|
||||
aria-hidden={backVisible ? undefined : 'true'}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goHome}
|
||||
tabIndex={backVisible ? undefined : -1}
|
||||
className="inline-flex items-center gap-1.5 rounded text-caption font-semibold text-ink-1 dark:text-white transition-colors
|
||||
hover:text-krow-blue focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50 cursor-pointer"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5 text-krow-blue" aria-hidden="true" />
|
||||
<span>Back to Home</span>
|
||||
</button>
|
||||
<span className="truncate text-[11px] font-medium text-ink-3">
|
||||
{view === 'history'
|
||||
? `${history.length} conversation${history.length === 1 ? '' : 's'}`
|
||||
: context.page}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/**
|
||||
* A capability test running through this panel.
|
||||
*
|
||||
@@ -1059,23 +1067,18 @@ export default function KrowAssistant({
|
||||
</div>
|
||||
|
||||
{/* ── Composer: fixed to the bottom in both states ───────────────── */}
|
||||
<div className="shrink-0 space-y-2 border-t border-white/60 px-3 pb-2.5 pt-2.5">
|
||||
{/* All suggestions on the landing screen, where they teach what can be
|
||||
asked. Capped once a thread exists, because from then on the vertical
|
||||
space belongs to the conversation. Expanded fits more per line, so it
|
||||
can afford one more.
|
||||
|
||||
Follow-ups are never capped: when Owliver has asked a question, its
|
||||
chips *are* the answers, and hiding three of the six roles would make
|
||||
the flow look broken. */}
|
||||
{!busy && view === 'chat' && (
|
||||
<PromptChips
|
||||
prompts={prompts}
|
||||
onSelect={runPrompt}
|
||||
max={isEmpty || followUp?.length ? undefined : expanded ? 4 : 3}
|
||||
align="start"
|
||||
/>
|
||||
)}
|
||||
{/* Focus is tracked on the whole region rather than on the textarea, so
|
||||
reaching for a suggestion does not close the panel out from under the
|
||||
click. In normal flow, never floating: an overlay here would sit on
|
||||
top of the answer, which is the mistake the removed Back to Home row
|
||||
made. The body above is `flex-1`, so it yields the height and the
|
||||
response stays whole and scrollable. */}
|
||||
<div
|
||||
className="shrink-0 space-y-2 border-t border-white/60 px-3 pb-2.5 pt-2.5"
|
||||
onFocusCapture={() => setComposerFocused(true)}
|
||||
onBlurCapture={onComposerBlur}
|
||||
>
|
||||
<SuggestedQuestions prompts={prompts} open={showSuggestions} onSelect={runPrompt} />
|
||||
{composer}
|
||||
<p className="px-1 text-[10px] leading-tight text-ink-4">
|
||||
Owliver reads this page's data. Check anything you act on.
|
||||
|
||||
@@ -24,7 +24,13 @@ import { cn } from '@/lib/utils';
|
||||
* Arrow keys move between chips, so the whole set is one tab stop.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function PromptChips({ prompts = [], onSelect, max = 0, align = 'center', className = '' }) {
|
||||
export function PromptChips({
|
||||
prompts = [], onSelect, max = 0, align = 'center', className = '',
|
||||
/* Taken out of the tab order while the row is collapsed but still mounted:
|
||||
a chip inside a zero-height container is invisible, and a Tab that lands on
|
||||
something invisible is a keyboard user losing their place. */
|
||||
focusable = true,
|
||||
}) {
|
||||
const chipRefs = React.useRef([]);
|
||||
const visible = max ? prompts.slice(0, max) : prompts;
|
||||
|
||||
@@ -59,6 +65,7 @@ export function PromptChips({ prompts = [], onSelect, max = 0, align = 'center',
|
||||
type="button"
|
||||
onClick={() => onSelect(prompt)}
|
||||
onKeyDown={(e) => onKeyDown(e, i)}
|
||||
tabIndex={focusable ? undefined : -1}
|
||||
title={prompt.prompt}
|
||||
className={cn(
|
||||
/* 14px is the radius `rounded-full` already produces on a one-line
|
||||
|
||||
@@ -22,10 +22,42 @@ import { usePageAction } from './PageContext';
|
||||
* snapshot, and settled blocks must not re-render with it.
|
||||
*/
|
||||
|
||||
const INLINE = /(\*\*[^*]+\*\*|_[^_]+_)/g;
|
||||
const INLINE = /(\*\*[^*]+\*\*|_[^_]+_|\[[^\]\n]+\]\([^)\s]*\))/g;
|
||||
const LINK = /^\[([^\]\n]+)\]\(([^)\s]*)\)$/;
|
||||
|
||||
/** Inline `**bold**` and `_italic_`. Kept deliberately small — structure is
|
||||
* carried by blocks, not by markup inside a paragraph. */
|
||||
/**
|
||||
* Where a link in an answer is allowed to point.
|
||||
*
|
||||
* An allow-list, and it is a security boundary rather than a tidiness rule: the
|
||||
* text being parsed here was written by a model, and a model reading a document
|
||||
* that says "link to javascript:…" is exactly the injection §I7 of CLAUDE.md
|
||||
* calls untrusted input. Anything not on this list renders as the plain text it
|
||||
* came from — visible, inert, and obvious.
|
||||
*
|
||||
* `#` alone is deliberately absent. A bare empty anchor is the shape the
|
||||
* citation sanitiser removes; one that reaches here is a link to nowhere, and
|
||||
* showing its label as text is better than an anchor that does nothing.
|
||||
*/
|
||||
const isSafeHref = (href) => (
|
||||
/^\/(?!\/)/.test(href) // in-app route
|
||||
|| /^#[^\s]+$/.test(href) // an anchor on this page, but not a bare '#'
|
||||
|| /^https?:\/\//i.test(href) // the open web
|
||||
|| /^mailto:[^\s]+$/i.test(href)
|
||||
);
|
||||
|
||||
/**
|
||||
* Inline `**bold**`, `_italic_` and `[label](href)`.
|
||||
*
|
||||
* Links were the gap: `markdownToBlocks` never touched them, and this renderer
|
||||
* had no case for them, so `[staffing policy](#staffing)` reached the reader as
|
||||
* its own source. Structure is still carried by blocks rather than by markup —
|
||||
* this stays three constructs, not a markdown library.
|
||||
*
|
||||
* A citation-shaped link never arrives here at all. `[337042b3](#)` is removed
|
||||
* upstream in `provider.js`, by the wrapper's shape, before a block is built —
|
||||
* so the two concerns stay apart: the sanitiser decides what is an internal
|
||||
* reference, and this decides how a real link looks.
|
||||
*/
|
||||
function Inline({ value }) {
|
||||
const parts = React.useMemo(() => String(value).split(INLINE).filter(Boolean), [value]);
|
||||
|
||||
@@ -36,6 +68,33 @@ function Inline({ value }) {
|
||||
if (part.startsWith('_') && part.endsWith('_')) {
|
||||
return <em key={i} className="text-ink-3">{part.slice(1, -1)}</em>;
|
||||
}
|
||||
|
||||
const link = LINK.exec(part);
|
||||
if (link) {
|
||||
const [, label, href] = link;
|
||||
if (!isSafeHref(href)) return part;
|
||||
/* An in-app route goes through the router, like every other internal link
|
||||
in this file — a full page load would throw away the conversation the
|
||||
reader is being pointed away from. Everything else is an anchor, and
|
||||
anything leaving the app opens away from it. */
|
||||
const external = /^https?:\/\//i.test(href);
|
||||
const className = 'font-medium text-krow-blue underline decoration-krow-blue/30 underline-offset-2'
|
||||
+ ' transition-colors hover:decoration-krow-blue focus-visible:outline-none'
|
||||
+ ' focus-visible:ring-2 focus-visible:ring-krow-blue/50 rounded-sm';
|
||||
|
||||
if (href.startsWith('/')) return <Link key={i} to={href} className={className}>{label}</Link>;
|
||||
return (
|
||||
<a
|
||||
key={i}
|
||||
href={href}
|
||||
className={className}
|
||||
{...(external ? { target: '_blank', rel: 'noreferrer noopener' } : null)}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return part;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -53,7 +53,22 @@ export function createAgentProvider({ baseUrl = '/api/v1' } = {}) {
|
||||
return {
|
||||
id: 'agent',
|
||||
|
||||
async *stream({ question, agent = null, confirmation = null, agentVersion = 0, signal }) {
|
||||
/**
|
||||
* Every snapshot leaves through here, and every snapshot is sanitised.
|
||||
*
|
||||
* The wrapper is the point. Below it there are four ways a response gets
|
||||
* built — streamed deltas, a completed run, a bounded run's trailing
|
||||
* message, and the two failure notes — and only one of them passes through
|
||||
* the markdown parser that removes citation ids. Sanitising at the yield
|
||||
* rather than at each construction means a fifth way, added later, cannot
|
||||
* reintroduce the leak by forgetting a call.
|
||||
*/
|
||||
async *stream(request) {
|
||||
for await (const snapshot of this.run(request)) yield sanitizeBlocks(snapshot);
|
||||
},
|
||||
|
||||
/** The run itself. Public only so `stream` can wrap it; call `stream`. */
|
||||
async *run({ question, agent = null, confirmation = null, agentVersion = 0, signal }) {
|
||||
/* No agent, no run. The panel resolves which agent covers the page before
|
||||
calling; reaching here without one means the routing layer changed and
|
||||
this should say so rather than guess at an agent id. */
|
||||
@@ -169,7 +184,9 @@ async function* readRunStream(response, signal) {
|
||||
|
||||
if (typeof event.delta === 'string') {
|
||||
text += event.delta;
|
||||
yield markdownToBlocks(text);
|
||||
/* Still arriving: the frontier rules apply, so a citation split
|
||||
across two frames is never rendered half-written. */
|
||||
yield markdownToBlocks(text, { partial: true });
|
||||
continue;
|
||||
}
|
||||
if (event.run) final = event.run;
|
||||
@@ -226,6 +243,290 @@ function toBlocks(run) {
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/* ── Citations ──────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Why any of this exists.
|
||||
*
|
||||
* The backend asks the model to cite: `knowledge/context.go` tells it "Each
|
||||
* <source> carries an id: cite it when you use what it says", and the
|
||||
* `knowledge_search` tool repeats it. What neither does is say HOW — so the
|
||||
* model picks a format, and picks a different one on a different day. The ids
|
||||
* themselves are `knowledge_chunks.id`, which are UUIDs, and the model quotes
|
||||
* them whole or truncated to their first block.
|
||||
*
|
||||
* The panel has no citation surface to render any of that into, so whatever
|
||||
* shape the model chose arrives on screen as raw markup. The formats seen so
|
||||
* far are a `<cite>` tag and a markdown link to an empty anchor; the rules
|
||||
* below are written against the SHAPE of an identifier rather than against
|
||||
* either format's syntax, so a third spelling of the same idea is far more
|
||||
* likely to be caught than to be a new bug.
|
||||
*
|
||||
* A citation id is hex and dashes — a UUID or a leading run of one. That is
|
||||
* what makes `10 applications`, `97% coverage` and `113 shifts` safe: decimal
|
||||
* counts in prose are not addresses, are never inside citation syntax, and no
|
||||
* rule here looks at a bare number.
|
||||
*/
|
||||
const CITATION_ID = String.raw`[0-9a-f]{4,}(?:-[0-9a-f]{4,})*`;
|
||||
|
||||
/**
|
||||
* The tag spelling — `<cite id="…">…</cite>`, whatever attributes it carries.
|
||||
*
|
||||
* Matched by TAG NAME, never by id: the ids are minted per run, so a rule
|
||||
* written against the ones in today's output would let tomorrow's through.
|
||||
*/
|
||||
const CITATION_TAG = /<\/?cit(?:e|ation)\b[^>]*>/gi;
|
||||
|
||||
/**
|
||||
* The link spelling, and the brackets the model wraps a run of them in —
|
||||
* `([337042b3](#), [2b94bc43](#))`.
|
||||
*
|
||||
* Identified by two conditions TOGETHER, never either alone: the target must be
|
||||
* a bare `#` anchor, AND the label must look like an identifier rather than
|
||||
* words. A real link has a real href, a real anchor link has a destination
|
||||
* after the `#`, and a link a person would click has a label they could read.
|
||||
* Requiring both is what keeps `[staffing policy](#staffing)`, `[Read more](/docs)`
|
||||
* and even `[Read more](#)` on screen.
|
||||
*
|
||||
* The group is removed whole rather than link by link, because removing them
|
||||
* one at a time leaves `(, )` behind — which reads worse than the ids did.
|
||||
*/
|
||||
const CITATION_GROUP = new RegExp(
|
||||
String.raw`\s*\(\s*\[${CITATION_ID}\]\(#\)(?:\s*,\s*\[${CITATION_ID}\]\(#\))*\s*\)`,
|
||||
'gi'
|
||||
);
|
||||
const CITATION_LINK = new RegExp(String.raw`\s*\[${CITATION_ID}\]\(#\)`, 'gi');
|
||||
|
||||
/**
|
||||
* The prose spelling: the model narrating the attribute rather than marking it
|
||||
* up — `(id `f34e8ef0-…`)`, `(reference `…`)`, `(source: `…`)`.
|
||||
*
|
||||
* Why this exists is the same reason the other two do. `context.go` hands the
|
||||
* model `<source id="…">` and tells it to cite the id without saying how, so
|
||||
* the model reaches for whatever syntax feels natural that day. This one is not
|
||||
* markup at all — it is the id written out in a parenthesis, which is why no
|
||||
* tag rule and no link rule saw it.
|
||||
*
|
||||
* THE WRAPPER IS WHAT IDENTIFIES IT, NEVER THE ID. That distinction is the
|
||||
* whole rule and it has to survive future edits: a worker's record id is the
|
||||
* same shape as a chunk id, so a rule that recognised ids rather than wrappers
|
||||
* would delete real data off a profile. `Worker ID: f34e8ef0-…` has no
|
||||
* parenthesis, so nothing here reaches it; `(id `f34e8ef0-…`)` is a
|
||||
* parenthesis containing a reference word and nothing but ids, which is not a
|
||||
* shape prose takes for any other reason.
|
||||
*
|
||||
* Backticks are optional on each side independently, because a model that opens
|
||||
* a code span and forgets to close it before the bracket must not defeat this.
|
||||
*/
|
||||
const REF_WORD = String.raw`(?:ids?|refs?|references?|sources?|citations?)`;
|
||||
const TICKED_ID = String.raw`\x60?${CITATION_ID}\x60?`;
|
||||
const CITATION_LABELLED = new RegExp(
|
||||
String.raw`\s*\(\s*${REF_WORD}\s*:?\s*${TICKED_ID}(?:\s*,\s*${TICKED_ID})*\s*\)`,
|
||||
'gi'
|
||||
);
|
||||
|
||||
/**
|
||||
* Ids in square brackets with nothing else in them — `[uuid]`, `[uuid, uuid]`.
|
||||
*
|
||||
* A markdown link is `[label](target)`; a bracket holding only identifiers is
|
||||
* not a link and is not something prose does. The lookahead leaves anything
|
||||
* followed by `(` to the link rules, so a genuine link whose label happens to
|
||||
* be a reference number keeps its destination and stays on screen.
|
||||
*/
|
||||
/* Stricter than CITATION_ID, and only here. A bare bracket has no reference
|
||||
word to disambiguate it, so the id itself has to carry the evidence: at least
|
||||
eight hex characters, or a dashed group. Without that `[2026]` is four hex
|
||||
digits and a year in brackets would disappear from an answer. */
|
||||
const BRACKETABLE_ID = String.raw`(?:[0-9a-f]{8,}|[0-9a-f]{4,}(?:-[0-9a-f]{4,})+)`;
|
||||
const CITATION_BRACKETED = new RegExp(
|
||||
String.raw`\s*\[\s*\x60?${BRACKETABLE_ID}\x60?(?:\s*,\s*\x60?${BRACKETABLE_ID}\x60?)*\s*\](?!\()`,
|
||||
'gi'
|
||||
);
|
||||
|
||||
/**
|
||||
* Whatever is still arriving at the end of the text.
|
||||
*
|
||||
* The streaming half of the problem, and it is a real one rather than a
|
||||
* theoretical one: `readRunStream` re-parses the WHOLE accumulated answer on
|
||||
* every delta, so a citation split across two frames is a state the reader can
|
||||
* see. `…before a first shift ([3370` renders for as long as the next delta
|
||||
* takes to arrive.
|
||||
*
|
||||
* Both rules are anchored to the end of the text, so they can only ever
|
||||
* describe the frontier of the stream and never something the answer has
|
||||
* already moved past. The fragment is held back until it completes, at which
|
||||
* point the rules above remove it properly — which is buffering, expressed as
|
||||
* a parse rather than as a second copy of the text.
|
||||
*/
|
||||
const CITATION_TAG_PARTIAL = /<\/?[a-z]*$|<\/?cit(?:e|ation)\b[^>]*$/i;
|
||||
const CITATION_LINK_PARTIAL = new RegExp([
|
||||
/* An open bracket holding at least one COMPLETE citation and not yet closed:
|
||||
`…shift ([337042b3](#)` and `…shift ([337042b3](#), `. Without this the
|
||||
complete link is removed by the rule above and the `(` is stranded. */
|
||||
String.raw`\s*\((?:\s*\[${CITATION_ID}\]\(#\)\s*,?)+\s*(?:\[[0-9a-f-]*(?:\](?:\(#?)?)?)?$`,
|
||||
/* One part-written, with or without its bracket: `([3370`, `[337042b3`,
|
||||
`[337042b3](`, `[337042b3](#`. */
|
||||
String.raw`\s*\(?\s*\[[0-9a-f-]*(?:\](?:\(#?)?)?$`,
|
||||
].join('|'), 'i');
|
||||
|
||||
/**
|
||||
* The same two, part-written.
|
||||
*
|
||||
* `Policy (id `f34e8ef0-9a01-5921` is a state the reader can see, because the
|
||||
* stream re-parses everything on every delta. Both are anchored to the end, so
|
||||
* they describe only the frontier.
|
||||
*
|
||||
* The labelled rule accepts any short leading word rather than only a reference
|
||||
* word, because `(i` and `(id` are prefixes of one and `(` is a prefix of
|
||||
* everything. The cost is that an ordinary parenthetical is held back for the
|
||||
* frames between its bracket and its first non-hex character —
|
||||
* `Omar Haddad (hired 2026-01-0` waits, `…(hired 2026-01-04) starts` does not.
|
||||
* A parenthesis arriving a frame late is not something a reader can notice; a
|
||||
* half-written reference id is exactly what they reported.
|
||||
*/
|
||||
const CITATION_LABELLED_PARTIAL = new RegExp(
|
||||
String.raw`\s*\((?:[a-z]{0,12}[\s:]*\x60?[0-9a-f-]*(?:\s*,\s*\x60?[0-9a-f-]*)*\x60?)?$`,
|
||||
'i'
|
||||
);
|
||||
const CITATION_BRACKETED_PARTIAL = new RegExp(
|
||||
String.raw`\s*\[\s*\x60?[0-9a-f-]*(?:\s*,\s*\x60?[0-9a-f-]*)*\x60?$`,
|
||||
'i'
|
||||
);
|
||||
|
||||
/**
|
||||
* What a lifted citation leaves behind.
|
||||
*
|
||||
* `check ()`, `check (, )`, `check .` — the wrapper is gone and its punctuation
|
||||
* is not, and a sentence ending in an empty bracket reads as broken markup
|
||||
* rather than as a clean sentence. Applied after the removals, never before.
|
||||
*/
|
||||
const EMPTY_PARENS = /\s*\(\s*[,;]*\s*\)/g;
|
||||
const SPACE_BEFORE_PUNCTUATION = /\s+([.,;:!?])/g;
|
||||
const DOUBLED_SPACES = / {2,}/g;
|
||||
|
||||
/**
|
||||
* Removes citation markup, keeping the sentence inside it.
|
||||
*
|
||||
* The wrapper is addressing, not content — it tells a client which retrieved
|
||||
* chunk a claim came from — and with nowhere to render it the honest move is to
|
||||
* show the claim and drop the envelope. The backend's citation metadata is
|
||||
* untouched: it is still on the run, still in the trajectory, and this only
|
||||
* decides what reaches a reader.
|
||||
*
|
||||
* Content is never altered, only the wrapper around it, so markdown inside a
|
||||
* citation — bold, a bullet, a table row — parses exactly as it would have
|
||||
* unwrapped.
|
||||
*
|
||||
* If the panel ever grows a real citation affordance, this is the seam: parse
|
||||
* the ids out here into a block the renderer can draw, rather than discarding
|
||||
* them. Nothing else has to move.
|
||||
*/
|
||||
export function stripCitations(markdown, { partial = false } = {}) {
|
||||
let out = String(markdown ?? '')
|
||||
.replace(CITATION_TAG, '')
|
||||
.replace(CITATION_GROUP, '')
|
||||
.replace(CITATION_LABELLED, '')
|
||||
.replace(CITATION_BRACKETED, '');
|
||||
|
||||
/**
|
||||
* The frontier rules, and ONLY while there is a frontier.
|
||||
*
|
||||
* They describe something that is still being written, so they are wrong to
|
||||
* apply to a finished answer — and quietly so. `…and note [12]` ends in a
|
||||
* bracket holding two hex characters, which is indistinguishable from the
|
||||
* first two characters of an id still arriving. Mid-stream, holding it back
|
||||
* for a frame is right. At the end of a completed answer there is nothing
|
||||
* more coming, the bracket is all there will ever be, and removing it deletes
|
||||
* a footnote marker from the reader's answer.
|
||||
*
|
||||
* The caller knows which it is: `readRunStream` passes `partial` on a delta
|
||||
* and not on the final snapshot. That is the only place the distinction
|
||||
* exists, so it is the only place it can be made.
|
||||
*
|
||||
* Order is load-bearing. `…shift ([337042b3](#),` is a COMPLETE link inside
|
||||
* an unclosed bracket — strip the link first and `(,` is left on screen,
|
||||
* which is the broken bracket this exists to prevent. Matching the
|
||||
* unterminated group first takes the whole fragment.
|
||||
*/
|
||||
if (partial) {
|
||||
out = out
|
||||
.replace(CITATION_TAG_PARTIAL, '')
|
||||
.replace(CITATION_LINK_PARTIAL, '')
|
||||
.replace(CITATION_LABELLED_PARTIAL, '')
|
||||
.replace(CITATION_BRACKETED_PARTIAL, '');
|
||||
}
|
||||
|
||||
return out
|
||||
.replace(CITATION_LINK, '')
|
||||
.replace(EMPTY_PARENS, '')
|
||||
.replace(SPACE_BEFORE_PUNCTUATION, '$1')
|
||||
.replace(DOUBLED_SPACES, ' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Keys that carry text a person reads, on any block.
|
||||
*
|
||||
* An allow-list rather than a deny-list, because the two mistakes do not cost
|
||||
* the same: missing a display field leaks an id, while sanitising an address
|
||||
* field would corrupt a confirmation token, a route or a record id and break
|
||||
* what it points at. A new block type gets its display keys covered for free; a
|
||||
* new addressing key is safe by default.
|
||||
*/
|
||||
const DISPLAY_KEYS = new Set([
|
||||
'text', 'sub', 'label', 'title', 'summary', 'caption',
|
||||
'description', 'detail', 'note', 'heading', 'hint',
|
||||
]);
|
||||
|
||||
/** Keys whose value is a list of sentences rather than one. */
|
||||
const DISPLAY_LISTS = new Set(['items', 'warnings', 'details', 'lines', 'rows']);
|
||||
|
||||
/**
|
||||
* Citation-proofs a whole response, whatever shape it arrived in.
|
||||
*
|
||||
* `markdownToBlocks` strips the model's markdown, and for a completed answer
|
||||
* that is the whole story. It is NOT the whole story for the response: the same
|
||||
* provider also emits `note(run.message)` when a run did not complete,
|
||||
* `note(event.error.message)` when the stream fails, and `confirmation(payload)`
|
||||
* carrying server wording composed around model-supplied arguments. None of
|
||||
* those go through the markdown parser, so each was a way for an id to reach
|
||||
* the DOM without passing the one place that removes them.
|
||||
*
|
||||
* Rather than a `stripCitations` call at each — three sites today, and a fourth
|
||||
* the next time the provider learns to say something — every block the agent
|
||||
* provider yields goes through here.
|
||||
*
|
||||
* Walks recursively so nested shapes are reached (a table's rows, a
|
||||
* confirmation's warnings, an insight's items) and touches only the keys above:
|
||||
* `token`, `id`, `route`, `to` and everything else addressing-like is left
|
||||
* exactly as the server sent it.
|
||||
*/
|
||||
export function sanitizeBlocks(blocks) {
|
||||
return (blocks || []).map((block) => sanitizeValue(block, null));
|
||||
}
|
||||
|
||||
function sanitizeValue(value, key) {
|
||||
if (typeof value === 'string') {
|
||||
return DISPLAY_KEYS.has(key) || DISPLAY_LISTS.has(key) ? stripCitations(value) : value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
/* The key travels into the elements, strings and objects alike. A string in
|
||||
`items` is display text; an object in `rows` is a row, and only the key
|
||||
says so — its own cell keys are positional (`c0`, `c1`) and carry no
|
||||
meaning at all. An object in `columns` still defers to its own keys. */
|
||||
return value.map((entry) => sanitizeValue(entry, key));
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
/* A table row is `{ c0: 'Applied', c1: '4' }` — the cell keys are
|
||||
positional and carry no meaning, so the row itself marks them. */
|
||||
out[k] = key === 'rows' && typeof v === 'string' ? stripCitations(v) : sanitizeValue(v, k);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a model's markdown into the block vocabulary the panel already renders.
|
||||
*
|
||||
@@ -247,8 +548,8 @@ function toBlocks(run) {
|
||||
* parser would be a large dependency in exchange for handling footnotes nobody
|
||||
* writes.
|
||||
*/
|
||||
export function markdownToBlocks(markdown) {
|
||||
const lines = String(markdown).replace(/\r\n/g, '\n').split('\n');
|
||||
export function markdownToBlocks(markdown, { partial = false } = {}) {
|
||||
const lines = stripCitations(markdown, { partial }).replace(/\r\n/g, '\n').split('\n');
|
||||
const blocks = [];
|
||||
let paragraph = [];
|
||||
let listItems = null;
|
||||
@@ -353,7 +654,15 @@ function parseTable(lines, start) {
|
||||
* left alone — those go through `Inline`, which renders bold properly.
|
||||
*/
|
||||
function stripInline(value) {
|
||||
return String(value).replace(/\*\*(.+?)\*\*/g, '$1').replace(/`(.+?)`/g, '$1').trim();
|
||||
return String(value)
|
||||
.replace(/\*\*(.+?)\*\*/g, '$1')
|
||||
.replace(/`(.+?)`/g, '$1')
|
||||
/* A link keeps its label and loses its target. Headings and cells are drawn
|
||||
as plain strings by their components, so an anchor cannot survive here —
|
||||
and the label alone reads correctly, where the raw `[label](href)` does
|
||||
not. `Inline` renders the real thing everywhere a link CAN be one. */
|
||||
.replace(/\[([^\]\n]+)\]\([^)\s]*\)/g, '$1')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -438,7 +747,7 @@ export function createAssistantProvider() {
|
||||
export function createUnconfiguredProvider() {
|
||||
return {
|
||||
id: 'unconfigured',
|
||||
// eslint-disable-next-line require-yield
|
||||
|
||||
async *stream() {
|
||||
yield [note(
|
||||
'Owliver is not configured on this deployment. Set VITE_AGENT_API and give the '
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { doc, heading, insights, list, note, skillSection, text } from './blocks';
|
||||
import { resolveUiEdit } from './uiEdit';
|
||||
import { ASSISTANT_CONTEXTS } from './contexts';
|
||||
import { poolFor } from '@/lib/workforce';
|
||||
import { matchSkill } from '@/lib/skills/registry';
|
||||
@@ -634,7 +635,7 @@ function declaredAnswer({ skill, capability, question, skillContext }) {
|
||||
*/
|
||||
function resolveSkill({
|
||||
question, contextId, disabledSkills, customSkills, roles, skillCategories, courses,
|
||||
companies = [], skillContext = null,
|
||||
companies = [], postings = null, skillContext = null,
|
||||
}) {
|
||||
/**
|
||||
* One question, one skill, then one way of answering it.
|
||||
@@ -743,7 +744,7 @@ function resolveSkill({
|
||||
*/
|
||||
const registry = flowFor(skill);
|
||||
if (registry) {
|
||||
return { kind: 'skill', skill, ...beginFlow({ registry, question, skill, ctx: { roles, companies } }) };
|
||||
return { kind: 'skill', skill, ...beginFlow({ registry, question, skill, ctx: { roles, companies, postings } }) };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -866,6 +867,7 @@ export function resolveIntent({
|
||||
company question. Read off the postings the caller can already see, so it
|
||||
expands nobody's view — see the `@companies` note in flows/position.js. */
|
||||
companies = [],
|
||||
postings = null,
|
||||
/**
|
||||
* The active agent, and where the reader is.
|
||||
*
|
||||
@@ -880,6 +882,14 @@ export function resolveIntent({
|
||||
was one. Only the draft flow reads it; a typed question carries none and
|
||||
resolves exactly as it always did. */
|
||||
positionId = null,
|
||||
/**
|
||||
* The layout session for this page, when there is one.
|
||||
*
|
||||
* Carries the tree on screen and whether something is already being
|
||||
* previewed. Absent — or on a page that composes no tree — every branch below
|
||||
* resolves exactly as it did before this existed.
|
||||
*/
|
||||
ui = null,
|
||||
}) {
|
||||
const context = ASSISTANT_CONTEXTS[contextId] ?? null;
|
||||
|
||||
@@ -911,9 +921,21 @@ export function resolveIntent({
|
||||
const draftIntent = resolveDraftAction(question, workforce || { positions: [] }, positionId);
|
||||
if (draftIntent) return draftIntent;
|
||||
|
||||
/**
|
||||
* 1b. Changing the page itself.
|
||||
*
|
||||
* Ahead of the skills because a request to hide a section is about the
|
||||
* interface, and a skill trigger reading the same words would answer about
|
||||
* the data behind it. Tightly gated: `resolveUiEdit` returns null unless the
|
||||
* page composes a tree AND the words name something on it, a registered
|
||||
* panel type, or the layout — so an ordinary question is never taken.
|
||||
*/
|
||||
const uiIntent = resolveUiEdit({ question, ui });
|
||||
if (uiIntent) return uiIntent;
|
||||
|
||||
/* 2. Current page skills — specific triggers, ahead of the general reader. */
|
||||
const skill = resolveSkill({
|
||||
question, contextId, disabledSkills, customSkills, roles, skillCategories, courses, companies,
|
||||
question, contextId, disabledSkills, customSkills, roles, skillCategories, courses, companies, postings,
|
||||
/* The envelope travels beside the collections rather than replacing them:
|
||||
a resolver reads records, and the envelope says where the reader is. A
|
||||
source that needs a position still finds it exactly where it always was. */
|
||||
|
||||
177
src/components/ai-assistant/uiEdit.js
Normal file
177
src/components/ai-assistant/uiEdit.js
Normal file
@@ -0,0 +1,177 @@
|
||||
import { doc, list, note, text } from './blocks';
|
||||
import { matchUiEdit } from '@/lib/ui/intent';
|
||||
import { outlineTree } from '@/lib/ui/inspect';
|
||||
import { dataSourceLabel } from '@/lib/skills/surfaces';
|
||||
|
||||
/**
|
||||
* Owliver's half of a layout change.
|
||||
*
|
||||
* Turns a request into an intent the panel can act on, and into the words that
|
||||
* go back. The understanding itself is in `lib/ui/intent.js`; this decides what
|
||||
* to say about it.
|
||||
*
|
||||
* Every outcome is one of four kinds, and the split matters:
|
||||
*
|
||||
* - `ui-preview` an operation to show, not to keep
|
||||
* - `ui-apply` / `ui-discard` acting on what is already shown
|
||||
* - `ui-answer` a question back, or a refusal — nothing changes
|
||||
*
|
||||
* A preview is never applied in the same turn. The person asked for a change;
|
||||
* they have not yet seen it, and agreeing to something unseen is not agreement.
|
||||
*/
|
||||
|
||||
/** The chips offered while something is being previewed. */
|
||||
const PREVIEW_CHIPS = [
|
||||
{ label: 'Apply', prompt: 'Apply the layout change' },
|
||||
{ label: 'Discard', prompt: 'Discard the layout change' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Read a layout request.
|
||||
*
|
||||
* Returns null for anything that is not one, which is most of what is typed —
|
||||
* and returning null is what leaves every existing Owliver answer exactly as it
|
||||
* was. The gate is in `matchUiEdit`: a verb alone is never enough.
|
||||
*/
|
||||
export function resolveUiEdit({ question, ui }) {
|
||||
if (!ui?.available) return null;
|
||||
|
||||
const match = matchUiEdit(question, {
|
||||
tree: ui.tree,
|
||||
registry: ui.registry,
|
||||
role: ui.role,
|
||||
previewing: ui.previewing,
|
||||
/* Which page this is. Owliver may only offer, and only accept, what this
|
||||
page can actually hold — the same scope the visual editor's picker uses,
|
||||
so the two can never disagree about what is addable here. */
|
||||
page: ui.page,
|
||||
});
|
||||
if (!match) return null;
|
||||
|
||||
switch (match.kind) {
|
||||
case 'inspect':
|
||||
return { kind: 'ui-answer', doc: describe(ui.tree, ui.registry) };
|
||||
|
||||
case 'apply':
|
||||
return {
|
||||
kind: 'ui-apply',
|
||||
doc: doc(text('Saved. This page will look like this the next time you open it.')),
|
||||
};
|
||||
|
||||
case 'discard':
|
||||
return {
|
||||
kind: 'ui-discard',
|
||||
doc: doc(text('Put back the way it was. Nothing was saved.')),
|
||||
};
|
||||
|
||||
case 'plan':
|
||||
return {
|
||||
kind: 'ui-preview',
|
||||
op: match.op,
|
||||
doc: doc(
|
||||
text(`${match.summary}. This is a preview — nothing is saved yet.`),
|
||||
note('Choose Apply to keep it, or Discard to put it back.')
|
||||
),
|
||||
followUp: PREVIEW_CHIPS,
|
||||
};
|
||||
|
||||
/**
|
||||
* More than one thing fits.
|
||||
*
|
||||
* Named back rather than guessed at. Editing the wrong section while
|
||||
* somebody is looking at another one is the failure the whole target
|
||||
* resolver exists to avoid, and a coin toss here would reintroduce it.
|
||||
*/
|
||||
case 'ambiguous':
|
||||
return {
|
||||
kind: 'ui-answer',
|
||||
doc: doc(
|
||||
text('More than one part of this page fits that. Which did you mean?'),
|
||||
list(match.candidates.map((node) => `${node.title || node.label} (${node.id})`))
|
||||
),
|
||||
followUp: match.candidates.slice(0, 3).map((node) => ({
|
||||
label: node.title || node.label,
|
||||
prompt: `${node.id}`,
|
||||
})),
|
||||
};
|
||||
|
||||
case 'unknown':
|
||||
return {
|
||||
kind: 'ui-answer',
|
||||
doc: doc(
|
||||
text('I could not find that on this page.'),
|
||||
note('Ask what is on this page to see what can be changed.')
|
||||
),
|
||||
followUp: [{ label: 'What is on this page?', prompt: 'What is on this page?' }],
|
||||
};
|
||||
|
||||
/** A type nobody has registered. Offered the real ones rather than invented. */
|
||||
case 'unknown-type':
|
||||
return {
|
||||
kind: 'ui-answer',
|
||||
doc: doc(
|
||||
text('I do not have that kind of panel.'),
|
||||
text(`I can use: ${match.offered.join(', ')}.`)
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* A shape with no reading named.
|
||||
*
|
||||
* The one place a data source could be invented, and the place it is most
|
||||
* firmly refused: the choices come from the closed vocabulary, and the
|
||||
* person picks.
|
||||
*/
|
||||
case 'needs-source':
|
||||
return {
|
||||
kind: 'ui-answer',
|
||||
doc: doc(
|
||||
text(`What should the ${match.type.label} show?`),
|
||||
list(match.options.map(dataSourceLabel))
|
||||
),
|
||||
followUp: match.options.slice(0, 3).map((id) => ({
|
||||
label: dataSourceLabel(id),
|
||||
prompt: `Add a ${match.type.label} showing ${dataSourceLabel(id)}`,
|
||||
})),
|
||||
};
|
||||
|
||||
/**
|
||||
* Asked to apply or discard with nothing being previewed.
|
||||
*
|
||||
* Answered here rather than left to fall through, because falling through
|
||||
* sent the panel's own chip text to the model, which replied — correctly
|
||||
* for what it is — that layout changes are not in its scope. The honest
|
||||
* answer is that there is nothing to act on.
|
||||
*/
|
||||
case 'nothing-previewed':
|
||||
return {
|
||||
kind: 'ui-answer',
|
||||
doc: doc(
|
||||
text(match.op === 'apply'
|
||||
? 'There is nothing to apply — no layout change is being previewed.'
|
||||
: 'There is nothing to discard — no layout change is being previewed.'),
|
||||
note('Ask what is on this page to see what can be changed.')
|
||||
),
|
||||
followUp: [{ label: 'What is on this page?', prompt: 'What is on this page?' }],
|
||||
};
|
||||
|
||||
case 'refused':
|
||||
return { kind: 'ui-answer', doc: doc(text(match.message)) };
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** What is on the page, as a reading rather than a change. */
|
||||
function describe(tree, registry) {
|
||||
const lines = outlineTree(tree, { registry });
|
||||
if (!lines.length) {
|
||||
return doc(text('This page is not one I can rearrange yet.'));
|
||||
}
|
||||
return doc(
|
||||
text('This page is made of these parts. You can hide, show or reorder any of them.'),
|
||||
list(lines),
|
||||
note('Say for example "hide the audit log" or "move the timeline to the top".')
|
||||
);
|
||||
}
|
||||
@@ -185,6 +185,60 @@ function normalizeDraftChip(chip) {
|
||||
return chip;
|
||||
}
|
||||
|
||||
/**
|
||||
* One question, reduced to what it asks.
|
||||
*
|
||||
* Case, surrounding space and a trailing question mark are not differences, so
|
||||
* "What should I do next?" and "what should i do next" are one question and are
|
||||
* not offered twice.
|
||||
*/
|
||||
const asQuestion = (value) => String(value || '').trim().toLowerCase().replace(/[?.!]+$/, '');
|
||||
|
||||
/**
|
||||
* The chips to offer after an answer: what this conversation has not covered.
|
||||
*
|
||||
* Two rules, and the second is the one that matters. The suggestions are ranked
|
||||
* by the SERVER against the question just asked — the panel does not decide what
|
||||
* is worth asking, it only decides what has already been said — and then
|
||||
* anything this thread has asked or already offered is removed.
|
||||
*
|
||||
* Without that second rule the row repeats. A page carries a handful of intents
|
||||
* and the top of that list barely moves between turns, so the same three chips
|
||||
* come back after every answer, including the one the reader has just pressed.
|
||||
* Removing what has been used leaves genuinely new ground each time and runs out
|
||||
* honestly rather than looping.
|
||||
*
|
||||
* There is NO fallback to the page's own ranking, and that is the correction a
|
||||
* live run forced. Asking "Summarize hiring activity" matches nothing in the
|
||||
* catalogue, so nothing was excluded, so the fallback returned the page's top
|
||||
* three — and the reader got "How healthy is the platform right now?" under an
|
||||
* answer about hiring activity, which is the generic-catalogue behaviour this
|
||||
* function exists to end. A page ranking is what to ask on a PAGE; it is not a
|
||||
* follow-up to anything. When the conversation has no next question, the honest
|
||||
* answer is none.
|
||||
*/
|
||||
export async function nextSteps({ question, history, refresh }) {
|
||||
if (!refresh) return undefined;
|
||||
|
||||
const used = new Set([asQuestion(question)]);
|
||||
for (const message of history) {
|
||||
if (message.role === 'user') used.add(asQuestion(message.text));
|
||||
for (const chip of message.followUp || []) used.add(asQuestion(chip.prompt || chip.label));
|
||||
}
|
||||
|
||||
const unused = (chips) => (chips || []).filter((chip) => {
|
||||
const key = asQuestion(chip.prompt || chip.label);
|
||||
if (!key || used.has(key)) return false;
|
||||
/* A list that repeats itself within one turn is the same defect at a
|
||||
smaller scale. */
|
||||
used.add(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
const onTopic = unused(await refresh({ query: question }));
|
||||
return onTopic.length ? onTopic : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* A stored thread, with any completed-then-reopen action stripped out.
|
||||
*
|
||||
@@ -246,6 +300,14 @@ export function useConversation({
|
||||
* "and now employee roles too". This is one entry in a map.
|
||||
*/
|
||||
flowWriters = {},
|
||||
/**
|
||||
* The page's layout session, when the surface has one.
|
||||
*
|
||||
* Read for the tree Owliver inspects and called to preview or keep a change.
|
||||
* Absent on every page that composes no tree, and every branch that touches
|
||||
* it checks first — so the panel behaves exactly as it did before on those.
|
||||
*/
|
||||
uiEditing = null,
|
||||
onAssignWorkers, onScheduleInterview,
|
||||
/* Finishing a draft: the same two mutations the Create Position form calls.
|
||||
Passed in rather than reached for, so this layer still writes nothing
|
||||
@@ -256,6 +318,9 @@ export function useConversation({
|
||||
/* The clients this organization already staffs for, offered as chips on the
|
||||
company question. Derived from postings the caller can already read. */
|
||||
companies = [],
|
||||
/* The caller's own postings, which is where role-to-certification relevance
|
||||
is observed from. See `certificationsForRole`. */
|
||||
postings = null,
|
||||
/* The worker profiles a declared role can be recorded against, as
|
||||
`{ id, name, email }`. Same rule: already-loaded, already-permitted rows. */
|
||||
workers = [],
|
||||
@@ -491,7 +556,7 @@ export function useConversation({
|
||||
skill,
|
||||
...advanceFlow({
|
||||
registry: flowFor(skill), flow: flowRef.current, answer: text, skill,
|
||||
ctx: { roles, companies, workers },
|
||||
ctx: { roles, companies, workers, postings },
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -507,11 +572,24 @@ export function useConversation({
|
||||
question: text,
|
||||
contextId: turnContext,
|
||||
disabledSkills: turnDisabled,
|
||||
customSkills, roles, skillCategories, companies,
|
||||
customSkills, roles, skillCategories, companies, postings,
|
||||
courses, workforce, skillContext, positionId,
|
||||
agent: turnAgent,
|
||||
agentCoversPage: turnCovers,
|
||||
agentSuggestion, owliverContext,
|
||||
ui: uiEditing
|
||||
? {
|
||||
available: Boolean(uiEditing.tree?.length),
|
||||
tree: uiEditing.tree,
|
||||
previewing: uiEditing.previewing,
|
||||
role: uiEditing.role || null,
|
||||
registry: uiEditing.registry || undefined,
|
||||
/* Where the reader is standing. What can be added here is a
|
||||
property of the page, not of the registry, and this is how
|
||||
the conversation learns it. */
|
||||
page: uiEditing.page || null,
|
||||
}
|
||||
: null,
|
||||
}),
|
||||
/* Only while a real agent is behind the panel. With the local
|
||||
simulator there is nothing better to defer TO, and deferring
|
||||
@@ -520,6 +598,37 @@ export function useConversation({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A layout change, acted on before the reply says what happened.
|
||||
*
|
||||
* Three kinds, and the split is the safety property: a preview is shown and
|
||||
* nothing is stored; an apply keeps what was already shown; an answer — a
|
||||
* question back, or a refusal — changes nothing at all. Owliver never
|
||||
* applies in the same turn it proposes.
|
||||
*
|
||||
* `propose` validates against the tree on screen and refuses rather than
|
||||
* previewing something that could not be kept, so a refusal here is
|
||||
* reported in the words the engine gave rather than a generic apology.
|
||||
*/
|
||||
if (intent.kind === 'ui-preview' && uiEditing) {
|
||||
const result = uiEditing.propose(intent.op);
|
||||
if (!result.ok) {
|
||||
intent = {
|
||||
...intent,
|
||||
kind: 'ui-answer',
|
||||
doc: doc(textBlock(result.problems[0]?.message || 'That change is not possible here.')),
|
||||
followUp: undefined,
|
||||
};
|
||||
}
|
||||
} else if (intent.kind === 'ui-apply' && uiEditing) {
|
||||
const result = await uiEditing.apply();
|
||||
if (!result.ok) {
|
||||
intent = { ...intent, doc: doc(textBlock('That change could not be saved.')) };
|
||||
}
|
||||
} else if (intent.kind === 'ui-discard' && uiEditing) {
|
||||
uiEditing.discard();
|
||||
}
|
||||
|
||||
/**
|
||||
* The one step that writes. It happens before the reply rather than after,
|
||||
* because the reply is the outcome — "Position created successfully" has to
|
||||
@@ -785,9 +894,53 @@ export function useConversation({
|
||||
// half-written answer loses what they were already reading. Stopping
|
||||
// before the first block, though, should leave no empty turn behind.
|
||||
if (latest.length) {
|
||||
/**
|
||||
* What to ask NEXT — which is not the same as what is worth asking.
|
||||
*
|
||||
* Every other path in this file ends its turn with `followUp`; the
|
||||
* agent path was the one that did not, so an agent answer was the only
|
||||
* kind that left the chip row empty.
|
||||
*
|
||||
* The first attempt at fixing that asked for the page's untyped
|
||||
* suggestions, and they are ranked by signal rather than by the
|
||||
* conversation — so a page with six intents offered its top three, and
|
||||
* offered the same three after every answer, including the one that had
|
||||
* just been asked. Three standing highlights repeated verbatim are not
|
||||
* follow-ups; they are the landing screen redrawn under a reply.
|
||||
*
|
||||
* So the question is passed to the server as the query, which ranks the
|
||||
* same catalogue against what was actually asked, and anything this
|
||||
* thread has already asked or already offered is removed. What is left
|
||||
* is what this conversation has not covered yet — which is what a
|
||||
* follow-up is. When nothing is left, nothing is shown: a panel with
|
||||
* nothing new to suggest should say so by being quiet, not by repeating
|
||||
* itself.
|
||||
*
|
||||
* A stopped run is offered nothing. The reader interrupted the answer,
|
||||
* so the next step it implies has not been established.
|
||||
*/
|
||||
let followUp;
|
||||
if (!controller.signal.aborted) {
|
||||
try {
|
||||
followUp = await nextSteps({
|
||||
question: text,
|
||||
history: messagesRef.current,
|
||||
refresh: onRefreshSuggestions,
|
||||
});
|
||||
} catch {
|
||||
/* The answer arrived; failing to fetch what to ask next is not a
|
||||
reason to withhold it. */
|
||||
}
|
||||
}
|
||||
|
||||
const next = [
|
||||
...messagesRef.current,
|
||||
{ role: 'assistant', blocks: latest, stopped: controller.signal.aborted || undefined },
|
||||
{
|
||||
role: 'assistant',
|
||||
blocks: latest,
|
||||
stopped: controller.signal.aborted || undefined,
|
||||
...(followUp ? { followUp } : null),
|
||||
},
|
||||
];
|
||||
messagesRef.current = next;
|
||||
persist(next);
|
||||
@@ -810,8 +963,13 @@ export function useConversation({
|
||||
onGenerateDescription, onAssignWorkers,
|
||||
onScheduleInterview,
|
||||
workforce, setFlow, disabledSkills,
|
||||
customSkills, roles, skillCategories, courses, skillContext, companies, workers,
|
||||
agent, agentCoversPage, agentSuggestion, owliverContext]);
|
||||
customSkills, roles, skillCategories, courses, skillContext, companies, workers, postings,
|
||||
agent, agentCoversPage, agentSuggestion, owliverContext,
|
||||
/* The layout session changes as a page's composition and the account's
|
||||
skills resolve, and a stale one means the tree Owliver inspects is the
|
||||
empty one from the first render — so a layout request falls through to
|
||||
the model and comes back as "I don't cover that". */
|
||||
uiEditing]);
|
||||
|
||||
const stop = React.useCallback(() => abortRef.current?.abort(), []);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user