The eleven `.js` files under `src/components/ai-assistant/`: the blocks
format, contexts, routing, history, placement, viewport, the greeting
and prompt tables in `dynamic`, the derivations in `insights`, and
`uiEdit`, whose boundary batch 1 already typed.
79 errors, and two optional markers cleared 63 of them.
`plural(n, word, irregular)` is called with two arguments sixty times in
`dynamic.ts` and its own body reads `irregular || \`${word}s\``, so the
third parameter has always been optional in everything but the
signature. `heading(value, sub)` is the same: `sub` is spread into the
block and `undefined` is what most callers mean. Marking both optional
is a statement about the existing contract, and the markers erase — the
emitted signatures still read `plural=(n,word,irregular)` and
`heading=(value,sub)`, checked in the output rather than assumed.
Those three `heading` errors landed in `lib/skills/workforceFlow.ts`,
already migrated and untouched here. Worth noting how that works: a
function's arity only starts being enforced on its callers once the file
defining it is TypeScript. Migrating a leaf makes claims about every
file that imports it, which is why this phase moves bottom-up.
The remaining nine were two `reduce` accumulators inferring `{}`, so
`Object.values` over them produced `unknown`. Both are now stated —
`{ label, count }` for the score bands, and the five-field hire grouping
— which is more useful than `any` and exactly what the lines below them
build.
Measured against `dde4ba6`:
typecheck 6 errors, unchanged; no new error anywhere
lint exit 0, 0 errors, 289 warnings
npm test 1684/1691, the same 7 failures verbatim
build exit 0, identical bundle hash 74d17e2d…
type erasure 83/83 byte-identical across Phase 11 so far
No baseline artifact touched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
1176 lines
46 KiB
TypeScript
1176 lines
46 KiB
TypeScript
import * as React from 'react';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { base44 } from '@/api/base44Client';
|
|
import {
|
|
useApplications, useCourses, useInterviews, useJobPostings, useStaff,
|
|
useUserActivity, useWorkerProfile, useWorkerProfiles,
|
|
} from '@/lib/krowHooks';
|
|
import { skillsForContext } from '@/lib/skills/registry';
|
|
import { advanceFlow } from '@/lib/skills/conversationFlow';
|
|
import { flowFor } from '@/lib/skills/flows';
|
|
import {
|
|
descriptionFailedReply, descriptionReply, draftActions, publishFailedReply, publishedFollowUp,
|
|
publishedReply, weightsSetReply, weightsUnchangedReply,
|
|
} from '@/lib/skills/draftFlow';
|
|
import {
|
|
assignmentDone, assignmentFailed, assignmentFollowUp, headcountFailed, headcountSet,
|
|
interviewDone, interviewFailed,
|
|
} from '@/lib/skills/workforceFlow';
|
|
import {
|
|
newConversationId, readHistory, recordFeedback, removeConversation, saveConversation,
|
|
} from './history';
|
|
import { storableContext } from '@/lib/agents/context';
|
|
import { buildFacts } from './insights';
|
|
import { agentRequest } from '@/lib/agents/runtime';
|
|
import { createAssistantProvider } from './provider';
|
|
import { preferAgent, resolveIntent } from './routing';
|
|
import { doc, text as textBlock, toSnapshots } from './blocks';
|
|
|
|
/** One provider instance for the app's lifetime. */
|
|
const provider = createAssistantProvider();
|
|
|
|
/**
|
|
* Whether a real agent is answering, as opposed to the local simulator.
|
|
*
|
|
* Read once, from the provider the app actually built. Not a separate flag: a
|
|
* second switch could disagree with the first, and "the panel thought it had an
|
|
* agent and did not" is a failure mode with no visible symptom beyond worse
|
|
* answers.
|
|
*/
|
|
const agentBacked = provider.id === 'agent';
|
|
|
|
/**
|
|
* Reads the same React Query caches the pages render from, so the assistant
|
|
* costs no extra requests and cannot be looking at a different snapshot than the
|
|
* card beside it.
|
|
*/
|
|
export function useAssistantFacts() {
|
|
const { data: applications = [] } = useApplications();
|
|
const { data: postings = [] } = useJobPostings();
|
|
const { data: interviews = [] } = useInterviews();
|
|
const { data: staff = [] } = useStaff();
|
|
const { data: profiles = [] } = useWorkerProfiles();
|
|
const { data: activity = [] } = useUserActivity();
|
|
const { data: courses = [] } = useCourses();
|
|
|
|
// The signed-in worker's own record, read from cache only: `enabled: false`
|
|
// means this never runs the query, which would *create* a WorkerProfile for an
|
|
// admin who has never opened Forge. So the Forge context is personal on pages
|
|
// that already loaded it, and falls back to library-level facts elsewhere
|
|
// rather than manufacturing a record as a side effect of opening a panel.
|
|
const { data: profile = null } = useWorkerProfile({ enabled: false });
|
|
|
|
/* The signed-in account, from the shared `['user']` cache the header and the
|
|
Profile page already read. */
|
|
const { data: user = null } = useQuery({
|
|
queryKey: ['user'],
|
|
queryFn: () => base44.auth.me().catch(() => null),
|
|
staleTime: Infinity,
|
|
});
|
|
|
|
return React.useMemo(
|
|
() => buildFacts({ applications, postings, interviews, staff, profiles, activity, courses, profile, user }),
|
|
[applications, postings, interviews, staff, profiles, activity, courses, profile, user]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The signed-in user's first name, for personalizing the greeting.
|
|
*
|
|
* Shares the `['user']` query key with the rest of the app, so this is a cache
|
|
* read rather than another request.
|
|
*/
|
|
export function useCurrentUserName() {
|
|
const { data } = useQuery({
|
|
queryKey: ['user'],
|
|
queryFn: () => base44.auth.me().catch(() => null),
|
|
staleTime: Infinity,
|
|
});
|
|
return data?.full_name || '';
|
|
}
|
|
|
|
/**
|
|
* Streams a locally built document through the same snapshot pipeline the
|
|
* provider uses, so a routing reply arrives exactly like any other answer
|
|
* rather than appearing instantly and reading as a different system.
|
|
*/
|
|
async function streamDocument({ document, signal, onFrame, onDone }) {
|
|
const snapshots = toSnapshots(document);
|
|
const complete = snapshots[snapshots.length - 1] || [];
|
|
|
|
let latest = [];
|
|
let interrupted = false;
|
|
|
|
for (const snapshot of snapshots) {
|
|
if (signal?.aborted) { interrupted = true; break; }
|
|
latest = snapshot;
|
|
onFrame(snapshot);
|
|
await new Promise((resolve) => setTimeout(resolve, 26));
|
|
}
|
|
|
|
/**
|
|
* An interrupted local document still settles complete.
|
|
*
|
|
* These documents are already fully computed before the first frame — the
|
|
* reveal is presentation, not generation. So an interruption should not leave
|
|
* a half-written sentence in the thread the way stopping a real generation
|
|
* would: there is nothing partial about the answer, only about the animation.
|
|
*
|
|
* This matters most after a write. The mutation refreshes every query it
|
|
* touched, the panel re-renders on the new data, and the reveal can be cut
|
|
* short — leaving the confirmation of something that definitely happened
|
|
* truncated mid-word.
|
|
*/
|
|
if (interrupted) onDone(complete);
|
|
else if (latest.length) onDone(latest);
|
|
}
|
|
|
|
/**
|
|
* The Create Position form, as an address.
|
|
*
|
|
* Named once because two things have to agree about it: nothing stored may
|
|
* offer a way back into the authoring form after a position has been saved.
|
|
*/
|
|
const AUTHORING_ROUTE = '/admin/positions/new';
|
|
|
|
/**
|
|
* The one wording every draft-continuation control resolves to.
|
|
*
|
|
* Named once because two things have to agree about it: the chip a suggestion
|
|
* writes today, and the chip a thread wrote months ago. Both must reach the
|
|
* same in-panel handler, so both are normalized to this phrasing rather than
|
|
* one being special-cased.
|
|
*/
|
|
const CONTINUE_DRAFT = /^continue the (.+) draft$/i;
|
|
|
|
/** The wording older builds used for the drafts question. */
|
|
const OLD_DRAFTS_PROMPT = /which positions are still in draft/i;
|
|
|
|
/**
|
|
* A stored chip, brought up to the current draft behavior.
|
|
*
|
|
* Threads persist as the blocks and chips they rendered, so a conversation
|
|
* written before draft continuation moved into the panel still carries the
|
|
* controls of that era: a `capability` that runs the read-only drafts report,
|
|
* or a prompt phrased so loosely that keyword routing sent it to the authoring
|
|
* form. Rewriting them in code was not enough — the ones already written down
|
|
* had to change behavior too.
|
|
*
|
|
* Deliberately narrow. Only chips that are *about continuing a draft* are
|
|
* touched, and only their routing metadata: the label the reader saw is left
|
|
* exactly as it was, so a thread reads as it always did and simply does the
|
|
* right thing when clicked.
|
|
*/
|
|
function normalizeDraftChip(chip) {
|
|
if (!chip) return chip;
|
|
|
|
const label = String(chip.label || '');
|
|
const prompt = String(chip.prompt || '');
|
|
|
|
/* "Continue the Bartender draft", however it was stored: the label is the
|
|
question, and no capability or route may intercept it. */
|
|
if (CONTINUE_DRAFT.test(label) || CONTINUE_DRAFT.test(prompt)) {
|
|
const { capability: _capability, route: _route, ...rest } = chip;
|
|
return { ...rest, prompt: CONTINUE_DRAFT.test(label) ? label : prompt };
|
|
}
|
|
|
|
/* The old drafts question ran a capability that could only describe drafts
|
|
and told the reader to open the form. The same words now resolve to the
|
|
draft flow, which continues one or offers the several by name. */
|
|
if (OLD_DRAFTS_PROMPT.test(prompt) || chip.capability === 'position-drafts') {
|
|
const { capability: _capability, route: _route, ...rest } = chip;
|
|
return { ...rest, prompt: prompt || 'Which positions are still in draft?' };
|
|
}
|
|
|
|
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.
|
|
*
|
|
* A reply is persisted as the blocks it rendered, so a thread written before
|
|
* this was fixed still carries the old "Continue to save" — a live control
|
|
* routing back into Create Position, offered on a position that was saved long
|
|
* ago. Removing it in code was not enough; the ones already written down had to
|
|
* stop working too.
|
|
*
|
|
* Deliberately narrow: only actions pointing at the authoring form are dropped,
|
|
* and only from stored threads. Every other block, chip and link is left as it
|
|
* was written, so a thread reads exactly as it did apart from the one control
|
|
* that should never have been there.
|
|
*/
|
|
function withoutAuthoringActions(messages = []) {
|
|
const leadsToForm = (target) => String(target || '').includes(AUTHORING_ROUTE);
|
|
|
|
return messages.map((message) => {
|
|
if (message.role !== 'assistant') return message;
|
|
|
|
const followUp = message.followUp
|
|
?.filter((chip) => !leadsToForm(chip.route))
|
|
.map(normalizeDraftChip);
|
|
const blocks = message.blocks?.map((block) => (
|
|
block?.type === 'insights'
|
|
? {
|
|
...block,
|
|
items: block.items.map(({ action, ...item }) => (
|
|
leadsToForm(action?.to) ? item : { ...item, ...(action ? { action } : null) }
|
|
)),
|
|
}
|
|
: block
|
|
));
|
|
|
|
return { ...message, ...(followUp ? { followUp } : null), ...(blocks ? { blocks } : null) };
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Conversation state for one page context.
|
|
*
|
|
* Owns the thread, the in-flight response, and abort handling. The thread is
|
|
* kept per context in sessionStorage so navigating away from Candidates and back
|
|
* does not silently discard the conversation — while a stale thread from last
|
|
* week is still not resurrected.
|
|
*
|
|
* `onNavigate` is called when a question belongs to another Admin page. Both
|
|
* entry points — a suggestion chip and a typed question — go through `send`, so
|
|
* routing applies to both without either knowing it exists.
|
|
*/
|
|
export function useConversation({
|
|
contextId, facts, onNavigate, onAction, onRefreshSuggestions,
|
|
/**
|
|
* How each conversation's record gets written, keyed by the flow's id.
|
|
*
|
|
* A single `onCreatePosition` prop was the last place the panel named one
|
|
* kind of record. A second conversation needed a second prop, a second branch
|
|
* at the write, and a second set of outcome renderers — three edits to answer
|
|
* "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
|
|
itself and there is one update path for a position. */
|
|
onUpdatePosition, onGenerateDescription,
|
|
workforce = null, disabledSkills = [], customSkills = [],
|
|
roles = [], skillCategories = [], courses = [], skillContext = null,
|
|
/* 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 = [],
|
|
/**
|
|
* The active agent and where the reader is.
|
|
*
|
|
* Both optional. `disabledSkills` already arrives carrying the agent's
|
|
* scoping — the caller applies `agentScopedDisabled` before handing it over —
|
|
* so nothing here decides what may be read. These are for attribution: which
|
|
* agent answered, and whether it belongs on this page.
|
|
*/
|
|
agent = null, agentCoversPage = true, agentSuggestion = null, owliverContext = null,
|
|
/* The page's own name, recorded with an archived thread so History can say
|
|
where a conversation happened without resolving the context again. */
|
|
pageLabel = '',
|
|
}) {
|
|
/**
|
|
* One thread per agent per page.
|
|
*
|
|
* Switching agent starts a new conversation rather than continuing the last
|
|
* one under a different name: the answers in a thread were produced by a
|
|
* particular agent's scope, and appending a differently-scoped reply to them
|
|
* would make the thread a record of something that never happened.
|
|
*
|
|
* With no agent the key is exactly what it was, so an existing stored thread
|
|
* is still found.
|
|
*/
|
|
const storageKey = agent?.id
|
|
? `krow_assistant:${agent.id}:${contextId}`
|
|
: `krow_assistant:${contextId}`;
|
|
const flowKey = `${storageKey}:flow`;
|
|
const idKey = `${storageKey}:id`;
|
|
|
|
const [messages, setMessages] = React.useState([]);
|
|
const [pending, setPending] = React.useState(null); // { blocks, thinking }
|
|
const [error, setError] = React.useState(null);
|
|
const abortRef = React.useRef(null);
|
|
|
|
/* The archive, held in state so opening History does not have to re-read
|
|
storage on every render, and so a new turn moves its own thread to the top
|
|
of the list while it is on screen. */
|
|
const [history, setHistory] = React.useState(() => readHistory());
|
|
|
|
/**
|
|
* Which conversation this is.
|
|
*
|
|
* The thread already persisted per page; what it lacked was an identity, so
|
|
* every new conversation overwrote the last. This id is what an archived
|
|
* record is keyed by — kept in sessionStorage beside the thread it belongs
|
|
* to, so a reload continues the same conversation rather than forking it.
|
|
*/
|
|
const conversationRef = React.useRef(null);
|
|
|
|
/* A guided skill in progress — which question it is on and what it has
|
|
collected. Kept beside the thread rather than inside it, and persisted for
|
|
the same reason the thread is: leaving Positions and coming back should not
|
|
silently abandon a position half-described. */
|
|
const flowRef = React.useRef(null);
|
|
|
|
/**
|
|
* What this conversation actually used.
|
|
*
|
|
* Accumulated as turns run, never predicted from what was available. A skill
|
|
* lands here when it *answered*; a tool when `runAction` was asked to perform
|
|
* it. That distinction is the whole value of the record — a list of what the
|
|
* page offered would describe the registry, and the insight figures built on
|
|
* it would describe the registry too.
|
|
*
|
|
* A ref rather than state: nothing re-renders when it changes, and it is read
|
|
* only at the moment a thread is written.
|
|
*/
|
|
const usedRef = React.useRef({ skills: [], tools: [], knowledge: [] });
|
|
|
|
const noteUsed = React.useCallback((kind, id) => {
|
|
if (!id) return;
|
|
const bucket = usedRef.current[kind];
|
|
if (bucket && !bucket.includes(id)) bucket.push(id);
|
|
}, []);
|
|
|
|
const setFlow = React.useCallback((flow) => {
|
|
flowRef.current = flow || null;
|
|
try {
|
|
if (flow) sessionStorage.setItem(flowKey, JSON.stringify(flow));
|
|
else sessionStorage.removeItem(flowKey);
|
|
} catch {
|
|
// The flow stays in memory only.
|
|
}
|
|
}, [flowKey]);
|
|
|
|
/* Load the stored thread, and reload when the context changes. */
|
|
React.useEffect(() => {
|
|
try {
|
|
const raw = sessionStorage.getItem(storageKey);
|
|
setMessages(raw ? withoutAuthoringActions(JSON.parse(raw)) : []);
|
|
} catch {
|
|
setMessages([]);
|
|
}
|
|
try {
|
|
conversationRef.current = sessionStorage.getItem(idKey) || null;
|
|
} catch {
|
|
conversationRef.current = null;
|
|
}
|
|
try {
|
|
const raw = sessionStorage.getItem(flowKey);
|
|
flowRef.current = raw ? JSON.parse(raw) : null;
|
|
} catch {
|
|
flowRef.current = null;
|
|
}
|
|
setPending(null);
|
|
setError(null);
|
|
/* A different page or agent is a different conversation, so what the last
|
|
one used does not carry over. */
|
|
usedRef.current = { skills: [], tools: [], knowledge: [] };
|
|
}, [storageKey, flowKey, idKey]);
|
|
|
|
/**
|
|
* Writes the thread, and records it.
|
|
*
|
|
* One function rather than two calls at each site: a thread that is persisted
|
|
* but not archived is a conversation History cannot show, and every path that
|
|
* adds a turn goes through here. The conversation is given its id on its
|
|
* first turn, so an untouched panel leaves nothing behind.
|
|
*/
|
|
const persist = React.useCallback((next) => {
|
|
setMessages(next);
|
|
try {
|
|
sessionStorage.setItem(storageKey, JSON.stringify(next));
|
|
} catch {
|
|
// Quota or private mode — the thread stays in memory only.
|
|
}
|
|
|
|
if (!next.length) return;
|
|
|
|
if (!conversationRef.current) {
|
|
conversationRef.current = newConversationId();
|
|
try {
|
|
sessionStorage.setItem(idKey, conversationRef.current);
|
|
} catch {
|
|
// The id stays in memory: this conversation archives under one id for
|
|
// as long as the panel is mounted, which is the common case.
|
|
}
|
|
}
|
|
|
|
setHistory(saveConversation({
|
|
id: conversationRef.current,
|
|
contextId,
|
|
page: pageLabel,
|
|
messages: next,
|
|
agentId: agent?.id ?? null,
|
|
/* Where the question was asked, reduced — see `storableContext`. */
|
|
pageContext: owliverContext ? storableContext(owliverContext) : null,
|
|
skillsUsed: usedRef.current.skills,
|
|
toolsUsed: usedRef.current.tools,
|
|
knowledgeUsed: usedRef.current.knowledge,
|
|
}));
|
|
}, [storageKey, idKey, contextId, pageLabel, agent, owliverContext]);
|
|
|
|
/* Abort any in-flight response when the context changes or we unmount. */
|
|
React.useEffect(() => () => abortRef.current?.abort(), [storageKey]);
|
|
|
|
// The stream callback runs outside render, so it needs the latest thread
|
|
// rather than the value captured when the request started.
|
|
const messagesRef = React.useRef(messages);
|
|
messagesRef.current = messages;
|
|
|
|
/**
|
|
* Asks something.
|
|
*
|
|
* `scope` runs this one turn against a *different* page and a different set
|
|
* of reachable skills, and exists for exactly one caller: testing a
|
|
* capability from the agent editor. The reader is standing on the
|
|
* configuration screen, so the panel's own context is the workspace — asking
|
|
* "what happened today?" there would resolve against the workspace's skills
|
|
* and prove nothing about the Activity capability being configured.
|
|
*
|
|
* It is an override of *which page this question is about*, never of what may
|
|
* be read: `resolveIntent` and the provider apply the same page rules to the
|
|
* substituted context that they apply to a real one, so a test cannot reach a
|
|
* record the real page would not have offered. The turn lands in the ordinary
|
|
* thread, is persisted and archived like any other, and nothing about it is
|
|
* simulated — it is the live pipeline pointed at another surface.
|
|
*/
|
|
/* The most recent question, for the confirmation path. A ref rather than
|
|
state: nothing renders from it, and making it state would re-render the
|
|
whole panel on every keystroke-completed turn for no visible reason. */
|
|
const lastQuestionRef = React.useRef('');
|
|
|
|
/* The agent version this conversation started on.
|
|
§3: running conversations pin the version they started with. Held in a ref
|
|
rather than state because nothing renders from it — and reset with the
|
|
thread, so a NEW conversation picks up whatever is current rather than
|
|
inheriting a version somebody has since moved on from. */
|
|
const pinnedVersionRef = React.useRef(0);
|
|
|
|
const send = React.useCallback(async ({
|
|
question, capability = null, positionId = null, scope = null,
|
|
}) => {
|
|
const text = String(question).trim();
|
|
if (!text) return;
|
|
|
|
/* The page this turn is about, and what is reachable there. Defaults are
|
|
the panel's own, so every existing caller is unchanged. */
|
|
const turnContext = scope?.contextId || contextId;
|
|
const turnDisabled = scope?.disabledSkills || disabledSkills;
|
|
const turnAgent = scope?.agent || agent;
|
|
const turnCovers = scope ? true : agentCoversPage;
|
|
|
|
abortRef.current?.abort();
|
|
const controller = new AbortController();
|
|
abortRef.current = controller;
|
|
|
|
const withUser = [...messagesRef.current, { role: 'user', text }];
|
|
messagesRef.current = withUser;
|
|
persist(withUser);
|
|
setError(null);
|
|
setPending({ blocks: [], thinking: true });
|
|
|
|
/**
|
|
* A guided skill in progress takes the message first: while Owliver is
|
|
* asking the questions, "Chennai" is an answer rather than a question about
|
|
* the page. The skill has to still be enabled — switching it off in Settings
|
|
* ends the flow rather than letting it run on without permission.
|
|
*/
|
|
let intent = null;
|
|
if (flowRef.current) {
|
|
const skill = skillsForContext(turnContext, turnDisabled, customSkills)
|
|
.find((s) => s.id === flowRef.current.skillId);
|
|
|
|
if (!skill) setFlow(null);
|
|
else {
|
|
intent = {
|
|
kind: 'flow',
|
|
skill,
|
|
...advanceFlow({
|
|
registry: flowFor(skill), flow: flowRef.current, answer: text, skill,
|
|
ctx: { roles, companies, workers, postings },
|
|
}),
|
|
};
|
|
}
|
|
}
|
|
|
|
/* A chip is this page's own capability by construction, so it never routes
|
|
away. A typed question might belong somewhere else entirely. */
|
|
if (!intent) {
|
|
intent = capability
|
|
? { kind: 'answer' }
|
|
: preferAgent(
|
|
resolveIntent({
|
|
question: text,
|
|
contextId: turnContext,
|
|
disabledSkills: turnDisabled,
|
|
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,
|
|
/* What this session last changed, so "change it back" has an
|
|
"it". A node id and nothing else — see `focus` on the
|
|
editing provider. */
|
|
focus: uiEditing.focus || null,
|
|
}
|
|
: null,
|
|
}),
|
|
/* Only while a real agent is behind the panel. With the local
|
|
simulator there is nothing better to defer TO, and deferring
|
|
would turn every template answer into a worse one. */
|
|
{ modelBacked: agentBacked },
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
* be true when it is said.
|
|
*/
|
|
if (intent.kind === 'flow' && intent.create) {
|
|
/* The skill says which conversation this is, so the write and the wording
|
|
of its outcome both come from that registry rather than from a name
|
|
hardcoded here. */
|
|
const registry = flowFor(intent.skill);
|
|
let created = null;
|
|
/* Kept, not swallowed. The reply states the outcome, and "it did not
|
|
work" is a worse outcome to state than the reason it did not: a
|
|
required field, a refused role, or an API that is not running. */
|
|
let failure = null;
|
|
try {
|
|
created = await flowWriters[registry.id]?.(
|
|
intent.create.draft, intent.skill, intent.create.status
|
|
);
|
|
} catch (error) {
|
|
created = null;
|
|
failure = error;
|
|
}
|
|
|
|
if (created?.id) {
|
|
/**
|
|
* What can now be asked, with the position in the database.
|
|
*
|
|
* The record exists, so the organization is materially different from
|
|
* what it was one turn ago — there is one more role to fill, or one
|
|
* more unfinished draft — and what is worth asking has changed with it.
|
|
* `onRefreshSuggestions` asks the server that question again rather
|
|
* than deriving an answer here: the ranking is the API's, against the
|
|
* rows it has just written, filtered by the caller's role.
|
|
*
|
|
* A draft is offered nothing. It is not finished being specified, and
|
|
* suggesting readings of a record the admin has not committed to would
|
|
* be answering about something that is not yet true.
|
|
*/
|
|
const ready = created.status === 'active';
|
|
let refreshed = [];
|
|
if (ready) {
|
|
try {
|
|
refreshed = (await onRefreshSuggestions?.()) || [];
|
|
} catch {
|
|
/* The position was created; failing to fetch what to ask next is
|
|
not a reason to report that it was not. */
|
|
refreshed = [];
|
|
}
|
|
}
|
|
|
|
intent = {
|
|
...intent,
|
|
flow: null,
|
|
doc: registry.outcome.created(created),
|
|
followUp: [...registry.outcome.followUp(created), ...refreshed],
|
|
};
|
|
} else {
|
|
/* Keep the answers: the summary is still there to try again from. */
|
|
intent = {
|
|
...intent,
|
|
flow: { ...intent.flow, stage: 'review' },
|
|
doc: registry.outcome.failed(failure?.message),
|
|
followUp: registry.outcome.retryChips,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Finishing a draft. Same contract as every other write in this file: it
|
|
* happens before the reply, because the reply states the outcome.
|
|
*
|
|
* Each branch calls the mutation the form already uses and then says what
|
|
* is now true of the record — no second store, and no navigation.
|
|
*/
|
|
if (intent.kind === 'draft' && intent.perform) {
|
|
const { action, position } = intent.perform;
|
|
|
|
if (action === 'generate_description') {
|
|
let result = null;
|
|
try {
|
|
result = await onGenerateDescription?.(position);
|
|
} catch {
|
|
result = null;
|
|
}
|
|
intent = {
|
|
...intent,
|
|
doc: result?.description ? descriptionReply(position, result) : descriptionFailedReply(position),
|
|
followUp: draftActions(result?.description ? { ...position, description: result.description } : position),
|
|
};
|
|
}
|
|
|
|
if (action === 'set_weights') {
|
|
let saved = null;
|
|
try {
|
|
saved = await onUpdatePosition?.(position.id, { vetting_criteria: intent.perform.weights });
|
|
} catch {
|
|
saved = null;
|
|
}
|
|
intent = {
|
|
...intent,
|
|
doc: saved ? weightsSetReply(position, intent.perform.weights) : weightsUnchangedReply(position),
|
|
followUp: draftActions(saved || position),
|
|
};
|
|
}
|
|
|
|
if (action === 'publish') {
|
|
let published = null;
|
|
try {
|
|
published = await onUpdatePosition?.(position.id, { status: 'active' });
|
|
} catch {
|
|
published = null;
|
|
}
|
|
intent = {
|
|
...intent,
|
|
doc: published ? publishedReply(position) : publishFailedReply(position),
|
|
followUp: published ? publishedFollowUp(published) : draftActions(position),
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The workforce write, on the same terms as the one above: it happens
|
|
* before the reply, because the reply states the new counts and those have
|
|
* to be true when they are read.
|
|
*
|
|
* `intent.assign` only exists on a turn the admin explicitly confirmed, and
|
|
* the plan inside it was re-derived from live data at that moment — so this
|
|
* writes what was agreed to or reports that it could not.
|
|
*/
|
|
if (intent.kind === 'workforce' && intent.assign) {
|
|
let done = null;
|
|
try {
|
|
done = await onAssignWorkers?.(intent.assign);
|
|
} catch {
|
|
done = null;
|
|
}
|
|
|
|
intent = {
|
|
...intent,
|
|
kind: 'answer-doc',
|
|
doc: done ? assignmentDone(intent.assign) : assignmentFailed(),
|
|
followUp: done ? assignmentFollowUp(intent.assign) : undefined,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The headcount a position never stated, supplied by the reader.
|
|
*
|
|
* Written through `useUpdateJobPosting` — the same mutation the Create
|
|
* Position form and the Positions page call — so there is one position
|
|
* record and one demand figure, not a number the panel remembers. The reply
|
|
* is the assignment proposal the reader originally asked for, re-derived
|
|
* against the saved record rather than against the number they typed, so
|
|
* what they see is what was actually stored.
|
|
*/
|
|
if (intent.kind === 'workforce' && intent.headcount) {
|
|
const { position: target, count } = intent.headcount;
|
|
let saved = null;
|
|
try {
|
|
saved = await onUpdatePosition?.(target.id, { headcount: count });
|
|
} catch {
|
|
saved = null;
|
|
}
|
|
|
|
intent = {
|
|
...intent,
|
|
kind: 'answer-doc',
|
|
...(saved
|
|
? headcountSet(saved, workforce?.context || {})
|
|
: headcountFailed(target)),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The interview step. Same contract as the assignment above: it runs before
|
|
* the reply, only on a confirmed turn, and through the app's existing
|
|
* status transition rather than a second interview store.
|
|
*/
|
|
if (intent.kind === 'workforce' && intent.interview) {
|
|
let moved = null;
|
|
try {
|
|
moved = await onScheduleInterview?.(intent.interview);
|
|
} catch {
|
|
moved = null;
|
|
}
|
|
|
|
intent = {
|
|
...intent,
|
|
kind: 'answer-doc',
|
|
doc: moved ? interviewDone(intent.interview) : interviewFailed(),
|
|
};
|
|
}
|
|
|
|
/* A skill that collects its input in the chat carries the state of that
|
|
collection on every turn — including the turn that starts it. */
|
|
if ('flow' in intent) setFlow(intent.flow);
|
|
|
|
/* What this turn used, recorded from the resolved intent rather than from
|
|
the page's offer. `intent.skill` is the definition that answered. */
|
|
noteUsed('skills', intent.skill?.id);
|
|
noteUsed('tools', intent.action?.name);
|
|
|
|
if (intent.kind !== 'answer') {
|
|
try {
|
|
await streamDocument({
|
|
document: intent.doc,
|
|
signal: controller.signal,
|
|
onFrame: (blocks) => setPending({ blocks, thinking: false }),
|
|
onDone: (blocks) => {
|
|
const next = [
|
|
...messagesRef.current,
|
|
{ role: 'assistant', blocks, followUp: intent.followUp },
|
|
];
|
|
messagesRef.current = next;
|
|
persist(next);
|
|
},
|
|
});
|
|
} finally {
|
|
setPending(null);
|
|
abortRef.current = null;
|
|
}
|
|
|
|
/* Navigate after the reply is on screen — except the reply does not
|
|
survive the move. The panel is page-scoped, so it re-mounts on the new
|
|
route and the message explaining why the reader moved is destroyed by
|
|
the navigation that message was explaining. The reader landed somewhere
|
|
else with a fresh greeting and no trace of what they asked.
|
|
|
|
So the question travels with the destination. The panel on the other
|
|
side asks it, which is what the reader wanted in the first place and
|
|
what the old copy ("ask again once the page loads") was apologising
|
|
for. Re-asking cannot loop: resolveIntent only navigates when the
|
|
destination differs from the current page. */
|
|
if (controller.signal.aborted) return;
|
|
|
|
if (intent.kind === 'navigate') onNavigate?.(intent.destination, text);
|
|
/* A skill's action runs after its reply, for the same reason: the user
|
|
should read why the form opened before it opens. */
|
|
if (intent.kind === 'skill' && intent.action) onAction?.(intent.action, intent.skill);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
let latest = [];
|
|
/* Held for the confirmation path: approving a proposed write resumes the
|
|
run, and a resumed run needs the question that produced the proposal. */
|
|
lastQuestionRef.current = text;
|
|
for await (const snapshot of provider.stream({
|
|
contextId: turnContext, capability, question: text, facts, signal: controller.signal,
|
|
/* What the agent *is*, never what it may read. The page settled that
|
|
before this call, and `agentRequest` carries no records. */
|
|
agent: turnAgent ? agentRequest(turnAgent, turnContext) : null,
|
|
owliverContext,
|
|
agentVersion: pinnedVersionRef.current,
|
|
})) {
|
|
if (controller.signal.aborted) break;
|
|
latest = snapshot;
|
|
setPending({ blocks: snapshot, thinking: false });
|
|
}
|
|
|
|
// Keep whatever arrived before the user stopped it — discarding a
|
|
// 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,
|
|
...(followUp ? { followUp } : null),
|
|
},
|
|
];
|
|
messagesRef.current = next;
|
|
persist(next);
|
|
}
|
|
} catch (e) {
|
|
if (e?.name !== 'AbortError') {
|
|
/* The reader gets one sentence; the developer gets the exception. A
|
|
capability that throws used to leave no trace anywhere, so "I could
|
|
not complete that" was the only evidence anything had gone wrong —
|
|
and it named neither the action nor the reason. */
|
|
if (import.meta.env.DEV) console.error('[owliver] action failed', e);
|
|
setError('I could not complete that. Try again in a moment.');
|
|
}
|
|
} finally {
|
|
setPending(null);
|
|
abortRef.current = null;
|
|
}
|
|
}, [contextId, facts, persist, onNavigate, onAction, flowWriters, onRefreshSuggestions,
|
|
onUpdatePosition,
|
|
onGenerateDescription, onAssignWorkers,
|
|
onScheduleInterview,
|
|
workforce, setFlow, disabledSkills,
|
|
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(), []);
|
|
|
|
/**
|
|
* Approves a proposed write, and lets the run finish.
|
|
*
|
|
* The second half of the confirmation flow. The first half ended with the
|
|
* agent describing something and doing nothing; this carries the person's
|
|
* decision back and the server performs exactly the call that description was
|
|
* issued against — same tool, same arguments, same caller. A token authorises
|
|
* one write and expires; it is not a mode.
|
|
*
|
|
* The original question is re-sent alongside it, because the run that resumes
|
|
* is a NEW run: it has to be able to reach the same tool call again for the
|
|
* token to match. That is why the token is not bound to a run id — see
|
|
* tools/confirm.go.
|
|
*
|
|
* Nothing happens locally. This layer does not write, does not optimistically
|
|
* mark anything done, and does not tell the user it worked: the answer that
|
|
* comes back says what actually happened, including a refusal if the world
|
|
* moved between the asking and the answering.
|
|
*/
|
|
const confirm = React.useCallback(async (block) => {
|
|
if (!block?.token) return;
|
|
|
|
/* The question this confirmation was raised for. Read from the thread
|
|
rather than held in state, so approving an older proposal still resends
|
|
the right question rather than whatever was typed most recently. */
|
|
const question = lastQuestionRef.current;
|
|
if (!question) return;
|
|
|
|
const controller = new AbortController();
|
|
abortRef.current = controller;
|
|
setError(null);
|
|
setPending({ blocks: [], thinking: true });
|
|
|
|
try {
|
|
let latest = [];
|
|
for await (const snapshot of provider.stream({
|
|
contextId, capability: null, question, facts,
|
|
agent: agent ? agentRequest(agent, contextId) : null,
|
|
owliverContext,
|
|
confirmation: block.token,
|
|
agentVersion: pinnedVersionRef.current,
|
|
signal: controller.signal,
|
|
})) {
|
|
if (controller.signal.aborted) break;
|
|
latest = snapshot;
|
|
setPending({ blocks: snapshot, thinking: false });
|
|
}
|
|
if (latest.length) {
|
|
const next = [...messagesRef.current, { role: 'assistant', blocks: latest }];
|
|
messagesRef.current = next;
|
|
persist(next);
|
|
}
|
|
} catch (e) {
|
|
if (e?.name !== 'AbortError') {
|
|
setError('That approval could not be completed. Nothing was changed.');
|
|
}
|
|
} finally {
|
|
setPending(null);
|
|
abortRef.current = null;
|
|
}
|
|
}, [contextId, facts, agent, owliverContext, persist]);
|
|
|
|
/**
|
|
* States something in the thread without a question having been asked.
|
|
*
|
|
* The workspace's own voice: "Attendance Analysis is now available to this
|
|
* agent." It is a real turn — persisted and archived by the same `persist`
|
|
* every answer goes through, so it survives a reload and appears in History
|
|
* exactly where it happened, rather than being a banner that evaporates.
|
|
*
|
|
* Nothing is generated. The caller supplies the sentence and the chips, which
|
|
* is what keeps this from being a second answering path: no skill runs, no
|
|
* provider is called, and nothing here can claim a figure.
|
|
*/
|
|
const announce = React.useCallback(({ text: body, followUp = null }) => {
|
|
const sentence = String(body || '').trim();
|
|
if (!sentence) return;
|
|
|
|
const message = {
|
|
role: 'assistant',
|
|
text: sentence,
|
|
blocks: doc(textBlock(sentence)).blocks,
|
|
...(followUp?.length ? { followUp } : null),
|
|
};
|
|
|
|
const next = [...messagesRef.current, message];
|
|
messagesRef.current = next;
|
|
persist(next);
|
|
}, [persist]);
|
|
|
|
/**
|
|
* Back to an empty panel.
|
|
*
|
|
* The thread being cleared is not discarded — `persist` has already archived
|
|
* every turn of it — so this releases the conversation rather than deleting
|
|
* it, and the next turn starts a new one. That is what makes "New
|
|
* conversation" and "Back to Home" safe: nothing is lost by leaving.
|
|
*/
|
|
const reset = React.useCallback(() => {
|
|
abortRef.current?.abort();
|
|
messagesRef.current = [];
|
|
setMessages([]);
|
|
setPending(null);
|
|
setError(null);
|
|
/* A new conversation abandons a half-collected position too — the questions
|
|
it was answering are no longer on screen. */
|
|
setFlow(null);
|
|
conversationRef.current = null;
|
|
try {
|
|
sessionStorage.removeItem(storageKey);
|
|
sessionStorage.removeItem(idKey);
|
|
} catch {
|
|
// Ignore.
|
|
}
|
|
}, [storageKey, idKey, setFlow]);
|
|
|
|
/**
|
|
* Reopens an archived conversation as the live thread.
|
|
*
|
|
* It resumes under its own id, so continuing an old conversation adds to that
|
|
* record instead of forking a near-duplicate beside it. Any half-collected
|
|
* skill flow is dropped: its questions belonged to the thread being left.
|
|
*/
|
|
const openConversation = React.useCallback((record) => {
|
|
if (!record?.id || !Array.isArray(record.messages)) return;
|
|
|
|
abortRef.current?.abort();
|
|
setPending(null);
|
|
setError(null);
|
|
setFlow(null);
|
|
|
|
/* Same rule as loading a stored thread: a conversation kept in History was
|
|
written before this was fixed too, and reopening it must not hand back a
|
|
route into the authoring form. */
|
|
const messages = withoutAuthoringActions(record.messages);
|
|
|
|
messagesRef.current = messages;
|
|
setMessages(messages);
|
|
conversationRef.current = record.id;
|
|
|
|
try {
|
|
sessionStorage.setItem(storageKey, JSON.stringify(messages));
|
|
sessionStorage.setItem(idKey, record.id);
|
|
} catch {
|
|
// The reopened thread stays in memory only.
|
|
}
|
|
}, [storageKey, idKey, setFlow]);
|
|
|
|
/** Forgets one archived conversation, clearing the panel if it is open. */
|
|
const forgetConversation = React.useCallback((id) => {
|
|
setHistory(removeConversation(id));
|
|
if (conversationRef.current === id) reset();
|
|
}, [reset]);
|
|
|
|
/**
|
|
* Rates the conversation on screen.
|
|
*
|
|
* The conversation rather than the turn: a reader judging an answer is
|
|
* judging the exchange that produced it, and a per-turn rating would ask them
|
|
* to score a paragraph out of context. Returns false when there is nothing to
|
|
* rate yet, so a caller can stay honest rather than pretending it landed.
|
|
*/
|
|
const submitFeedback = React.useCallback((rating, noteText = '') => {
|
|
if (!conversationRef.current) return false;
|
|
setHistory(recordFeedback(conversationRef.current, rating ? { rating, note: noteText } : null));
|
|
return true;
|
|
}, []);
|
|
|
|
/** How the conversation on screen is currently rated, or null. */
|
|
const feedback = React.useMemo(
|
|
() => history.find((r) => r.id === conversationRef.current)?.feedback ?? null,
|
|
[history]
|
|
);
|
|
|
|
return {
|
|
messages,
|
|
pending,
|
|
error,
|
|
busy: Boolean(pending),
|
|
send,
|
|
announce,
|
|
stop,
|
|
/** Approves a proposed write and resumes the run. See `confirm`. */
|
|
confirm,
|
|
reset,
|
|
submitFeedback,
|
|
feedback,
|
|
/** Every archived conversation, newest first. */
|
|
history,
|
|
/** The conversation on screen, so History can mark it. */
|
|
conversationId: conversationRef.current,
|
|
openConversation,
|
|
forgetConversation,
|
|
providerId: provider.id,
|
|
};
|
|
}
|