@robota-sdk/agent-ui-web 0.0.0-stage → 3.0.0-beta.101
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/CHANGELOG.md +557 -0
- package/LICENSE +661 -0
- package/README.md +19 -2
- package/dist/browser/index.d.ts +736 -0
- package/dist/browser/index.d.ts.map +1 -0
- package/dist/browser/index.js +8525 -0
- package/dist/browser/index.js.map +1 -0
- package/dist/node/index.cjs +8575 -0
- package/dist/node/index.d.cts +736 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +736 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +8525 -0
- package/dist/node/index.js.map +1 -0
- package/docs/README.md +62 -0
- package/docs/SPEC.md +130 -0
- package/package.json +99 -3
- package/src/app-view/bridge.ts +217 -0
- package/src/app-view/call-tool-result.ts +58 -0
- package/src/app-view/host-context.ts +37 -0
- package/src/app-view/types.ts +12 -0
- package/src/client/runtime-host.ts +84 -0
- package/src/client/ws-session-client.ts +131 -0
- package/src/clipboard.ts +81 -0
- package/src/components/AgentActivityPanel.tsx +371 -0
- package/src/components/AgentSwitcherSheet.tsx +93 -0
- package/src/components/AppView.tsx +147 -0
- package/src/components/Brand.tsx +33 -0
- package/src/components/Composer.tsx +685 -0
- package/src/components/ConversationView.tsx +789 -0
- package/src/components/Dialog.tsx +230 -0
- package/src/components/DiffLines.tsx +81 -0
- package/src/components/ExecutionDetailSheet.tsx +142 -0
- package/src/components/HelpSheet.tsx +104 -0
- package/src/components/PermissionPrompt.tsx +762 -0
- package/src/components/PersonalUsageDashboard.tsx +39 -0
- package/src/components/PersonalUsageDashboardDetails.tsx +368 -0
- package/src/components/PersonalUsageDashboardSections.tsx +250 -0
- package/src/components/PopupMenu.tsx +166 -0
- package/src/components/ProjectPanel.tsx +268 -0
- package/src/components/SessionMonitor.tsx +180 -0
- package/src/components/SessionSidebar.tsx +429 -0
- package/src/components/SessionSurface.tsx +482 -0
- package/src/components/SessionSurfaceChrome.tsx +319 -0
- package/src/components/SettingsFields.tsx +159 -0
- package/src/components/SettingsGeneralSection.tsx +49 -0
- package/src/components/SettingsMcpSection.tsx +157 -0
- package/src/components/SettingsPermissionsSection.tsx +137 -0
- package/src/components/SettingsPluginsSection.tsx +165 -0
- package/src/components/SettingsProvidersSection.tsx +211 -0
- package/src/components/SettingsScreen.tsx +340 -0
- package/src/components/StatusControls.tsx +294 -0
- package/src/components/composer-attachments.ts +246 -0
- package/src/components/personal-usage-dashboard-types.ts +26 -0
- package/src/driver-labels.ts +88 -0
- package/src/execution-entry-status.ts +55 -0
- package/src/goal-view.ts +19 -0
- package/src/hooks/command-menu.ts +65 -0
- package/src/hooks/excluded-commands.ts +52 -0
- package/src/hooks/prompt-state.ts +83 -0
- package/src/hooks/server-message-handling.ts +95 -0
- package/src/hooks/session-client-types.ts +344 -0
- package/src/hooks/ui-intent-state.ts +74 -0
- package/src/hooks/use-agent-switcher-state.ts +125 -0
- package/src/hooks/use-app-view-requests.ts +178 -0
- package/src/hooks/use-execution-detail.ts +118 -0
- package/src/hooks/use-media-query.ts +27 -0
- package/src/hooks/use-model-list.ts +45 -0
- package/src/hooks/use-personal-usage.ts +138 -0
- package/src/hooks/use-project-panel.ts +117 -0
- package/src/hooks/use-schedules-state.ts +92 -0
- package/src/hooks/use-session-directory.ts +202 -0
- package/src/hooks/use-settings-state.ts +115 -0
- package/src/hooks/useSessionClient.ts +1051 -0
- package/src/index.ts +72 -0
- package/src/product-identity.tsx +26 -0
- package/src/schedule-view.ts +92 -0
- package/src/styles/surface.css +231 -0
- package/src/styles/surface.css.d.mts +1 -0
- package/src/styles/theme.css +38 -0
- package/src/styles/theme.css.d.mts +1 -0
package/docs/README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# @robota-sdk/agent-ui-web
|
|
2
|
+
|
|
3
|
+
The **GUI presentation layer** for an agent session — the graphical analog of `@robota-sdk/agent-ui-terminal`.
|
|
4
|
+
It reconstructs conversation state from the transport-neutral `TServerMessage` stream and renders it as React
|
|
5
|
+
components. Its public `/client` entry supplies the browser-safe reducer, WebSocket client, session shell
|
|
6
|
+
and runtime-host contract for a consumer-owned renderer. The same components serve the repository's GUI
|
|
7
|
+
web app and browser-remote surface.
|
|
8
|
+
|
|
9
|
+
## What it owns
|
|
10
|
+
|
|
11
|
+
- `useSessionClient(makeClient)` — the transport-neutral session reducer (generic over the status type).
|
|
12
|
+
- `useWsSession(url)` + `createWsSessionClient` — the localhost WebSocket binding.
|
|
13
|
+
- `resolveClientRuntimeHost(environment)` — a browser endpoint resolver or a narrow desktop bridge
|
|
14
|
+
adapter. The desktop bridge owns trust, endpoint validation and runtime lifecycle; a renderer asks
|
|
15
|
+
`trustQuestion` before `getEndpoint` and calls `signalReady` after connection.
|
|
16
|
+
- Prompt state: `applyPromptEvent`, `permissionResponse`, `askResponse`.
|
|
17
|
+
- UI-intent state: `guiScreenForUiIntent` (the GUI screen a command's UI intent opens, if any) and
|
|
18
|
+
`describeUiIntentForGui` (the explicit "not available on this surface" line otherwise).
|
|
19
|
+
- Components: `SessionSurface`, `CenteredChrome`, `ConversationView`, `AgentActivityPanel`,
|
|
20
|
+
`PermissionPrompt`, `SessionSidebar` (the host's sessions; switch or start one), `SessionMonitor` (a
|
|
21
|
+
self-contained session page for a WebSocket URL), and `PersonalUsageDashboard` (7- and 30-day usage).
|
|
22
|
+
- Brand marks: `ProductMark`, `ProductWordmark` — render them inside a `agent-ui` scope.
|
|
23
|
+
- `styles/surface.css` — the design scoped to `.agent-ui` (bundled Pretendard, light and dark tokens, Tailwind
|
|
24
|
+
token map, base layers); import it into a host app's Tailwind entry to embed the surface.
|
|
25
|
+
- `styles/theme.css` — `surface.css` plus what a page that is only the surface owns (height, background,
|
|
26
|
+
scrollbars); that page puts `agent-ui` on its `<html>`.
|
|
27
|
+
|
|
28
|
+
## Using it
|
|
29
|
+
|
|
30
|
+
```tsx
|
|
31
|
+
import { useWsSession, SessionSurface } from '@robota-sdk/agent-ui-web/client';
|
|
32
|
+
|
|
33
|
+
export function App({ url }: { url: string }) {
|
|
34
|
+
const state = useWsSession(url);
|
|
35
|
+
return <SessionSurface state={state} surface="app" />;
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The components author Tailwind utility classes and ship **no compiled CSS** — the consumer owns the Tailwind
|
|
40
|
+
entry and sources this package's `src`:
|
|
41
|
+
|
|
42
|
+
```css
|
|
43
|
+
/* app entry css */
|
|
44
|
+
@import 'tailwindcss';
|
|
45
|
+
@import '@robota-sdk/agent-ui-web/styles/theme.css';
|
|
46
|
+
@source '../../node_modules/@robota-sdk/agent-ui-web/src';
|
|
47
|
+
@source './';
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
A different transport supplies its own `makeClient` (a `TMakeSessionClient<TStatus>`) and, if it adds
|
|
51
|
+
connection states, instantiates `useSessionClient<ItsStatus>` — see `useRtcSession` in `@robota-sdk/agent-transport-webrtc-web`.
|
|
52
|
+
|
|
53
|
+
See [SPEC.md](./SPEC.md) for the full contract.
|
|
54
|
+
|
|
55
|
+
The source repository's Cedar renderer fixture shows a consumer-owned toolbar calling a custom
|
|
56
|
+
command, desktop trust admission, session restoration and reconnect. Its local-pack
|
|
57
|
+
build is prerelease evidence; a clean registry-installed build is required after publication.
|
|
58
|
+
|
|
59
|
+
The package tarball includes source styles, declarations and its AGPL license. React is a peer dependency;
|
|
60
|
+
the consumer supplies React, React DOM, Tailwind v4 and a bundler. Third-party runtime dependencies are
|
|
61
|
+
declared in `package.json`; a distributable renderer must include their notices and licenses in its own
|
|
62
|
+
artifact. The package does not include the private GUI web app or Electron shell.
|
package/docs/SPEC.md
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# SPEC.md — @robota-sdk/agent-ui-web
|
|
2
|
+
|
|
3
|
+
## Transport Admission
|
|
4
|
+
|
|
5
|
+
transport-admission: none — a presentation layer. It renders a server-message stream that some
|
|
6
|
+
other transport has already admitted a peer onto, and opens no socket of its own.
|
|
7
|
+
|
|
8
|
+
## Purpose
|
|
9
|
+
|
|
10
|
+
The GUI presentation layer for a running agent session — the graphical analog of the terminal
|
|
11
|
+
presentation package. It reconstructs conversation state from the transport-neutral server-message
|
|
12
|
+
stream and renders it as React components, and it ships the session shell (title/status bar,
|
|
13
|
+
conversation column, background-activity rail, and a composer with the pending question docked above
|
|
14
|
+
it). It is consumed by the GUI web app — which the desktop app loads and the CLI serves — and by the
|
|
15
|
+
browser-remote surface.
|
|
16
|
+
|
|
17
|
+
This package sits in the transport/presentation layer. It is a pure UI + wire-reducer library — it
|
|
18
|
+
does not own session lifecycle, conversation history, or agent runtime state.
|
|
19
|
+
|
|
20
|
+
## Contract and guarantees
|
|
21
|
+
|
|
22
|
+
- The session reducer is generic over the connection-status type, so a transport can widen the
|
|
23
|
+
status union (e.g. to add pairing/failure states) without this package depending on that
|
|
24
|
+
transport.
|
|
25
|
+
- Every server-message wire variant is assigned an explicit disposition — a specialized reducer
|
|
26
|
+
path, transport lifecycle handling, or an explicit "intentionally not rendered" marker. An
|
|
27
|
+
unrecognized or not-yet-supported message never falls through silently.
|
|
28
|
+
- The conversation carries what the CLI's transcript carries: a typed command's outcome and a
|
|
29
|
+
finished turn's tool calls sit in it where they happened, and never push the composer out of view —
|
|
30
|
+
the same whether they streamed in live or were rebuilt from a reload, reconnect, or session resume's
|
|
31
|
+
history replay, never falling back to bare text once a tool call is gone from what a live stream
|
|
32
|
+
would have shown. A change made through a status control instead confirms itself through the
|
|
33
|
+
control's own label — never a conversation entry — with a failed change surfaced as a toast instead.
|
|
34
|
+
Session and protocol failures are also toasts, beside the conversation rather than in it.
|
|
35
|
+
- A tool call's diff or output is server-computed and shown on demand, preserving its observation
|
|
36
|
+
types, call identity and success or denial/failure through live delivery and replay. Resource references remain inert because
|
|
37
|
+
displaying a contributor's URI grants no read authority; the one exception is a view the call's own
|
|
38
|
+
server declared for a model call, read on demand through its session-owned view authority. The host
|
|
39
|
+
admits the document only in an opaque frame with enforced native network isolation; the bridge
|
|
40
|
+
reaches only that view's declared tools through the session's permission gate. Replacing a view
|
|
41
|
+
or its session revokes its pending local requests. A surface without a suitable sandbox retains
|
|
42
|
+
the text result and explains why the app is unavailable.
|
|
43
|
+
Unsupported media is explained rather than silently discarded. Output is never recomputed here or buried behind a truncated path or argument; an internal signal tool never renders as a call,
|
|
44
|
+
and a projected `/command` tool shows the command it ran, not the provider-facing tool name.
|
|
45
|
+
- A UI-intent opens its GUI screen when there is one — the session picker is the session sidebar
|
|
46
|
+
(unless the host cannot list sessions), settings and the plugin manager both open the Settings
|
|
47
|
+
modal (the plugin manager on its Plugins section), and the agent switcher is a sheet listing the
|
|
48
|
+
session's agent definitions with the current one checked. A command with no GUI path at all (its
|
|
49
|
+
presentation belongs to a terminal, or the product has deliberately left it out — see
|
|
50
|
+
`packages/agent-gui-web/docs/SPEC.md`) never reaches the session from this surface in the first
|
|
51
|
+
place: it is caught on the way out and answered with its own plain sentence naming what to use
|
|
52
|
+
instead. Any other UI-intent this surface simply has no screen for yet answers the same way, in its
|
|
53
|
+
own words — never the generic "not available on this surface" line, and never a silent no-op — in
|
|
54
|
+
place of the command's own reply when this surface's command awaits one, at once otherwise; an
|
|
55
|
+
intent kind not yet known when this package was written still gets an explicit, honest line.
|
|
56
|
+
- The session sidebar lists the host's sessions by a stable title — never the latest reply, which
|
|
57
|
+
changes every turn — and never loses one silently: a record unreadable and belonging to this
|
|
58
|
+
workspace is folded into one plain line rather than shown as a row; a record belonging to another
|
|
59
|
+
workspace, or whose workspace cannot be told, is not this surface's business and is left off the
|
|
60
|
+
list entirely. A switch replaces everything the surface shows with what the new session holds; a
|
|
61
|
+
refused switch shows the host's reason. A row can be renamed or deleted from the list itself,
|
|
62
|
+
without switching to it first; a refusal states why.
|
|
63
|
+
- The agent switcher's roster and current selection are a typed read (`get-agent-definitions`), but
|
|
64
|
+
choosing a row is not a dedicated write: it sends `/agent <name>` through the same `command` path
|
|
65
|
+
typing it would, and the sheet shows that command's own reply as a plain line — never a
|
|
66
|
+
conversation card, unlike every other command result this reducer renders.
|
|
67
|
+
- The Agents panel's Scheduled group and Goal row are additive to the execution-workspace rail:
|
|
68
|
+
a schedule or a goal alone (no running background task or loop) still opens the rail, and a
|
|
69
|
+
schedule's Pause/Resume and Delete, and a goal's Cancel, run the same command/control paths their
|
|
70
|
+
slash commands already use — the panel adds no write this package did not already expose.
|
|
71
|
+
- The title bar and the page title both name the workspace folder the session works in, once the
|
|
72
|
+
host has said one; the desktop window follows the page title.
|
|
73
|
+
- A pending permission or ask prompt is visible whatever view is open, because a gated turn waits
|
|
74
|
+
on it. It never takes focus from a field the person is typing in, and its keys answer it only once
|
|
75
|
+
it has that focus; a click answers at once, except on a multi-select ask, whose options toggle
|
|
76
|
+
until it is submitted.
|
|
77
|
+
- Nothing typed is lost to a lost connection: while the transport is not `connected`, the composer
|
|
78
|
+
keeps its draft and refuses to submit it, and a banner above the conversation — never a full-screen
|
|
79
|
+
replacement — says so: retrying while attempts continue, and once they give up, either the
|
|
80
|
+
host-supplied reconnect action or how to reopen the page.
|
|
81
|
+
- A session-rename or history-clear broadcast from any other surface is folded into this reducer's
|
|
82
|
+
state, so co-driving surfaces stay in sync.
|
|
83
|
+
- The personal usage dashboard is opt-in per surface (browser/remote consumers stay opted out by
|
|
84
|
+
default) and correlates its own requests, keeping latest-request-wins semantics on responses.
|
|
85
|
+
- A background-activity entry opens a detail sheet (status, what it was asked, its paginated
|
|
86
|
+
transcript, its result) on click — except the main-thread entry, which instead returns to the
|
|
87
|
+
conversation already shown beside it. A stoppable entry's Stop is routed by whether the entry
|
|
88
|
+
carries a loop id, never guessed from its name or task kind: a `/loop`-managed entry (fixed or
|
|
89
|
+
self-paced) sends `/loop stop <id>`, because a self-paced loop's own background-task
|
|
90
|
+
representation, when it has one, is only its disposable wake timer — cancelling that would leave
|
|
91
|
+
the loop itself running. Every other entry sends `cancel-background-task`.
|
|
92
|
+
- The Project panel (git status, one file's diff, project memory) correlates its own requests the
|
|
93
|
+
same way, and every file status it shows is a plain word, never a raw git XY code (#3277 "usable by
|
|
94
|
+
anyone"); a workspace that is not a git repository, or a diff whose path the host refused, is one
|
|
95
|
+
plain sentence, never left as a stuck loading state.
|
|
96
|
+
- A session error finalizes any partial streamed text and clears thinking/running-tool state
|
|
97
|
+
before the next turn begins.
|
|
98
|
+
- Malformed server frames are surfaced through the client's callback path and never thrown inside
|
|
99
|
+
the socket handler.
|
|
100
|
+
- The package ships no compiled CSS — it authors Tailwind utility classes as source, and the
|
|
101
|
+
consumer owns the Tailwind entry point that compiles them. Its design applies only inside the
|
|
102
|
+
`agent-ui` scope that each of its root components opens, so a host with tokens of its own under the
|
|
103
|
+
same names can embed the surface without either overriding the other.
|
|
104
|
+
- Its public browser entry exposes a narrow runtime-host contract: a native host owns trust and
|
|
105
|
+
endpoint authority, while a renderer can ask for trust before connecting, observe lifecycle
|
|
106
|
+
changes, and offer reconnect without importing the native implementation.
|
|
107
|
+
|
|
108
|
+
## Non-goals / boundaries
|
|
109
|
+
|
|
110
|
+
- Does not own the wire protocol framing (message types) — that belongs to the transport package.
|
|
111
|
+
- Does not own the transport-facing contract types (interaction/event/workspace) — those belong to
|
|
112
|
+
the transport-interface package.
|
|
113
|
+
- Does not own session/runtime contracts or agent-core types, and has no dependency on the
|
|
114
|
+
agent framework, session, or core packages.
|
|
115
|
+
- Does not own the CLI sidecar server.
|
|
116
|
+
- Does not own the WebRTC remote peer or pairing flow — that is a separate package that consumes
|
|
117
|
+
this package's reducer and components and widens the status union.
|
|
118
|
+
- Does not own the Electron shell or sidecar process supervision — that is the desktop app.
|
|
119
|
+
- Is not re-exported through a sibling product package; consumers import it directly.
|
|
120
|
+
|
|
121
|
+
## Design decisions
|
|
122
|
+
|
|
123
|
+
- The reducer is generic over its status type rather than importing a widened status union from
|
|
124
|
+
the WebRTC package, because importing that union would create a package cycle.
|
|
125
|
+
- The session shell component holds no session/transport logic of its own — it only forwards user
|
|
126
|
+
intent through the reducer's send/answer handles — so a new GUI surface can reuse it purely by
|
|
127
|
+
supplying its own client factory.
|
|
128
|
+
- There is deliberately no built-in screen for every UI-intent kind; unhandled kinds get an
|
|
129
|
+
explicit notice instead of a screen, so a future surface can add a real screen incrementally
|
|
130
|
+
without ever risking a silent drop in the meantime.
|
package/package.json
CHANGED
|
@@ -1,6 +1,102 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@robota-sdk/agent-ui-web",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "3.0.0-beta.101",
|
|
4
|
+
"description": "Browser-safe React session presentation, wire reducer, WebSocket client and runtime-host contract for consumer-owned agent renderers.",
|
|
5
|
+
"license": "AGPL-3.0-only OR LicenseRef-Commercial",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "dist/node/index.js",
|
|
8
|
+
"types": "dist/node/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"source": "./src/index.ts",
|
|
12
|
+
"import": {
|
|
13
|
+
"types": "./dist/node/index.d.ts",
|
|
14
|
+
"default": "./dist/node/index.js"
|
|
15
|
+
},
|
|
16
|
+
"require": {
|
|
17
|
+
"types": "./dist/node/index.d.cts",
|
|
18
|
+
"default": "./dist/node/index.cjs"
|
|
19
|
+
},
|
|
20
|
+
"default": "./dist/node/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./client": {
|
|
23
|
+
"source": "./src/index.ts",
|
|
24
|
+
"import": {
|
|
25
|
+
"types": "./dist/browser/index.d.ts",
|
|
26
|
+
"default": "./dist/browser/index.js"
|
|
27
|
+
},
|
|
28
|
+
"default": "./dist/browser/index.js"
|
|
29
|
+
},
|
|
30
|
+
"./styles/theme.css": {
|
|
31
|
+
"types": "./src/styles/theme.css.d.mts",
|
|
32
|
+
"browser": "./src/styles/theme.css",
|
|
33
|
+
"import": "./src/styles/theme.css"
|
|
34
|
+
},
|
|
35
|
+
"./styles/surface.css": {
|
|
36
|
+
"types": "./src/styles/surface.css.d.mts",
|
|
37
|
+
"browser": "./src/styles/surface.css",
|
|
38
|
+
"import": "./src/styles/surface.css"
|
|
39
|
+
},
|
|
40
|
+
"./package.json": "./package.json"
|
|
41
|
+
},
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=22.12.0"
|
|
44
|
+
},
|
|
45
|
+
"files": [
|
|
46
|
+
"dist",
|
|
47
|
+
"src",
|
|
48
|
+
"!src/**/__tests__/**",
|
|
49
|
+
"!src/testing/**",
|
|
50
|
+
"README.md",
|
|
51
|
+
"docs/README.md",
|
|
52
|
+
"docs/SPEC.md",
|
|
53
|
+
"CHANGELOG.md",
|
|
54
|
+
"LICENSE"
|
|
55
|
+
],
|
|
56
|
+
"dependencies": {
|
|
57
|
+
"lucide-react": "^1.26.0",
|
|
58
|
+
"pretendard": "^1.3.9",
|
|
59
|
+
"react-markdown": "^10.1.0",
|
|
60
|
+
"remark-gfm": "^4.0.1",
|
|
61
|
+
"@robota-sdk/agent-interface-command": "3.0.0-beta.101",
|
|
62
|
+
"@robota-sdk/agent-interface-execution": "3.0.0-beta.101",
|
|
63
|
+
"@robota-sdk/agent-interface-transport": "3.0.0-beta.101",
|
|
64
|
+
"@robota-sdk/agent-interface-session": "3.0.0-beta.101",
|
|
65
|
+
"@robota-sdk/agent-transport": "3.0.0-beta.101"
|
|
66
|
+
},
|
|
67
|
+
"peerDependencies": {
|
|
68
|
+
"react": ">=18"
|
|
69
|
+
},
|
|
70
|
+
"devDependencies": {
|
|
71
|
+
"@testing-library/react": "^16.1.0",
|
|
72
|
+
"@types/react": "^19.2.17",
|
|
73
|
+
"@types/react-dom": "^19.2.3",
|
|
74
|
+
"jsdom": "^25.0.1",
|
|
75
|
+
"react": "19.2.8",
|
|
76
|
+
"react-dom": "19.2.8",
|
|
77
|
+
"rimraf": "^5.0.0",
|
|
78
|
+
"tsdown": "^0.22.14",
|
|
79
|
+
"typescript": "^6.0.3",
|
|
80
|
+
"vitest": "^3.2.6"
|
|
81
|
+
},
|
|
82
|
+
"volta": {
|
|
83
|
+
"extends": "../../package.json"
|
|
84
|
+
},
|
|
85
|
+
"repository": {
|
|
86
|
+
"type": "git",
|
|
87
|
+
"url": "https://github.com/woojubb/robota"
|
|
88
|
+
},
|
|
89
|
+
"bugs": {
|
|
90
|
+
"url": "https://github.com/woojubb/robota/issues"
|
|
91
|
+
},
|
|
92
|
+
"homepage": "https://robota.io/",
|
|
93
|
+
"scripts": {
|
|
94
|
+
"build": "rimraf dist && tsdown",
|
|
95
|
+
"build:js": "pnpm run build",
|
|
96
|
+
"build:types": "pnpm run build",
|
|
97
|
+
"typecheck": "tsgo --noEmit",
|
|
98
|
+
"test": "vitest run --passWithNoTests",
|
|
99
|
+
"test:coverage": "vitest run --coverage --passWithNoTests",
|
|
100
|
+
"clean": "rimraf dist"
|
|
101
|
+
}
|
|
6
102
|
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { outcomeToCallToolResult, type ICallToolResult } from './call-tool-result.js';
|
|
2
|
+
|
|
3
|
+
import type { TAppToolArguments, TAppToolOutcome, TAppView } from './types.js';
|
|
4
|
+
|
|
5
|
+
export const APP_VIEW_PROTOCOL_VERSION = '2026-01-26';
|
|
6
|
+
export const APP_VIEW_MIN_HEIGHT_PX = 48;
|
|
7
|
+
export const APP_VIEW_MAX_HEIGHT_PX = 640;
|
|
8
|
+
|
|
9
|
+
const INVALID_PARAMS = -32602;
|
|
10
|
+
const METHOD_NOT_FOUND = -32601;
|
|
11
|
+
const HOST_ERROR = -32000;
|
|
12
|
+
|
|
13
|
+
const SANDBOX_PROXY_READY = 'ui/notifications/sandbox-proxy-ready';
|
|
14
|
+
const SANDBOX_RESOURCE_READY = 'ui/notifications/sandbox-resource-ready';
|
|
15
|
+
|
|
16
|
+
type TJsonRpcId = string | number;
|
|
17
|
+
|
|
18
|
+
export interface IAppViewMessage {
|
|
19
|
+
jsonrpc: '2.0';
|
|
20
|
+
id?: TJsonRpcId;
|
|
21
|
+
method?: string;
|
|
22
|
+
params?: unknown;
|
|
23
|
+
result?: unknown;
|
|
24
|
+
error?: { code: number; message: string };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface IAppViewHostContext {
|
|
28
|
+
theme: 'light' | 'dark';
|
|
29
|
+
displayMode: 'inline';
|
|
30
|
+
availableDisplayModes: readonly ['inline'];
|
|
31
|
+
containerDimensions: { maxHeight: number };
|
|
32
|
+
platform: 'desktop' | 'web';
|
|
33
|
+
locale: string;
|
|
34
|
+
timeZone: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface IAppViewBridgeOptions {
|
|
38
|
+
view: TAppView;
|
|
39
|
+
|
|
40
|
+
toolResult: ICallToolResult;
|
|
41
|
+
hostInfo: { name: string; version: string };
|
|
42
|
+
hostContext: IAppViewHostContext;
|
|
43
|
+
|
|
44
|
+
post: (message: IAppViewMessage) => void;
|
|
45
|
+
|
|
46
|
+
source: () => MessageEventSource | null;
|
|
47
|
+
|
|
48
|
+
callAppTool: (runtimeName: string, args: TAppToolArguments) => Promise<TAppToolOutcome>;
|
|
49
|
+
|
|
50
|
+
onHeight: (px: number) => void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface IAppViewBridge {
|
|
54
|
+
handleMessage: (event: MessageEvent) => void;
|
|
55
|
+
dispose: () => void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
59
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
|
|
60
|
+
const prototype: unknown = Object.getPrototypeOf(value);
|
|
61
|
+
return prototype === null || Object.getPrototypeOf(prototype) === null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function errorMessage(error: unknown): string {
|
|
65
|
+
return error instanceof Error ? error.message : String(error);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function createAppViewBridge(options: IAppViewBridgeOptions): IAppViewBridge {
|
|
69
|
+
const { view } = options;
|
|
70
|
+
let disposed = false;
|
|
71
|
+
let sandboxAnswered = false;
|
|
72
|
+
let initialization: 'uninitialized' | 'initializing' | 'ready' = 'uninitialized';
|
|
73
|
+
let callInFlight = false;
|
|
74
|
+
|
|
75
|
+
const respond = (id: TJsonRpcId, result: unknown): void => {
|
|
76
|
+
options.post({ jsonrpc: '2.0', id, result });
|
|
77
|
+
};
|
|
78
|
+
const refuse = (id: TJsonRpcId, code: number, message: string): void => {
|
|
79
|
+
options.post({ jsonrpc: '2.0', id, error: { code, message } });
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const callTool = (id: TJsonRpcId, params: unknown): void => {
|
|
83
|
+
if (initialization !== 'ready') {
|
|
84
|
+
refuse(id, HOST_ERROR, 'This app view has not initialized.');
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const name = isPlainObject(params) ? params['name'] : undefined;
|
|
88
|
+
const runtimeName =
|
|
89
|
+
typeof name === 'string' && Object.hasOwn(view.callableTools, name)
|
|
90
|
+
? view.callableTools[name]
|
|
91
|
+
: undefined;
|
|
92
|
+
if (!isPlainObject(params) || runtimeName === undefined) {
|
|
93
|
+
refuse(id, INVALID_PARAMS, 'This tool is not available to this view.');
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const rawArguments = params['arguments'] === undefined ? {} : params['arguments'];
|
|
97
|
+
if (!isPlainObject(rawArguments)) {
|
|
98
|
+
refuse(id, INVALID_PARAMS, 'Tool arguments must be an object.');
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (callInFlight) {
|
|
102
|
+
refuse(id, HOST_ERROR, 'Another tool call from this view is still running.');
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
callInFlight = true;
|
|
106
|
+
let outcome: Promise<TAppToolOutcome>;
|
|
107
|
+
try {
|
|
108
|
+
outcome = options.callAppTool(runtimeName, rawArguments as TAppToolArguments);
|
|
109
|
+
} catch (error: unknown) {
|
|
110
|
+
outcome = Promise.reject(error);
|
|
111
|
+
}
|
|
112
|
+
outcome.then(
|
|
113
|
+
(settled) => {
|
|
114
|
+
callInFlight = false;
|
|
115
|
+
if (!disposed) respond(id, outcomeToCallToolResult(settled));
|
|
116
|
+
},
|
|
117
|
+
(error: unknown) => {
|
|
118
|
+
callInFlight = false;
|
|
119
|
+
if (!disposed) refuse(id, HOST_ERROR, errorMessage(error));
|
|
120
|
+
},
|
|
121
|
+
);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const handleRequest = (id: TJsonRpcId, method: string, params: unknown): void => {
|
|
125
|
+
switch (method) {
|
|
126
|
+
case 'ui/initialize':
|
|
127
|
+
if (initialization === 'uninitialized') initialization = 'initializing';
|
|
128
|
+
respond(id, {
|
|
129
|
+
protocolVersion: APP_VIEW_PROTOCOL_VERSION,
|
|
130
|
+
hostCapabilities: { serverTools: {} },
|
|
131
|
+
hostInfo: options.hostInfo,
|
|
132
|
+
hostContext: options.hostContext,
|
|
133
|
+
});
|
|
134
|
+
return;
|
|
135
|
+
case 'tools/call':
|
|
136
|
+
callTool(id, params);
|
|
137
|
+
return;
|
|
138
|
+
case 'ping':
|
|
139
|
+
respond(id, {});
|
|
140
|
+
return;
|
|
141
|
+
case 'ui/request-display-mode':
|
|
142
|
+
respond(id, { mode: 'inline' });
|
|
143
|
+
return;
|
|
144
|
+
default:
|
|
145
|
+
refuse(id, METHOD_NOT_FOUND, `This host does not support ${method}.`);
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const handleNotification = (method: string, params: unknown): void => {
|
|
150
|
+
switch (method) {
|
|
151
|
+
case SANDBOX_PROXY_READY:
|
|
152
|
+
// The sandbox page is the only sender; the document goes to it once, never again.
|
|
153
|
+
if (sandboxAnswered) return;
|
|
154
|
+
sandboxAnswered = true;
|
|
155
|
+
options.post({
|
|
156
|
+
jsonrpc: '2.0',
|
|
157
|
+
method: SANDBOX_RESOURCE_READY,
|
|
158
|
+
params: { html: view.html },
|
|
159
|
+
});
|
|
160
|
+
return;
|
|
161
|
+
case 'ui/notifications/initialized':
|
|
162
|
+
if (initialization !== 'initializing') return;
|
|
163
|
+
initialization = 'ready';
|
|
164
|
+
options.post({
|
|
165
|
+
jsonrpc: '2.0',
|
|
166
|
+
method: 'ui/notifications/tool-input',
|
|
167
|
+
params: { arguments: view.toolInput ?? {} },
|
|
168
|
+
});
|
|
169
|
+
options.post({
|
|
170
|
+
jsonrpc: '2.0',
|
|
171
|
+
method: 'ui/notifications/tool-result',
|
|
172
|
+
params: options.toolResult,
|
|
173
|
+
});
|
|
174
|
+
return;
|
|
175
|
+
case 'ui/notifications/size-changed': {
|
|
176
|
+
const height = isPlainObject(params) ? params['height'] : undefined;
|
|
177
|
+
if (typeof height !== 'number' || !Number.isFinite(height)) return;
|
|
178
|
+
options.onHeight(
|
|
179
|
+
Math.min(APP_VIEW_MAX_HEIGHT_PX, Math.max(APP_VIEW_MIN_HEIGHT_PX, Math.round(height))),
|
|
180
|
+
);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
default:
|
|
184
|
+
// Anything else a view announces (logging, its own notices) has no consumer here.
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
handleMessage(event) {
|
|
191
|
+
if (disposed) return;
|
|
192
|
+
const frame = options.source();
|
|
193
|
+
if (frame === null || event.source !== frame) return;
|
|
194
|
+
const data: unknown = event.data;
|
|
195
|
+
if (!isPlainObject(data) || data['jsonrpc'] !== '2.0') return;
|
|
196
|
+
const { method, id } = data;
|
|
197
|
+
if (typeof method !== 'string') return;
|
|
198
|
+
if (id === undefined) {
|
|
199
|
+
handleNotification(method, data['params']);
|
|
200
|
+
} else if (typeof id === 'string' || typeof id === 'number') {
|
|
201
|
+
handleRequest(id, method, data['params']);
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
dispose() {
|
|
205
|
+
if (disposed) return;
|
|
206
|
+
disposed = true;
|
|
207
|
+
if (initialization === 'ready') {
|
|
208
|
+
options.post({
|
|
209
|
+
jsonrpc: '2.0',
|
|
210
|
+
id: 'teardown',
|
|
211
|
+
method: 'ui/resource-teardown',
|
|
212
|
+
params: { reason: 'closed' },
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { TAppToolOutcome, TAppToolPart, TAppView } from './types.js';
|
|
2
|
+
import type { IActiveTool } from '../hooks/session-client-types.js';
|
|
3
|
+
|
|
4
|
+
export type TCallToolContent =
|
|
5
|
+
| { type: 'text'; text: string }
|
|
6
|
+
| { type: 'image'; data: string; mimeType: string }
|
|
7
|
+
| { type: 'audio'; data: string; mimeType: string };
|
|
8
|
+
|
|
9
|
+
export interface ICallToolResult {
|
|
10
|
+
content: TCallToolContent[];
|
|
11
|
+
structuredContent?: Record<string, unknown>;
|
|
12
|
+
isError: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
16
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function contentFromParts(parts: readonly TAppToolPart[] | undefined): TCallToolContent[] {
|
|
20
|
+
const content: TCallToolContent[] = [];
|
|
21
|
+
for (const part of parts ?? []) {
|
|
22
|
+
if (part.type === 'text') {
|
|
23
|
+
content.push({ type: 'text', text: part.text });
|
|
24
|
+
} else if (part.type === 'image_inline') {
|
|
25
|
+
content.push({ type: 'image', data: part.data, mimeType: part.mimeType });
|
|
26
|
+
} else if (part.type === 'audio_inline') {
|
|
27
|
+
content.push({ type: 'audio', data: part.data, mimeType: part.mimeType });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return content;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function outcomeToCallToolResult(outcome: TAppToolOutcome): ICallToolResult {
|
|
34
|
+
let content = contentFromParts(outcome.parts);
|
|
35
|
+
if (content.length === 0 && typeof outcome.data === 'string') {
|
|
36
|
+
content = [{ type: 'text', text: outcome.data }];
|
|
37
|
+
}
|
|
38
|
+
if (!outcome.success && outcome.error !== undefined) {
|
|
39
|
+
content = [...content, { type: 'text', text: outcome.error }];
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
content,
|
|
43
|
+
...(isRecord(outcome.data) ? { structuredContent: outcome.data } : {}),
|
|
44
|
+
isError: !outcome.success,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function toCallToolResult(tool: IActiveTool, view: TAppView): ICallToolResult {
|
|
49
|
+
let content = contentFromParts(tool.toolResultParts);
|
|
50
|
+
if (content.length === 0 && tool.toolResultData !== undefined) {
|
|
51
|
+
content = [{ type: 'text', text: tool.toolResultData }];
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
content,
|
|
55
|
+
...(isRecord(view.structuredContent) ? { structuredContent: view.structuredContent } : {}),
|
|
56
|
+
isError: tool.status === 'error',
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createContext } from 'react';
|
|
2
|
+
|
|
3
|
+
import { APP_VIEW_MAX_HEIGHT_PX, type IAppViewHostContext } from './bridge.js';
|
|
4
|
+
|
|
5
|
+
import type { TAppToolArguments, TAppToolOutcome, TAppView } from './types.js';
|
|
6
|
+
|
|
7
|
+
export interface IAppViewHost {
|
|
8
|
+
sandboxUrl?: string;
|
|
9
|
+
|
|
10
|
+
readAppView: (viewKey: string, signal?: AbortSignal) => Promise<TAppView>;
|
|
11
|
+
callAppTool: (
|
|
12
|
+
viewKey: string,
|
|
13
|
+
name: string,
|
|
14
|
+
args: TAppToolArguments,
|
|
15
|
+
signal?: AbortSignal,
|
|
16
|
+
) => Promise<TAppToolOutcome>;
|
|
17
|
+
hostInfo: { name: string; version: string };
|
|
18
|
+
platform: 'desktop' | 'web';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const AppViewHostContext = createContext<IAppViewHost | null>(null);
|
|
22
|
+
|
|
23
|
+
export function readAppViewHostContext(platform: IAppViewHost['platform']): IAppViewHostContext {
|
|
24
|
+
const dark =
|
|
25
|
+
typeof window !== 'undefined' &&
|
|
26
|
+
typeof window.matchMedia === 'function' &&
|
|
27
|
+
window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
28
|
+
return {
|
|
29
|
+
theme: dark ? 'dark' : 'light',
|
|
30
|
+
displayMode: 'inline',
|
|
31
|
+
availableDisplayModes: ['inline'],
|
|
32
|
+
containerDimensions: { maxHeight: APP_VIEW_MAX_HEIGHT_PX },
|
|
33
|
+
platform,
|
|
34
|
+
locale: typeof navigator === 'undefined' ? 'en-US' : navigator.language,
|
|
35
|
+
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { TClientMessage, TServerMessage } from '@robota-sdk/agent-transport';
|
|
2
|
+
|
|
3
|
+
export type TAppView = Extract<TServerMessage, { type: 'app_view' }>['view'];
|
|
4
|
+
|
|
5
|
+
export type TAppToolOutcome = Omit<
|
|
6
|
+
Extract<TServerMessage, { type: 'app_tool_result' }>,
|
|
7
|
+
'type' | 'requestId'
|
|
8
|
+
>;
|
|
9
|
+
|
|
10
|
+
export type TAppToolArguments = Extract<TClientMessage, { type: 'call-app-tool' }>['arguments'];
|
|
11
|
+
|
|
12
|
+
export type TAppToolPart = NonNullable<TAppToolOutcome['parts']>[number];
|