update agents skill design
This commit is contained in:
196
src/components/ai-assistant/AgentContext.jsx
Normal file
196
src/components/ai-assistant/AgentContext.jsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import * as React from 'react';
|
||||
import { usePreferences } from '@/lib/krowHooks';
|
||||
import { allAgents } from '@/lib/agents/registry';
|
||||
import {
|
||||
agentCovers, nativeAgentForContext, resolveAgentForTurn, resolveDefaultAgent, resolveSelection,
|
||||
} from '@/lib/agents/runtime';
|
||||
|
||||
/**
|
||||
* Which agent is answering.
|
||||
*
|
||||
* Built on the same principles as `PageContext.jsx`, and deliberately kept
|
||||
* beside it rather than merged into it:
|
||||
*
|
||||
* - **The page is not an input to itself.** This context reads the page's
|
||||
* assistant context to decide a *default*, and never the other way round.
|
||||
* Nothing here can change which page the reader is on, which is what makes
|
||||
* "switching agent cannot change PageContext" true by construction rather
|
||||
* than by care.
|
||||
* - **Session-scoped, like the panel's own window state.** Choosing an agent
|
||||
* for the afternoon should not rewrite an account default, and a new tab
|
||||
* should open on the page's own agent.
|
||||
*
|
||||
* Resolution order: what this session chose *and that still applies here* → the
|
||||
* account's stated default, on the same condition → the page's own agent → the
|
||||
* general agent. A page therefore always opens on the agent written for it, a
|
||||
* page nobody wrote one for opens on the general agent rather than on nothing,
|
||||
* and a deliberate choice still survives navigation across the pages it covers.
|
||||
*
|
||||
* **A selection is made somewhere.** That is the correction this file carries:
|
||||
* it stores the context a choice was made on, not only the agent's id. Storing
|
||||
* the id alone meant a specialist chosen on Positions followed the reader onto
|
||||
* Settings and constrained a page nobody had chosen it for — the panel looked
|
||||
* broken and the reason was invisible. The rule itself lives in
|
||||
* `runtime.resolveSelection` as a pure function, so what applies where is
|
||||
* decided in one place and can be proved without a React tree.
|
||||
*/
|
||||
|
||||
const AgentContext = React.createContext(null);
|
||||
|
||||
const SESSION_KEY = 'krow_assistant:agent';
|
||||
|
||||
/**
|
||||
* The stored selection: `{ id, contextId }`.
|
||||
*
|
||||
* A bare string is still read, because that is what earlier sessions wrote and
|
||||
* a stored value from yesterday should not throw. It resolves as a choice made
|
||||
* nowhere, which is the honest reading of it — and the conservative one, since a
|
||||
* selection with no context applies only where it legitimately covers.
|
||||
*/
|
||||
function readSelection() {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(SESSION_KEY);
|
||||
if (!raw) return null;
|
||||
if (!raw.startsWith('{')) return { id: raw, contextId: null };
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed?.id ? { id: parsed.id, contextId: parsed.contextId ?? null } : null;
|
||||
} catch {
|
||||
/* Private mode, or a value that will not parse: the session simply always
|
||||
opens on the page's own agent. */
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeSelection(selection) {
|
||||
try {
|
||||
if (selection?.id) sessionStorage.setItem(SESSION_KEY, JSON.stringify(selection));
|
||||
else sessionStorage.removeItem(SESSION_KEY);
|
||||
} catch {
|
||||
/* Held in memory for this session only. */
|
||||
}
|
||||
}
|
||||
|
||||
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. */
|
||||
const agents = React.useMemo(
|
||||
() => allAgents(preferences.customAgents || [], { customSkills: preferences.customSkills || [] }),
|
||||
[preferences.customAgents, preferences.customSkills]
|
||||
);
|
||||
|
||||
const [selection, setSelection] = React.useState(() => readSelection());
|
||||
|
||||
const select = React.useCallback((id) => {
|
||||
/* Stamped with where it was chosen. A specialist picked on a page it does
|
||||
not cover is a deliberate act and is honoured *here*; the stamp is what
|
||||
stops it from becoming a decision about every other page too. */
|
||||
const next = id ? { id, contextId } : null;
|
||||
setSelection(next);
|
||||
writeSelection(next);
|
||||
}, [contextId]);
|
||||
|
||||
/** Back to whichever agent this page resolves on its own. */
|
||||
const clearSelection = React.useCallback(() => {
|
||||
setSelection(null);
|
||||
writeSelection(null);
|
||||
}, []);
|
||||
|
||||
/* What the stored choice means on the page the reader is on now. */
|
||||
const applied = React.useMemo(
|
||||
() => resolveSelection(agents, selection, contextId),
|
||||
[agents, selection, contextId]
|
||||
);
|
||||
|
||||
/**
|
||||
* Storage housekeeping, in one place.
|
||||
*
|
||||
* Retiring a spent selection and re-stamping a live one are both writes about
|
||||
* *navigation*, not about a choice, so they belong in an effect rather than in
|
||||
* `select`. Both converge: the effect only writes when the stored value would
|
||||
* actually change.
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
if (applied.retire) {
|
||||
setSelection(null);
|
||||
writeSelection(null);
|
||||
return;
|
||||
}
|
||||
if (applied.id && selection?.contextId !== contextId) {
|
||||
const next = { id: applied.id, contextId };
|
||||
setSelection(next);
|
||||
writeSelection(next);
|
||||
}
|
||||
}, [applied, selection, contextId]);
|
||||
|
||||
const value = React.useMemo(() => {
|
||||
/**
|
||||
* What this turn asks for.
|
||||
*
|
||||
* The session's choice first, then the account default — and the account
|
||||
* default is put through the same rule, as a choice made nowhere. A stated
|
||||
* default that does not cover this page is not a decision about this page,
|
||||
* so it must not constrain it; the page resolves its own agent instead.
|
||||
*/
|
||||
const stated = applied.id
|
||||
|| resolveSelection(agents, preferences.defaultAgentId || null, contextId).id
|
||||
|| null;
|
||||
|
||||
const turn = resolveAgentForTurn(agents, stated, contextId);
|
||||
/* The page's own agent, offered as the way out of a constrained state.
|
||||
Null on a page nobody wrote one for — `fallback` is what answers there. */
|
||||
const native = nativeAgentForContext(agents, contextId);
|
||||
/* What this page opens on with nothing chosen: its own agent, or the
|
||||
general one. Never null, so no page is ever left without an agent. */
|
||||
const fallback = resolveDefaultAgent(agents, contextId);
|
||||
|
||||
return {
|
||||
agents,
|
||||
/* The agent that will answer. Never silently swapped: a reader who chose
|
||||
one *for this page* gets that one, with `covers` saying whether it
|
||||
belongs here. A choice carried in from another page is not that. */
|
||||
agent: turn.agent,
|
||||
covers: turn.covers,
|
||||
native,
|
||||
/* What this page resolves to with nothing chosen — its own agent, or the
|
||||
general one. This is what a constrained answer points at, so that the
|
||||
way out of a constrained state is named on every page rather than only
|
||||
on the pages that have a specialist. */
|
||||
defaultAgent: fallback,
|
||||
/* True while this page is simply showing the agent it resolves on its
|
||||
own, whether that is its specialist or the general agent. */
|
||||
isNative: Boolean(fallback && turn.agent && fallback.id === turn.agent.id && !applied.id),
|
||||
chosenId: applied.id,
|
||||
select,
|
||||
clearSelection,
|
||||
/* Whether a given agent belongs on this page, for the list. */
|
||||
coversPage: (candidate) => agentCovers(candidate, contextId),
|
||||
};
|
||||
}, [agents, applied, preferences.defaultAgentId, contextId, select, clearSelection]);
|
||||
|
||||
return <AgentContext.Provider value={value}>{children}</AgentContext.Provider>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The active agent.
|
||||
*
|
||||
* Returns an inert value outside a provider, so a panel rendered in isolation —
|
||||
* a test, a storybook — behaves as it did before agents existed rather than
|
||||
* crashing.
|
||||
*/
|
||||
export function useActiveAgent() {
|
||||
return React.useContext(AgentContext) ?? {
|
||||
agents: [],
|
||||
agent: null,
|
||||
covers: true,
|
||||
native: null,
|
||||
defaultAgent: null,
|
||||
isNative: false,
|
||||
chosenId: null,
|
||||
select: () => {},
|
||||
clearSelection: () => {},
|
||||
coversPage: () => false,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user