@stratusagent/dashboard 0.6.0
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/LICENSE +21 -0
- package/README.md +82 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +87 -0
- package/dist/index.js.map +1 -0
- package/package.json +46 -0
- package/ui/app.js +370 -0
- package/ui/favicon.svg +4 -0
- package/ui/index.html +10 -0
- package/ui/lib/api.js +120 -0
- package/ui/lib/avatar.js +28 -0
- package/ui/lib/dom.js +83 -0
- package/ui/styles.css +208 -0
- package/ui/views/agent.js +704 -0
- package/ui/views/dashboard.js +148 -0
- package/ui/views/plugins.js +86 -0
- package/ui/views/settings.js +365 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { ago, duration, el, until } from '../lib/dom.js';
|
|
2
|
+
import { api } from '../lib/api.js';
|
|
3
|
+
import { avatar } from '../lib/avatar.js';
|
|
4
|
+
import { isActive, navigate, refreshCore, store } from '../app.js';
|
|
5
|
+
|
|
6
|
+
export const activityPanel = (filter) => {
|
|
7
|
+
const lines = (filter ? store.activity.filter(filter) : store.activity).slice(0, 60);
|
|
8
|
+
return el('section', { class: 'card' },
|
|
9
|
+
el('div', { class: 'card-head' },
|
|
10
|
+
el('h2', {}, 'Activity'),
|
|
11
|
+
el('span', { class: 'stream-status' },
|
|
12
|
+
el('span', { class: `dot${store.stream === 'live' ? '' : ' offline'}` }),
|
|
13
|
+
store.stream === 'live' ? 'live' : 'reconnecting',
|
|
14
|
+
),
|
|
15
|
+
),
|
|
16
|
+
lines.length === 0
|
|
17
|
+
? el('p', { class: 'empty' }, 'Nothing yet. Events appear here as agents work.')
|
|
18
|
+
: el('div', { class: 'activity' }, ...lines.map((line) => el('div', { class: 'line' },
|
|
19
|
+
el('span', { class: 'when' }, ago(new Date(line.at).toISOString())),
|
|
20
|
+
el('span', { class: 'what', title: line.sessionId },
|
|
21
|
+
line.agent ? `${line.agent} ${line.text}` : line.text),
|
|
22
|
+
))),
|
|
23
|
+
);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const approvalCard = () => {
|
|
27
|
+
if (store.approvals.length === 0) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const decide = async (requestId, answer) => {
|
|
32
|
+
try {
|
|
33
|
+
await api.resolveApproval({ requestId, answer, actor: 'dashboard' });
|
|
34
|
+
} catch {
|
|
35
|
+
// A request that is no longer pending was already decided, expired, or
|
|
36
|
+
// its turn was cancelled. Refreshing shows the truth; retrying would
|
|
37
|
+
// just fail again.
|
|
38
|
+
}
|
|
39
|
+
await refreshCore();
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
return el('section', { class: 'card' },
|
|
43
|
+
el('div', { class: 'card-head' },
|
|
44
|
+
el('h2', {}, 'Approvals'),
|
|
45
|
+
el('span', { class: 'pill risk-gated' }, `${store.approvals.length} waiting`),
|
|
46
|
+
),
|
|
47
|
+
el('div', { class: 'approvals-list' }, ...store.approvals.map((request) => {
|
|
48
|
+
const agent = store.agents.find((candidate) => candidate.id === request.agentId);
|
|
49
|
+
return el('div', { class: 'approval' },
|
|
50
|
+
el('div', { class: 'row' },
|
|
51
|
+
agent ? avatar(agent, 24) : null,
|
|
52
|
+
el('div', { class: 'grow' },
|
|
53
|
+
el('div', { class: 'title' }, `${agent?.name ?? request.agentId} wants to run ${request.call.toolName}`),
|
|
54
|
+
el('div', { class: 'sub' },
|
|
55
|
+
request.expiresAt ? `expires ${until(request.expiresAt)}` : 'no expiry',
|
|
56
|
+
` · parked ${ago(request.parkedAt)}`),
|
|
57
|
+
),
|
|
58
|
+
el('span', { class: `pill risk-${request.risk}` }, request.risk),
|
|
59
|
+
),
|
|
60
|
+
el('div', { class: 'cmd' }, JSON.stringify(request.call.input)),
|
|
61
|
+
el('div', { class: 'actions' },
|
|
62
|
+
el('button', { class: 'primary small', onClick: () => decide(request.requestId, 'once') }, 'Allow once'),
|
|
63
|
+
el('button', { class: 'small', onClick: () => decide(request.requestId, 'always') }, 'Always allow'),
|
|
64
|
+
el('button', { class: 'small danger', onClick: () => decide(request.requestId, 'deny') }, 'Deny'),
|
|
65
|
+
),
|
|
66
|
+
);
|
|
67
|
+
})),
|
|
68
|
+
);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const quickStats = () => {
|
|
72
|
+
const health = store.health;
|
|
73
|
+
const sessions = health?.sessions ?? { total: 0, byStatus: {} };
|
|
74
|
+
const running = sessions.byStatus?.running ?? 0;
|
|
75
|
+
const active = store.agents.filter(isActive).length;
|
|
76
|
+
|
|
77
|
+
return el('section', { class: 'card' },
|
|
78
|
+
el('h2', {}, 'Quick Stats'),
|
|
79
|
+
store.offlineSince
|
|
80
|
+
? el('div', { class: 'banner bad' }, `stratusd stopped answering ${ago(new Date(store.offlineSince).toISOString())} — these numbers are from before that.`)
|
|
81
|
+
: null,
|
|
82
|
+
el('div', { class: 'stats' },
|
|
83
|
+
el('div', { class: 'stat' },
|
|
84
|
+
el('div', { class: 'value' }, String(store.agents.length)),
|
|
85
|
+
el('div', { class: 'label' }, `agents · ${active} active`)),
|
|
86
|
+
el('div', { class: 'stat' },
|
|
87
|
+
el('div', { class: 'value' }, String(running)),
|
|
88
|
+
el('div', { class: 'label' }, 'turns running')),
|
|
89
|
+
el('div', { class: 'stat' },
|
|
90
|
+
el('div', { class: 'value' }, String(sessions.total)),
|
|
91
|
+
el('div', { class: 'label' }, 'sessions stored')),
|
|
92
|
+
el('div', { class: `stat${store.approvals.length > 0 ? ' bad' : ''}` },
|
|
93
|
+
el('div', { class: 'value' }, String(store.approvals.length)),
|
|
94
|
+
el('div', { class: 'label' }, 'awaiting approval')),
|
|
95
|
+
el('div', { class: 'stat' },
|
|
96
|
+
el('div', { class: 'value' }, health ? duration(health.uptimeMs) : '—'),
|
|
97
|
+
el('div', { class: 'label' }, 'uptime')),
|
|
98
|
+
),
|
|
99
|
+
health?.runtimes?.length
|
|
100
|
+
? el('div', {},
|
|
101
|
+
el('h3', {}, 'Serving'),
|
|
102
|
+
el('div', { class: 'rows' }, ...health.runtimes.map((runtime) => el('div', { class: 'row' },
|
|
103
|
+
el('div', { class: 'grow' },
|
|
104
|
+
el('div', { class: 'title' }, runtime.model ? `${runtime.provider} · ${runtime.model}` : runtime.provider),
|
|
105
|
+
el('div', { class: 'sub' }, `credentials: ${runtime.credentials}`)),
|
|
106
|
+
))))
|
|
107
|
+
: null,
|
|
108
|
+
);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const recentSessions = () => el('section', { class: 'card' },
|
|
112
|
+
el('div', { class: 'card-head' },
|
|
113
|
+
el('h2', {}, 'Recent Sessions'),
|
|
114
|
+
el('button', { class: 'small', onClick: () => void refreshCore() }, 'Refresh'),
|
|
115
|
+
),
|
|
116
|
+
store.sessions.length === 0
|
|
117
|
+
? el('p', { class: 'empty' }, 'No conversations yet. Open an agent to start one.')
|
|
118
|
+
: el('div', { class: 'rows' }, ...store.sessions.map((session) => {
|
|
119
|
+
const agent = store.agents.find((candidate) => candidate.id === session.agentId);
|
|
120
|
+
return el('div', { class: 'row' },
|
|
121
|
+
agent ? avatar(agent, 24) : null,
|
|
122
|
+
el('div', { class: 'grow' },
|
|
123
|
+
el('div', { class: 'title', title: session.id },
|
|
124
|
+
session.id.length <= 34 ? session.id : `${session.id.slice(0, 16)}…${session.id.slice(-10)}`),
|
|
125
|
+
el('div', { class: 'sub' }, `${agent?.name ?? session.agentId} · ${ago(session.updatedAt)}`)),
|
|
126
|
+
el('span', { class: `pill ${session.status}` }, session.status.replace('_', ' ')),
|
|
127
|
+
agent
|
|
128
|
+
? el('button', {
|
|
129
|
+
class: 'small',
|
|
130
|
+
onClick: () => navigate({ name: 'agent', agentId: agent.id, sessionId: session.id }),
|
|
131
|
+
}, 'Open')
|
|
132
|
+
: null,
|
|
133
|
+
);
|
|
134
|
+
})),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
export const renderDashboard = () => {
|
|
138
|
+
const node = el('div', { class: 'main' });
|
|
139
|
+
const update = () => node.replaceChildren(
|
|
140
|
+
el('div', { class: 'column' }, quickStats(), approvalCard(), recentSessions()),
|
|
141
|
+
el('div', { class: 'column' }, activityPanel()),
|
|
142
|
+
);
|
|
143
|
+
update();
|
|
144
|
+
// Nothing on this screen holds a caret or a scroll position worth keeping,
|
|
145
|
+
// so it simply rebuilds — the honest default when there is no reason to be
|
|
146
|
+
// cleverer.
|
|
147
|
+
return { node, update };
|
|
148
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { el } from '../lib/dom.js';
|
|
2
|
+
import { api } from '../lib/api.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What this daemon can actually do, and whose code it is.
|
|
6
|
+
*
|
|
7
|
+
* Two lists rather than one, because either alone misleads. The tools say
|
|
8
|
+
* what an agent can be granted and at what risk; the plugins say what the
|
|
9
|
+
* daemon was *asked* to load — including one that failed, which is
|
|
10
|
+
* invisible in a list of tools and is often the reason somebody opened this
|
|
11
|
+
* screen.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const riskPill = (risk) => el('span', { class: `pill risk-${risk}` }, risk);
|
|
15
|
+
|
|
16
|
+
const toolRow = (tool) => el('div', { class: 'row' },
|
|
17
|
+
el('div', { class: 'grow' },
|
|
18
|
+
el('div', { class: 'title' }, tool.name),
|
|
19
|
+
tool.description ? el('div', { class: 'sub' }, tool.description) : null,
|
|
20
|
+
),
|
|
21
|
+
riskPill(tool.risk),
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
const pluginCard = (plugin) => {
|
|
25
|
+
if (plugin.error) {
|
|
26
|
+
return el('section', { class: 'card' },
|
|
27
|
+
el('div', { class: 'card-head' },
|
|
28
|
+
el('h2', {}, plugin.package),
|
|
29
|
+
el('span', { class: 'pill failed' }, 'did not load'),
|
|
30
|
+
),
|
|
31
|
+
el('p', { class: 'field-note' }, plugin.error),
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return el('section', { class: 'card' },
|
|
36
|
+
el('div', { class: 'card-head' },
|
|
37
|
+
el('h2', {}, plugin.package),
|
|
38
|
+
// Not decoration: a third-party plugin cannot declare a tool `safe`,
|
|
39
|
+
// so this says why the risks below read the way they do.
|
|
40
|
+
el('span', { class: plugin.trusted ? 'pill' : 'pill risk-gated' },
|
|
41
|
+
plugin.trusted ? 'first-party' : 'third-party'),
|
|
42
|
+
),
|
|
43
|
+
(plugin.tools ?? []).length === 0
|
|
44
|
+
? el('p', { class: 'field-note' }, 'Contributes no tools.')
|
|
45
|
+
: el('div', {}, ...(plugin.tools ?? []).map(toolRow)),
|
|
46
|
+
);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const kernelCard = (tools) => el('section', { class: 'card' },
|
|
50
|
+
el('div', { class: 'card-head' },
|
|
51
|
+
el('h2', {}, 'Built in'),
|
|
52
|
+
el('span', { class: 'pill' }, `${tools.length} tool${tools.length === 1 ? '' : 's'}`),
|
|
53
|
+
),
|
|
54
|
+
el('div', {}, ...tools.map(toolRow)),
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const emptyCard = () => el('section', { class: 'card' },
|
|
58
|
+
el('h2', {}, 'No plugins installed'),
|
|
59
|
+
el('p', {}, 'Capability arrives as plugins: a filesystem, a shell, a browser, a web fetcher. Install one and list it in ~/.stratus/config.json.'),
|
|
60
|
+
el('pre', { class: 'cmd' }, 'npm install @stratusagent/tool-fs\n\n"plugins": {\n "@stratusagent/tool-fs": { "enabled": true, "roots": ["~/notes"] }\n}'),
|
|
61
|
+
el('p', { class: 'field-note' }, 'Installing a plugin grants no agent anything — each agent’s soul still lists the tools it may call, on its Settings tab.'),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
export const renderPlugins = () => {
|
|
65
|
+
const node = el('div', { class: 'main solo' }, el('section', { class: 'card' }, el('p', {}, 'Loading…')));
|
|
66
|
+
|
|
67
|
+
const paint = (tools, plugins) => {
|
|
68
|
+
const kernel = tools.filter((tool) => !tool.package);
|
|
69
|
+
node.replaceChildren(
|
|
70
|
+
...(plugins.length === 0 ? [emptyCard()] : plugins.map(pluginCard)),
|
|
71
|
+
...(kernel.length > 0 ? [kernelCard(kernel)] : []),
|
|
72
|
+
);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
api.tools().then(
|
|
76
|
+
(payload) => paint(payload.tools ?? [], payload.plugins ?? []),
|
|
77
|
+
(error) => {
|
|
78
|
+
node.replaceChildren(el('section', { class: 'card' },
|
|
79
|
+
el('h2', {}, 'Plugins'),
|
|
80
|
+
el('p', { class: 'field-note' }, `Could not read the tool catalog: ${error.message}`),
|
|
81
|
+
));
|
|
82
|
+
},
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
return { node };
|
|
86
|
+
};
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import { el } from '../lib/dom.js';
|
|
2
|
+
import { api } from '../lib/api.js';
|
|
3
|
+
import { refreshCore, store } from '../app.js';
|
|
4
|
+
|
|
5
|
+
const PROVIDERS = ['anthropic', 'openai'];
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Bring a `<select>`'s options up to date without disturbing a choice.
|
|
9
|
+
*
|
|
10
|
+
* The option lists here are data — models a sign-in can reach, agents on the
|
|
11
|
+
* roster — and they arrive after the form is already on screen. Rebuilding
|
|
12
|
+
* the whole pane to show them would throw away everything typed into it, so
|
|
13
|
+
* the select is kept and only its options are replaced, restoring the current
|
|
14
|
+
* value when it survived the refresh.
|
|
15
|
+
*/
|
|
16
|
+
const syncOptions = (select, options, fallback) => {
|
|
17
|
+
const signature = options.map((option) => option.value).join(' ');
|
|
18
|
+
if (select.dataset.signature === signature) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
select.dataset.signature = signature;
|
|
22
|
+
// A choice someone made survives; a value the browser merely settled on
|
|
23
|
+
// does not. The pane renders once before its data arrives, so the picker
|
|
24
|
+
// starts on whatever the empty option list allowed — and treating that as a
|
|
25
|
+
// selection meant the configured model never got selected when it finally
|
|
26
|
+
// appeared, leaving the picker on "follow the default" and the next Save
|
|
27
|
+
// deleting a pin nobody touched.
|
|
28
|
+
const chosen = select.dataset.touched === 'yes' ? select.value : fallback;
|
|
29
|
+
select.replaceChildren(...options.map((option) => el('option', { value: option.value }, option.label)));
|
|
30
|
+
const wanted = options.some((option) => option.value === chosen) ? chosen : fallback;
|
|
31
|
+
if (wanted !== undefined) {
|
|
32
|
+
select.value = wanted;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Mark a select as carrying a real choice rather than a default. */
|
|
37
|
+
const touchOnChange = (select) => {
|
|
38
|
+
select.addEventListener('change', () => { select.dataset.touched = 'yes'; });
|
|
39
|
+
return select;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const renderSettings = (section) => {
|
|
43
|
+
const state = { credentials: undefined, models: undefined, config: undefined, notice: undefined, busy: false };
|
|
44
|
+
const node = el('div', { class: 'main solo' });
|
|
45
|
+
|
|
46
|
+
const say = (text, kind = 'ok') => {
|
|
47
|
+
state.notice = { text, kind };
|
|
48
|
+
update();
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const load = async () => {
|
|
52
|
+
try {
|
|
53
|
+
if (section === 'providers' || section === 'channels') {
|
|
54
|
+
state.credentials = await api.credentials();
|
|
55
|
+
}
|
|
56
|
+
if (section === 'models') {
|
|
57
|
+
const [models, config] = await Promise.all([api.models(), api.config()]);
|
|
58
|
+
state.models = models.models ?? [];
|
|
59
|
+
state.config = config.config ?? {};
|
|
60
|
+
}
|
|
61
|
+
} catch (error) {
|
|
62
|
+
say(error.message, 'bad');
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
update();
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The pane, built once and kept.
|
|
70
|
+
*
|
|
71
|
+
* `update()` runs on every notice and on both edges of every request — the
|
|
72
|
+
* busy flag alone fires it twice per click. Rebuilding the pane there put a
|
|
73
|
+
* fresh, empty form on screen the moment someone pressed Verify, so a key
|
|
74
|
+
* that had just been checked was gone before it could be saved, and Save
|
|
75
|
+
* would then store an empty credential. Only the data-driven regions
|
|
76
|
+
* refresh; what someone typed is state, not a projection of the server.
|
|
77
|
+
*/
|
|
78
|
+
let pane;
|
|
79
|
+
|
|
80
|
+
// ---- providers ---------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
const buildProviders = () => {
|
|
83
|
+
const provider = el('select', {}, ...PROVIDERS.map((name) => el('option', { value: name }, name)));
|
|
84
|
+
const type = el('select', {},
|
|
85
|
+
el('option', { value: 'api_key' }, 'API key'),
|
|
86
|
+
el('option', { value: 'oauth_token' }, 'Claude subscription token'));
|
|
87
|
+
const value = el('input', { type: 'password', placeholder: 'paste the key' });
|
|
88
|
+
const baseUrl = el('input', { placeholder: 'optional — a proxy or local endpoint' });
|
|
89
|
+
|
|
90
|
+
const verify = async () => {
|
|
91
|
+
state.busy = true; update();
|
|
92
|
+
try {
|
|
93
|
+
const verdict = await api.verifyKey({
|
|
94
|
+
provider: provider.value,
|
|
95
|
+
// The type decides how the value is checked at all: a subscription
|
|
96
|
+
// token cannot reach the models endpoint, and checked as an API
|
|
97
|
+
// key it comes back rejected — condemning a credential that works
|
|
98
|
+
// once saved.
|
|
99
|
+
type: type.value,
|
|
100
|
+
key: value.value,
|
|
101
|
+
...(baseUrl.value.trim() ? { baseUrl: baseUrl.value.trim() } : {}),
|
|
102
|
+
});
|
|
103
|
+
// Only an explicit auth failure condemns a key: plenty of compatible
|
|
104
|
+
// endpoints have no models route at all, and "unreachable" there says
|
|
105
|
+
// nothing about whether the key works.
|
|
106
|
+
say(
|
|
107
|
+
verdict.status === 'ok' ? 'That key works.'
|
|
108
|
+
: verdict.status === 'rejected' ? `Rejected: ${verdict.detail ?? 'the provider refused it'}`
|
|
109
|
+
: `Could not check it: ${verdict.detail ?? 'unreachable'} — it may still be fine.`,
|
|
110
|
+
verdict.status === 'rejected' ? 'bad' : 'ok',
|
|
111
|
+
);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
say(error.message, 'bad');
|
|
114
|
+
}
|
|
115
|
+
state.busy = false; update();
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const save = async () => {
|
|
119
|
+
state.busy = true; update();
|
|
120
|
+
try {
|
|
121
|
+
await api.storeKey(provider.value, {
|
|
122
|
+
type: type.value,
|
|
123
|
+
value: value.value,
|
|
124
|
+
...(baseUrl.value.trim() ? { baseUrl: baseUrl.value.trim() } : {}),
|
|
125
|
+
});
|
|
126
|
+
value.value = '';
|
|
127
|
+
state.credentials = await api.credentials();
|
|
128
|
+
say('Stored in ~/.stratus/credentials.json (0600).');
|
|
129
|
+
} catch (error) {
|
|
130
|
+
say(error.message, 'bad');
|
|
131
|
+
}
|
|
132
|
+
state.busy = false; update();
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const signIns = el('div', { class: 'rows' });
|
|
136
|
+
const verifyButton = el('button', { onClick: () => void verify() }, 'Verify');
|
|
137
|
+
const saveButton = el('button', { class: 'primary', onClick: () => void save() }, 'Save');
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
node: el('section', { class: 'card' },
|
|
141
|
+
el('h2', {}, 'Providers'),
|
|
142
|
+
signIns,
|
|
143
|
+
el('p', { class: 'field-note' }, 'Keys are never sent back out — this page can tell you a sign-in exists, not what it is.'),
|
|
144
|
+
el('h3', {}, 'Add or replace a sign-in'),
|
|
145
|
+
el('div', { class: 'form-grid' },
|
|
146
|
+
el('div', {}, el('label', {}, 'Provider'), provider),
|
|
147
|
+
el('div', {}, el('label', {}, 'Type'), type),
|
|
148
|
+
),
|
|
149
|
+
el('label', {}, 'Key'), value,
|
|
150
|
+
el('label', {}, 'Endpoint'), baseUrl,
|
|
151
|
+
el('p', { class: 'field-note' }, 'A key is bound to the endpoint you save it with, and is never sent anywhere else.'),
|
|
152
|
+
el('div', { class: 'actions' }, verifyButton, saveButton),
|
|
153
|
+
),
|
|
154
|
+
refresh() {
|
|
155
|
+
signIns.replaceChildren(...(state.credentials?.providers ?? []).map((entry) => el('div', { class: 'row' },
|
|
156
|
+
el('div', { class: 'grow' },
|
|
157
|
+
el('div', { class: 'title' }, entry.provider),
|
|
158
|
+
el('div', { class: 'sub' }, entry.stored
|
|
159
|
+
? `${entry.type === 'oauth_token' ? 'Claude subscription' : 'API key'}${entry.baseUrl ? ` · ${entry.baseUrl}` : ''}`
|
|
160
|
+
: 'not signed in')),
|
|
161
|
+
el('span', { class: `pill${entry.stored ? ' running' : ''}` }, entry.stored ? 'signed in' : 'none'),
|
|
162
|
+
)));
|
|
163
|
+
verifyButton.disabled = state.busy;
|
|
164
|
+
saveButton.disabled = state.busy;
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
// ---- models ------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
const buildModels = () => {
|
|
172
|
+
const picker = touchOnChange(el('select', {}));
|
|
173
|
+
|
|
174
|
+
const save = async () => {
|
|
175
|
+
state.busy = true; update();
|
|
176
|
+
try {
|
|
177
|
+
// Re-read rather than spreading the snapshot this page loaded with.
|
|
178
|
+
// PUT replaces the whole document, so a save built on a stale copy
|
|
179
|
+
// silently reverts anything another surface changed in the meantime
|
|
180
|
+
// — the CLI editing `approvals`, another tab picking a soul. This
|
|
181
|
+
// narrows that window to the round trip below; it does not close it,
|
|
182
|
+
// which would need a precondition the API does not offer yet.
|
|
183
|
+
const config = (await api.config()).config ?? {};
|
|
184
|
+
state.config = config;
|
|
185
|
+
const [provider, model] = picker.value ? picker.value.split(/:(.*)/s) : [undefined, undefined];
|
|
186
|
+
// A whole document, because PUT replaces rather than merges — sending
|
|
187
|
+
// only the two changed keys would silently drop everything else in
|
|
188
|
+
// the file, approvals and soul included. Which makes the *removals*
|
|
189
|
+
// below load-bearing rather than tidying.
|
|
190
|
+
const next = { ...config };
|
|
191
|
+
if (picker.dataset.touched !== 'yes') {
|
|
192
|
+
// Nobody chose anything here, so this save has no opinion about the
|
|
193
|
+
// model. Deriving one from the picker's displayed value would let
|
|
194
|
+
// opening the page and pressing Save rewrite a pin — which is how a
|
|
195
|
+
// configuration the catalog could not express got deleted.
|
|
196
|
+
} else if (provider || model) {
|
|
197
|
+
if (provider) {
|
|
198
|
+
next.provider = provider;
|
|
199
|
+
} else {
|
|
200
|
+
delete next.provider;
|
|
201
|
+
}
|
|
202
|
+
if (model) {
|
|
203
|
+
next.model = model;
|
|
204
|
+
} else {
|
|
205
|
+
delete next.model;
|
|
206
|
+
}
|
|
207
|
+
if (provider && provider !== config.provider) {
|
|
208
|
+
// An endpoint and a key-selector are chosen for one provider.
|
|
209
|
+
// Carried across, they point the new provider at the old one's
|
|
210
|
+
// service, or hand it the wrong credential — the same
|
|
211
|
+
// normalization the CLI setup flow performs on a switch.
|
|
212
|
+
delete next.baseUrl;
|
|
213
|
+
delete next.apiKeyEnv;
|
|
214
|
+
}
|
|
215
|
+
} else {
|
|
216
|
+
// "Follow the default" has to actually clear the pin. Leaving the
|
|
217
|
+
// old provider and model in place would report success and change
|
|
218
|
+
// nothing, with the previous model still there on reload.
|
|
219
|
+
delete next.provider;
|
|
220
|
+
delete next.model;
|
|
221
|
+
}
|
|
222
|
+
await api.saveConfig(next);
|
|
223
|
+
state.config = (await api.config()).config ?? {};
|
|
224
|
+
await refreshCore();
|
|
225
|
+
say('Saved. Every agent that does not pin its own follows this.');
|
|
226
|
+
} catch (error) {
|
|
227
|
+
say(error.message, 'bad');
|
|
228
|
+
}
|
|
229
|
+
state.busy = false; update();
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const empty = el('p', { class: 'empty' }, 'No models reachable yet — add a provider sign-in first.');
|
|
233
|
+
const saveButton = el('button', { class: 'primary', onClick: () => void save() }, 'Save');
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
node: el('section', { class: 'card' },
|
|
237
|
+
el('h2', {}, 'Models'),
|
|
238
|
+
empty,
|
|
239
|
+
el('label', {}, 'Default model'), picker,
|
|
240
|
+
el('p', { class: 'field-note' }, 'Listed live from the sign-ins you have. An agent whose soul pins a provider or model keeps it, whatever this says.'),
|
|
241
|
+
el('div', { class: 'actions' }, saveButton),
|
|
242
|
+
),
|
|
243
|
+
refresh() {
|
|
244
|
+
const config = state.config ?? {};
|
|
245
|
+
// Either half counts as a pin. A config naming only a provider takes
|
|
246
|
+
// that provider's default model, and one naming only a model keeps
|
|
247
|
+
// the provider from elsewhere — both are configurations someone
|
|
248
|
+
// chose, and reading only the pair called them unpinned.
|
|
249
|
+
const pinned = config.provider || config.model
|
|
250
|
+
? `${config.provider ?? ''}:${config.model ?? ''}`
|
|
251
|
+
: '';
|
|
252
|
+
const listed = (state.models ?? []).map((model) => ({
|
|
253
|
+
value: `${model.provider}:${model.id}`,
|
|
254
|
+
label: `${model.id} — ${model.provider}`,
|
|
255
|
+
}));
|
|
256
|
+
// The configured pin, even when the live catalog does not list it —
|
|
257
|
+
// an endpoint that omits the model, a sign-in the provider is
|
|
258
|
+
// currently refusing, or a partial pin the catalog cannot express.
|
|
259
|
+
// Without an option to select, the browser leaves the picker on
|
|
260
|
+
// "follow the default", which is a claim about the config that is not
|
|
261
|
+
// true. Labelled for what it is rather than shown as a normal choice.
|
|
262
|
+
if (pinned && !listed.some((option) => option.value === pinned)) {
|
|
263
|
+
const label = config.provider && config.model
|
|
264
|
+
? `${config.model} — ${config.provider} (not listed right now)`
|
|
265
|
+
: config.provider
|
|
266
|
+
? `${config.provider} — its default model`
|
|
267
|
+
: `${config.model} — whichever provider is default`;
|
|
268
|
+
listed.unshift({ value: pinned, label });
|
|
269
|
+
}
|
|
270
|
+
syncOptions(picker, [{ value: '', label: 'follow the default' }, ...listed], pinned);
|
|
271
|
+
empty.hidden = (state.models ?? []).length > 0;
|
|
272
|
+
saveButton.disabled = state.busy;
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
// ---- channels ----------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
const buildChannels = () => {
|
|
280
|
+
const agentPicker = touchOnChange(el('select', {}));
|
|
281
|
+
const appToken = el('input', { type: 'password', placeholder: 'xapp-…' });
|
|
282
|
+
const botToken = el('input', { type: 'password', placeholder: 'xoxb-…' });
|
|
283
|
+
|
|
284
|
+
const save = async () => {
|
|
285
|
+
state.busy = true; update();
|
|
286
|
+
try {
|
|
287
|
+
await api.storeChannel('slack', {
|
|
288
|
+
agentId: agentPicker.value,
|
|
289
|
+
appToken: appToken.value,
|
|
290
|
+
botToken: botToken.value,
|
|
291
|
+
});
|
|
292
|
+
appToken.value = '';
|
|
293
|
+
botToken.value = '';
|
|
294
|
+
state.credentials = await api.credentials();
|
|
295
|
+
say('Stored. Restart stratusd to bring that agent online in Slack.');
|
|
296
|
+
} catch (error) {
|
|
297
|
+
say(error.message, 'bad');
|
|
298
|
+
}
|
|
299
|
+
state.busy = false; update();
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
const bound = el('div', {});
|
|
303
|
+
const saveButton = el('button', { class: 'primary', onClick: () => void save() }, 'Save');
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
node: el('section', { class: 'card' },
|
|
307
|
+
el('h2', {}, 'Channels'),
|
|
308
|
+
bound,
|
|
309
|
+
el('h3', {}, 'Connect an agent'),
|
|
310
|
+
el('label', {}, 'Agent'), agentPicker,
|
|
311
|
+
el('label', {}, 'App token'), appToken,
|
|
312
|
+
el('label', {}, 'Bot token'), botToken,
|
|
313
|
+
el('p', { class: 'field-note' }, 'Channel tokens are gateway secrets in their own namespace — an agent can never read the tokens of the transport carrying it.'),
|
|
314
|
+
el('div', { class: 'actions' }, saveButton),
|
|
315
|
+
),
|
|
316
|
+
refresh() {
|
|
317
|
+
// The built-in agent included, deliberately. It is the only agent a
|
|
318
|
+
// fresh install has, the gateway dispatches Slack turns to it, and
|
|
319
|
+
// the CLI's own Channels list starts with it — filtering it out here
|
|
320
|
+
// left the picker empty on exactly the install that needs it, with a
|
|
321
|
+
// Save that could only fail validation.
|
|
322
|
+
syncOptions(
|
|
323
|
+
agentPicker,
|
|
324
|
+
store.agents.map((agent) => ({ value: agent.id, label: agent.name })),
|
|
325
|
+
);
|
|
326
|
+
const connected = state.credentials?.channels?.slack ?? [];
|
|
327
|
+
bound.replaceChildren(connected.length === 0
|
|
328
|
+
? el('p', { class: 'empty' }, 'No agent is connected to Slack yet.')
|
|
329
|
+
: el('div', { class: 'rows' }, ...connected.map((agentId) => {
|
|
330
|
+
const agent = store.agents.find((candidate) => candidate.id === agentId);
|
|
331
|
+
return el('div', { class: 'row' },
|
|
332
|
+
el('div', { class: 'grow' },
|
|
333
|
+
el('div', { class: 'title' }, agent?.name ?? agentId),
|
|
334
|
+
el('div', { class: 'sub' }, 'Slack app connected')),
|
|
335
|
+
el('span', { class: 'pill running' }, 'slack'),
|
|
336
|
+
);
|
|
337
|
+
})));
|
|
338
|
+
saveButton.disabled = state.busy;
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
const notice = el('div', {});
|
|
344
|
+
|
|
345
|
+
const update = () => {
|
|
346
|
+
pane ??= section === 'models' ? buildModels()
|
|
347
|
+
: section === 'channels' ? buildChannels()
|
|
348
|
+
: buildProviders();
|
|
349
|
+
pane.refresh();
|
|
350
|
+
notice.replaceChildren(
|
|
351
|
+
...(state.notice ? [el('div', { class: `notice ${state.notice.kind}` }, state.notice.text)] : []),
|
|
352
|
+
);
|
|
353
|
+
if (!node.firstChild) {
|
|
354
|
+
node.replaceChildren(el('div', { class: 'column' }, pane.node, notice));
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
update();
|
|
359
|
+
void load();
|
|
360
|
+
|
|
361
|
+
// Nothing here re-renders from the event stream: these are forms, and a
|
|
362
|
+
// repaint driven by another agent's activity would take the caret out of
|
|
363
|
+
// whatever someone was typing.
|
|
364
|
+
return { node };
|
|
365
|
+
};
|