@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Stratus Labs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# @stratusagent/dashboard
|
|
2
|
+
|
|
3
|
+
The Stratus web dashboard: the roster, live chat with your agents, pending
|
|
4
|
+
approvals, and settings — served by `@stratusagent/control-api` at `/`.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
npm install -g @stratusagent/control-api @stratusagent/dashboard
|
|
8
|
+
stratus dashboard
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
`stratus dashboard` finds a running daemon (or starts one), mints a
|
|
12
|
+
single-use sign-in link, and opens your browser at it.
|
|
13
|
+
|
|
14
|
+
## Why it is a separate package
|
|
15
|
+
|
|
16
|
+
The control API has three consumers and only one of them is a web page. The
|
|
17
|
+
macOS app (roadmap step 07) is deliberately *not* a chat app, and a headless
|
|
18
|
+
VM deployment wants the API with no UI at all. Keeping the UI here means both
|
|
19
|
+
of those install the API alone, and this package can grow without weighting
|
|
20
|
+
them.
|
|
21
|
+
|
|
22
|
+
The API auto-loads it: if this package resolves, the dashboard is served. Pass
|
|
23
|
+
`ui: false` to `createControlApi` to run the API bare on a machine that has it
|
|
24
|
+
installed anyway.
|
|
25
|
+
|
|
26
|
+
## No build step
|
|
27
|
+
|
|
28
|
+
The `ui/` directory is what ships: hand-written HTML, CSS, and ES modules, no
|
|
29
|
+
bundler and no framework. What a contributor edits is exactly what a user
|
|
30
|
+
loads, and there is no build output to go stale against the source.
|
|
31
|
+
|
|
32
|
+
That is a real constraint, not a preference — it is why the rendering layer is
|
|
33
|
+
a hundred lines of `el()` in `ui/lib/dom.js` rather than a dependency, and why
|
|
34
|
+
every string reaches the DOM as a text node. There is deliberately no
|
|
35
|
+
`innerHTML` helper in this package.
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
ui/
|
|
39
|
+
index.html the shell
|
|
40
|
+
styles.css tokens, light and dark
|
|
41
|
+
app.js store, router, live stream, sidebar
|
|
42
|
+
lib/ dom helpers, the API client, avatars
|
|
43
|
+
views/ dashboard, agent, settings, plugins
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## What the screens do
|
|
47
|
+
|
|
48
|
+
**Dashboard** — quick stats from `GET /health` (agents, running turns, stored
|
|
49
|
+
sessions, waiting approvals, uptime, and what the daemon is serving with
|
|
50
|
+
which credentials), the approvals panel when anything is parked, recent
|
|
51
|
+
conversations, and a live activity feed.
|
|
52
|
+
|
|
53
|
+
**Agent** — one agent, three tabs. *Chat* streams a conversation as it
|
|
54
|
+
happens: text arrives token by token, tool calls appear as status lines, and
|
|
55
|
+
the transcript is re-read from the daemon when the turn settles so the page
|
|
56
|
+
never quietly disagrees with what is stored. *Activity* is the same event
|
|
57
|
+
stream filtered to that agent. *Settings* edits the soul — name, persona,
|
|
58
|
+
provider and model pins — through a validated round-trip.
|
|
59
|
+
|
|
60
|
+
**Settings** — provider sign-ins (with a live key check before storing),
|
|
61
|
+
the default model listed from what those sign-ins can actually reach, and
|
|
62
|
+
Slack channel tokens.
|
|
63
|
+
|
|
64
|
+
**Plugins** — an honest placeholder. Tool packs are roadmap step 06.
|
|
65
|
+
|
|
66
|
+
## Two things the page will not do
|
|
67
|
+
|
|
68
|
+
- **It never renders a secret.** `GET /credentials` reports that a sign-in
|
|
69
|
+
exists, its type, and its endpoint. There is nothing to show and nothing to
|
|
70
|
+
leak.
|
|
71
|
+
- **It only talks to its own origin.** The page is served with a
|
|
72
|
+
`default-src 'self'` content security policy — no external scripts, styles,
|
|
73
|
+
images, or connections. A local dashboard holding a live session against
|
|
74
|
+
your daemon is not a place to be relaxed about that.
|
|
75
|
+
|
|
76
|
+
## When the daemon restarts
|
|
77
|
+
|
|
78
|
+
Sessions live in the daemon's memory, so restarting it signs the browser out
|
|
79
|
+
and the page says so rather than rendering an empty roster that looks like a
|
|
80
|
+
fleet that vanished. Run `stratus dashboard` again. If the socket drops
|
|
81
|
+
without the daemon dying, the page reconnects on its own with a backoff and
|
|
82
|
+
re-reads everything it missed.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ServerResponse } from 'node:http';
|
|
2
|
+
export interface DashboardAssetOptions {
|
|
3
|
+
/** Override the asset directory. Tests use it; nothing else should. */
|
|
4
|
+
root?: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Static assets for the control API to serve at `/`.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately not a server: the API already owns a socket, an auth model, and
|
|
10
|
+
* an origin check, and a second server would need its own copy of all three.
|
|
11
|
+
* This is the smallest thing that can answer "is there a file for this path".
|
|
12
|
+
*/
|
|
13
|
+
export declare const createDashboardAssets: (options?: DashboardAssetOptions) => {
|
|
14
|
+
serve(pathname: string, response: ServerResponse): Promise<boolean>;
|
|
15
|
+
};
|
|
16
|
+
export type DashboardAssets = ReturnType<typeof createDashboardAssets>;
|
|
17
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAyBhD,MAAM,WAAW,qBAAqB;IACpC,uEAAuE;IACvE,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED;;;;;;GAMG;AACH,eAAO,MAAM,qBAAqB,GAAI,UAAS,qBAA0B;oBAI/C,MAAM,YAAY,cAAc,GAAG,OAAO,CAAC,OAAO,CAAC;CAuD5E,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG,UAAU,CAAC,OAAO,qBAAqB,CAAC,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { createReadStream } from 'node:fs';
|
|
2
|
+
import { stat } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { pipeline } from 'node:stream/promises';
|
|
6
|
+
/**
|
|
7
|
+
* Where the built UI lives, relative to this module.
|
|
8
|
+
*
|
|
9
|
+
* `ui/` is a sibling of `dist/`, and both ship in the package. The assets are
|
|
10
|
+
* hand-written ES modules with no build step, so what is published is exactly
|
|
11
|
+
* what a contributor edits — there is no bundle to go stale against the source.
|
|
12
|
+
*/
|
|
13
|
+
const UI_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'ui');
|
|
14
|
+
const CONTENT_TYPES = {
|
|
15
|
+
'.html': 'text/html; charset=utf-8',
|
|
16
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
17
|
+
'.css': 'text/css; charset=utf-8',
|
|
18
|
+
'.svg': 'image/svg+xml',
|
|
19
|
+
'.png': 'image/png',
|
|
20
|
+
'.woff2': 'font/woff2',
|
|
21
|
+
'.json': 'application/json; charset=utf-8',
|
|
22
|
+
'.ico': 'image/x-icon',
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Static assets for the control API to serve at `/`.
|
|
26
|
+
*
|
|
27
|
+
* Deliberately not a server: the API already owns a socket, an auth model, and
|
|
28
|
+
* an origin check, and a second server would need its own copy of all three.
|
|
29
|
+
* This is the smallest thing that can answer "is there a file for this path".
|
|
30
|
+
*/
|
|
31
|
+
export const createDashboardAssets = (options = {}) => {
|
|
32
|
+
const root = options.root ?? UI_ROOT;
|
|
33
|
+
return {
|
|
34
|
+
async serve(pathname, response) {
|
|
35
|
+
// A single-page app: every unknown path is a route the client renders,
|
|
36
|
+
// so the shell answers for all of them. Real assets are matched first.
|
|
37
|
+
const requested = pathname === '/' ? '/index.html' : pathname;
|
|
38
|
+
let decoded;
|
|
39
|
+
try {
|
|
40
|
+
decoded = decodeURIComponent(requested);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
// Resolved and then checked, not sanitised. Stripping `..` by hand is a
|
|
46
|
+
// game of encodings nobody wins; asking whether the resolved path is
|
|
47
|
+
// still inside the asset directory is a question with one answer.
|
|
48
|
+
const candidate = path.resolve(root, `.${path.posix.normalize(decoded)}`);
|
|
49
|
+
const inside = candidate === root || candidate.startsWith(`${root}${path.sep}`);
|
|
50
|
+
const target = inside && path.extname(candidate) !== ''
|
|
51
|
+
? candidate
|
|
52
|
+
: path.join(root, 'index.html');
|
|
53
|
+
let stats;
|
|
54
|
+
try {
|
|
55
|
+
stats = await stat(target);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
if (!stats.isFile()) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
response.statusCode = 200;
|
|
64
|
+
response.setHeader('content-type', CONTENT_TYPES[path.extname(target)] ?? 'application/octet-stream');
|
|
65
|
+
response.setHeader('content-length', stats.size);
|
|
66
|
+
// The shell and its modules are versioned with the daemon, and a
|
|
67
|
+
// dashboard that keeps showing last release's UI after an upgrade is a
|
|
68
|
+
// bug report nobody can reproduce. Loopback makes revalidation free.
|
|
69
|
+
response.setHeader('cache-control', 'no-cache');
|
|
70
|
+
// A page that only ever talks to its own origin, with no inline script
|
|
71
|
+
// and nothing embeddable: say so, so a mistake later has to be
|
|
72
|
+
// deliberate rather than accidental.
|
|
73
|
+
response.setHeader('content-security-policy', [
|
|
74
|
+
"default-src 'self'",
|
|
75
|
+
"img-src 'self' data:",
|
|
76
|
+
"connect-src 'self'",
|
|
77
|
+
"frame-ancestors 'none'",
|
|
78
|
+
"base-uri 'none'",
|
|
79
|
+
"form-action 'none'",
|
|
80
|
+
].join('; '));
|
|
81
|
+
response.setHeader('x-content-type-options', 'nosniff');
|
|
82
|
+
await pipeline(createReadStream(target), response);
|
|
83
|
+
return true;
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
};
|
|
87
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@stratusagent/dashboard",
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"types": "./dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"import": "./dist/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"ui"
|
|
16
|
+
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=22.13 <23 || >=23.4"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@types/node": "^24.6.0",
|
|
22
|
+
"typescript": "^5.9.3"
|
|
23
|
+
},
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/stratuslabs/agent.git",
|
|
28
|
+
"directory": "packages/dashboard"
|
|
29
|
+
},
|
|
30
|
+
"description": "The Stratus web dashboard: a static, build-step-free UI served by @stratusagent/control-api — roster, live chat, approvals, and settings",
|
|
31
|
+
"keywords": [
|
|
32
|
+
"stratus",
|
|
33
|
+
"agent",
|
|
34
|
+
"ai",
|
|
35
|
+
"llm",
|
|
36
|
+
"dashboard"
|
|
37
|
+
],
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"build": "tsc -b",
|
|
43
|
+
"typecheck": "tsc --noEmit --project tsconfig.test.json",
|
|
44
|
+
"test": "node --test --experimental-strip-types ./test/**/*.test.ts"
|
|
45
|
+
}
|
|
46
|
+
}
|
package/ui/app.js
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import { api, connectEvents, ApiError } from './lib/api.js';
|
|
2
|
+
import { clear, el } from './lib/dom.js';
|
|
3
|
+
import { avatar } from './lib/avatar.js';
|
|
4
|
+
import { renderDashboard } from './views/dashboard.js';
|
|
5
|
+
import { renderAgent } from './views/agent.js';
|
|
6
|
+
import { renderSettings } from './views/settings.js';
|
|
7
|
+
import { renderPlugins } from './views/plugins.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Everything the page knows, in one object.
|
|
11
|
+
*
|
|
12
|
+
* A store and a full re-render rather than fine-grained bindings: the whole
|
|
13
|
+
* document is a few hundred nodes, and correctness-by-rerender beats a
|
|
14
|
+
* diffing scheme nobody will maintain. The chat transcript is the one
|
|
15
|
+
* exception — it appends, because re-rendering a streaming reply per token
|
|
16
|
+
* would fight the scroll position.
|
|
17
|
+
*/
|
|
18
|
+
export const store = {
|
|
19
|
+
route: { name: 'dashboard' },
|
|
20
|
+
health: undefined,
|
|
21
|
+
agents: [],
|
|
22
|
+
sessions: [],
|
|
23
|
+
approvals: [],
|
|
24
|
+
activity: [],
|
|
25
|
+
stream: 'connecting',
|
|
26
|
+
/** Set when the daemon stops answering, so the page says so instead of going stale. */
|
|
27
|
+
offlineSince: undefined,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const listeners = new Set();
|
|
31
|
+
export const subscribe = (fn) => {
|
|
32
|
+
listeners.add(fn);
|
|
33
|
+
return () => listeners.delete(fn);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
let renderQueued = false;
|
|
37
|
+
export const render = () => {
|
|
38
|
+
// Coalesced: a burst of events during a streaming turn would otherwise
|
|
39
|
+
// re-render the shell per token.
|
|
40
|
+
if (renderQueued) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
renderQueued = true;
|
|
44
|
+
queueMicrotask(() => {
|
|
45
|
+
renderQueued = false;
|
|
46
|
+
paint();
|
|
47
|
+
});
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const navigate = (route) => {
|
|
51
|
+
// A different session on the same agent is the same view, told to switch —
|
|
52
|
+
// not a new one, or opening a conversation from the dashboard would
|
|
53
|
+
// discard whatever the composer already had in it.
|
|
54
|
+
if (route.name === 'agent' && store.route.name === 'agent'
|
|
55
|
+
&& route.agentId === store.route.agentId && route.sessionId) {
|
|
56
|
+
current.view?.openSession?.(route.sessionId);
|
|
57
|
+
}
|
|
58
|
+
store.route = route;
|
|
59
|
+
const hash = route.name === 'agent'
|
|
60
|
+
? `#/agents/${encodeURIComponent(route.agentId)}`
|
|
61
|
+
: route.name === 'settings'
|
|
62
|
+
? `#/settings/${route.section}`
|
|
63
|
+
: `#/${route.name}`;
|
|
64
|
+
if (location.hash !== hash) {
|
|
65
|
+
history.pushState(null, '', hash);
|
|
66
|
+
}
|
|
67
|
+
render();
|
|
68
|
+
void refreshForRoute();
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const routeFromHash = () => {
|
|
72
|
+
const parts = location.hash.replace(/^#\/?/, '').split('/').filter(Boolean);
|
|
73
|
+
if (parts[0] === 'agents' && parts[1]) {
|
|
74
|
+
return { name: 'agent', agentId: decodeURIComponent(parts[1]) };
|
|
75
|
+
}
|
|
76
|
+
if (parts[0] === 'settings') {
|
|
77
|
+
return { name: 'settings', section: parts[1] ?? 'providers' };
|
|
78
|
+
}
|
|
79
|
+
if (parts[0] === 'plugins') {
|
|
80
|
+
return { name: 'plugins' };
|
|
81
|
+
}
|
|
82
|
+
return { name: 'dashboard' };
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// ---- data ----------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Refresh the shared data every screen leans on.
|
|
89
|
+
*
|
|
90
|
+
* A 401 here means the daemon restarted — its cookie sessions live in memory —
|
|
91
|
+
* so the page says exactly that instead of rendering an empty roster that
|
|
92
|
+
* looks like a fleet that vanished.
|
|
93
|
+
*/
|
|
94
|
+
export const refreshCore = async () => {
|
|
95
|
+
try {
|
|
96
|
+
const [health, agents, approvals] = await Promise.all([
|
|
97
|
+
api.health(),
|
|
98
|
+
api.agents(),
|
|
99
|
+
api.approvals(),
|
|
100
|
+
]);
|
|
101
|
+
store.health = health;
|
|
102
|
+
store.agents = agents.agents ?? [];
|
|
103
|
+
store.approvals = approvals.approvals ?? [];
|
|
104
|
+
store.offlineSince = undefined;
|
|
105
|
+
} catch (error) {
|
|
106
|
+
if (error instanceof ApiError && error.status === 401) {
|
|
107
|
+
store.route = { name: 'signed-out' };
|
|
108
|
+
} else {
|
|
109
|
+
store.offlineSince = store.offlineSince ?? Date.now();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
render();
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const refreshForRoute = async () => {
|
|
116
|
+
if (store.route.name === 'dashboard') {
|
|
117
|
+
const listed = await api.sessions(undefined, 12).catch(() => ({ sessions: [] }));
|
|
118
|
+
store.sessions = listed.sessions ?? [];
|
|
119
|
+
render();
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// ---- live stream ---------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
const ACTIVITY_LIMIT = 200;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Which session belongs to which agent.
|
|
129
|
+
*
|
|
130
|
+
* Only `session.created` carries the agent, so it seeds this and everything
|
|
131
|
+
* later in that conversation reads from it — the same trick the daemon's own
|
|
132
|
+
* log writer uses, and for the same reason.
|
|
133
|
+
*/
|
|
134
|
+
const agentBySession = new Map();
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* One line of the activity feed, or null for events that are not news.
|
|
138
|
+
*
|
|
139
|
+
* `session.updated` and `provider.response` are the loop talking to itself:
|
|
140
|
+
* a status of "running" the instant after "started a conversation", and a
|
|
141
|
+
* response that the tool lines below already describe. Rendering them fills
|
|
142
|
+
* the feed with restatements and pushes the things a person actually wants
|
|
143
|
+
* to see off the top of it.
|
|
144
|
+
*/
|
|
145
|
+
const describe = (envelope) => {
|
|
146
|
+
const event = envelope.event;
|
|
147
|
+
switch (event.type) {
|
|
148
|
+
case 'session.created': return 'started a conversation';
|
|
149
|
+
case 'session.completed': return 'finished a turn';
|
|
150
|
+
case 'session.failed': return `turn failed — ${event.error}`;
|
|
151
|
+
case 'tool.called': return `ran ${event.call.toolName}`;
|
|
152
|
+
case 'tool.completed': return `${event.result.toolName} ${event.result.ok ? 'succeeded' : 'failed'}`;
|
|
153
|
+
case 'tool.denied': return `${event.call.toolName} was denied`;
|
|
154
|
+
case 'tool.approval-requested': return `${event.call.toolName} needs approval`;
|
|
155
|
+
case 'tool.approval-resolved': return `approval ${event.answer} (${event.reason})`;
|
|
156
|
+
default: return null;
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/** Handlers a screen installs to watch its own conversation live. */
|
|
161
|
+
const envelopeHandlers = new Set();
|
|
162
|
+
export const onEnvelope = (fn) => {
|
|
163
|
+
envelopeHandlers.add(fn);
|
|
164
|
+
return () => envelopeHandlers.delete(fn);
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* How many screens are listening. Exposed so a leak is observable: a view
|
|
169
|
+
* that is replaced without being torn down keeps updating detached DOM and
|
|
170
|
+
* re-fetching on every completed turn, and the only symptom is a page that
|
|
171
|
+
* gets slower the more of it you have used.
|
|
172
|
+
*/
|
|
173
|
+
export const envelopeHandlerCount = () => envelopeHandlers.size;
|
|
174
|
+
|
|
175
|
+
const handleEnvelope = (envelope) => {
|
|
176
|
+
for (const handler of envelopeHandlers) {
|
|
177
|
+
handler(envelope);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (envelope.event.type === 'session.created') {
|
|
181
|
+
agentBySession.set(envelope.sessionId, envelope.event.agentId);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Deltas are a per-token firehose. They drive the transcript through the
|
|
185
|
+
// handlers above; putting each one in an activity feed would render nothing
|
|
186
|
+
// but noise and pin the CPU.
|
|
187
|
+
const text = describe(envelope);
|
|
188
|
+
if (text) {
|
|
189
|
+
const agentId = agentBySession.get(envelope.sessionId)
|
|
190
|
+
?? store.sessions.find((session) => session.id === envelope.sessionId)?.agentId;
|
|
191
|
+
store.activity.unshift({
|
|
192
|
+
at: Date.now(),
|
|
193
|
+
sessionId: envelope.sessionId,
|
|
194
|
+
agent: store.agents.find((candidate) => candidate.id === agentId)?.name ?? agentId,
|
|
195
|
+
text,
|
|
196
|
+
});
|
|
197
|
+
store.activity.length = Math.min(store.activity.length, ACTIVITY_LIMIT);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Approvals and roster activity are shared state, so they refresh for
|
|
201
|
+
// everyone rather than only for whoever is looking at the right screen.
|
|
202
|
+
const type = envelope.event.type;
|
|
203
|
+
if (type === 'tool.approval-requested' || type === 'tool.approval-resolved') {
|
|
204
|
+
void api.approvals().then((listed) => {
|
|
205
|
+
store.approvals = listed.approvals ?? [];
|
|
206
|
+
render();
|
|
207
|
+
}).catch(() => {});
|
|
208
|
+
}
|
|
209
|
+
if (type === 'session.completed' || type === 'session.failed' || type === 'session.created') {
|
|
210
|
+
void refreshCore();
|
|
211
|
+
void refreshForRoute();
|
|
212
|
+
}
|
|
213
|
+
render();
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
// ---- shell ---------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
const logo = () => {
|
|
219
|
+
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
220
|
+
svg.setAttribute('width', '22');
|
|
221
|
+
svg.setAttribute('height', '22');
|
|
222
|
+
svg.setAttribute('viewBox', '0 0 24 24');
|
|
223
|
+
svg.setAttribute('fill', 'none');
|
|
224
|
+
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
225
|
+
path.setAttribute('d', 'M3 15a4 4 0 0 1 3.2-3.9A5.5 5.5 0 0 1 17 10.3 3.6 3.6 0 0 1 20.5 15 3.5 3.5 0 0 1 17 18H6.5A3.5 3.5 0 0 1 3 15Z');
|
|
226
|
+
path.setAttribute('stroke', 'currentColor');
|
|
227
|
+
path.setAttribute('stroke-width', '1.6');
|
|
228
|
+
path.setAttribute('stroke-linejoin', 'round');
|
|
229
|
+
svg.append(path);
|
|
230
|
+
return svg;
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Whether an agent counts as active right now.
|
|
235
|
+
*
|
|
236
|
+
* The API reports a timestamp and a count, deliberately not a verdict — the
|
|
237
|
+
* window is a rendering decision, and it lives here so changing it does not
|
|
238
|
+
* mean upgrading a daemon. Both inputs matter: a turn parked on a human has
|
|
239
|
+
* not saved since it parked, so the timestamp alone would call it idle at
|
|
240
|
+
* exactly the moment it most needs attention.
|
|
241
|
+
*/
|
|
242
|
+
const ACTIVE_WINDOW_MS = 10 * 60_000;
|
|
243
|
+
export const isActive = (agent) =>
|
|
244
|
+
(agent.activeSessions ?? 0) > 0
|
|
245
|
+
|| (agent.lastActiveAt !== undefined && Date.now() - Date.parse(agent.lastActiveAt) < ACTIVE_WINDOW_MS);
|
|
246
|
+
|
|
247
|
+
const sidebar = () => {
|
|
248
|
+
const route = store.route;
|
|
249
|
+
const item = (label, target, extra = {}) => el('button', {
|
|
250
|
+
class: `nav-item${extra.sub ? ' sub' : ''}${extra.active ? ' active' : ''}`,
|
|
251
|
+
onClick: () => navigate(target),
|
|
252
|
+
}, extra.children ?? label);
|
|
253
|
+
|
|
254
|
+
return el('nav', { class: 'sidebar' },
|
|
255
|
+
el('div', { class: 'brand' }, logo(), 'Stratus Agent'),
|
|
256
|
+
item('Dashboard', { name: 'dashboard' }, { active: route.name === 'dashboard' }),
|
|
257
|
+
item('Plugins', { name: 'plugins' }, { active: route.name === 'plugins' }),
|
|
258
|
+
el('div', { class: 'nav-section' }, 'Settings'),
|
|
259
|
+
...['providers', 'models', 'channels'].map((section) => item(
|
|
260
|
+
section.charAt(0).toUpperCase() + section.slice(1),
|
|
261
|
+
{ name: 'settings', section },
|
|
262
|
+
{ sub: true, active: route.name === 'settings' && route.section === section },
|
|
263
|
+
)),
|
|
264
|
+
el('div', { class: 'nav-section' }, 'Agents'),
|
|
265
|
+
...store.agents.map((agent) => item(agent.name, { name: 'agent', agentId: agent.id }, {
|
|
266
|
+
active: route.name === 'agent' && route.agentId === agent.id,
|
|
267
|
+
children: el('span', { class: 'agent-row' },
|
|
268
|
+
avatar(agent),
|
|
269
|
+
el('span', { class: 'grow' }, agent.name),
|
|
270
|
+
el('span', { class: `dot${isActive(agent) ? '' : ' idle'}`, title: isActive(agent) ? 'active recently' : 'idle' }),
|
|
271
|
+
),
|
|
272
|
+
})),
|
|
273
|
+
el('div', { class: 'sidebar-foot' },
|
|
274
|
+
el('span', {}, 'Stratus Agent'),
|
|
275
|
+
el('span', {}, `v${store.health?.version ?? '—'}`),
|
|
276
|
+
),
|
|
277
|
+
);
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const signedOut = () => el('div', { class: 'main solo' },
|
|
281
|
+
el('div', { class: 'card' },
|
|
282
|
+
el('h2', {}, 'Not signed in'),
|
|
283
|
+
el('p', {}, 'This browser has no session with stratusd. Sessions live in the daemon’s memory, so restarting it signs you out.'),
|
|
284
|
+
el('p', { class: 'field-note' }, 'Run stratus dashboard in a terminal to open a fresh one.'),
|
|
285
|
+
),
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* A view is `{ node, update? }`, and the difference matters.
|
|
290
|
+
*
|
|
291
|
+
* Rebuilding the whole document on every store change is fine for the shell,
|
|
292
|
+
* which holds no state a person can lose. It is wrong for a screen with a
|
|
293
|
+
* text box and a streaming transcript: a repaint per token would take the
|
|
294
|
+
* caret out of the composer mid-sentence and rebuild a reply as it arrives.
|
|
295
|
+
* So a view is constructed once per route and told to update itself, and it
|
|
296
|
+
* decides what is safe to touch.
|
|
297
|
+
*/
|
|
298
|
+
let current = { key: undefined, view: undefined };
|
|
299
|
+
|
|
300
|
+
const routeKey = (route) => route.name === 'agent'
|
|
301
|
+
? `agent:${route.agentId}`
|
|
302
|
+
: route.name === 'settings'
|
|
303
|
+
? `settings:${route.section}`
|
|
304
|
+
: route.name;
|
|
305
|
+
|
|
306
|
+
const buildView = (route) => {
|
|
307
|
+
switch (route.name) {
|
|
308
|
+
case 'agent': return renderAgent(route.agentId, route.sessionId);
|
|
309
|
+
case 'settings': return renderSettings(route.section);
|
|
310
|
+
case 'plugins': return renderPlugins();
|
|
311
|
+
default: return renderDashboard();
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
const paint = () => {
|
|
316
|
+
const root = document.getElementById('app');
|
|
317
|
+
|
|
318
|
+
if (store.route.name === 'signed-out') {
|
|
319
|
+
current.view?.destroy?.();
|
|
320
|
+
current = { key: undefined, view: undefined };
|
|
321
|
+
root.replaceChildren(signedOut());
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const key = routeKey(store.route);
|
|
326
|
+
if (key !== current.key || !current.view) {
|
|
327
|
+
// Torn down before it is dropped. The agent view subscribes to the event
|
|
328
|
+
// stream; without this, every agent ever visited keeps a live handler
|
|
329
|
+
// updating detached DOM and re-fetching transcripts on every completed
|
|
330
|
+
// turn — work that multiplies with each screen someone opens.
|
|
331
|
+
current.view?.destroy?.();
|
|
332
|
+
current = { key, view: buildView(store.route) };
|
|
333
|
+
} else {
|
|
334
|
+
current.view.update?.();
|
|
335
|
+
}
|
|
336
|
+
root.replaceChildren(sidebar(), current.view.node);
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
// ---- boot ----------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
window.addEventListener('popstate', () => {
|
|
342
|
+
store.route = routeFromHash();
|
|
343
|
+
render();
|
|
344
|
+
void refreshForRoute();
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
store.route = routeFromHash();
|
|
348
|
+
await refreshCore();
|
|
349
|
+
await refreshForRoute();
|
|
350
|
+
|
|
351
|
+
connectEvents({
|
|
352
|
+
onEnvelope: handleEnvelope,
|
|
353
|
+
onStatus: (status) => {
|
|
354
|
+
store.stream = status;
|
|
355
|
+
if (status === 'live') {
|
|
356
|
+
// Reconnected: whatever happened while the socket was down is only in
|
|
357
|
+
// the store, so re-read rather than leaving a stale page behind.
|
|
358
|
+
void refreshCore();
|
|
359
|
+
void refreshForRoute();
|
|
360
|
+
// Shared data is not enough for a view holding a turn of its own. A
|
|
361
|
+
// completion that arrived during the outage is never replayed, so a
|
|
362
|
+
// chat view would sit disabled on "Working…" for a turn that finished
|
|
363
|
+
// minutes ago.
|
|
364
|
+
void current.view?.reconcile?.();
|
|
365
|
+
}
|
|
366
|
+
render();
|
|
367
|
+
},
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
render();
|
package/ui/favicon.svg
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
|
2
|
+
<rect width="24" height="24" rx="5" fill="#101828"/>
|
|
3
|
+
<path d="M4 15.2a3.6 3.6 0 0 1 2.9-3.5 5 5 0 0 1 9.7-1.6 3.3 3.3 0 0 1 3.2 4.3A3.2 3.2 0 0 1 16.8 17H7.2A3.2 3.2 0 0 1 4 15.2Z" stroke="#6294f8" stroke-width="1.5" stroke-linejoin="round"/>
|
|
4
|
+
</svg>
|
package/ui/index.html
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<meta charset="utf-8" />
|
|
3
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
4
|
+
<title>Stratus Agent</title>
|
|
5
|
+
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
|
6
|
+
<link rel="stylesheet" href="/styles.css" />
|
|
7
|
+
<div id="app" class="app">
|
|
8
|
+
<div class="booting">Connecting to stratusd…</div>
|
|
9
|
+
</div>
|
|
10
|
+
<script type="module" src="/app.js"></script>
|