portal-mojo 0.2.21 → 0.2.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/ModelTable.md +11 -0
- package/docs/admin-assistant.md +75 -23
- package/docs/admin-dns-domains.md +96 -90
- package/docs/admin-dns-records.md +1 -1
- package/docs/admin-dns-registrar.md +29 -4
- package/docs/admin-dns.md +2 -3
- package/docs/admin-modals.md +21 -0
- package/docs/menus-and-access.md +24 -0
- package/package.json +1 -1
- package/src/admin/assistant/AssistantFeed.tsx +7 -4
- package/src/admin/assistant/AssistantSetupPage.tsx +141 -64
- package/src/admin/assistant/api.ts +9 -7
- package/src/admin/assistant/pages.tsx +125 -109
- package/src/admin/assistant/setup.ts +1 -1
- package/src/admin/assistant/skill-lifecycle.ts +5 -4
- package/src/admin/core/index.ts +10 -2
- package/src/admin/dns/DnsRecordsPanel.tsx +45 -9
- package/src/admin/dns/DomainAssignment.tsx +55 -0
- package/src/admin/dns/DomainDetail.tsx +55 -38
- package/src/admin/dns/DomainOnboardingContent.tsx +9 -4
- package/src/admin/dns/DomainPurchaseDetail.tsx +42 -7
- package/src/admin/dns/DomainPurchaseWizard.tsx +102 -72
- package/src/admin/dns/DomainPurchasesPage.tsx +1 -1
- package/src/admin/dns/DomainRegisterExisting.tsx +21 -6
- package/src/admin/dns/DomainRegistrarSettings.tsx +28 -48
- package/src/admin/dns/DomainRegistrationPanel.tsx +127 -0
- package/src/admin/dns/DomainsPage.tsx +25 -24
- package/src/admin/dns/ProviderCredentialsPage.tsx +32 -18
- package/src/admin/dns/RegistrantContactPage.tsx +73 -27
- package/src/admin/dns/api.ts +26 -25
- package/src/admin/dns/domain-authority.ts +11 -0
- package/src/admin/dns/index.ts +2 -2
- package/src/admin/dns/purchase-data.ts +5 -3
- package/src/admin/dns/registrar-api.ts +57 -11
- package/src/admin/domains/infrastructure.ts +8 -8
- package/src/admin/domains/operations.ts +6 -6
- package/src/admin/edge/VhostsPage.tsx +6 -1
- package/src/admin/jobs/JobDashboardPage.tsx +14 -4
- package/src/client/mock.ts +14 -8
- package/src/client/params.ts +3 -2
- package/src/styles/components/admin-assistant.css +83 -20
- package/src/styles/components/admin-domains.css +67 -11
- package/src/styles/components/admin-edge.css +1 -0
- package/src/styles/components/admin-jobs.css +1 -0
- package/src/ui/FilterBar.tsx +16 -10
- package/src/ui/ModelTable.tsx +7 -3
- package/src/ui/SidebarNav.tsx +10 -1
- package/src/ui/menu-registry.ts +2 -0
- package/src/ui/preset-owned.ts +39 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
|
|
1
|
+
import { useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore, type ReactNode } from 'react';
|
|
2
2
|
import { useQueryClient } from '@tanstack/react-query';
|
|
3
3
|
import { useAuthSnapshot, useMe, type Me } from '../../client/me';
|
|
4
4
|
import { getAuthSessionRevision, getAuthSnapshot, isReauthRequired, requestFreshAuth, subscribeAuth } from '../../client/auth';
|
|
@@ -7,6 +7,11 @@ import {
|
|
|
7
7
|
type AssistantSetupState, type SetupAction, type SetupKey, type SetupCheck, type SetupTarget,
|
|
8
8
|
} from './setup';
|
|
9
9
|
|
|
10
|
+
import { Popover } from '../../ui/Popover';
|
|
11
|
+
import { Tabs } from '../../ui/Tabs';
|
|
12
|
+
import { copyText } from '../../ui/secret-dialog';
|
|
13
|
+
|
|
14
|
+
type SetupPanel = 'model' | 'credentials' | 'safety' | 'triage';
|
|
10
15
|
type Operation = { controller: AbortController; revision: number };
|
|
11
16
|
/** Also gates direct component consumers, independently of navigation and lazy-route admission. */
|
|
12
17
|
export function AssistantSetupPage() {
|
|
@@ -16,8 +21,32 @@ export function AssistantSetupPage() {
|
|
|
16
21
|
if (!auth.authenticated || me?.is_superuser !== true || String(me.id) !== auth.uid) return <div className="panel panel-pad" role="alert"><h2>Owner access required</h2><p>Assistant setup requires a literal installation superuser signed in as a person.</p></div>;
|
|
17
22
|
return <OwnerSetup key={`${auth.uid}:${revision}`} ownerId={me.id} revision={revision} />;
|
|
18
23
|
}
|
|
24
|
+
function SetupHelp({ label, children }: { label: string; children: ReactNode }) {
|
|
25
|
+
const anchor = useRef<HTMLButtonElement>(null), id = useId();
|
|
26
|
+
const [open, setOpen] = useState(false);
|
|
27
|
+
return <><button type="button" ref={anchor} className="assistant-setup-help" aria-label={`About ${label}`} aria-expanded={open} aria-controls={open ? id : undefined} onClick={() => setOpen(!open)}><i className="bi bi-info-circle" aria-hidden="true" /></button><Popover anchorRef={anchor} open={open} onClose={() => setOpen(false)} id={id} aria-label={`About ${label}`} className="assistant-setup-help-content">{children}</Popover></>;
|
|
28
|
+
}
|
|
29
|
+
/** Local native dialog: shares the page's live state and dies with its owner scope. */
|
|
30
|
+
function SetupDialog({ title, locked, onClose, children, confirmation = false }: { title: string; locked: boolean; onClose: () => void; children: ReactNode; confirmation?: boolean }) {
|
|
31
|
+
const ref = useRef<HTMLDialogElement>(null), id = useId();
|
|
32
|
+
useLayoutEffect(() => {
|
|
33
|
+
const dialog = ref.current, opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
34
|
+
dialog?.showModal();
|
|
35
|
+
return () => { if (dialog?.open) dialog.close(); if (opener?.isConnected) opener.focus(); };
|
|
36
|
+
}, []);
|
|
37
|
+
const dismiss = () => { if (!locked) onClose(); };
|
|
38
|
+
return <dialog ref={ref} className={`assistant-setup-dialog${confirmation ? ' assistant-setup-dialog-confirm' : ''}`} aria-labelledby={id}
|
|
39
|
+
onCancel={event => { event.preventDefault(); dismiss(); }} onMouseDown={event => { if (event.target === ref.current) dismiss(); }}
|
|
40
|
+
onClose={() => { if (ref.current && !ref.current.open) { if (locked) ref.current.showModal(); else onClose(); } }}>
|
|
41
|
+
<header><h2 id={id}>{title}</h2>{!confirmation && <button className="btn-icon" type="button" aria-label="Close setup dialog" disabled={locked} onClick={dismiss}><i className="bi bi-x-lg" aria-hidden="true" /></button>}</header>
|
|
42
|
+
<div className="assistant-setup-dialog-content">{children}</div>
|
|
43
|
+
{!confirmation && <footer><button className="btn" type="button" disabled={locked} onClick={dismiss}>Done</button></footer>}
|
|
44
|
+
</dialog>;
|
|
45
|
+
}
|
|
19
46
|
function KeySummary({ label, value, check }: { label: string; value: SetupKey; check: SetupCheck }) {
|
|
20
|
-
return <div className="assistant-setup-key-status"><
|
|
47
|
+
return <div className="assistant-setup-key-status"><strong>{value.configured ? 'Key configured' : 'No key configured'}</strong>
|
|
48
|
+
<p className="dim">{value.source === 'admin' ? 'Saved in Admin' : value.source === 'deployment' ? 'Provided by deployment' : 'Add a key below'}{value.hint && <> · <code>{value.hint}</code></>}</p>
|
|
49
|
+
<p className={check.ok === false ? 'assistant-setup-warning' : 'dim'}>Last check: {check.ok === null ? 'Not checked' : check.ok ? 'Accepted' : 'Failed'} <SetupHelp label={`${label} verification`}>{check.message}{check.at && <p>Checked {check.at}</p>}<p>A key check does not prove that all Assistant requests can run.</p></SetupHelp></p></div>;
|
|
21
50
|
}
|
|
22
51
|
function OwnerSetup({ ownerId, revision }: { ownerId: number; revision: number }) {
|
|
23
52
|
const queryClient = useQueryClient();
|
|
@@ -30,7 +59,10 @@ function OwnerSetup({ ownerId, revision }: { ownerId: number; revision: number }
|
|
|
30
59
|
const [pending, setPending] = useState(''); const [notice, setNotice] = useState(''); const [error, setError] = useState('');
|
|
31
60
|
const [confirmation, setConfirmation] = useState<{ message: string; accept: () => void; cancel: () => void } | null>(null);
|
|
32
61
|
const live = useRef(false); const operation = useRef<Operation | null>(null);
|
|
33
|
-
const
|
|
62
|
+
const [surface, setSurface] = useState('ai');
|
|
63
|
+
const [panel, setPanel] = useState<SetupPanel | null>(null);
|
|
64
|
+
const [keyTarget, setKeyTarget] = useState<SetupTarget>('assistant');
|
|
65
|
+
const [safetyTab, setSafetyTab] = useState('policy');
|
|
34
66
|
const validOwner = () => {
|
|
35
67
|
const auth = getAuthSnapshot();
|
|
36
68
|
const me = queryClient.getQueryData<Me>(['me', auth.uid]);
|
|
@@ -39,7 +71,7 @@ function OwnerSetup({ ownerId, revision }: { ownerId: number; revision: number }
|
|
|
39
71
|
const clearDrafts = () => { setDraft(''); setHandlerDraft(''); setBefore(''); setLimit('25'); setModelDraft(''); setModeDraft(''); };
|
|
40
72
|
const dispose = () => {
|
|
41
73
|
operation.current?.controller.abort(); operation.current = null;
|
|
42
|
-
clearDrafts(); setConfirmation(null); setPending(''); setState(null); stateRef.current = null; setNotice(''); setError('');
|
|
74
|
+
clearDrafts(); setPanel(null); setConfirmation(null); setPending(''); setState(null); stateRef.current = null; setNotice(''); setError('');
|
|
43
75
|
};
|
|
44
76
|
const guard = (op: Operation) => {
|
|
45
77
|
if (!validOwner() || operation.current !== op || op.controller.signal.aborted) throw new DOMException('Setup scope ended', 'AbortError');
|
|
@@ -61,7 +93,7 @@ function OwnerSetup({ ownerId, revision }: { ownerId: number; revision: number }
|
|
|
61
93
|
setError(assistantSetupCredentialRefusal(err) ?? (mutation ? 'The operation was refused or its outcome is unknown. Refreshing current configuration; no operation will be retried automatically.' : 'Assistant setup could not be loaded. Try Refresh setup.'));
|
|
62
94
|
if (mutation) {
|
|
63
95
|
try { apply(await getAssistantSetup(options(op)), op); }
|
|
64
|
-
catch { if (validOwner() && operation.current === op) { setState(null); stateRef.current = null; } }
|
|
96
|
+
catch { if (validOwner() && operation.current === op) { setState(null); stateRef.current = null; setPanel(null); } }
|
|
65
97
|
}
|
|
66
98
|
}
|
|
67
99
|
async function load(probe?: 'models' | 'discovery') {
|
|
@@ -105,7 +137,10 @@ function OwnerSetup({ ownerId, revision }: { ownerId: number; revision: number }
|
|
|
105
137
|
finish(op);
|
|
106
138
|
}
|
|
107
139
|
};
|
|
108
|
-
if (confirm)
|
|
140
|
+
if (confirm) {
|
|
141
|
+
let accepted = false;
|
|
142
|
+
setConfirmation({ message: confirm, accept: () => { if (accepted) return; accepted = true; void run(); }, cancel: () => finish(op) });
|
|
143
|
+
}
|
|
109
144
|
else void run();
|
|
110
145
|
}
|
|
111
146
|
useEffect(() => {
|
|
@@ -130,67 +165,109 @@ function OwnerSetup({ ownerId, revision }: { ownerId: number; revision: number }
|
|
|
130
165
|
|
|
131
166
|
const saveKey = (target: SetupTarget) => change((current) => assistantSetupSave(current, target === 'assistant' ? { api_key: draft } : { handler_api_key: handlerDraft }), 'Checking and saving key…', undefined, target);
|
|
132
167
|
const clearKey = (target: SetupTarget) => change((current) => assistantSetupSave(current, target === 'assistant' ? { clear_api_key: true } : { clear_handler_api_key: true }), 'Clearing protected key…', 'Clear the protected Admin credential? A deployment credential may become active. Review the returned source after saving.', target);
|
|
133
|
-
const
|
|
168
|
+
const openPanel = (next: SetupPanel, target: SetupTarget = 'assistant') => {
|
|
169
|
+
if (locked) return;
|
|
170
|
+
setDraft(''); setHandlerDraft(''); setBefore(''); setLimit('25');
|
|
171
|
+
setModelDraft(state?.model.capability?.mode === 'pinned' ? state.model.selected : '');
|
|
172
|
+
setModeDraft(state?.model.capability?.mode === 'auto' || state?.model.capability?.mode === 'pinned' ? state.model.capability.mode : '');
|
|
173
|
+
setKeyTarget(target); setSafetyTab('policy'); setNotice(''); setError(''); setPanel(next);
|
|
174
|
+
};
|
|
175
|
+
const closePanel = () => { if (!operation.current) { clearDrafts(); setPanel(null); } };
|
|
176
|
+
const messages = <>{(notice || (pending && !confirmation)) && <p className="assistant-setup-notice" role="status" aria-live="polite">{notice || pending}</p>}{error && <p className="assistant-setup-warning" role="alert">{error}</p>}</>;
|
|
177
|
+
const provider = state?.route.provider === 'anthropic' ? 'Anthropic' : state?.route.provider === 'bedrock' ? 'AWS Bedrock' : 'Unavailable';
|
|
178
|
+
const configured = !!state?.route.ready && state.assistant_installed && state.realtime_installed;
|
|
179
|
+
const recoveryPanel: SetupPanel = state?.emergency_stop || !state?.assistant_installed || !state.realtime_installed ? 'safety'
|
|
180
|
+
: state.route.error === 'credential_missing' || (state.route.credential && !state.route.credential_configured) ? 'credentials'
|
|
181
|
+
: modelEditable && state.model.capability?.mode === 'auto' && !state.model.effective && state.route.provider && state.route.credential ? 'model' : 'safety';
|
|
182
|
+
const readiness = state ? assistantSetupReadiness(state) : '';
|
|
183
|
+
const key = state && (keyTarget === 'assistant' ? state.key : state.handler_key);
|
|
184
|
+
const keyLabel = keyTarget === 'assistant' ? 'Assistant' : 'Platform';
|
|
185
|
+
const panelTitles: Record<SetupPanel, string> = { model: 'Model', credentials: 'API keys', safety: 'Safety & usage', triage: 'Incident triage' };
|
|
134
186
|
return <main className="assistant-setup" aria-labelledby="assistant-setup-title">
|
|
135
|
-
<header className="assistant-setup-header"><div><
|
|
136
|
-
|
|
137
|
-
{
|
|
138
|
-
{confirmation && <section className="panel panel-pad assistant-setup-confirm" role="group" aria-label="Confirm setup operation"><p>{confirmation.message}</p><div className="assistant-action-row"><button className="btn" type="button" autoFocus onClick={confirmation.cancel}>Cancel</button><button className="btn btn-primary" type="button" onClick={confirmation.accept}>Confirm operation</button></div></section>}
|
|
187
|
+
<header className="assistant-setup-header"><div><h1 id="assistant-setup-title">Assistant setup</h1><p className="dim">Configure the built-in AI Assistant or connect external agents.</p></div><button type="button" className="btn" disabled={locked} onClick={() => void load()}>Refresh setup</button></header>
|
|
188
|
+
{!panel && messages}
|
|
189
|
+
{!state && !pending && <button className="btn" type="button" onClick={() => void load()}>Retry setup</button>}
|
|
139
190
|
{state && <>
|
|
140
|
-
<
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
<datalist id="assistant-model-choices">{state.model.choices.map((choice) => <option key={choice.id} value={choice.id}>{choice.label}</option>)}</datalist>
|
|
152
|
-
<p className="dim" id="assistant-model-help">Automatic lets the backend resolve a compatible Sonnet from its provider catalogue. A manual pin takes effect only after saving. Refreshing cannot replace a manual pin.</p>
|
|
153
|
-
<div className="assistant-action-row"><button className="btn btn-primary" type="button" disabled={locked || !modeDraft || (modeDraft === 'auto' ? !autoAvailable : !/^[a-z0-9][a-z0-9.\-]{0,80}$/.test(modelDraft))} onClick={() => change(() => modeDraft === 'auto' ? { action: 'set_model_selection', mode: 'auto' } : { action: 'set_model_selection', mode: 'pinned', model: modelDraft }, 'Saving model selection…')}>Save model selection</button><button className="btn" type="button" disabled={locked} onClick={() => void load('models')}>Refresh provider catalogue</button></div>
|
|
154
|
-
</>}
|
|
155
|
-
{capability && <div className="assistant-setup-catalogue" aria-label="Provider catalogue status">
|
|
156
|
-
<p>Catalogue: <strong>{capability.discovery.status === 'fresh' ? 'Fresh cache' : capability.discovery.status === 'stale' ? 'Stale cache — cached resolution only' : capability.discovery.status === 'unavailable' ? 'Unavailable — no usable automatic resolution' : 'Not applicable to the selected mode'}</strong>{capability.discovery.error_code && ' · The backend reports a discovery problem.'}</p>
|
|
157
|
-
<dl className="assistant-setup-facts"><div><dt>Last successful fetch</dt><dd>{capability.discovery.fetched_at || 'None reported'}</dd></div><div><dt>Last attempt</dt><dd>{capability.discovery.attempted_at || 'None reported'}</dd></div><div><dt>Fresh until</dt><dd>{capability.discovery.expires_at || 'Not applicable'}</dd></div><div><dt>Usable cached limit</dt><dd>{capability.discovery.max_stale_at || 'Not applicable'}</dd></div></dl>
|
|
158
|
-
</div>}
|
|
159
|
-
</section>
|
|
160
|
-
{state.route.credential === 'handler' && <div className="assistant-setup-warning"><p>This deployed route uses the Platform credential. An Assistant key will not supply it.</p><button className="btn" type="button" onClick={openPlatform}>Open Platform credential</button></div>}
|
|
161
|
-
{state.route.credential === 'aws' && <p>The Bedrock route uses deployment AWS identity. Browser key fields do not configure that identity.</p>}
|
|
162
|
-
<KeySummary label="Assistant key" value={state.key} check={state.verify} />
|
|
163
|
-
<label className="assistant-setup-field" htmlFor="assistant-replacement-key">Replacement Assistant key<input id="assistant-replacement-key" className="input" type="password" value={draft} onChange={(event) => setDraft(event.target.value)} autoComplete="new-password" spellCheck={false} maxLength={4096} disabled={locked} aria-describedby="assistant-key-help" /></label>
|
|
164
|
-
<p className="dim" id="assistant-key-help">Write-only replacement. The server checks a new key before storing it. Nothing saves while you type.</p>
|
|
165
|
-
<div className="assistant-action-row"><button type="button" className="btn btn-primary" disabled={locked || !draft.trim()} onClick={() => saveKey('assistant')}>Check and save key</button><button type="button" className="btn" disabled={locked} onClick={() => change(() => ({ action: 'verify', target: 'assistant' }), 'Checking current key…')}>Check current key</button><button type="button" className="btn" disabled={locked || state.key.source !== 'admin'} onClick={() => clearKey('assistant')}>Clear stored Assistant key</button></div>
|
|
166
|
-
<div className="assistant-setup-switch"><span>Assistant is <strong>{state.enabled ? 'enabled' : 'disabled'}</strong>. Enabling does not override deployment policy or emergency stops.</span><button className="btn" type="button" disabled={locked} onClick={() => change((current) => assistantSetupSave(current, { enabled: !current.enabled }), 'Saving Assistant switch…')}>{state.enabled ? 'Disable Assistant' : 'Enable Assistant'}</button></div>
|
|
191
|
+
<Tabs activeKey={surface} onActiveKeyChange={next => { if (!locked) { closePanel(); setNotice(''); setError(''); setSurface(next); } }} ariaLabel="Setup area" items={[
|
|
192
|
+
{ key: 'ai', label: 'AI Assistant', disabled: locked && surface !== 'ai', panel: <div className="assistant-setup-area">
|
|
193
|
+
<section className="panel panel-pad assistant-setup-overview" aria-label="Assistant status">
|
|
194
|
+
<div className="assistant-setup-status"><div><span className={`assistant-setup-dot${state.emergency_stop ? ' is-stopped' : configured && state.enabled ? ' is-ready' : ''}`} /><h2>{state.emergency_stop ? 'AI Assistant stopped' : !configured ? 'Setup needed' : state.enabled ? 'AI Assistant enabled' : 'AI Assistant disabled'}</h2><SetupHelp label="Assistant status"><p>{readiness}</p><p>The switch does not override deployment policy or emergency stops.</p></SetupHelp></div><button className={`btn${!state.enabled ? ' btn-primary' : ''}`} type="button" disabled={locked} onClick={() => change(current => assistantSetupSave(current, { enabled: !current.enabled }), 'Saving Assistant switch…')}>{state.enabled ? 'Disable Assistant' : 'Enable Assistant'}</button></div>
|
|
195
|
+
{(!configured || state.emergency_stop) && <p className="assistant-setup-warning">{readiness} <button className="assistant-setup-text-button" type="button" disabled={locked} onClick={() => openPanel(recoveryPanel, state.route.credential === 'handler' ? 'handler' : 'assistant')}>Review setup</button></p>}
|
|
196
|
+
<div className="assistant-setup-row"><div><h3>Model <SetupHelp label="Model"><p>The provider comes from your deployment. Automatic selection follows the backend's compatible Sonnet catalogue; a manual pin keeps a specific model.</p></SetupHelp></h3><p>{provider} · {capability?.mode === 'auto' ? 'Automatic' : capability?.mode === 'pinned' ? 'Manual pin' : 'Deployment managed'}</p><code>{state.model.effective || state.route.model || 'Model unavailable'}</code></div><button className="btn" type="button" disabled={locked} onClick={() => openPanel('model')}>{modelEditable ? 'Configure model' : 'View model'}</button></div>
|
|
197
|
+
<div className="assistant-setup-row"><div><h3>Connection <SetupHelp label="Connection"><p>The active route selects an Assistant key, Platform key or deployment AWS identity. These credentials do not substitute for each other.</p></SetupHelp></h3><p>{state.route.credential === 'aws' ? 'AWS identity · managed by deployment' : state.route.credential === 'handler' ? `Platform key · ${state.route.credential_configured ? 'configured' : 'missing'}` : state.route.credential === 'admin' ? `Assistant key · ${state.route.credential_configured ? 'configured' : 'missing'}` : 'Deployment route unavailable'}</p></div><button className="btn" type="button" disabled={locked} onClick={() => openPanel('credentials', state.route.credential === 'handler' ? 'handler' : 'assistant')}>Manage keys</button></div>
|
|
198
|
+
</section>
|
|
199
|
+
<section className="panel assistant-setup-tools" aria-label="Assistant controls">
|
|
200
|
+
<div className="assistant-setup-row"><i className="bi bi-shield-check" aria-hidden="true" /><div><h3>Safety & usage</h3><p>{state.emergency_stop ? 'Emergency stop active' : state.route.error ? 'Policy needs attention' : 'No emergency stop'}</p></div><button className="btn" type="button" disabled={locked} onClick={() => openPanel('safety')}>Review safety</button></div>
|
|
201
|
+
<div className="assistant-setup-row"><i className="bi bi-activity" aria-hidden="true" /><div><h3>Incident triage <SetupHelp label="Incident triage"><p>Let the Assistant process new security incidents automatically. Older incidents need a separate, explicitly bounded run.</p></SetupHelp></h3><p>{state.autonomous_triage ? 'Automatic triage enabled' : 'Automatic triage disabled'}</p></div><button className="btn" type="button" disabled={locked} onClick={() => openPanel('triage')}>Manage triage</button></div>
|
|
167
202
|
</section>
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
<p>
|
|
171
|
-
<
|
|
172
|
-
<div className="assistant-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
{
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
<
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
<div className="assistant-action-row">{(['all', 'anthropic', 'bedrock'] as const).map((provider) => <button key={provider} type="button" className="btn" disabled={locked} onClick={() => change(() => ({ action: 'reset_breaker', ...(provider === 'all' ? {} : { provider }) }), 'Resetting breakers…', `Reset ${provider} circuit breakers? Requests may try again; policy and emergency stops remain enforced.`)}>Reset {provider} breakers</button>)}</div>
|
|
184
|
-
</div></details>
|
|
185
|
-
<details className="panel assistant-setup-advanced"><summary>Remote agent access and connections</summary><div className="panel-pad"><p>Remote agents can receive Tools, Full API or both kinds of access according to their grants. Full API access carries the signed-in user’s API authority. Turning access off pauses connections without revoking them.</p>
|
|
186
|
-
<p>Remote access is <strong>{state.mcp.enabled ? 'on' : 'off'}</strong>.</p><button type="button" className="btn" disabled={locked} onClick={() => change((current) => assistantSetupSave(current, { mcp_enabled: !current.mcp.enabled }), 'Saving remote access…', 'Change remote agent access? Existing grants are retained; Disconnect revokes them.')}>{state.mcp.enabled ? 'Pause remote access' : 'Enable remote access'}</button>
|
|
187
|
-
<label className="assistant-setup-field" htmlFor="assistant-connect-address">Connect address (select to copy)<input id="assistant-connect-address" className="input" value={state.mcp.url} readOnly onFocus={(event) => event.target.select()} /></label><p className="dim">{state.mcp.discovery_url || 'No public discovery address configured.'}</p>
|
|
188
|
-
<button type="button" className="btn" disabled={locked} onClick={() => void load('discovery')}>Check public discovery</button><p role={state.mcp.discovery.ok === false ? 'alert' : undefined}>Discovery: {state.mcp.discovery.ok === null ? 'Not checked' : state.mcp.discovery.ok ? 'Reachable' : 'Failed'} · {state.mcp.discovery.detail}{state.mcp.discovery.checked_at && ` · ${state.mcp.discovery.checked_at}`}</p>
|
|
189
|
-
<h3>Connected agents · {state.mcp.grant_count} total</h3><p className="dim">Showing {state.mcp.grants.length} connections, capped at 200. Disconnect all spans Tools and Full API grants for every user.</p><div className="assistant-table-scroll"><table><caption>Remote connections</caption><thead><tr><th>Client / user</th><th>Access</th><th>Last used / expires</th><th>Connection</th></tr></thead><tbody>{state.mcp.grants.map((row) => <tr key={row.id}><td>{row.client.name || row.client.client_id}<br />{row.user.display_name || row.user.email}</td><td>{row.access === 'api' ? 'Full API' : row.access === 'both' ? 'Tools and Full API' : 'Tools'}</td><td>{row.last_used || 'Not used'}<br />{row.expires}</td><td><button type="button" className="btn" disabled={locked} onClick={() => change(() => ({ action: 'revoke_grant', grant_id: row.id }), 'Disconnecting agent…', `Disconnect ${row.client.name || 'this agent'} for ${row.user.display_name || row.user.email}? A new authorization is required to reconnect.`)}>Disconnect #{row.id}</button></td></tr>)}</tbody></table></div><button type="button" className="btn" disabled={locked || state.mcp.grant_count === 0} onClick={() => change(() => ({ action: 'revoke_all_grants' }), 'Disconnecting all agents…', 'Disconnect every remote Tools and Full API grant, for every user? New authorizations will be required.')}>Disconnect all agents</button>
|
|
190
|
-
</div></details>
|
|
191
|
-
<details className="panel assistant-setup-advanced"><summary>Incident triage</summary><div className="panel-pad"><p>Autonomous incident triage is <strong>{state.autonomous_triage ? 'on' : 'off'}</strong>. Enabling establishes a no-history watermark; older incidents need an explicit historical run.</p><p>Watermark: {state.autonomous_triage_activated_at || 'Not activated'}</p><button type="button" className="btn" disabled={locked} onClick={() => change((current) => assistantSetupSave(current, { autonomous_triage: !current.autonomous_triage }), 'Saving autonomous triage…', 'Change autonomous incident triage? New activation establishes a watermark; it does not queue older incidents.')}>{state.autonomous_triage ? 'Disable autonomous triage' : 'Enable autonomous triage'}</button>
|
|
192
|
-
<h3>Historical incident triage</h3><label className="assistant-setup-field" htmlFor="assistant-triage-before">Incidents before (local time)<input className="input" id="assistant-triage-before" type="datetime-local" value={before} onChange={(event) => setBefore(event.target.value)} disabled={locked} /></label><label className="assistant-setup-field" htmlFor="assistant-triage-limit">Maximum incidents (1–100)<input className="input" id="assistant-triage-limit" type="number" min={1} max={100} step={1} value={limit} onChange={(event) => setLimit(event.target.value)} disabled={locked} /></label><button type="button" className="btn" disabled={locked || !before || !Number.isInteger(Number(limit)) || Number(limit) < 1 || Number(limit) > 100} onClick={() => change(() => ({ action: 'historical_triage', before: new Date(before).toISOString(), limit: Number(limit) }), 'Queueing historical triage…', `Queue up to ${limit} incidents before ${before}? This explicitly processes historical incidents and can incur provider cost.`)}>Queue historical triage</button>
|
|
193
|
-
</div></details>
|
|
203
|
+
</div> },
|
|
204
|
+
{ key: 'mcp', label: 'MCP connections', disabled: locked && surface !== 'mcp', panel: <section className="panel panel-pad assistant-setup-mcp" aria-label="MCP connections">
|
|
205
|
+
<h2>Connect an external agent</h2><p className="dim">Use this address in your AI client. No AI provider, model or API key is needed here.</p>
|
|
206
|
+
<div className="assistant-setup-row"><div><h3>MCP access <SetupHelp label="Remote access"><p>Tools, Full API or combined grants follow each user's authorization. Full API carries the signed-in user's API authority. Pausing retains grants; Disconnect revokes them.</p></SetupHelp></h3><p>{state.mcp.enabled ? 'Enabled' : 'Paused'}</p></div><button className="btn" type="button" disabled={locked} onClick={() => change(current => assistantSetupSave(current, { mcp_enabled: !current.mcp.enabled }), 'Saving remote access…', 'Change remote agent access? Existing grants are retained; Disconnect revokes them.')}>{state.mcp.enabled ? 'Pause remote access' : 'Enable remote access'}</button></div>
|
|
207
|
+
<div className="assistant-setup-field"><span><label htmlFor="assistant-connect-address">Connect address</label> <SetupHelp label="Connect address"><p>Add this address to your AI client's MCP connections, then sign in there. Select the address to copy it. No connection is created on this page.</p></SetupHelp></span><div className="assistant-setup-address"><input id="assistant-connect-address" className="input" value={state.mcp.url} readOnly onFocus={event => event.target.select()} /><button className="btn" type="button" disabled={locked || !state.mcp.url} onClick={() => { void copyText(state.mcp.url).then(copied => { if (validOwner()) setNotice(copied ? 'Connect address copied.' : 'Select the address and copy it manually.'); }); }}>Copy address</button></div></div>
|
|
208
|
+
{!state.mcp.url && <p className="assistant-setup-warning">No public connect address is configured. Review deployment configuration.</p>}
|
|
209
|
+
<div className="assistant-action-row"><button className="btn" type="button" disabled={locked} onClick={() => void load('discovery')}>Check public discovery</button><span>Discovery: {state.mcp.discovery.ok === null ? 'Not checked' : state.mcp.discovery.ok ? 'Reachable' : 'Failed'} <SetupHelp label="Public discovery"><p>{state.mcp.discovery.detail}</p><p>{state.mcp.discovery_url || 'No public discovery address configured.'}</p>{state.mcp.discovery.checked_at && <p>{state.mcp.discovery.checked_at}</p>}</SetupHelp></span></div>
|
|
210
|
+
{state.mcp.discovery.ok === false && <p className="assistant-setup-warning" role="alert">Discovery failed. {state.mcp.discovery.detail}</p>}
|
|
211
|
+
<h3>Connected agents · {state.mcp.grant_count}</h3>
|
|
212
|
+
{!state.mcp.grant_count && <p className="dim">No agents connected. Add the connect address in your AI client to get started.</p>}
|
|
213
|
+
{state.mcp.grant_count > state.mcp.grants.length && <p className="dim">Showing {state.mcp.grants.length} of {state.mcp.grant_count} connections (up to 200).</p>}
|
|
214
|
+
<div className="assistant-setup-connections">{state.mcp.grants.map(row => <article key={row.id} className="assistant-setup-row"><div><strong>{row.client.name || row.client.client_id}</strong><p>{row.user.display_name || row.user.email}</p><span className="dim">{row.access === 'api' ? 'Full API' : row.access === 'both' ? 'Tools and Full API' : 'Tools'} <SetupHelp label={`Connection ${row.id}`}><p>Last used: {row.last_used || 'Not used'}</p><p>Expires: {row.expires || 'Not reported'}</p></SetupHelp></span></div><button className="btn" type="button" disabled={locked} onClick={() => change(() => ({ action: 'revoke_grant', grant_id: row.id }), 'Disconnecting agent…', `Disconnect ${row.client.name || 'this agent'} for ${row.user.display_name || row.user.email}? A new authorization is required to reconnect.`)}>Disconnect #{row.id}</button></article>)}</div>
|
|
215
|
+
{state.mcp.grant_count > 0 && <div className="assistant-setup-danger-row"><button className="btn" type="button" disabled={locked} onClick={() => change(() => ({ action: 'revoke_all_grants' }), 'Disconnecting all agents…', 'Disconnect every remote Tools and Full API grant, for every user? New authorizations will be required.')}>Disconnect all agents</button></div>}
|
|
216
|
+
</section> },
|
|
217
|
+
]} />
|
|
194
218
|
</>}
|
|
219
|
+
{state && panel && <SetupDialog title={panelTitles[panel]} locked={locked} onClose={closePanel}>
|
|
220
|
+
{messages}
|
|
221
|
+
{panel === 'model' && <>
|
|
222
|
+
<p className="dim">{provider} · source: {state.model.source || 'Unavailable'}</p>
|
|
223
|
+
<div className="assistant-setup-current"><span>Current model</span><code>{state.model.effective || state.route.model || 'Unavailable'}</code></div>
|
|
224
|
+
{restriction && <p className="assistant-setup-warning" id="assistant-model-restriction">{restriction}</p>}
|
|
225
|
+
{modelEditable && <>
|
|
226
|
+
<label className="assistant-setup-field" htmlFor="assistant-model-mode">Selection method <select className="input" id="assistant-model-mode" value={modeDraft} disabled={locked} onChange={event => setModeDraft(event.target.value as 'auto' | 'pinned')}>
|
|
227
|
+
{!modeDraft && <option value="" disabled>Choose selection method</option>}<option value="auto" disabled={!autoAvailable}>Automatic — latest Sonnet</option><option value="pinned">Manual pin</option>
|
|
228
|
+
</select></label>
|
|
229
|
+
{modeDraft === 'pinned' && <label className="assistant-setup-field" htmlFor="assistant-model-pin">Manual model identifier<input className="input" id="assistant-model-pin" value={modelDraft} onChange={event => setModelDraft(event.target.value)} disabled={locked} list="assistant-model-choices" maxLength={81} autoComplete="off" spellCheck={false} /></label>}
|
|
230
|
+
<datalist id="assistant-model-choices">{state.model.choices.map(choice => <option key={choice.id} value={choice.id}>{choice.label}</option>)}</datalist>
|
|
231
|
+
<p className="dim">{modeDraft === 'pinned' ? 'Keeps this model until you change it.' : 'Uses the latest compatible Sonnet resolved by the backend.'} <SetupHelp label="Model selection"><p>Changes take effect only after saving. Refreshing the catalogue cannot replace a manual pin.</p></SetupHelp></p>
|
|
232
|
+
<div className="assistant-action-row"><button className="btn btn-primary" type="button" disabled={locked || !modeDraft || (modeDraft === 'auto' ? !autoAvailable : !/^[a-z0-9][a-z0-9.\-]{0,80}$/.test(modelDraft))} onClick={() => change(() => modeDraft === 'auto' ? { action: 'set_model_selection', mode: 'auto' } : { action: 'set_model_selection', mode: 'pinned', model: modelDraft }, 'Saving model selection…')}>Save model selection</button><button className="btn" type="button" disabled={locked} onClick={() => void load('models')}>Refresh provider catalogue</button></div>
|
|
233
|
+
</>}
|
|
234
|
+
{capability && <div className="assistant-setup-catalogue"><span>Catalogue: <strong>{capability.discovery.status === 'fresh' ? 'Fresh cache' : capability.discovery.status === 'stale' ? 'Stale cache — cached resolution only' : capability.discovery.status === 'unavailable' ? 'Unavailable — no usable automatic resolution' : 'Not applicable to the selected mode'}</strong></span><SetupHelp label="Provider catalogue"><dl className="assistant-setup-facts"><div><dt>Last successful fetch</dt><dd>{capability.discovery.fetched_at || 'None reported'}</dd></div><div><dt>Last attempt</dt><dd>{capability.discovery.attempted_at || 'None reported'}</dd></div><div><dt>Fresh until</dt><dd>{capability.discovery.expires_at || 'Not applicable'}</dd></div><div><dt>Usable cached limit</dt><dd>{capability.discovery.max_stale_at || 'Not applicable'}</dd></div></dl>{capability.discovery.error_code && <p>The backend reports a discovery problem.</p>}</SetupHelp></div>}
|
|
235
|
+
</>}
|
|
236
|
+
{panel === 'credentials' && <>
|
|
237
|
+
{state.route.credential === 'aws' && <p className="assistant-setup-warning">This route uses deployment AWS identity. API keys below do not configure that identity.</p>}
|
|
238
|
+
{state.route.credential === 'handler' && <p className="dim">This route uses the Platform key.</p>}
|
|
239
|
+
{!state.route.credential && <p className="assistant-setup-warning">The deployment route is unavailable. Adding a key may not resolve it; review Safety & usage.</p>}
|
|
240
|
+
<Tabs activeKey={keyTarget} onActiveKeyChange={value => { if (!locked) { setDraft(''); setHandlerDraft(''); setKeyTarget(value as SetupTarget); setNotice(''); setError(''); } }} ariaLabel="Credential type" items={(['assistant', 'handler'] as const).map(target => ({ key: target, label: target === 'assistant' ? 'Assistant key' : 'Platform key', disabled: locked && keyTarget !== target, panel: target === keyTarget && key && <>
|
|
241
|
+
<p className="dim">{keyTarget === 'assistant' ? 'Used when the deployed route selects the Assistant credential.' : 'Used only by deployed routes selecting the Platform credential.'} <SetupHelp label="API key storage"><p>Keys are write-only. A new key is checked before encrypted storage. Nothing saves while you type. Removing an Admin key can activate a deployment fallback.</p></SetupHelp></p>
|
|
242
|
+
<KeySummary label={`${keyLabel} key`} value={key} check={keyTarget === 'assistant' ? state.verify : state.handler_verify} />
|
|
243
|
+
<label className="assistant-setup-field" htmlFor={keyTarget === 'assistant' ? 'assistant-replacement-key' : 'handler-replacement-key'}>{key.configured ? `Replacement ${keyLabel} key` : `${keyLabel} API key`}<input id={keyTarget === 'assistant' ? 'assistant-replacement-key' : 'handler-replacement-key'} className="input" type="password" value={keyTarget === 'assistant' ? draft : handlerDraft} onChange={event => keyTarget === 'assistant' ? setDraft(event.target.value) : setHandlerDraft(event.target.value)} autoComplete="new-password" spellCheck={false} maxLength={4096} disabled={locked} /></label>
|
|
244
|
+
<div className="assistant-action-row"><button className="btn btn-primary" type="button" disabled={locked || !(keyTarget === 'assistant' ? draft : handlerDraft).trim()} onClick={() => saveKey(keyTarget)}>{keyTarget === 'assistant' ? 'Check and save key' : 'Check and save Platform key'}</button><button className="btn" type="button" disabled={locked} onClick={() => change(() => ({ action: 'verify', target: keyTarget }), 'Checking current key…')}>{keyTarget === 'assistant' ? 'Check current key' : 'Check current Platform key'}</button></div>
|
|
245
|
+
<div className="assistant-setup-danger-row"><button className="btn" type="button" disabled={locked || key.source !== 'admin'} onClick={() => clearKey(keyTarget)}>{`Clear stored ${keyLabel} key`}</button></div>
|
|
246
|
+
</> }))} />
|
|
247
|
+
</>}
|
|
248
|
+
{panel === 'safety' && <Tabs activeKey={safetyTab} onActiveKeyChange={value => { if (!locked) setSafetyTab(value); }} ariaLabel="Safety controls" items={[
|
|
249
|
+
{ key: 'policy', label: 'Policy & stops', disabled: locked && safetyTab !== 'policy', panel: <>
|
|
250
|
+
<h3>Deployed policy <SetupHelp label="Deployed policy"><p>Deployment owns the policy and runtime route. Acknowledging activates the current policy on this node; it does not check the fleet or release an emergency stop.</p>{capability && <p>Deployed policy model: <code>{capability.policy_model || 'Unavailable'}</code></p>}{state.route.can_invoke !== undefined && <p>Guarded request admission: {state.route.can_invoke ? 'Allowed to attempt' : 'Blocked'} · separate from configuration readiness</p>}</SetupHelp></h3>
|
|
251
|
+
<p>Policy status: {state.route.error || 'Ready'}</p><button className="btn" type="button" disabled={locked} onClick={() => change(() => ({ action: 'activate_policy' }), 'Activating policy…', 'Acknowledge the currently deployed policy on this node? This does not edit policy content, check other nodes or release an emergency stop.')}>Acknowledge deployed policy</button>
|
|
252
|
+
<h3>Emergency stop <SetupHelp label="Emergency stop"><p>The Admin stop is stored in the database. A deployment stop is read-only here and must be removed in deployment configuration. Both block AI requests; MCP connections are separate.</p></SetupHelp></h3><dl className="assistant-setup-facts"><div><dt>Deployment stop (read-only)</dt><dd>{state.emergency_stop_static ? 'On' : 'Off'}</dd></div><div><dt>Admin stop</dt><dd>{state.emergency_stop_database ? 'On' : 'Off'}</dd></div><div><dt>Requests stopped</dt><dd>{state.emergency_stop ? 'On' : 'Off'}</dd></div></dl>
|
|
253
|
+
{state.emergency_stop_static && <p className="assistant-setup-warning">A deployment stop is active. Releasing the database stop cannot resume requests.</p>}
|
|
254
|
+
<button className="btn" type="button" disabled={locked} onClick={() => change(current => assistantSetupSave(current, { emergency_stop: !current.emergency_stop_database }), 'Saving database stop…', 'Change only the database emergency stop? A deployment stop remains authoritative.')}>{state.emergency_stop_database ? 'Release Admin stop' : 'Stop AI requests'}</button>
|
|
255
|
+
</> },
|
|
256
|
+
{ key: 'usage', label: 'Usage & recovery', disabled: locked && safetyTab !== 'usage', panel: <>
|
|
257
|
+
<h3>Requests · last {state.safety.hours || 24} hours</h3>{state.safety.error && <p className="assistant-setup-warning" role="alert">Safety statistics unavailable.</p>}
|
|
258
|
+
{state.safety.requests.length ? <div className="assistant-table-scroll"><table><caption>Guarded request aggregates</caption><thead><tr><th>Provider / feature</th><th>Status</th><th>Requests</th><th>Tokens in / out</th></tr></thead><tbody>{state.safety.requests.map((row, i) => <tr key={i}><td>{row.provider} / {row.feature}</td><td>{row.status}</td><td>{row.requests}</td><td>{row.input_tokens} / {row.output_tokens}</td></tr>)}</tbody></table></div> : !state.safety.error && <p className="dim">No requests reported.</p>}
|
|
259
|
+
<h3>Circuit breakers <SetupHelp label="Circuit breakers"><p>Breakers pause requests after repeated provider errors. Reset allows another attempt; policy and emergency stops stay enforced.</p></SetupHelp></h3>{state.safety.breakers.length ? <div className="assistant-table-scroll"><table><caption>Breaker state by provider</caption><thead><tr><th>Provider</th><th>State</th><th>Error</th><th>Count</th></tr></thead><tbody>{state.safety.breakers.map((row, i) => <tr key={i}><td>{row.provider}</td><td>{row.state}</td><td>{row.error_code || 'None'}</td><td>{row.count}</td></tr>)}</tbody></table></div> : <p className="dim">No breaker records reported.</p>}
|
|
260
|
+
<div className="assistant-action-row">{(['all', 'anthropic', 'bedrock'] as const).map(provider => <button key={provider} className="btn" type="button" disabled={locked} onClick={() => change(() => ({ action: 'reset_breaker', ...(provider === 'all' ? {} : { provider }) }), 'Resetting breakers…', `Reset ${provider} circuit breakers? Requests may try again; policy and emergency stops remain enforced.`)}>Reset {provider} breakers</button>)}</div>
|
|
261
|
+
</> },
|
|
262
|
+
]} />}
|
|
263
|
+
{panel === 'triage' && <>
|
|
264
|
+
<div className="assistant-setup-row"><div><h3>Automatic incident triage <SetupHelp label="Automatic incident triage"><p>Enabling establishes a no-history watermark. Only new incidents are processed automatically; older ones need an explicit run.</p><p>Watermark: {state.autonomous_triage_activated_at || 'Not activated'}</p></SetupHelp></h3><p>{state.autonomous_triage ? 'Enabled' : 'Disabled'}</p></div><button className="btn" type="button" disabled={locked} onClick={() => change(current => assistantSetupSave(current, { autonomous_triage: !current.autonomous_triage }), 'Saving autonomous triage…', 'Change autonomous incident triage? New activation establishes a watermark; it does not queue older incidents.')}>{state.autonomous_triage ? 'Disable autonomous triage' : 'Enable autonomous triage'}</button></div>
|
|
265
|
+
<h3>Process older incidents</h3><p className="dim">Queue a limited historical run. Provider charges may apply.</p>
|
|
266
|
+
<label className="assistant-setup-field" htmlFor="assistant-triage-before">Incidents before (local time)<input className="input" id="assistant-triage-before" type="datetime-local" value={before} onChange={event => setBefore(event.target.value)} disabled={locked} /></label>
|
|
267
|
+
<label className="assistant-setup-field" htmlFor="assistant-triage-limit">Maximum incidents (1–100)<input className="input" id="assistant-triage-limit" type="number" min={1} max={100} step={1} value={limit} onChange={event => setLimit(event.target.value)} disabled={locked} /></label>
|
|
268
|
+
<button className="btn btn-primary" type="button" disabled={locked || !before || !Number.isFinite(Date.parse(before)) || !Number.isInteger(Number(limit)) || Number(limit) < 1 || Number(limit) > 100} onClick={() => change(() => ({ action: 'historical_triage', before: new Date(before).toISOString(), limit: Number(limit) }), 'Queueing historical triage…', `Queue up to ${limit} incidents before ${before}? This explicitly processes historical incidents and can incur provider cost.`)}>Queue historical triage</button>
|
|
269
|
+
</>}
|
|
270
|
+
</SetupDialog>}
|
|
271
|
+
{confirmation && <SetupDialog title="Confirm change" locked={false} onClose={confirmation.cancel} confirmation><p>{confirmation.message}</p><div className="modal-actions"><button className="btn" type="button" autoFocus onClick={confirmation.cancel}>Cancel</button><button className="btn btn-primary" type="button" onClick={confirmation.accept}>Confirm operation</button></div></SetupDialog>}
|
|
195
272
|
</main>;
|
|
196
273
|
}
|
|
@@ -1,17 +1,19 @@
|
|
|
1
|
+
import type { Params } from '../../client/types';
|
|
1
2
|
import type { FileReference } from '../../client/record-feed';
|
|
2
|
-
import { mojoCall, mojoDelete, mojoList, type
|
|
3
|
+
import { mojoCall, mojoDelete, mojoList, type FetchOpts } from '../../client/client';
|
|
3
4
|
import { projectConversation, projectConversationSummary, projectReply, projectSkill } from './data';
|
|
4
5
|
import type { AssistantConversation, AssistantConversationSummary, AssistantReply, AssistantSkill } from './types';
|
|
5
6
|
|
|
7
|
+
type RequestOptions = Pick<FetchOpts, 'signal' | 'beforeSend'>;
|
|
6
8
|
const malformed = (label: string): never => { throw new Error(`${label} returned an invalid response`); };
|
|
7
|
-
export async function sendAssistantMessage(message: string, conversationId?: number, attachments: readonly FileReference[] = []): Promise<AssistantReply> { const ids = attachments.map((file) => file.id); if (ids.length > 5 || new Set(ids).size !== ids.length || ids.some((id) => !Number.isSafeInteger(id) || id <= 0)) throw new Error('Assistant attachments must be 1–5 unique completed File references'); const body = await mojoCall('/api/assistant', { method: 'POST', body: { message, ...(conversationId != null ? { conversation_id: conversationId } : {}), ...(ids.length ? { attachments: ids } : {}) } }); return projectReply(body.data) ?? malformed('Assistant'); }
|
|
9
|
+
export async function sendAssistantMessage(message: string, conversationId?: number, attachments: readonly FileReference[] = [], options: RequestOptions = {}): Promise<AssistantReply> { const ids = attachments.map((file) => file.id); if (ids.length > 5 || new Set(ids).size !== ids.length || ids.some((id) => !Number.isSafeInteger(id) || id <= 0)) throw new Error('Assistant attachments must be 1–5 unique completed File references'); const body = await mojoCall('/api/assistant', { ...options, method: 'POST', body: { message, ...(conversationId != null ? { conversation_id: conversationId } : {}), ...(ids.length ? { attachments: ids } : {}) } }); return projectReply(body.data) ?? malformed('Assistant'); }
|
|
8
10
|
export async function createAssistantContext(model: 'incident.Incident' | 'incident.Ticket', pk: number): Promise<AssistantConversation> { const body = await mojoCall('/api/assistant/context', { method: 'POST', body: { model, pk } }); const data = body.data as Record<string, unknown> | undefined; const id = typeof data?.conversation_id === 'number' ? data.conversation_id : null; if (id == null) return malformed('Assistant context'); return getAssistantConversation(id); }
|
|
9
|
-
export async function getAssistantConversation(id: number): Promise<AssistantConversation> { const body = await mojoCall(`/api/assistant/conversation/${id}`, { params: { graph: 'detail' } }); return projectConversation(body.data) ?? malformed('Conversation'); }
|
|
11
|
+
export async function getAssistantConversation(id: number, options: RequestOptions = {}): Promise<AssistantConversation> { const body = await mojoCall(`/api/assistant/conversation/${id}`, { ...options, params: { graph: 'detail' } }); return projectConversation(body.data) ?? malformed('Conversation'); }
|
|
10
12
|
export async function listAssistantConversations(params: Params = {}): Promise<{ rows: AssistantConversationSummary[]; count: number }> { const page = await mojoList<unknown>('/api/assistant/conversation', { start: 0, size: 100, sort: '-modified', ...params }); return { rows: page.rows.map(projectConversationSummary).filter((v): v is AssistantConversationSummary => v != null), count: page.count }; }
|
|
11
13
|
export const deleteAssistantConversation = (id: number): Promise<void> => mojoDelete('/api/assistant/conversation', id);
|
|
12
|
-
export async function getAssistantSkill(id: number): Promise<AssistantSkill> { const body = await mojoCall(`/api/assistant/skill/${id}`, { params: { graph: 'detail' } }); return projectSkill(body.data) ?? malformed('Skill'); }
|
|
14
|
+
export async function getAssistantSkill(id: number, options: RequestOptions = {}): Promise<AssistantSkill> { const body = await mojoCall(`/api/assistant/skill/${id}`, { ...options, params: { graph: 'detail' } }); return projectSkill(body.data) ?? malformed('Skill'); }
|
|
13
15
|
export async function listAssistantSkills(params: Params = {}): Promise<{ rows: AssistantSkill[]; count: number }> { const page = await mojoList<unknown>('/api/assistant/skill', { start: 0, size: 100, sort: 'name', ...params }); return { rows: page.rows.map(projectSkill).filter((v): v is AssistantSkill => v != null), count: page.count }; }
|
|
14
16
|
export const deleteAssistantSkill = (id: number): Promise<void> => mojoDelete('/api/assistant/skill', id);
|
|
15
|
-
export async function getAssistantMemory(tier: 'global' | 'user' | 'group', group?: number): Promise<Record<string, unknown>> { const body = await mojoCall(`/api/assistant/memory/${tier}`, { params: group == null ? {} : { group } }); return body.data && typeof body.data === 'object' && !Array.isArray(body.data) ? body.data as Record<string, unknown> : {}; }
|
|
16
|
-
export async function saveAssistantMemory(tier: 'global' | 'user' | 'group', key: string, value: string, group?: number): Promise<void> { await mojoCall(`/api/assistant/memory/${tier}`, { method: 'POST', params: group == null ? {} : { group }, body: { key, value } }); }
|
|
17
|
-
export async function deleteAssistantMemory(tier: 'global' | 'user' | 'group', key: string, group?: number): Promise<void> { await mojoCall(`/api/assistant/memory/${tier}/${encodeURIComponent(key)}`, { method: 'DELETE', params: group == null ? {} : { group } }); }
|
|
17
|
+
export async function getAssistantMemory(tier: 'global' | 'user' | 'group', group?: number, options: RequestOptions = {}): Promise<Record<string, unknown>> { const body = await mojoCall(`/api/assistant/memory/${tier}`, { ...options, params: group == null ? {} : { group } }); return body.data && typeof body.data === 'object' && !Array.isArray(body.data) ? body.data as Record<string, unknown> : {}; }
|
|
18
|
+
export async function saveAssistantMemory(tier: 'global' | 'user' | 'group', key: string, value: string, group?: number, options: RequestOptions = {}): Promise<void> { await mojoCall(`/api/assistant/memory/${tier}`, { ...options, method: 'POST', params: group == null ? {} : { group }, body: { key, value } }); }
|
|
19
|
+
export async function deleteAssistantMemory(tier: 'global' | 'user' | 'group', key: string, group?: number, options: RequestOptions = {}): Promise<void> { await mojoCall(`/api/assistant/memory/${tier}/${encodeURIComponent(key)}`, { ...options, method: 'DELETE', params: group == null ? {} : { group } }); }
|