feat: add runtime seam, Hermes adapter support, and demo gateway mode (#89)
* fix: include kanbanImmersive in immersiveOverlayActive calculation When Kanban board is open, HUD elements (camera preset buttons, edit toolbar, overlays) should be suppressed. The kanbanImmersive flag was defined but not included in the immersiveOverlayActive condition, causing HUD elements to remain visible. This fix adds kanbanImmersive to the immersiveOverlayActive calculation so HUD elements are properly hidden when the Kanban board is open. Co-authored-by: Luke The Dev <iamlukethedev@users.noreply.github.com> * Fix: Hide mini status bar when Kanban immersive overlay is open Wraps the bottom-left mini status bar (showing agent stats, vibe score, and control hints) with !immersiveOverlayActive check to match the behavior of other HUD elements like camera controls and toolbar. This ensures the status bar is properly hidden when the Kanban board or any other immersive overlay is active, maintaining a clean immersive experience. Co-authored-by: Luke The Dev <iamlukethedev@users.noreply.github.com> * chore: drop unrelated package-lock line from branch Co-authored-by: Luke The Dev <iamlukethedev@users.noreply.github.com> * universal-backend-plan * backend-neutral runtime seam * package.json update * feat: add Hermes gateway adapter as alternative to OpenClaw Adds a WebSocket adapter that lets Claw3D connect to a Hermes AI agent runtime without any changes to the frontend. The adapter implements the full Claw3D gateway protocol and bridges it to the Hermes HTTP API. Changes: - server/hermes-gateway-adapter.js: WebSocket bridge implementing the Claw3D gateway protocol against the Hermes HTTP API. Supports all core methods (agents, sessions, chat streaming, cron, config, files, approvals) and multi-agent orchestration via spawn_agent/delegate_task tools. Persists conversation history to ~/.hermes/clawd3d-history.json. - scripts/clawd3d-start.sh: All-in-one startup script that launches Hermes, the adapter, and the Next.js dev server with auto port conflict resolution. Alias as `claw3d` for convenience. - src/features/office/hooks/useCronAgents.ts: Hook that polls the gateway for cron-scheduled agents and surfaces them in the 3D office. - package.json: adds `hermes-adapter` npm script - .env.example: documents Hermes config vars - docs/hermes-gateway.md: setup guide and protocol reference Usage: npm run hermes-adapter # start adapter (connect to http://localhost:8642) npm run dev # start Claw3D, point browser at localhost:3000 # or: bash scripts/clawd3d-start.sh (starts everything automatically) Both OpenClaw and Hermes are supported simultaneously — the gateway URL in NEXT_PUBLIC_GATEWAY_URL determines which backend Claw3D connects to. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add read_agent_context tool for cross-agent coordination Agents can now read each other's conversation history via the read_agent_context tool, enabling the orchestrator to check what a sub-agent has done before re-delegating work. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: wire Hermes office UX and role-aware runtime updates * feature update - demomode & hermes adapter * fix lint blockers * lintfix #2 * fix: stabilize retro office camera preset callbacks * Initial plan * fix: stabilize retro office overview preset hooks Agent-Logs-Url: https://github.com/gsknnft/Claw3D/sessions/9cc71555-591e-44cf-aec4-25affbdcb405 Co-authored-by: gsknnft <123185582+gsknnft@users.noreply.github.com> * feat: add truthful backend selection, Hermes adapter hardening, and demo gateway mode * fix: address bugbot review and finalize backend selection * fixed - onboarding and hermes calls * office systems roadmap * feat specs in docs * specs ready * feat: continue custom runtime seam and gateway alignment * custom lane wired * feat: add custom runtime provider path and office runtime alignment * runtime fixes * fix lukes findings * fix lukes findings #2 * stable UI & connect screen page -> overlay * better baseline for connection * stable providers & ui rendering * best launch yet * nearly no gateway on reconnect * auto reconnect last state * fix: preserve selected runtime across reconnects Keep backend selection aligned with the operator's chosen runtime instead of reviving a mismatched last-known-good adapter, and keep custom runtimes prompting for reconnect when Studio cannot auto-connect them. Made-with: Cursor --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Luke The Dev <iamlukethedev@users.noreply.github.com> Co-authored-by: Elias Pfeffer <eliaspfeffer@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: iamlukethedev <lucas.guilherme@smartwayslfl.com>
This commit is contained in:
@@ -23,6 +23,18 @@ DEBUG=true
|
|||||||
# OPENCLAW_GATEWAY_SSH_PORT=
|
# OPENCLAW_GATEWAY_SSH_PORT=
|
||||||
# OPENCLAW_GATEWAY_SSH_STRICT_HOST_KEY_CHECKING=accept-new
|
# OPENCLAW_GATEWAY_SSH_STRICT_HOST_KEY_CHECKING=accept-new
|
||||||
|
|
||||||
|
# Hermes Agent adapter (alternative to OpenClaw)
|
||||||
|
# Run `npm run hermes-adapter` to start the adapter, then connect Claw3D to ws://localhost:18789
|
||||||
|
# HERMES_API_URL=http://localhost:8642
|
||||||
|
# HERMES_API_KEY=
|
||||||
|
# HERMES_ADAPTER_PORT=18789
|
||||||
|
# HERMES_MODEL=hermes
|
||||||
|
# HERMES_AGENT_NAME=Hermes
|
||||||
|
|
||||||
|
# Demo gateway (no OpenClaw or Hermes required)
|
||||||
|
# Run `npm run demo-gateway` and connect Claw3D to ws://localhost:18789
|
||||||
|
# DEMO_ADAPTER_PORT=18789
|
||||||
|
|
||||||
# Optional: voice features
|
# Optional: voice features
|
||||||
# ELEVENLABS_API_KEY=
|
# ELEVENLABS_API_KEY=
|
||||||
# ELEVENLABS_VOICE_ID=21m00Tcm4TlvDq8ikWAM
|
# ELEVENLABS_VOICE_ID=21m00Tcm4TlvDq8ikWAM
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
## Summary
|
||||||
|
|
||||||
|
This PR started as the Hermes/demo/runtime seam work and has now grown into
|
||||||
|
the main runtime stabilization branch for Claw3D.
|
||||||
|
|
||||||
|
The branch now does four things:
|
||||||
|
|
||||||
|
- keeps the Hermes adapter path working and easier to debug
|
||||||
|
- makes backend selection more explicit and truthful in Studio
|
||||||
|
- extends the runtime seam beyond OpenClaw-only assumptions
|
||||||
|
- fixes capability mismatches that were exposing unsupported UI/actions
|
||||||
|
|
||||||
|
This is now the right branch for the Hermes/runtime review path.
|
||||||
|
|
||||||
|
## What Changed Since The Earlier PR Notes
|
||||||
|
|
||||||
|
The earlier summary for this work is now outdated.
|
||||||
|
|
||||||
|
This branch no longer only has a single OpenClaw-shaped runtime path.
|
||||||
|
It now includes:
|
||||||
|
|
||||||
|
- persisted backend selection in Studio settings
|
||||||
|
- active backend identity surfaced in the UI
|
||||||
|
- real provider selection across:
|
||||||
|
- `openclaw`
|
||||||
|
- `hermes`
|
||||||
|
- `demo`
|
||||||
|
- `custom`
|
||||||
|
- Hermes/demo hello identity support
|
||||||
|
- stronger office/runtime alignment so the office is less tightly coupled to
|
||||||
|
raw gateway assumptions
|
||||||
|
- runtime and reconnect fixes discovered while exercising Hermes in the real UI
|
||||||
|
|
||||||
|
## Included In This PR
|
||||||
|
|
||||||
|
### 1. Hermes/runtime stability and reconnect behavior
|
||||||
|
|
||||||
|
- improved Hermes adapter/runtime selection handling
|
||||||
|
- reduced OpenClaw-specific assumptions leaking into Hermes/demo/custom paths
|
||||||
|
- stabilized reconnect and connect-screen behavior in the office flow
|
||||||
|
- improved local backend switching/debuggability from inside Studio
|
||||||
|
- Studio now only auto-connects from a verified last-known-good runtime state;
|
||||||
|
otherwise it shows the connect overlay and waits for explicit operator choice
|
||||||
|
- fixed a gateway connect-screen hydration bug caused by invalid HTML around the
|
||||||
|
inline loading avatar
|
||||||
|
- fixed an onboarding hydration mismatch caused by reading browser storage
|
||||||
|
during initial render
|
||||||
|
- kept the office mounted while gateway loading/prompt states are shown as
|
||||||
|
overlays instead of full page swaps
|
||||||
|
- kept the office visible behind the runtime overlay, while making the connect
|
||||||
|
modal itself more opaque/readable
|
||||||
|
- aligned Studio proxy URL resolution back to upstream `main`
|
||||||
|
- added a small first auto-connect delay for Hermes/Demo so the initial browser
|
||||||
|
ws proxy attempt is less likely to race Next dev startup
|
||||||
|
- removed destructive office camera resets that were firing on runtime-status
|
||||||
|
and agent-count changes
|
||||||
|
- manual connect now cancels pending auto-connect/retry overlap before opening a
|
||||||
|
new Hermes/Demo attempt
|
||||||
|
- Hermes/Demo gateway connects now allow a second internal attempt before
|
||||||
|
surfacing failure, which materially reduces the first-connect miss pattern in
|
||||||
|
local Studio testing
|
||||||
|
|
||||||
|
### 2. Truthful backend selection
|
||||||
|
|
||||||
|
- Studio persists the selected backend instead of only URL/token
|
||||||
|
- the UI shows selected vs active backend
|
||||||
|
- backend settings are now clearer and less misleading when switching between:
|
||||||
|
- OpenClaw
|
||||||
|
- Hermes
|
||||||
|
- Demo
|
||||||
|
- Custom
|
||||||
|
|
||||||
|
### 3. Runtime seam expansion
|
||||||
|
|
||||||
|
- provider selection is no longer effectively OpenClaw-only
|
||||||
|
- Hermes and Demo use real provider paths
|
||||||
|
- groundwork for direct HTTP-backed custom runtimes is included on this branch
|
||||||
|
|
||||||
|
### 4. Office/runtime alignment
|
||||||
|
|
||||||
|
- office bootstrap and runtime handling are less coupled to raw gateway transport
|
||||||
|
- blank-office / reconnect edge cases were investigated and several stability
|
||||||
|
fixes landed during Hermes validation
|
||||||
|
|
||||||
|
### 5. Luke’s findings addressed
|
||||||
|
|
||||||
|
The Demo cron mismatch called out in review is fixed.
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
|
||||||
|
- `Automations` is hidden when `supportsCapability("cron")` is false
|
||||||
|
- cron create/run/delete now fail fast with a clear user-facing error when the
|
||||||
|
runtime does not support cron
|
||||||
|
- added tests covering:
|
||||||
|
- no Automations tab when cron is unsupported
|
||||||
|
- cron mutations blocked when cron capability is unavailable
|
||||||
|
|
||||||
|
## Review Follow-Up For Luke
|
||||||
|
|
||||||
|
Addressed review finding:
|
||||||
|
|
||||||
|
- Demo runtime no longer exposes unsupported cron mutation behavior through the
|
||||||
|
settings flow
|
||||||
|
- unsupported cron actions are blocked before any RPC is sent
|
||||||
|
|
||||||
|
Files directly involved in that fix:
|
||||||
|
|
||||||
|
- `src/features/agents/screens/AgentsPageScreen.tsx`
|
||||||
|
- `src/features/agents/operations/settingsSidebarTabs.ts`
|
||||||
|
- `src/features/agents/operations/useAgentSettingsMutationController.ts`
|
||||||
|
- `tests/unit/settingsSidebarTabs.test.ts`
|
||||||
|
- `tests/unit/useAgentSettingsMutationController.test.ts`
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- [x] `npm run typecheck`
|
||||||
|
- [x] `npx vitest run tests/unit/useGatewayConnection.test.ts tests/unit/useRuntimeConnection.test.ts`
|
||||||
|
- [x] `npx vitest run tests/unit/useAgentSettingsMutationController.test.ts tests/unit/settingsSidebarTabs.test.ts`
|
||||||
|
- [x] `npx vitest run tests/unit/demoGatewayAdapter.test.ts`
|
||||||
|
|
||||||
|
## Scope Notes
|
||||||
|
|
||||||
|
This PR still does **not** claim:
|
||||||
|
|
||||||
|
- ACP-native Hermes integration
|
||||||
|
- process launch from Studio
|
||||||
|
- fully mature custom-runtime streaming/session semantics
|
||||||
|
- finished office-system feature implementation
|
||||||
|
|
||||||
|
## Office Systems Note
|
||||||
|
|
||||||
|
The office-system roadmap/spec work exists, but it is **not** part of this
|
||||||
|
implementation scope.
|
||||||
|
|
||||||
|
That work was split into feature issue `#90`.
|
||||||
|
|
||||||
|
Current docs/specs are planning artifacts only:
|
||||||
|
|
||||||
|
- bulletin board
|
||||||
|
- whiteboard
|
||||||
|
- meeting room workflow
|
||||||
|
- QA department
|
||||||
|
- desk progression
|
||||||
|
- hierarchy/teams
|
||||||
|
|
||||||
|
Nothing from those office-system specs should be interpreted as already built
|
||||||
|
in this PR.
|
||||||
|
|
||||||
|
## Suggested Reviewer Focus
|
||||||
|
|
||||||
|
- Hermes adapter/runtime stability in the real Studio/office flow
|
||||||
|
- whether backend selection is now clearer and less misleading
|
||||||
|
- whether capability claims are honest, especially for Demo
|
||||||
|
- whether the runtime/provider seam changes remain coherent for upstream use
|
||||||
|
|
||||||
|
## Remaining Follow-Up Work
|
||||||
|
|
||||||
|
- ACP-backed Hermes provider as separate work
|
||||||
|
- better Hermes profile/model visibility in settings
|
||||||
|
- stronger reconnect persistence behavior
|
||||||
|
- richer multi-agent Hermes flows
|
||||||
|
- continued custom-runtime and downstream orchestrator work
|
||||||
|
|
||||||
|
## Current Operator Status
|
||||||
|
|
||||||
|
As of the latest local verification on April 1, 2026:
|
||||||
|
|
||||||
|
- Hermes now reaches a stable connected office state without the earlier heavy
|
||||||
|
scene flicker/reset behavior
|
||||||
|
- the office remains visible behind gateway loading/error overlays
|
||||||
|
- the Demo cron capability mismatch called out in review is fixed end-to-end
|
||||||
|
- one residual Hermes issue may still remain in some local dev sessions:
|
||||||
|
the first automatic connection attempt can miss once before a manual
|
||||||
|
`Hermes backend` + `Connect` succeeds cleanly
|
||||||
|
|
||||||
|
That remaining first-attempt Hermes miss is now the only notable runtime issue
|
||||||
|
still observed in manual browser testing for this pass.
|
||||||
@@ -29,17 +29,23 @@ You walk through your AI workplace.
|
|||||||
|
|
||||||
## What Claw3D Is
|
## What Claw3D Is
|
||||||
|
|
||||||
OpenClaw is the intelligence and task-execution layer.
|
|
||||||
|
|
||||||
Claw3D is the visualization and interaction layer.
|
Claw3D is the visualization and interaction layer.
|
||||||
|
|
||||||
|
Today it can sit on top of:
|
||||||
|
|
||||||
|
- OpenClaw through the existing gateway flow
|
||||||
|
- Hermes through the bundled WebSocket adapter
|
||||||
|
- a direct HTTP `custom` runtime provider for orchestrator-backed stacks
|
||||||
|
- a built-in demo gateway for office exploration without a real agent framework
|
||||||
|
|
||||||
In practical terms, this app gives you:
|
In practical terms, this app gives you:
|
||||||
|
|
||||||
- a live `/office` retro-office environment where agents appear as workers moving through a shared 3D world
|
- a live `/office` retro-office environment where agents appear as workers moving through a shared 3D world
|
||||||
- an `/office/builder` surface for editing and publishing office layouts
|
- an `/office/builder` surface for editing and publishing office layouts
|
||||||
- a gateway-first architecture that keeps agent state in OpenClaw while Studio stores local UI preferences
|
- a gateway-first architecture that keeps runtime state in the connected backend while Studio stores local UI preferences
|
||||||
|
- a backend-neutral runtime seam inside Studio so additional providers can be integrated without rewriting the whole UI
|
||||||
|
|
||||||
This repository does not build or modify the OpenClaw runtime itself. It is the frontend and proxy layer that connects to an existing OpenClaw Gateway.
|
This repository does not build the upstream runtimes themselves. It is the frontend, Studio, and adapter/proxy layer that connects to a runtime speaking the Claw3D gateway protocol.
|
||||||
|
|
||||||
## Why It Exists
|
## Why It Exists
|
||||||
|
|
||||||
@@ -71,13 +77,16 @@ Requirements:
|
|||||||
|
|
||||||
- Node.js 20+ recommended.
|
- Node.js 20+ recommended.
|
||||||
- npm 10+ recommended.
|
- npm 10+ recommended.
|
||||||
- A working OpenClaw installation with a reachable Gateway URL and token.
|
- One of:
|
||||||
|
- a working OpenClaw installation with a reachable Gateway URL and token
|
||||||
|
- Hermes with the bundled adapter
|
||||||
|
- the built-in demo gateway for local exploration
|
||||||
|
|
||||||
Prerequisite:
|
Prerequisite:
|
||||||
|
|
||||||
- Claw3D does not install, build, or run OpenClaw for you.
|
- Claw3D does not install or build OpenClaw or Hermes for you.
|
||||||
- Before starting Claw3D, make sure your OpenClaw gateway is already running and that you know the gateway URL and token you want Studio to use.
|
- Before starting Claw3D against a real backend, make sure your chosen runtime is already running and that you know the gateway URL and token Studio should use.
|
||||||
- This repository is the UI and Studio/proxy layer only.
|
- For a no-framework local office demo, run the bundled demo gateway instead.
|
||||||
- If you need a full cross-machine setup guide (OpenClaw + Tailscale + Claw3D), follow [`TUTORIAL.md`](TUTORIAL.md).
|
- If you need a full cross-machine setup guide (OpenClaw + Tailscale + Claw3D), follow [`TUTORIAL.md`](TUTORIAL.md).
|
||||||
|
|
||||||
Run from source:
|
Run from source:
|
||||||
@@ -91,6 +100,65 @@ npm run dev
|
|||||||
```
|
```
|
||||||
|
|
||||||
Then open `http://localhost:3000` and configure the gateway URL and token in Studio.
|
Then open `http://localhost:3000` and configure the gateway URL and token in Studio.
|
||||||
|
Studio now also persists the selected backend mode (`OpenClaw`, `Hermes`, `Demo`, or `Custom`) and
|
||||||
|
shows the active backend reported by the connected gateway.
|
||||||
|
|
||||||
|
### Custom runtime mode
|
||||||
|
|
||||||
|
If you are integrating an orchestrator-backed runtime through the `custom`
|
||||||
|
provider seam, start your runtime first, then start Claw3D:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open `http://localhost:3000`, choose `Custom backend`, and point the
|
||||||
|
upstream URL at your runtime boundary, for example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:7770
|
||||||
|
```
|
||||||
|
|
||||||
|
Current `custom` runtime expectations:
|
||||||
|
|
||||||
|
- `GET /health`
|
||||||
|
- `GET /state`
|
||||||
|
- `GET /registry`
|
||||||
|
- `POST /v1/chat/completions`
|
||||||
|
|
||||||
|
The browser does not call that runtime directly. Claw3D proxies the
|
||||||
|
`custom` provider through its own same-origin route at
|
||||||
|
`/api/runtime/custom`, which avoids browser-side CORS problems and keeps
|
||||||
|
the provider transport separate from the OpenClaw/Hermes gateway path.
|
||||||
|
|
||||||
|
### Demo mode
|
||||||
|
|
||||||
|
If you only want to see the office and agent interactions without installing OpenClaw or Hermes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run demo-gateway
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Then connect Studio to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ws://localhost:18789
|
||||||
|
```
|
||||||
|
|
||||||
|
This starts a mock local gateway with demo agents, streaming chat, session previews, and office presence.
|
||||||
|
In the connect screen, choose `Demo backend`, then connect.
|
||||||
|
|
||||||
|
### Hermes adapter
|
||||||
|
|
||||||
|
If you want to use Hermes instead of OpenClaw:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run hermes-adapter
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
See [`docs/hermes-gateway.md`](docs/hermes-gateway.md) for setup details and current scope.
|
||||||
|
|
||||||
For a local gateway on the same machine, the usual upstream URL is:
|
For a local gateway on the same machine, the usual upstream URL is:
|
||||||
|
|
||||||
@@ -98,6 +166,8 @@ For a local gateway on the same machine, the usual upstream URL is:
|
|||||||
ws://localhost:18789
|
ws://localhost:18789
|
||||||
```
|
```
|
||||||
|
|
||||||
|
In the connect screen, choose `Hermes backend`, then connect.
|
||||||
|
|
||||||
## How It Connects
|
## How It Connects
|
||||||
|
|
||||||
Claw3D uses two separate network hops:
|
Claw3D uses two separate network hops:
|
||||||
@@ -168,6 +238,8 @@ See [`.env.example`](.env.example) for the full local development template.
|
|||||||
## Scripts
|
## Scripts
|
||||||
|
|
||||||
- `npm run dev` starts the Studio dev server.
|
- `npm run dev` starts the Studio dev server.
|
||||||
|
- `npm run hermes-adapter` starts the Hermes WebSocket adapter.
|
||||||
|
- `npm run demo-gateway` starts the built-in mock gateway for demo mode.
|
||||||
- `npm run build` builds the production Next.js app.
|
- `npm run build` builds the production Next.js app.
|
||||||
- `npm run start` starts the production server.
|
- `npm run start` starts the production server.
|
||||||
- `npm run lint` runs ESLint.
|
- `npm run lint` runs ESLint.
|
||||||
@@ -189,6 +261,7 @@ See [`.env.example`](.env.example) for the full local development template.
|
|||||||
- [`ROADMAP.md`](ROADMAP.md): near-term priorities and contributor-friendly work areas.
|
- [`ROADMAP.md`](ROADMAP.md): near-term priorities and contributor-friendly work areas.
|
||||||
- [`docs/pi-chat-streaming.md`](docs/pi-chat-streaming.md): gateway runtime streaming and transcript rendering.
|
- [`docs/pi-chat-streaming.md`](docs/pi-chat-streaming.md): gateway runtime streaming and transcript rendering.
|
||||||
- [`docs/permissions-sandboxing.md`](docs/permissions-sandboxing.md): Studio permissions and OpenClaw behavior.
|
- [`docs/permissions-sandboxing.md`](docs/permissions-sandboxing.md): Studio permissions and OpenClaw behavior.
|
||||||
|
- [`docs/hermes-gateway.md`](docs/hermes-gateway.md): Hermes adapter setup, capabilities, and current limitations.
|
||||||
|
|
||||||
## Current Limitations
|
## Current Limitations
|
||||||
|
|
||||||
@@ -202,6 +275,7 @@ If the UI loads but Connect fails, the problem is usually on the Studio -> Gatew
|
|||||||
|
|
||||||
- Confirm the upstream URL and token in Studio settings.
|
- Confirm the upstream URL and token in Studio settings.
|
||||||
- `EPROTO` or `wrong version number` usually means `wss://` was used against a non-TLS endpoint.
|
- `EPROTO` or `wrong version number` usually means `wss://` was used against a non-TLS endpoint.
|
||||||
|
- `INVALID_REQUEST` errors mentioning `minProtocol` or `maxProtocol` usually mean the gateway is too old for Claw3D protocol v3. Upgrade OpenClaw, use the Hermes adapter, or run `npm run demo-gateway`.
|
||||||
- `401 Studio access token required` usually means `STUDIO_ACCESS_TOKEN` is enabled and the request is missing the expected `studio_access` cookie.
|
- `401 Studio access token required` usually means `STUDIO_ACCESS_TOKEN` is enabled and the request is missing the expected `studio_access` cookie.
|
||||||
- Helpful proxy error codes include `studio.gateway_url_missing`, `studio.gateway_token_missing`, `studio.upstream_error`, and `studio.upstream_closed`.
|
- Helpful proxy error codes include `studio.gateway_url_missing`, `studio.gateway_token_missing`, `studio.upstream_error`, and `studio.upstream_closed`.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,380 @@
|
|||||||
|
# Agent State Model Spec
|
||||||
|
|
||||||
|
> Seventh concrete office-system feature for Claw3D, formalizing operational agent states before any deeper simulation or affective layer is introduced.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add a public, office-visible agent state model that explains how an agent is currently operating.
|
||||||
|
|
||||||
|
The state model should answer:
|
||||||
|
|
||||||
|
- what is this agent doing right now?
|
||||||
|
- are they available?
|
||||||
|
- are they blocked?
|
||||||
|
- are they overloaded?
|
||||||
|
- should the office route more work to them?
|
||||||
|
|
||||||
|
This state model should be understandable to users and stable across backends.
|
||||||
|
|
||||||
|
## Core Principle
|
||||||
|
|
||||||
|
Start with operational states, not emotional roleplay.
|
||||||
|
|
||||||
|
That means the visible model should describe work conditions such as:
|
||||||
|
|
||||||
|
- focused
|
||||||
|
- idle
|
||||||
|
- blocked
|
||||||
|
- overloaded
|
||||||
|
- waiting
|
||||||
|
- recovering
|
||||||
|
- degraded
|
||||||
|
|
||||||
|
The office should show states that are useful for coordination.
|
||||||
|
|
||||||
|
Any richer or more proprietary internal signal stack can feed these states later, but should not be required to understand them.
|
||||||
|
|
||||||
|
## Why This Matters
|
||||||
|
|
||||||
|
Claw3D already visualizes activity, presence, and meeting participation.
|
||||||
|
|
||||||
|
What is still missing is a reliable office-wide language for work condition and agent load.
|
||||||
|
|
||||||
|
A proper state model would improve:
|
||||||
|
|
||||||
|
- delegation
|
||||||
|
- meeting routing
|
||||||
|
- desk progression meaning
|
||||||
|
- team visibility
|
||||||
|
- office atmosphere
|
||||||
|
|
||||||
|
It also gives a clean place for future internal state systems to map into.
|
||||||
|
|
||||||
|
## Product Position
|
||||||
|
|
||||||
|
The agent state model is:
|
||||||
|
|
||||||
|
- a public coordination surface
|
||||||
|
- not a hidden internal metric system
|
||||||
|
- not a mood simulator in V1
|
||||||
|
|
||||||
|
It should help the user make decisions quickly.
|
||||||
|
|
||||||
|
## Recommended Visible States
|
||||||
|
|
||||||
|
Suggested initial state set:
|
||||||
|
|
||||||
|
- `idle`
|
||||||
|
- `focused`
|
||||||
|
- `working`
|
||||||
|
- `waiting`
|
||||||
|
- `blocked`
|
||||||
|
- `overloaded`
|
||||||
|
- `recovering`
|
||||||
|
- `degraded`
|
||||||
|
- `meeting`
|
||||||
|
- `error`
|
||||||
|
|
||||||
|
### Idle
|
||||||
|
|
||||||
|
The agent is available and not actively engaged in a task.
|
||||||
|
|
||||||
|
### Focused
|
||||||
|
|
||||||
|
The agent is actively working and should not be interrupted casually.
|
||||||
|
|
||||||
|
### Working
|
||||||
|
|
||||||
|
The agent is doing normal active work without special load concerns.
|
||||||
|
|
||||||
|
### Waiting
|
||||||
|
|
||||||
|
The agent is paused on a dependency, approval, or external result.
|
||||||
|
|
||||||
|
### Blocked
|
||||||
|
|
||||||
|
The agent cannot make forward progress because a required condition is missing.
|
||||||
|
|
||||||
|
### Overloaded
|
||||||
|
|
||||||
|
The agent has too much work, too much context pressure, or too many active demands.
|
||||||
|
|
||||||
|
### Recovering
|
||||||
|
|
||||||
|
The agent has recently completed intense work or a failure state and should stabilize before taking more on.
|
||||||
|
|
||||||
|
### Degraded
|
||||||
|
|
||||||
|
The agent is operational but impaired in quality, speed, or confidence.
|
||||||
|
|
||||||
|
### Meeting
|
||||||
|
|
||||||
|
The agent is participating in a coordination workflow and is temporarily occupied there.
|
||||||
|
|
||||||
|
### Error
|
||||||
|
|
||||||
|
The agent encountered a concrete failure or unrecoverable problem and needs attention.
|
||||||
|
|
||||||
|
## Relationship To Existing Presence
|
||||||
|
|
||||||
|
Claw3D already uses simpler presence states such as:
|
||||||
|
|
||||||
|
- idle
|
||||||
|
- working
|
||||||
|
- meeting
|
||||||
|
- error
|
||||||
|
|
||||||
|
This spec should extend that concept rather than replace it abruptly.
|
||||||
|
|
||||||
|
The new states should be layered so older/fallback providers can still map into the simpler model.
|
||||||
|
|
||||||
|
## Public vs Internal State
|
||||||
|
|
||||||
|
This distinction matters.
|
||||||
|
|
||||||
|
### Public State
|
||||||
|
|
||||||
|
What Claw3D shows in the office:
|
||||||
|
|
||||||
|
- stable
|
||||||
|
- understandable
|
||||||
|
- backend-neutral
|
||||||
|
- useful for coordination
|
||||||
|
|
||||||
|
### Internal State
|
||||||
|
|
||||||
|
What a stack like Vera might use internally:
|
||||||
|
|
||||||
|
- latent regime
|
||||||
|
- coherence
|
||||||
|
- confidence or control signals
|
||||||
|
- advanced routing/load heuristics
|
||||||
|
|
||||||
|
Internal state can be richer.
|
||||||
|
|
||||||
|
Public state should remain simpler and more durable.
|
||||||
|
|
||||||
|
## Suggested Mapping Model
|
||||||
|
|
||||||
|
Recommended structure:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type OfficeAgentState =
|
||||||
|
| "idle"
|
||||||
|
| "focused"
|
||||||
|
| "working"
|
||||||
|
| "waiting"
|
||||||
|
| "blocked"
|
||||||
|
| "overloaded"
|
||||||
|
| "recovering"
|
||||||
|
| "degraded"
|
||||||
|
| "meeting"
|
||||||
|
| "error";
|
||||||
|
|
||||||
|
type AgentStateReason =
|
||||||
|
| "task_active"
|
||||||
|
| "approval_pending"
|
||||||
|
| "dependency_wait"
|
||||||
|
| "meeting_active"
|
||||||
|
| "high_load"
|
||||||
|
| "recent_failure"
|
||||||
|
| "runtime_signal"
|
||||||
|
| "manual_override"
|
||||||
|
| "unknown";
|
||||||
|
|
||||||
|
type OfficeAgentStateSnapshot = {
|
||||||
|
state: OfficeAgentState;
|
||||||
|
reason: AgentStateReason;
|
||||||
|
updatedAt: string;
|
||||||
|
note?: string | null;
|
||||||
|
confidence?: number | null;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
The `reason` is important because it prevents the state from feeling arbitrary.
|
||||||
|
|
||||||
|
## V1 Derivation Rules
|
||||||
|
|
||||||
|
The first version should use straightforward observable signals.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- active run -> `working` or `focused`
|
||||||
|
- pending approval -> `waiting`
|
||||||
|
- unresolved dependency or explicit blocker -> `blocked`
|
||||||
|
- too many simultaneous demands -> `overloaded`
|
||||||
|
- active standup/meeting -> `meeting`
|
||||||
|
- recent hard failure -> `error` or `degraded`
|
||||||
|
- no recent work -> `idle`
|
||||||
|
|
||||||
|
This gives immediate utility without needing a deeper cognitive model.
|
||||||
|
|
||||||
|
## Relationship To Other Office Systems
|
||||||
|
|
||||||
|
### Meetings
|
||||||
|
|
||||||
|
Meeting participation should temporarily dominate many other states.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
- a focused agent who joins a standup becomes `meeting` while the meeting is active
|
||||||
|
|
||||||
|
### Bulletin Board
|
||||||
|
|
||||||
|
The bulletin board should be able to surface meaningful state changes.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- "Alice blocked waiting on approval"
|
||||||
|
- "Bob overloaded during release push"
|
||||||
|
- "Hermes degraded after provider failure"
|
||||||
|
|
||||||
|
### Whiteboard
|
||||||
|
|
||||||
|
Planning workflows can use state to decide:
|
||||||
|
|
||||||
|
- who is available
|
||||||
|
- who should not be interrupted
|
||||||
|
- who is the right candidate for next actions
|
||||||
|
|
||||||
|
### QA Department
|
||||||
|
|
||||||
|
QA and state should influence each other.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- repeated QA failures may push an agent or workflow into `degraded`
|
||||||
|
- review-ready but approval-blocked work can show `waiting`
|
||||||
|
|
||||||
|
### Desk Progression / Hierarchy
|
||||||
|
|
||||||
|
More mature roles may tolerate:
|
||||||
|
|
||||||
|
- more context
|
||||||
|
- more delegation
|
||||||
|
- higher review authority
|
||||||
|
|
||||||
|
But the visible state model should still be shared across all roles.
|
||||||
|
|
||||||
|
## Visual Expression
|
||||||
|
|
||||||
|
States should be visible in restrained ways.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- nameplate subtitle or badge
|
||||||
|
- desk lighting/accent
|
||||||
|
- movement pacing
|
||||||
|
- speech bubble framing
|
||||||
|
- office panel badges
|
||||||
|
|
||||||
|
Examples by state:
|
||||||
|
|
||||||
|
- `focused`: sharper or brighter work signal
|
||||||
|
- `waiting`: subdued idle with pending marker
|
||||||
|
- `blocked`: warning tone
|
||||||
|
- `overloaded`: high activity / stress marker
|
||||||
|
- `meeting`: meeting-specific signal
|
||||||
|
- `degraded`: weakened signal, slower feel
|
||||||
|
|
||||||
|
Keep the visual language readable, not noisy.
|
||||||
|
|
||||||
|
## Human Interaction Model
|
||||||
|
|
||||||
|
The human should be able to:
|
||||||
|
|
||||||
|
- see current state
|
||||||
|
- understand why the state was chosen
|
||||||
|
- optionally override state in limited cases
|
||||||
|
|
||||||
|
The user should not have to guess what "degraded" or "blocked" means.
|
||||||
|
|
||||||
|
## Provider / Backend Considerations
|
||||||
|
|
||||||
|
Different runtimes will expose different levels of insight.
|
||||||
|
|
||||||
|
That is fine.
|
||||||
|
|
||||||
|
The public state model should support:
|
||||||
|
|
||||||
|
- direct provider-native state
|
||||||
|
- derived state from Claw3D activity
|
||||||
|
- optional custom stack enrichments
|
||||||
|
|
||||||
|
This is especially important for:
|
||||||
|
|
||||||
|
- OpenClaw
|
||||||
|
- Hermes
|
||||||
|
- Demo mode
|
||||||
|
- future custom providers
|
||||||
|
|
||||||
|
## Vera / Coherence / Latent-Regime Compatibility
|
||||||
|
|
||||||
|
This spec intentionally leaves room for deeper internal models without exposing them directly.
|
||||||
|
|
||||||
|
Good future pattern:
|
||||||
|
|
||||||
|
- internal stack computes richer latent regime / coherence / workload state
|
||||||
|
- adapter maps that into public office states
|
||||||
|
- Claw3D shows the public office state plus optional note/reason
|
||||||
|
|
||||||
|
This preserves:
|
||||||
|
|
||||||
|
- clean UX
|
||||||
|
- backend neutrality
|
||||||
|
- proprietary implementation freedom
|
||||||
|
|
||||||
|
## V1 Scope
|
||||||
|
|
||||||
|
Recommended V1 scope:
|
||||||
|
|
||||||
|
- define expanded office agent state enum
|
||||||
|
- derive state from observable office/runtime signals
|
||||||
|
- surface state in UI and office visuals
|
||||||
|
- show a short reason or note when helpful
|
||||||
|
|
||||||
|
Do not build the full simulation layer yet.
|
||||||
|
|
||||||
|
## Out of Scope For V1
|
||||||
|
|
||||||
|
- emotional simulation
|
||||||
|
- arbitrary personality modeling
|
||||||
|
- hidden scoring systems shown as fake moods
|
||||||
|
- deep physiological metaphors
|
||||||
|
|
||||||
|
Those can come later if they remain useful and readable.
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
Recommended order:
|
||||||
|
|
||||||
|
1. Define public state model and reason codes.
|
||||||
|
2. Add derivation rules from current runtime/task/meeting signals.
|
||||||
|
3. Update office visuals and badges.
|
||||||
|
4. Add optional state note/reason surfaces.
|
||||||
|
5. Leave room for future provider-specific enrichments.
|
||||||
|
|
||||||
|
## Existing Code Seams
|
||||||
|
|
||||||
|
This should likely align with:
|
||||||
|
|
||||||
|
- current office presence/state handling
|
||||||
|
- runtime event bridge and latest-update logic
|
||||||
|
- standup meeting state
|
||||||
|
- task and approval signals
|
||||||
|
- office visual systems
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
V1 is successful if:
|
||||||
|
|
||||||
|
- users can tell what condition each agent is in
|
||||||
|
- state changes help routing and coordination
|
||||||
|
- the model works without any proprietary backend
|
||||||
|
- the design still leaves room for richer future internal stacks
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The agent state model should become the office’s shared language for work condition.
|
||||||
|
|
||||||
|
It should be simple enough to understand immediately, but structured enough to accept richer future inputs from advanced runtime stacks.
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
# Hermes Gateway Adapter
|
||||||
|
|
||||||
|
Claw3D can run against Hermes by using the bundled adapter in
|
||||||
|
[`server/hermes-gateway-adapter.js`](../server/hermes-gateway-adapter.js).
|
||||||
|
|
||||||
|
This is the current production-ready Hermes path in this repository.
|
||||||
|
It is not yet a fully native Studio-side Hermes provider. Instead, it
|
||||||
|
uses the runtime seam in Studio while Hermes is exposed through a
|
||||||
|
Claw3D-compatible WebSocket adapter.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```text
|
||||||
|
Browser UI <-> Studio runtime/client <-> Hermes gateway adapter <-> Hermes HTTP API
|
||||||
|
```
|
||||||
|
|
||||||
|
The frontend keeps using the Claw3D gateway protocol. The Hermes adapter
|
||||||
|
translates that protocol into Hermes HTTP calls and streams the results
|
||||||
|
back as gateway events.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
### 1. Start Hermes
|
||||||
|
|
||||||
|
Start your Hermes API server. The default expected endpoint is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://localhost:8642
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Configure environment
|
||||||
|
|
||||||
|
Copy `.env.example` to `.env` and set the Hermes values:
|
||||||
|
|
||||||
|
```env
|
||||||
|
NEXT_PUBLIC_GATEWAY_URL=ws://localhost:18789
|
||||||
|
|
||||||
|
HERMES_API_URL=http://localhost:8642
|
||||||
|
HERMES_API_KEY=
|
||||||
|
HERMES_ADAPTER_PORT=18789
|
||||||
|
HERMES_MODEL=hermes
|
||||||
|
HERMES_AGENT_NAME=Hermes
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Start Claw3D and the adapter
|
||||||
|
|
||||||
|
In separate terminals:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run hermes-adapter
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open `http://localhost:3000` and connect to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ws://localhost:18789
|
||||||
|
```
|
||||||
|
|
||||||
|
In the connect screen, select `Hermes backend`. Claw3D will persist that
|
||||||
|
selection in Studio settings and show `Hermes` as the active backend once
|
||||||
|
the adapter hello response is received.
|
||||||
|
|
||||||
|
### 4. Optional all-in-one local startup
|
||||||
|
|
||||||
|
The repo also includes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/clawd3d-start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
That script now resolves the repo root dynamically from the script
|
||||||
|
location instead of assuming a machine-specific checkout path.
|
||||||
|
|
||||||
|
## What this adapter supports
|
||||||
|
|
||||||
|
The adapter currently supports the Claw3D surfaces needed for normal
|
||||||
|
office use:
|
||||||
|
|
||||||
|
- Agent listing, creation, update, and deletion
|
||||||
|
- Session listing, preview, patch, reset, and history lookup
|
||||||
|
- Chat send, targeted abort, and run wait
|
||||||
|
- Config get/set/patch shims needed by the Studio UI
|
||||||
|
- Models and skills status
|
||||||
|
- Exec approvals surfaces used by the current UI
|
||||||
|
- Cron list/add/remove/patch/run
|
||||||
|
- Multi-agent orchestration tools on the Hermes side
|
||||||
|
|
||||||
|
## Hermes orchestration tools
|
||||||
|
|
||||||
|
The main Hermes agent acts as an orchestrator with these tools:
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|---|---|
|
||||||
|
| `spawn_agent` | Create a specialist sub-agent |
|
||||||
|
| `delegate_task` | Send work to a specific agent |
|
||||||
|
| `list_team` | List active agents, names, and roles |
|
||||||
|
| `configure_agent` | Update agent name, role, instructions, or settings |
|
||||||
|
| `dismiss_agent` | Remove an agent from the team |
|
||||||
|
| `read_agent_context` | Read another agent's recent conversation history for coordination |
|
||||||
|
|
||||||
|
Sub-agents appear in the office as separate characters and keep their
|
||||||
|
own conversation state.
|
||||||
|
|
||||||
|
## Production-readiness notes
|
||||||
|
|
||||||
|
This adapter includes the fixes that blocked the original Hermes PR:
|
||||||
|
|
||||||
|
- `chat.abort` now aborts only the requested `runId` or `sessionKey`
|
||||||
|
instead of cancelling every active run
|
||||||
|
- history clears from `sessions.reset`, `agents.delete`, and
|
||||||
|
`dismiss_agent` now persist to disk immediately
|
||||||
|
- `scripts/clawd3d-start.sh` no longer hardcodes one developer's local path
|
||||||
|
|
||||||
|
## ACP status
|
||||||
|
|
||||||
|
Hermes has a real ACP surface and that remains the preferred long-term
|
||||||
|
integration direction.
|
||||||
|
|
||||||
|
This branch does not replace the adapter with ACP yet. The current
|
||||||
|
production-ready path uses the adapter because it works with the existing
|
||||||
|
Claw3D gateway contract today and is ready for upstream testing now.
|
||||||
|
|
||||||
|
The runtime seam added in Studio is what makes an ACP-backed Hermes
|
||||||
|
provider feasible as a follow-up without reworking the whole UI again.
|
||||||
|
|
||||||
|
## Persistence
|
||||||
|
|
||||||
|
Conversation history is stored at:
|
||||||
|
|
||||||
|
```text
|
||||||
|
~/.hermes/clawd3d-history.json
|
||||||
|
```
|
||||||
|
|
||||||
|
It is loaded on startup and updated when conversations change.
|
||||||
|
|
||||||
|
## Current limitations
|
||||||
|
|
||||||
|
- Hermes is integrated through the adapter path today, not yet through a
|
||||||
|
dedicated native Studio provider implementation
|
||||||
|
- Config and approvals behavior still matches the current adapter contract,
|
||||||
|
not a fully Hermes-native settings model
|
||||||
|
- This path is intended to get Hermes working reliably now while the
|
||||||
|
broader runtime-provider architecture continues to mature
|
||||||
|
|
||||||
|
## When to use demo mode instead
|
||||||
|
|
||||||
|
If you only want to see the office boot without installing Hermes or
|
||||||
|
OpenClaw, use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run demo-gateway
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
That starts a bundled mock gateway for a no-framework Claw3D demo.
|
||||||
|
|
||||||
|
## Using OpenClaw instead
|
||||||
|
|
||||||
|
If you want the OpenClaw path, do not run the Hermes adapter. Start
|
||||||
|
OpenClaw and point Claw3D at that gateway instead.
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
# Custom Provider Reference
|
||||||
|
|
||||||
|
> Reference implementation guide for plugging a non-OpenClaw, non-Hermes runtime into Claw3D through the upstream-safe `custom` provider seam.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Show how a custom orchestration stack should plug into Claw3D without requiring:
|
||||||
|
|
||||||
|
- a named built-in provider
|
||||||
|
- OpenClaw emulation
|
||||||
|
- Hermes adapter semantics
|
||||||
|
|
||||||
|
The shape should be:
|
||||||
|
|
||||||
|
- Claw3D sees `custom`
|
||||||
|
- the external runtime stays implementation-specific
|
||||||
|
- Claw3D core remains generic
|
||||||
|
|
||||||
|
## Current Implementation Notes
|
||||||
|
|
||||||
|
The current `custom` branch path is deliberately conservative.
|
||||||
|
|
||||||
|
What exists today:
|
||||||
|
|
||||||
|
- provider selection and metadata flow through the Studio runtime seam
|
||||||
|
- same-origin runtime proxying through `/api/runtime/custom`
|
||||||
|
- health, state, and registry probing
|
||||||
|
- direct chat via `/v1/chat/completions`
|
||||||
|
- office/bootstrap/chat/model loading routed through the provider layer
|
||||||
|
|
||||||
|
What still needs to mature:
|
||||||
|
|
||||||
|
- true provider-native streaming
|
||||||
|
- stronger multi-session persistence semantics
|
||||||
|
- integration tests against a live custom runtime
|
||||||
|
- richer office presentation of runtime metadata, lanes, and model identity
|
||||||
|
|
||||||
|
## Position
|
||||||
|
|
||||||
|
A custom runtime should sit at the Claw3D boundary as an orchestrator-backed service.
|
||||||
|
|
||||||
|
That means:
|
||||||
|
|
||||||
|
- Claw3D should talk to one stable runtime boundary
|
||||||
|
- that boundary may route work to internal worker processes, models, or lanes
|
||||||
|
- those internal details should remain mostly hidden behind the provider
|
||||||
|
|
||||||
|
This keeps the integration:
|
||||||
|
|
||||||
|
- stable
|
||||||
|
- upstream-safe
|
||||||
|
- reusable for multiple custom stacks later
|
||||||
|
|
||||||
|
## Why The Custom Provider Exists
|
||||||
|
|
||||||
|
Not every useful runtime should need a named upstream provider branch.
|
||||||
|
|
||||||
|
Some stacks will be:
|
||||||
|
|
||||||
|
- internal
|
||||||
|
- experimental
|
||||||
|
- organization-specific
|
||||||
|
- built around custom orchestration
|
||||||
|
|
||||||
|
The `custom` provider exists so those stacks can integrate cleanly without forcing Claw3D core to absorb stack-specific assumptions.
|
||||||
|
|
||||||
|
## Recommended Boundary
|
||||||
|
|
||||||
|
Claw3D should integrate with the custom runtime's orchestrator or gateway layer, not with its individual workers.
|
||||||
|
|
||||||
|
Recommended public boundary:
|
||||||
|
|
||||||
|
- `POST /v1/chat/completions`
|
||||||
|
- `POST /v1/completions`
|
||||||
|
- `POST /v1/contracted-completions`
|
||||||
|
- `GET /health`
|
||||||
|
- `GET /state`
|
||||||
|
- `GET /registry`
|
||||||
|
|
||||||
|
These do not need to be mandatory for every implementation, but they are a strong reference shape because they provide:
|
||||||
|
|
||||||
|
- chat entry points
|
||||||
|
- reachability
|
||||||
|
- runtime summary
|
||||||
|
- model or role registry visibility
|
||||||
|
|
||||||
|
## Runtime Shape
|
||||||
|
|
||||||
|
Recommended mapping:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Claw3D
|
||||||
|
-> custom provider
|
||||||
|
-> custom orchestrator / gateway
|
||||||
|
-> internal routing
|
||||||
|
-> workers / roles / models / tools
|
||||||
|
```
|
||||||
|
|
||||||
|
This means the custom provider should not need to know:
|
||||||
|
|
||||||
|
- worker ports
|
||||||
|
- shard startup semantics
|
||||||
|
- internal runtime policy
|
||||||
|
- proprietary state heuristics
|
||||||
|
|
||||||
|
It only needs:
|
||||||
|
|
||||||
|
- request/response surfaces
|
||||||
|
- route or session metadata
|
||||||
|
- health/state/registry metadata
|
||||||
|
|
||||||
|
## Provider Identity
|
||||||
|
|
||||||
|
Upstream-facing identity should remain:
|
||||||
|
|
||||||
|
- `providerId: "custom"`
|
||||||
|
|
||||||
|
Implementation-specific identity should appear as metadata, not provider class naming.
|
||||||
|
|
||||||
|
Suggested metadata:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type CustomRuntimeMetadata = {
|
||||||
|
id: "custom";
|
||||||
|
label: "Custom Runtime";
|
||||||
|
runtimeName?: string | null;
|
||||||
|
vendor?: string | null;
|
||||||
|
runtimeVersion?: string | null;
|
||||||
|
routeProfile?: string | null;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
This gives the UI enough identity without forcing upstream Claw3D to branch on every runtime brand.
|
||||||
|
|
||||||
|
## Capability Profile
|
||||||
|
|
||||||
|
Initial `custom` provider capability claims should be conservative and honest.
|
||||||
|
|
||||||
|
Likely V1 support for many custom runtimes:
|
||||||
|
|
||||||
|
- `agents`
|
||||||
|
- `sessions`
|
||||||
|
- `chat`
|
||||||
|
- `streaming`
|
||||||
|
- `agent_roles`
|
||||||
|
- `config`
|
||||||
|
|
||||||
|
Possible later support depending on implementation:
|
||||||
|
|
||||||
|
- `files`
|
||||||
|
- `approvals`
|
||||||
|
- `skills`
|
||||||
|
- `cron`
|
||||||
|
- `session_settings`
|
||||||
|
|
||||||
|
Important rule:
|
||||||
|
|
||||||
|
Do not claim support just because the backend can theoretically do something. Claim support only where the provider exposes a stable Claw3D-facing behavior.
|
||||||
|
|
||||||
|
## Agent Mapping
|
||||||
|
|
||||||
|
Claw3D agents should not be modeled as one fixed backend process each unless the runtime truly behaves that way.
|
||||||
|
|
||||||
|
Instead, a custom runtime may map agents to:
|
||||||
|
|
||||||
|
- roles
|
||||||
|
- lanes
|
||||||
|
- strategies
|
||||||
|
- departments
|
||||||
|
- routed execution identities
|
||||||
|
|
||||||
|
Example office-facing identities:
|
||||||
|
|
||||||
|
- `Main`
|
||||||
|
- `Assistant`
|
||||||
|
- `Coder`
|
||||||
|
- `Reviewer`
|
||||||
|
- `Professor`
|
||||||
|
|
||||||
|
The exact labels are implementation-specific.
|
||||||
|
|
||||||
|
The important point is that the provider should expose office-meaningful identities rather than leaking raw backend topology.
|
||||||
|
|
||||||
|
## Session Mapping
|
||||||
|
|
||||||
|
Claw3D sessions should map to runtime conversations or execution threads, not to whichever backend storage structure happens to exist internally.
|
||||||
|
|
||||||
|
Recommended session metadata:
|
||||||
|
|
||||||
|
- `sessionKey`
|
||||||
|
- conversation or thread id
|
||||||
|
- active role
|
||||||
|
- lane
|
||||||
|
- requested model
|
||||||
|
- resolved model
|
||||||
|
- request id
|
||||||
|
|
||||||
|
The provider should normalize these into a stable session model no matter what the backend calls them internally.
|
||||||
|
|
||||||
|
## Event Mapping
|
||||||
|
|
||||||
|
The provider should translate runtime activity into the same normalized Claw3D runtime events used elsewhere.
|
||||||
|
|
||||||
|
Recommended mappings:
|
||||||
|
|
||||||
|
- runtime agent change -> `presence.changed`
|
||||||
|
- conversation/session update -> `session.activity`
|
||||||
|
- streaming token output -> `chat.delta`
|
||||||
|
- final assistant turn -> `chat.final`
|
||||||
|
- routed or backend failure -> `chat.error`
|
||||||
|
- request lifecycle -> `run.lifecycle`
|
||||||
|
- tool or workflow progress -> `tool.progress`
|
||||||
|
|
||||||
|
Implementation-specific metadata is allowed, but only as additive metadata.
|
||||||
|
|
||||||
|
## Route Metadata
|
||||||
|
|
||||||
|
Many custom runtimes will have useful execution metadata.
|
||||||
|
|
||||||
|
The provider may expose optional metadata such as:
|
||||||
|
|
||||||
|
- selected role
|
||||||
|
- candidate roles
|
||||||
|
- lane
|
||||||
|
- routing reason
|
||||||
|
- registry profile
|
||||||
|
- resolved model id
|
||||||
|
- worker or runtime health summary
|
||||||
|
|
||||||
|
This metadata should be visible in diagnostics or advanced panels, not required for basic user flow.
|
||||||
|
|
||||||
|
## State And Health Surfaces
|
||||||
|
|
||||||
|
The custom provider should prefer orchestrator-level health/state queries first.
|
||||||
|
|
||||||
|
Recommended usage:
|
||||||
|
|
||||||
|
- `/health`
|
||||||
|
- basic runtime reachability
|
||||||
|
- `/state`
|
||||||
|
- route profile, active roles, worker/runtime summary
|
||||||
|
- `/registry`
|
||||||
|
- active model, role, or profile catalog
|
||||||
|
|
||||||
|
Worker-level status endpoints should stay behind the provider unless the UI needs a diagnostic drill-down.
|
||||||
|
|
||||||
|
## Relationship To Agent State
|
||||||
|
|
||||||
|
The custom provider is the right place to map richer internal runtime signals into Claw3D's public office state model.
|
||||||
|
|
||||||
|
Public office state should remain simple:
|
||||||
|
|
||||||
|
- `focused`
|
||||||
|
- `working`
|
||||||
|
- `blocked`
|
||||||
|
- `overloaded`
|
||||||
|
- `degraded`
|
||||||
|
|
||||||
|
Internally, a custom stack may derive those from richer signals such as:
|
||||||
|
|
||||||
|
- route stress
|
||||||
|
- worker saturation
|
||||||
|
- runtime policy
|
||||||
|
- confidence or control signals
|
||||||
|
- proprietary orchestration heuristics
|
||||||
|
|
||||||
|
That should remain implementation-private.
|
||||||
|
|
||||||
|
Claw3D only needs the resulting office-safe state and maybe a public reason label.
|
||||||
|
|
||||||
|
## Relationship To Office Systems
|
||||||
|
|
||||||
|
The custom provider should support office systems without Claw3D needing to know the backend's internals.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- bulletin board gets agent/session/task context
|
||||||
|
- whiteboard gets planning-session context
|
||||||
|
- meetings get coordination state
|
||||||
|
- QA gets review or readiness metadata
|
||||||
|
|
||||||
|
Again, the provider should expose only what the office needs.
|
||||||
|
|
||||||
|
## Suggested V1 Scope
|
||||||
|
|
||||||
|
Recommended first `custom` provider scope:
|
||||||
|
|
||||||
|
1. Reach the custom orchestrator over `/v1/chat/completions`.
|
||||||
|
2. Pull metadata from `/health`, `/state`, and `/registry`.
|
||||||
|
3. Map runtime roles or routes into Claw3D agent identities.
|
||||||
|
4. Surface streaming and final turns.
|
||||||
|
5. Expose route metadata in a diagnostics-friendly way.
|
||||||
|
6. Map simple public office states from observable runtime conditions.
|
||||||
|
|
||||||
|
This is enough to make a custom runtime useful in Claw3D without overexposing internals.
|
||||||
|
|
||||||
|
## Suggested V2 Scope
|
||||||
|
|
||||||
|
Once the V1 provider is stable, add:
|
||||||
|
|
||||||
|
- richer session persistence
|
||||||
|
- role-aware office teams
|
||||||
|
- custom runtime diagnostics panel
|
||||||
|
- office state enrichment from internal stack signals
|
||||||
|
- hooks into bulletin board, whiteboard, QA, and meeting systems
|
||||||
|
|
||||||
|
## Explicit Non-Goals
|
||||||
|
|
||||||
|
This reference should not require:
|
||||||
|
|
||||||
|
- runtime-specific branches throughout Claw3D core
|
||||||
|
- OpenClaw compatibility shims
|
||||||
|
- routing logic duplicated in the frontend
|
||||||
|
- direct worker orchestration in the browser
|
||||||
|
|
||||||
|
The provider should remain the containment layer.
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
# Custom Runtime Provider Spec
|
||||||
|
|
||||||
|
> Generic extension seam for non-OpenClaw, non-Hermes stacks.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Define a clean `custom` runtime provider class for Claw3D.
|
||||||
|
|
||||||
|
This provider should let external orchestration stacks integrate with Claw3D through a stable seam without requiring:
|
||||||
|
|
||||||
|
- OpenClaw emulation
|
||||||
|
- Hermes-specific semantics
|
||||||
|
- named first-class core support for every stack
|
||||||
|
|
||||||
|
The idea is:
|
||||||
|
|
||||||
|
- upstream concept: `custom` provider
|
||||||
|
- downstream implementations provide their own runtime behavior against that seam
|
||||||
|
|
||||||
|
## Current Branch Status
|
||||||
|
|
||||||
|
On `dev/vera_lane`, the `custom` provider is no longer just a design
|
||||||
|
placeholder.
|
||||||
|
|
||||||
|
Current implemented behavior:
|
||||||
|
|
||||||
|
- `custom` is a first-class provider ID in the runtime seam
|
||||||
|
- Studio persists the selected backend mode as `custom`
|
||||||
|
- the provider exposes runtime metadata such as `runtimeName`, `vendor`,
|
||||||
|
`runtimeVersion`, and `routeProfile` when available
|
||||||
|
- Claw3D probes `GET /health`, `GET /state`, and `GET /registry`
|
||||||
|
- chat uses a direct HTTP path to `POST /v1/chat/completions`
|
||||||
|
- browser traffic is proxied through Claw3D's same-origin
|
||||||
|
`/api/runtime/custom` route instead of calling the runtime directly
|
||||||
|
|
||||||
|
Still intentionally missing in this branch:
|
||||||
|
|
||||||
|
- normalized streaming event support
|
||||||
|
- richer session persistence beyond the synthetic provider session layer
|
||||||
|
- direct approvals/files/cron surfaces
|
||||||
|
- process auto-launch from Studio
|
||||||
|
|
||||||
|
## Why This Matters
|
||||||
|
|
||||||
|
Not every useful runtime should have to become a named built-in provider in upstream Claw3D.
|
||||||
|
|
||||||
|
A clean custom provider seam gives:
|
||||||
|
|
||||||
|
- extensibility
|
||||||
|
- lower upstream friction
|
||||||
|
- room for proprietary or stack-specific orchestration
|
||||||
|
- a path for advanced internal systems without polluting core abstractions
|
||||||
|
|
||||||
|
This is especially important when a stack is:
|
||||||
|
|
||||||
|
- internal
|
||||||
|
- organization-specific
|
||||||
|
- experimental
|
||||||
|
- orchestration-heavy
|
||||||
|
|
||||||
|
## Product Position
|
||||||
|
|
||||||
|
The `custom` provider should be treated as:
|
||||||
|
|
||||||
|
- a first-class extension seam
|
||||||
|
- not a hack
|
||||||
|
- not a vendor-specific side path
|
||||||
|
|
||||||
|
This is the upstream-safe abstraction.
|
||||||
|
|
||||||
|
## Relationship To Existing Provider Work
|
||||||
|
|
||||||
|
Claw3D’s runtime abstraction already points toward multiple providers:
|
||||||
|
|
||||||
|
- `openclaw`
|
||||||
|
- `hermes`
|
||||||
|
- future providers
|
||||||
|
|
||||||
|
The `custom` provider should sit alongside those as a generic lane for:
|
||||||
|
|
||||||
|
- external orchestrators
|
||||||
|
- private agent stacks
|
||||||
|
- organization-specific routing systems
|
||||||
|
|
||||||
|
## Core Principle
|
||||||
|
|
||||||
|
Do not leak implementation-specific internal logic into the generic provider contract.
|
||||||
|
|
||||||
|
The provider should expose:
|
||||||
|
|
||||||
|
- common runtime behaviors
|
||||||
|
- provider capabilities
|
||||||
|
- normalized events
|
||||||
|
- optional metadata
|
||||||
|
|
||||||
|
It should not require upstream Claw3D to know:
|
||||||
|
|
||||||
|
- runtime-specific routing internals
|
||||||
|
- proprietary state logic
|
||||||
|
- private planning models
|
||||||
|
- stack-specific heuristics
|
||||||
|
|
||||||
|
## Provider Identity
|
||||||
|
|
||||||
|
Suggested provider IDs:
|
||||||
|
|
||||||
|
- `openclaw`
|
||||||
|
- `hermes`
|
||||||
|
- `custom`
|
||||||
|
|
||||||
|
Then allow custom metadata such as:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type CustomRuntimeDescriptor = {
|
||||||
|
providerId: "custom";
|
||||||
|
runtimeName: string;
|
||||||
|
runtimeVersion?: string | null;
|
||||||
|
vendor?: string | null;
|
||||||
|
capabilities?: string[];
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
This gives Claw3D enough identity for UI without hardcoding a brand into the provider class.
|
||||||
|
|
||||||
|
## Reference Implementation Strategy
|
||||||
|
|
||||||
|
The `custom` provider should exist as a generic extension seam.
|
||||||
|
|
||||||
|
That means:
|
||||||
|
|
||||||
|
- upstream Claw3D gets `custom`
|
||||||
|
- downstream stacks supply their own behavior
|
||||||
|
- others can later implement their own custom providers against the same seam
|
||||||
|
|
||||||
|
That is much easier to justify upstream than:
|
||||||
|
|
||||||
|
- adding a deeply stack-specific built-in provider before the generic extension seam exists
|
||||||
|
|
||||||
|
## Suggested Contract Shape
|
||||||
|
|
||||||
|
This should align with the existing runtime abstraction, but allow custom metadata.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type RuntimeProviderId = "openclaw" | "hermes" | "custom";
|
||||||
|
|
||||||
|
type RuntimeProviderMetadata = {
|
||||||
|
id: RuntimeProviderId;
|
||||||
|
label: string;
|
||||||
|
runtimeName?: string | null;
|
||||||
|
runtimeVersion?: string | null;
|
||||||
|
vendor?: string | null;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
The `custom` provider should still implement the same base runtime methods:
|
||||||
|
|
||||||
|
- list agents
|
||||||
|
- list sessions
|
||||||
|
- send chat
|
||||||
|
- abort run
|
||||||
|
- wait for run
|
||||||
|
- stream normalized events
|
||||||
|
- expose capabilities honestly
|
||||||
|
|
||||||
|
## Capability Philosophy
|
||||||
|
|
||||||
|
The custom provider should be capability-driven, not assumption-driven.
|
||||||
|
|
||||||
|
That means if a custom stack supports:
|
||||||
|
|
||||||
|
- roles
|
||||||
|
- approvals
|
||||||
|
- files
|
||||||
|
- cron
|
||||||
|
- whiteboard integration
|
||||||
|
- meeting signals
|
||||||
|
|
||||||
|
it should declare those clearly.
|
||||||
|
|
||||||
|
If it does not, the UI should degrade honestly.
|
||||||
|
|
||||||
|
## Event Model
|
||||||
|
|
||||||
|
The custom provider should emit the same normalized event categories as other providers.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- `presence.changed`
|
||||||
|
- `session.activity`
|
||||||
|
- `chat.delta`
|
||||||
|
- `chat.final`
|
||||||
|
- `chat.error`
|
||||||
|
- `run.lifecycle`
|
||||||
|
- `tool.progress`
|
||||||
|
|
||||||
|
This keeps Claw3D stable even if the custom stack has richer private event semantics internally.
|
||||||
|
|
||||||
|
## Custom Metadata Surface
|
||||||
|
|
||||||
|
The custom provider may optionally expose richer metadata for display.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- routed lane
|
||||||
|
- active model id
|
||||||
|
- strategy label
|
||||||
|
- execution mode
|
||||||
|
- custom stack status
|
||||||
|
|
||||||
|
Important:
|
||||||
|
|
||||||
|
This metadata should be additive and optional.
|
||||||
|
|
||||||
|
It should not change the core runtime contract.
|
||||||
|
|
||||||
|
## Reference Implementation Model
|
||||||
|
|
||||||
|
A custom provider can reasonably map:
|
||||||
|
|
||||||
|
- agents -> routed roles / lanes
|
||||||
|
- sessions -> runtime conversations
|
||||||
|
- streaming -> orchestrator or gateway output streams
|
||||||
|
- provider metadata -> runtime name, lane, model, or route state
|
||||||
|
|
||||||
|
The public upstream concept remains `custom`.
|
||||||
|
|
||||||
|
Implementation-specific mapping stays in the runtime layer.
|
||||||
|
|
||||||
|
## Relationship To Agent State Model
|
||||||
|
|
||||||
|
The custom provider is the right place for richer internal state to enter Claw3D.
|
||||||
|
|
||||||
|
For example:
|
||||||
|
|
||||||
|
- a custom runtime computes deeper control or state signals
|
||||||
|
- the provider maps them into public office states
|
||||||
|
|
||||||
|
This keeps:
|
||||||
|
|
||||||
|
- internal intelligence private
|
||||||
|
- public office state understandable
|
||||||
|
|
||||||
|
## Relationship To Office Systems
|
||||||
|
|
||||||
|
The custom provider should be able to support office systems without Claw3D needing to know the backend’s internals.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- bulletin board gets agent/session/task context
|
||||||
|
- whiteboard gets planning-session context
|
||||||
|
- meetings get coordination state
|
||||||
|
- QA gets review or readiness metadata
|
||||||
|
|
||||||
|
Again, the custom provider should expose only what the office needs.
|
||||||
|
|
||||||
|
## V1 Scope
|
||||||
|
|
||||||
|
Recommended V1 scope:
|
||||||
|
|
||||||
|
- define `custom` provider identity
|
||||||
|
- allow custom provider metadata
|
||||||
|
- ensure capability-driven UI behavior
|
||||||
|
- make no runtime-specific assumptions in core Claw3D
|
||||||
|
|
||||||
|
Actual runtime adapters can be implemented separately against that seam.
|
||||||
|
|
||||||
|
## Out of Scope For V1
|
||||||
|
|
||||||
|
- embedding proprietary internal orchestration logic in Claw3D core
|
||||||
|
- hardcoding any one runtime as upstream architecture
|
||||||
|
- requiring all custom providers to support advanced signals
|
||||||
|
|
||||||
|
The point is generic extensibility first.
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
Recommended order:
|
||||||
|
|
||||||
|
1. Add `custom` as a first-class runtime provider identity.
|
||||||
|
2. Add a metadata surface for runtime name/vendor/version.
|
||||||
|
3. Ensure the provider factory supports `custom`.
|
||||||
|
4. Keep the runtime event and capability model normalized.
|
||||||
|
5. Implement Vera as the first reference adapter outside the generic contract.
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
This spec is successful if:
|
||||||
|
|
||||||
|
- upstream Claw3D gains a clean extension seam
|
||||||
|
- Vera can integrate without bloating core architecture
|
||||||
|
- future stacks can follow the same pattern
|
||||||
|
- the office systems continue to work against normalized runtime behavior
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The `custom` runtime provider is the right upstream abstraction for stack-specific orchestrators.
|
||||||
|
|
||||||
|
It gives Claw3D extensibility, gives Vera a clean path in, and avoids hardwiring a personal stack directly into the core product model.
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
# Universal Backend Plan
|
||||||
|
|
||||||
|
> Backend-neutral Claw3D integration plan for OpenClaw, Hermes, Vera, and other runtimes.
|
||||||
|
|
||||||
|
## Recommendation
|
||||||
|
|
||||||
|
Do not treat PR #70 as the long-term integration architecture.
|
||||||
|
|
||||||
|
It is useful as a short-term compatibility shim and a source of a few good UX changes, but it does not make Claw3D backend-neutral. It keeps Claw3D OpenClaw-shaped and makes Hermes imitate OpenClaw.
|
||||||
|
|
||||||
|
That matters because:
|
||||||
|
|
||||||
|
- Hermes already has real control surfaces: ACP and an OpenAI-compatible API server.
|
||||||
|
- Vera already has a real orchestrator/gateway shape.
|
||||||
|
- Every future backend would otherwise need to keep emulating the OpenClaw gateway protocol.
|
||||||
|
|
||||||
|
The better path is:
|
||||||
|
|
||||||
|
1. Keep OpenClaw support intact.
|
||||||
|
2. Extract a backend-neutral runtime adapter inside Claw3D.
|
||||||
|
3. Add Hermes and Vera providers against their native surfaces where possible.
|
||||||
|
4. Cherry-pick the high-value UI pieces from PR #70 into that new architecture.
|
||||||
|
|
||||||
|
## What To Reuse From PR #70
|
||||||
|
|
||||||
|
These are worth keeping:
|
||||||
|
|
||||||
|
- Multi-agent UX concepts.
|
||||||
|
- `read_agent_context` as a coordination primitive.
|
||||||
|
- Agent `role` flowing into the 3D office nameplate.
|
||||||
|
- Click-to-chat behavior.
|
||||||
|
- Live speech bubble rendering for streaming text.
|
||||||
|
- Hermes-specific env var documentation.
|
||||||
|
|
||||||
|
These are not the right long-term seam:
|
||||||
|
|
||||||
|
- A full OpenClaw-protocol emulator as the primary Hermes integration.
|
||||||
|
- Fake-success implementations for `config.*` and approvals.
|
||||||
|
- Synthesizing runtime freshness from `Date.now()` instead of real event/message timestamps.
|
||||||
|
|
||||||
|
## Target Architecture
|
||||||
|
|
||||||
|
Claw3D should stop treating the browser gateway client as the backend abstraction.
|
||||||
|
|
||||||
|
Instead, Studio should expose a backend-neutral runtime service with provider adapters:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Browser UI
|
||||||
|
-> Studio runtime API
|
||||||
|
-> OpenClaw provider
|
||||||
|
-> Hermes provider
|
||||||
|
-> Vera provider
|
||||||
|
```
|
||||||
|
|
||||||
|
The browser can still use WebSocket streaming from Studio, but the messages should be Claw3D-native runtime events rather than implicitly OpenClaw events.
|
||||||
|
|
||||||
|
## Core Adapter Contract
|
||||||
|
|
||||||
|
Suggested TypeScript shape:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type RuntimeCapability =
|
||||||
|
| "agents"
|
||||||
|
| "sessions"
|
||||||
|
| "chat"
|
||||||
|
| "streaming"
|
||||||
|
| "agent_roles"
|
||||||
|
| "files"
|
||||||
|
| "skills"
|
||||||
|
| "cron"
|
||||||
|
| "approvals"
|
||||||
|
| "config"
|
||||||
|
| "session_settings";
|
||||||
|
|
||||||
|
export type RuntimeEvent =
|
||||||
|
| { type: "presence.changed"; agents: RuntimeAgentSummary[] }
|
||||||
|
| { type: "session.activity"; sessionKey: string; agentId: string; at: number }
|
||||||
|
| { type: "chat.delta"; runId: string; sessionKey: string; text: string; at: number }
|
||||||
|
| { type: "chat.final"; runId: string; sessionKey: string; text: string; at: number }
|
||||||
|
| { type: "chat.error"; runId: string; sessionKey: string; message: string; at: number }
|
||||||
|
| { type: "run.lifecycle"; runId: string; sessionKey: string; phase: "start" | "end" | "error"; at: number }
|
||||||
|
| { type: "tool.progress"; runId: string; sessionKey: string; label: string; at: number };
|
||||||
|
|
||||||
|
export interface RuntimeProvider {
|
||||||
|
readonly id: string;
|
||||||
|
readonly label: string;
|
||||||
|
getCapabilities(): Promise<Set<RuntimeCapability>>;
|
||||||
|
connect(): Promise<void>;
|
||||||
|
disconnect(): Promise<void>;
|
||||||
|
subscribe(listener: (event: RuntimeEvent) => void): () => void;
|
||||||
|
listAgents(): Promise<RuntimeAgentSummary[]>;
|
||||||
|
listSessions(input?: { agentId?: string }): Promise<RuntimeSessionSummary[]>;
|
||||||
|
getSessionPreview(keys: string[]): Promise<RuntimeSessionPreview[]>;
|
||||||
|
sendChat(input: { sessionKey: string; message: string; agentId?: string }): Promise<{ runId: string }>;
|
||||||
|
abortRun(input: { runId?: string; sessionKey?: string }): Promise<void>;
|
||||||
|
waitForRun(input: { runId: string; timeoutMs?: number }): Promise<"running" | "done">;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional features such as config editing, approvals, files, skills, and cron should sit behind capability checks instead of being assumed to exist.
|
||||||
|
|
||||||
|
## Capability Matrix
|
||||||
|
|
||||||
|
Initial expected support:
|
||||||
|
|
||||||
|
| Capability | OpenClaw | Hermes | Vera |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Agents | Native | Native | Provider-defined |
|
||||||
|
| Sessions | Native | Native | Provider-defined |
|
||||||
|
| Chat send/abort/wait | Native | Native | Native via orchestrator |
|
||||||
|
| Streaming | Native | Native | Native |
|
||||||
|
| Agent roles | Native-ish | Native | Native |
|
||||||
|
| Files | Native | Partial | Optional |
|
||||||
|
| Skills | Native | Native | Optional |
|
||||||
|
| Cron | Native | Native | Optional |
|
||||||
|
| Approvals | Native | Partial | Optional |
|
||||||
|
| Config mutation | Native | Limited | Limited |
|
||||||
|
|
||||||
|
Important rule:
|
||||||
|
|
||||||
|
If a provider does not support a surface, Claw3D should disable or hide the UI for it. It should not fake a successful write.
|
||||||
|
|
||||||
|
## Provider Strategy
|
||||||
|
|
||||||
|
### OpenClaw Provider
|
||||||
|
|
||||||
|
Use the existing gateway client as the first provider implementation.
|
||||||
|
|
||||||
|
This keeps current behavior working while the rest of the app migrates to the adapter contract.
|
||||||
|
|
||||||
|
### Hermes Provider
|
||||||
|
|
||||||
|
Preferred order:
|
||||||
|
|
||||||
|
1. ACP for session-aware agent orchestration.
|
||||||
|
2. Hermes API server for OpenAI-compatible chat and streaming.
|
||||||
|
3. OpenClaw-protocol shim only as a temporary bridge.
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
|
||||||
|
- ACP is a better semantic fit for sessions, cancellation, fork/resume, approvals, and editor-style state.
|
||||||
|
- The Hermes API server is already stable and useful for chat, tool calling, and cron-backed service behavior.
|
||||||
|
- The OpenClaw shim should be treated as transitional compatibility, not the permanent contract.
|
||||||
|
|
||||||
|
### Vera Provider
|
||||||
|
|
||||||
|
Target the Vera orchestrator, not individual `vera-torch` workers.
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
- `POST /v1/chat/completions`
|
||||||
|
- `POST /v1/completions`
|
||||||
|
- `POST /v1/contracted-completions`
|
||||||
|
- `GET /health`
|
||||||
|
- `GET /state`
|
||||||
|
- `GET /registry`
|
||||||
|
|
||||||
|
The Vera provider should map Claw3D agent identities to routed roles or lanes rather than pretending Vera is an OpenClaw gateway.
|
||||||
|
|
||||||
|
## Event Model
|
||||||
|
|
||||||
|
Current Claw3D expects OpenClaw-flavored `chat`, `agent`, and `presence` events.
|
||||||
|
|
||||||
|
That is too narrow for universal providers. Studio should normalize provider-native updates into a Claw3D event model with explicit semantics:
|
||||||
|
|
||||||
|
- `presence.changed`
|
||||||
|
- `session.activity`
|
||||||
|
- `chat.delta`
|
||||||
|
- `chat.final`
|
||||||
|
- `chat.error`
|
||||||
|
- `run.lifecycle`
|
||||||
|
- `tool.progress`
|
||||||
|
|
||||||
|
Then the browser UI can consume one stable event shape no matter what backend is in use.
|
||||||
|
|
||||||
|
## High-Value PR Split
|
||||||
|
|
||||||
|
Recommended implementation order:
|
||||||
|
|
||||||
|
### PR 1: Runtime Abstraction
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
|
||||||
|
- Introduce the provider interface.
|
||||||
|
- Wrap current OpenClaw behavior in an `openclaw` provider.
|
||||||
|
- Move capability checks into the UI state layer.
|
||||||
|
- Add a Studio-level runtime event normalization layer.
|
||||||
|
|
||||||
|
This is the most important PR.
|
||||||
|
|
||||||
|
### PR 2: Safe UX Cherry-Picks From PR #70
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
|
||||||
|
- Agent `role` in store and office UI.
|
||||||
|
- Click-to-chat.
|
||||||
|
- Streaming speech bubbles.
|
||||||
|
|
||||||
|
These are good product improvements and do not require committing to the Hermes shim architecture.
|
||||||
|
|
||||||
|
### PR 3: Hermes Native Provider
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
|
||||||
|
- Add a `hermes` provider using ACP where possible.
|
||||||
|
- Use Hermes API server for chat/streaming surfaces.
|
||||||
|
- Expose capabilities honestly.
|
||||||
|
- Persist and surface real timestamps from Hermes session/message state.
|
||||||
|
|
||||||
|
Keep the shim optional for compatibility, not required.
|
||||||
|
|
||||||
|
### PR 4: Vera Provider
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
|
||||||
|
- Add a `vera` provider against the Vera orchestrator.
|
||||||
|
- Map Claw3D agents to Vera roles or lanes.
|
||||||
|
- Surface orchestrator state and routed worker identity.
|
||||||
|
|
||||||
|
### PR 5: Optional Compatibility Layer Cleanup
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
|
||||||
|
- Retire or reduce the Hermes OpenClaw shim.
|
||||||
|
- Convert shim-only routes into provider-native routes where possible.
|
||||||
|
|
||||||
|
## Near-Term Guidance For Luke
|
||||||
|
|
||||||
|
If Luke wants "drop-in Hermes support right now", PR #70 is directionally useful.
|
||||||
|
|
||||||
|
If Luke wants "Claw3D should support any backend cleanly", PR #70 should not be the mainline architecture.
|
||||||
|
|
||||||
|
Best compromise:
|
||||||
|
|
||||||
|
- Do not merge PR #70 as the final backend architecture.
|
||||||
|
- Split out the UI improvements and any safe Hermes-specific pieces.
|
||||||
|
- Open a new architecture PR for the runtime provider seam.
|
||||||
|
- Rebase Hermes integration on top of that seam.
|
||||||
|
|
||||||
|
## Why This Also Helps Vera
|
||||||
|
|
||||||
|
This path avoids making Vera imitate OpenClaw.
|
||||||
|
|
||||||
|
Instead, Vera can appear as:
|
||||||
|
|
||||||
|
- a routed multi-role intelligence backend,
|
||||||
|
- with Claw3D visualizing agents, runs, status, and streamed text,
|
||||||
|
- while preserving Vera-specific routing, lane, and model identity.
|
||||||
|
|
||||||
|
That gives Claw3D a broader identity:
|
||||||
|
|
||||||
|
- similar to the OpenClaw ecosystem,
|
||||||
|
- but not subordinate to OpenClaw's protocol and assumptions.
|
||||||
|
|
||||||
|
## Proposed First Deliverable
|
||||||
|
|
||||||
|
The first concrete deliverable should be a new PR that does only this:
|
||||||
|
|
||||||
|
- add the provider interface,
|
||||||
|
- wrap existing OpenClaw integration behind it,
|
||||||
|
- add capability flags,
|
||||||
|
- make the UI stop assuming config/approval/file support from every backend.
|
||||||
|
|
||||||
|
That PR creates the seam both Hermes and Vera need.
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
# Bulletin Board Spec
|
||||||
|
|
||||||
|
> First concrete office-system feature for Claw3D.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add a shared bulletin board inside the office that acts as the visible coordination surface for:
|
||||||
|
|
||||||
|
- goals
|
||||||
|
- announcements
|
||||||
|
- blockers
|
||||||
|
- handoff notes
|
||||||
|
- standup outcomes
|
||||||
|
- lightweight task cards
|
||||||
|
|
||||||
|
This should be the first step toward making Claw3D a real agent operations environment instead of only a gateway visualizer.
|
||||||
|
|
||||||
|
## Product Position
|
||||||
|
|
||||||
|
The bulletin board is not a replacement for the existing task board or Kanban views.
|
||||||
|
|
||||||
|
It is the office-native layer above them.
|
||||||
|
|
||||||
|
Think of it as:
|
||||||
|
|
||||||
|
- the most important things the office should see right now
|
||||||
|
- the shared memory wall
|
||||||
|
- the in-world coordination surface
|
||||||
|
|
||||||
|
## Why This Feature First
|
||||||
|
|
||||||
|
This is the best first office-system feature because it is:
|
||||||
|
|
||||||
|
- easy to understand
|
||||||
|
- visually natural in the office
|
||||||
|
- useful even before deeper simulation systems exist
|
||||||
|
- compatible with all backends
|
||||||
|
- able to reuse existing task and standup signals
|
||||||
|
|
||||||
|
It also creates a clean landing zone for future systems:
|
||||||
|
|
||||||
|
- whiteboards
|
||||||
|
- meeting summaries
|
||||||
|
- QA queues
|
||||||
|
- hierarchy / department routing
|
||||||
|
- shared office memory
|
||||||
|
|
||||||
|
## Primary Use Cases
|
||||||
|
|
||||||
|
### Shared Goals
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- "Ship Hermes adapter support"
|
||||||
|
- "Fix production bug in standup flow"
|
||||||
|
- "Prepare Friday release review"
|
||||||
|
|
||||||
|
### Announcements
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- "Hermes provider smoke test passed"
|
||||||
|
- "Build is blocked on QA"
|
||||||
|
- "Meeting starts in 5 minutes"
|
||||||
|
|
||||||
|
### Blockers
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- "Gateway auth broken on staging"
|
||||||
|
- "Agent Alice waiting on review"
|
||||||
|
- "No provider token configured"
|
||||||
|
|
||||||
|
### Handoffs
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- "Backend done, hand off to QA"
|
||||||
|
- "Needs design signoff"
|
||||||
|
- "Waiting for owner approval"
|
||||||
|
|
||||||
|
### Meeting Output
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- standup summary
|
||||||
|
- decisions made
|
||||||
|
- next actions
|
||||||
|
- active speaker queue
|
||||||
|
|
||||||
|
## V1 Scope
|
||||||
|
|
||||||
|
V1 should stay intentionally small.
|
||||||
|
|
||||||
|
The board should support a few card types and simple interactions, not a full project-management suite.
|
||||||
|
|
||||||
|
### Card Types
|
||||||
|
|
||||||
|
Initial types:
|
||||||
|
|
||||||
|
- `goal`
|
||||||
|
- `announcement`
|
||||||
|
- `blocker`
|
||||||
|
- `handoff`
|
||||||
|
- `meeting_note`
|
||||||
|
|
||||||
|
### Card Fields
|
||||||
|
|
||||||
|
Minimum shape:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type BulletinBoardCardType =
|
||||||
|
| "goal"
|
||||||
|
| "announcement"
|
||||||
|
| "blocker"
|
||||||
|
| "handoff"
|
||||||
|
| "meeting_note";
|
||||||
|
|
||||||
|
type BulletinBoardCard = {
|
||||||
|
id: string;
|
||||||
|
type: BulletinBoardCardType;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
authorType: "human" | "agent" | "system";
|
||||||
|
authorId?: string | null;
|
||||||
|
authorName?: string | null;
|
||||||
|
agentId?: string | null;
|
||||||
|
sessionKey?: string | null;
|
||||||
|
taskId?: string | null;
|
||||||
|
pinned: boolean;
|
||||||
|
archived: boolean;
|
||||||
|
priority?: "low" | "normal" | "high";
|
||||||
|
tags?: string[];
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Basic Interactions
|
||||||
|
|
||||||
|
V1 interactions:
|
||||||
|
|
||||||
|
- create card
|
||||||
|
- edit card
|
||||||
|
- pin/unpin card
|
||||||
|
- archive/unarchive card
|
||||||
|
- filter by type
|
||||||
|
- filter by author
|
||||||
|
- open linked session or linked agent
|
||||||
|
|
||||||
|
No drag-and-drop lane system is required for V1.
|
||||||
|
|
||||||
|
## Visual Design
|
||||||
|
|
||||||
|
The bulletin board should feel like a wall-mounted coordination surface inside the office.
|
||||||
|
|
||||||
|
Possible visual forms:
|
||||||
|
|
||||||
|
- cork board
|
||||||
|
- notice board
|
||||||
|
- sprint wall
|
||||||
|
- pinboard with index cards / sticky notes
|
||||||
|
|
||||||
|
The in-world object should have:
|
||||||
|
|
||||||
|
- a visible prop in the retro office
|
||||||
|
- a click target
|
||||||
|
- an immersive detail panel when opened
|
||||||
|
|
||||||
|
It should feel distinct from the existing Kanban board.
|
||||||
|
|
||||||
|
Suggested difference:
|
||||||
|
|
||||||
|
- Kanban = detailed task workflow
|
||||||
|
- Bulletin board = office-wide signal surface
|
||||||
|
|
||||||
|
## Information Hierarchy
|
||||||
|
|
||||||
|
At a glance, the board should answer:
|
||||||
|
|
||||||
|
1. What is the office trying to do?
|
||||||
|
2. What is blocked?
|
||||||
|
3. What changed recently?
|
||||||
|
4. What needs a human to notice?
|
||||||
|
|
||||||
|
Recommended layout:
|
||||||
|
|
||||||
|
- pinned cards first
|
||||||
|
- blockers prominently visible
|
||||||
|
- recent announcements grouped together
|
||||||
|
- meeting notes grouped separately
|
||||||
|
|
||||||
|
## Integration Points
|
||||||
|
|
||||||
|
The feature should hook into systems Claw3D already has.
|
||||||
|
|
||||||
|
### Task Board / Kanban
|
||||||
|
|
||||||
|
Use the bulletin board as a summary layer over the task board, not a duplicate.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- show a pinned goal card that links into Kanban
|
||||||
|
- create blocker cards when a task enters a blocked state
|
||||||
|
- create handoff cards when work moves between agents or departments
|
||||||
|
|
||||||
|
### Standup System
|
||||||
|
|
||||||
|
The standup system already exists.
|
||||||
|
|
||||||
|
Use it to populate:
|
||||||
|
|
||||||
|
- current meeting announcement
|
||||||
|
- summary card after standup completes
|
||||||
|
- follow-up note cards for unresolved blockers
|
||||||
|
|
||||||
|
### Agent Sessions
|
||||||
|
|
||||||
|
Cards should be linkable to:
|
||||||
|
|
||||||
|
- an agent
|
||||||
|
- a session key
|
||||||
|
- a run or task where applicable
|
||||||
|
|
||||||
|
That lets the user jump from "office signal" to "underlying conversation or task".
|
||||||
|
|
||||||
|
### Runtime-Neutral Backends
|
||||||
|
|
||||||
|
The board must not depend on OpenClaw-specific methods.
|
||||||
|
|
||||||
|
It should operate off:
|
||||||
|
|
||||||
|
- Claw3D state
|
||||||
|
- local persisted office data
|
||||||
|
- optional provider metadata when available
|
||||||
|
|
||||||
|
That keeps it usable with:
|
||||||
|
|
||||||
|
- OpenClaw
|
||||||
|
- Hermes
|
||||||
|
- Vera
|
||||||
|
- Demo mode
|
||||||
|
|
||||||
|
## Storage Model
|
||||||
|
|
||||||
|
V1 storage should be local office data persisted through the same Studio settings path used by other office preferences.
|
||||||
|
|
||||||
|
Suggested storage location:
|
||||||
|
|
||||||
|
- studio office settings keyed by gateway URL
|
||||||
|
|
||||||
|
Example shape:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type OfficePreference = {
|
||||||
|
bulletinBoard?: {
|
||||||
|
cards: BulletinBoardCard[];
|
||||||
|
updatedAt?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Why:
|
||||||
|
|
||||||
|
- matches existing office preference patterns
|
||||||
|
- backend-neutral
|
||||||
|
- fast to implement
|
||||||
|
- easy to migrate later
|
||||||
|
|
||||||
|
## Authoring Rules
|
||||||
|
|
||||||
|
Cards may be created by:
|
||||||
|
|
||||||
|
- human user
|
||||||
|
- agent action
|
||||||
|
- system automation
|
||||||
|
|
||||||
|
Recommended rules:
|
||||||
|
|
||||||
|
- human cards should always be editable
|
||||||
|
- system cards can be archived but not freely mutated
|
||||||
|
- agent cards should show authorship clearly
|
||||||
|
|
||||||
|
That keeps provenance visible without overcomplicating the model.
|
||||||
|
|
||||||
|
## V1 Automation
|
||||||
|
|
||||||
|
Useful automations to add early:
|
||||||
|
|
||||||
|
- create a meeting note card after standup
|
||||||
|
- create a blocker card from explicit blocked-state flows
|
||||||
|
- create announcement cards for major office events
|
||||||
|
|
||||||
|
Keep automation conservative.
|
||||||
|
|
||||||
|
The board should not flood itself with noise.
|
||||||
|
|
||||||
|
## UI Surfaces
|
||||||
|
|
||||||
|
### In-World Object
|
||||||
|
|
||||||
|
Add a dedicated bulletin board prop to the office layout.
|
||||||
|
|
||||||
|
It should:
|
||||||
|
|
||||||
|
- be visible from the main office floor
|
||||||
|
- support hover/click affordance
|
||||||
|
- open an immersive board panel
|
||||||
|
|
||||||
|
### Sidebar / Panel Access
|
||||||
|
|
||||||
|
Also add a panel entry for cases where the user wants quick access without camera movement.
|
||||||
|
|
||||||
|
Possible placement:
|
||||||
|
|
||||||
|
- HQ sidebar tab
|
||||||
|
- office control panel
|
||||||
|
|
||||||
|
### Agent Interaction
|
||||||
|
|
||||||
|
Optional for V1:
|
||||||
|
|
||||||
|
- agents can approach the board during meetings or handoffs
|
||||||
|
- pinned cards can be reflected in ambient office behavior
|
||||||
|
|
||||||
|
This is useful but not required for first delivery.
|
||||||
|
|
||||||
|
## Out of Scope For V1
|
||||||
|
|
||||||
|
Do not include these initially:
|
||||||
|
|
||||||
|
- full Kanban replacement
|
||||||
|
- freehand drawing
|
||||||
|
- multiplayer collaborative editing
|
||||||
|
- complicated permission lattice
|
||||||
|
- department-specific boards
|
||||||
|
- heavy simulation logic
|
||||||
|
- arbitrary external integrations
|
||||||
|
|
||||||
|
Those belong in later systems.
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
Recommended order:
|
||||||
|
|
||||||
|
1. Define board card types and storage schema.
|
||||||
|
2. Add persisted board data to office settings.
|
||||||
|
3. Add a simple board panel UI.
|
||||||
|
4. Add in-world bulletin board prop and open interaction.
|
||||||
|
5. Connect standup summary output.
|
||||||
|
6. Add simple blocker / announcement automation.
|
||||||
|
|
||||||
|
## Existing Code Seams
|
||||||
|
|
||||||
|
This feature should likely align with:
|
||||||
|
|
||||||
|
- task board state and controller logic in `src/features/office/tasks`
|
||||||
|
- standup flows in `src/features/office/hooks/useOfficeStandupController.ts`
|
||||||
|
- office settings persistence
|
||||||
|
- retro office object interaction in `src/features/retro-office/RetroOffice3D.tsx`
|
||||||
|
- furniture/object definitions in `src/features/retro-office/objects`
|
||||||
|
|
||||||
|
This is intentional.
|
||||||
|
|
||||||
|
The bulletin board should reuse existing office mechanics where possible.
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
V1 is successful if:
|
||||||
|
|
||||||
|
- the user can open the board from inside the office
|
||||||
|
- the board shows office-relevant cards, not just generic notes
|
||||||
|
- standup or blocker information can appear on the board
|
||||||
|
- cards can link back into agents/sessions/tasks
|
||||||
|
- the system works with Hermes, OpenClaw, and demo mode
|
||||||
|
|
||||||
|
## Future Extensions
|
||||||
|
|
||||||
|
Once V1 is stable, this can grow into:
|
||||||
|
|
||||||
|
- department boards
|
||||||
|
- QA wall
|
||||||
|
- release wall
|
||||||
|
- meeting room whiteboard handoff
|
||||||
|
- agent-authored summaries
|
||||||
|
- office-wide historical archive
|
||||||
|
- team-specific bulletin surfaces
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The bulletin board should become the first shared memory surface inside Claw3D.
|
||||||
|
|
||||||
|
It is the clearest next step toward making the office itself the product.
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
# Desk Progression Spec
|
||||||
|
|
||||||
|
> Fifth concrete office-system feature for Claw3D, connecting visible office presence to role maturity, permissions, and capability growth.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add a desk progression system so agents visibly grow from limited office members into more capable contributors.
|
||||||
|
|
||||||
|
Desk progression should connect:
|
||||||
|
|
||||||
|
- role maturity
|
||||||
|
- workspace/tool access
|
||||||
|
- permissions
|
||||||
|
- office identity
|
||||||
|
- visible progression in the environment
|
||||||
|
|
||||||
|
The goal is not just cosmetics.
|
||||||
|
|
||||||
|
The goal is to make office growth legible and meaningful.
|
||||||
|
|
||||||
|
## Product Position
|
||||||
|
|
||||||
|
Desk progression should be the physical expression of organizational state.
|
||||||
|
|
||||||
|
It answers questions like:
|
||||||
|
|
||||||
|
- is this agent an intern or a fully trusted contributor?
|
||||||
|
- what tools can they use?
|
||||||
|
- how much autonomy do they have?
|
||||||
|
- how much context or responsibility should they carry?
|
||||||
|
|
||||||
|
In other words:
|
||||||
|
|
||||||
|
- desk progression = visible capability ladder
|
||||||
|
|
||||||
|
## Why This Feature Matters
|
||||||
|
|
||||||
|
Without progression, all agents tend to feel flat.
|
||||||
|
|
||||||
|
Desk progression creates:
|
||||||
|
|
||||||
|
- visible hierarchy without requiring a complex org chart first
|
||||||
|
- a natural path for permissions and access
|
||||||
|
- stronger office storytelling
|
||||||
|
- motivation for role specialization and promotion systems later
|
||||||
|
|
||||||
|
It also gives you a much cleaner bridge between:
|
||||||
|
|
||||||
|
- abstract policy
|
||||||
|
- physical office layout
|
||||||
|
- agent identity
|
||||||
|
|
||||||
|
## Core Principle
|
||||||
|
|
||||||
|
Do not start with fake game stats.
|
||||||
|
|
||||||
|
Start with operational capability tiers that can later gain more playful flavor.
|
||||||
|
|
||||||
|
That means progression should first affect:
|
||||||
|
|
||||||
|
- tool access
|
||||||
|
- workspace access
|
||||||
|
- review requirements
|
||||||
|
- ability to spawn/delegate
|
||||||
|
- context budget or workload tolerance
|
||||||
|
|
||||||
|
The visual office layer should reflect those operational differences.
|
||||||
|
|
||||||
|
## Example Role Ladder
|
||||||
|
|
||||||
|
Recommended initial tiers:
|
||||||
|
|
||||||
|
- `intern`
|
||||||
|
- `probation`
|
||||||
|
- `employee`
|
||||||
|
- `senior`
|
||||||
|
- `lead`
|
||||||
|
- `contractor`
|
||||||
|
|
||||||
|
These are examples, not hard-coded lore.
|
||||||
|
|
||||||
|
### Intern
|
||||||
|
|
||||||
|
Characteristics:
|
||||||
|
|
||||||
|
- limited tools
|
||||||
|
- limited workspace access
|
||||||
|
- small or shared desk
|
||||||
|
- requires close oversight
|
||||||
|
|
||||||
|
### Probation
|
||||||
|
|
||||||
|
Characteristics:
|
||||||
|
|
||||||
|
- basic desk
|
||||||
|
- restricted autonomy
|
||||||
|
- still under review for sensitive actions
|
||||||
|
|
||||||
|
### Employee
|
||||||
|
|
||||||
|
Characteristics:
|
||||||
|
|
||||||
|
- normal desk
|
||||||
|
- normal task ownership
|
||||||
|
- standard office access
|
||||||
|
|
||||||
|
### Senior
|
||||||
|
|
||||||
|
Characteristics:
|
||||||
|
|
||||||
|
- stronger autonomy
|
||||||
|
- wider task scope
|
||||||
|
- can mentor or review others
|
||||||
|
|
||||||
|
### Lead
|
||||||
|
|
||||||
|
Characteristics:
|
||||||
|
|
||||||
|
- can coordinate others
|
||||||
|
- can trigger certain meetings
|
||||||
|
- can manage or route work more broadly
|
||||||
|
|
||||||
|
### Contractor
|
||||||
|
|
||||||
|
Characteristics:
|
||||||
|
|
||||||
|
- useful specialist
|
||||||
|
- limited long-term authority
|
||||||
|
- constrained workspace and access model
|
||||||
|
|
||||||
|
## Suggested Capability Model
|
||||||
|
|
||||||
|
V1 should describe progression in terms of clear capability flags.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type DeskTier =
|
||||||
|
| "intern"
|
||||||
|
| "probation"
|
||||||
|
| "employee"
|
||||||
|
| "senior"
|
||||||
|
| "lead"
|
||||||
|
| "contractor";
|
||||||
|
|
||||||
|
type DeskCapabilityProfile = {
|
||||||
|
tier: DeskTier;
|
||||||
|
canUseFileTools: boolean;
|
||||||
|
canUseWebTools: boolean;
|
||||||
|
canInstallSkills: boolean;
|
||||||
|
canRequestApprovalsDirectly: boolean;
|
||||||
|
canReviewOthers: boolean;
|
||||||
|
canTriggerMeetings: boolean;
|
||||||
|
canCreateTasks: boolean;
|
||||||
|
canDelegateTasks: boolean;
|
||||||
|
workspaceAccess: "none" | "limited" | "standard" | "extended";
|
||||||
|
contextBudgetClass: "small" | "normal" | "large";
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
The exact values can evolve, but the idea should remain:
|
||||||
|
|
||||||
|
- tier drives visible access differences
|
||||||
|
|
||||||
|
## Visual Expression
|
||||||
|
|
||||||
|
Each tier should map to a clear desk/environment feel.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
### Intern Desk
|
||||||
|
|
||||||
|
- minimal desk
|
||||||
|
- no dedicated computer or weaker setup
|
||||||
|
- fewer personal objects
|
||||||
|
- close to a shared area or support station
|
||||||
|
|
||||||
|
### Probation Desk
|
||||||
|
|
||||||
|
- basic computer
|
||||||
|
- little customization
|
||||||
|
- modest footprint
|
||||||
|
|
||||||
|
### Employee Desk
|
||||||
|
|
||||||
|
- normal workstation
|
||||||
|
- standard office setup
|
||||||
|
- stable identity in the room
|
||||||
|
|
||||||
|
### Senior Desk
|
||||||
|
|
||||||
|
- expanded desk
|
||||||
|
- more equipment / screens / references
|
||||||
|
- visually established presence
|
||||||
|
|
||||||
|
### Lead Desk
|
||||||
|
|
||||||
|
- premium workstation
|
||||||
|
- visibility within the office
|
||||||
|
- closer proximity to planning or meeting surfaces
|
||||||
|
|
||||||
|
### Contractor Desk
|
||||||
|
|
||||||
|
- temporary station
|
||||||
|
- portable or isolated feel
|
||||||
|
- clearly functional but not deeply embedded
|
||||||
|
|
||||||
|
## Relationship To Existing Systems
|
||||||
|
|
||||||
|
Desk progression should integrate with real Claw3D systems rather than sit beside them.
|
||||||
|
|
||||||
|
### Permissions
|
||||||
|
|
||||||
|
Claw3D already has permission and approval surfaces.
|
||||||
|
|
||||||
|
Desk progression should act as a higher-level office policy layer that influences:
|
||||||
|
|
||||||
|
- what defaults an agent gets
|
||||||
|
- whether sensitive actions need review
|
||||||
|
- what tools or flows are emphasized
|
||||||
|
|
||||||
|
Important:
|
||||||
|
|
||||||
|
This does not need to replace existing permission logic.
|
||||||
|
|
||||||
|
It should help explain and structure it.
|
||||||
|
|
||||||
|
### Workspace Access
|
||||||
|
|
||||||
|
Agents already have real workspaces.
|
||||||
|
|
||||||
|
Desk progression should help determine:
|
||||||
|
|
||||||
|
- how much workspace freedom an agent gets
|
||||||
|
- whether they operate in restricted or normal modes
|
||||||
|
- whether some installs or edits require higher tiers
|
||||||
|
|
||||||
|
### QA Department
|
||||||
|
|
||||||
|
More mature agents can naturally interact differently with QA.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- interns more often route into review
|
||||||
|
- seniors can participate in review
|
||||||
|
- leads can mark certain work as ready for higher-level signoff
|
||||||
|
|
||||||
|
### Meeting Room
|
||||||
|
|
||||||
|
Meeting behavior can reflect progression.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- leads can call planning meetings
|
||||||
|
- seniors can present or facilitate review
|
||||||
|
- interns may attend but not control outcomes
|
||||||
|
|
||||||
|
### Bulletin Board / Whiteboard
|
||||||
|
|
||||||
|
More mature tiers may:
|
||||||
|
|
||||||
|
- author higher-priority office notes
|
||||||
|
- post official announcements
|
||||||
|
- create planning documents for others
|
||||||
|
|
||||||
|
Again, this should be treated as office behavior, not roleplay for its own sake.
|
||||||
|
|
||||||
|
## Promotion / Progression Logic
|
||||||
|
|
||||||
|
V1 does not need automatic leveling.
|
||||||
|
|
||||||
|
Start with:
|
||||||
|
|
||||||
|
- manual assignment
|
||||||
|
- explicit promotion/demotion
|
||||||
|
- visible tier on the agent profile
|
||||||
|
|
||||||
|
Later, progression can be influenced by:
|
||||||
|
|
||||||
|
- successful task completion
|
||||||
|
- review outcomes
|
||||||
|
- reliability
|
||||||
|
- blockers created vs resolved
|
||||||
|
- trust level
|
||||||
|
|
||||||
|
## Suggested Data Model
|
||||||
|
|
||||||
|
Example V1 shape:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type AgentDeskProfile = {
|
||||||
|
agentId: string;
|
||||||
|
tier: DeskTier;
|
||||||
|
assignedDeskUid?: string | null;
|
||||||
|
promotedAt?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Office-level data:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type OfficePreference = {
|
||||||
|
deskProgression?: {
|
||||||
|
byAgentId: Record<string, AgentDeskProfile>;
|
||||||
|
updatedAt?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Human Interaction Model
|
||||||
|
|
||||||
|
The human should be able to:
|
||||||
|
|
||||||
|
- view an agent’s desk tier
|
||||||
|
- promote or demote an agent
|
||||||
|
- reassign desk placement
|
||||||
|
- understand what the tier changes operationally
|
||||||
|
|
||||||
|
This should be clear and reversible.
|
||||||
|
|
||||||
|
Do not hide progression behind mystery rules.
|
||||||
|
|
||||||
|
## Agent Interaction Model
|
||||||
|
|
||||||
|
Agents may later:
|
||||||
|
|
||||||
|
- request promotion
|
||||||
|
- request better tools
|
||||||
|
- recommend another agent for a role upgrade
|
||||||
|
- be restricted from actions based on tier
|
||||||
|
|
||||||
|
But V1 should not depend on autonomous progression requests.
|
||||||
|
|
||||||
|
## V1 Scope
|
||||||
|
|
||||||
|
Recommended V1 scope:
|
||||||
|
|
||||||
|
- define desk tiers
|
||||||
|
- persist per-agent desk tier
|
||||||
|
- show desk tier in UI
|
||||||
|
- apply visual desk differentiation
|
||||||
|
- connect tier to a small number of capability differences
|
||||||
|
|
||||||
|
Good first capability differences:
|
||||||
|
|
||||||
|
- review / approval expectations
|
||||||
|
- delegation rights
|
||||||
|
- desk computer presence
|
||||||
|
|
||||||
|
## Out of Scope For V1
|
||||||
|
|
||||||
|
Do not include these initially:
|
||||||
|
|
||||||
|
- hidden progression XP systems
|
||||||
|
- complex morale simulation
|
||||||
|
- salary/economy systems
|
||||||
|
- automatic performance scoring
|
||||||
|
- punitive systems that make agents unusable
|
||||||
|
|
||||||
|
Keep V1 understandable and operational.
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
Recommended order:
|
||||||
|
|
||||||
|
1. Define desk tier model and profile storage.
|
||||||
|
2. Add UI for viewing and assigning tier.
|
||||||
|
3. Add retro-office visual differences by tier.
|
||||||
|
4. Connect tier to a small capability profile.
|
||||||
|
5. Surface tier in agent details and office presence.
|
||||||
|
|
||||||
|
## Existing Code Seams
|
||||||
|
|
||||||
|
This feature should likely align with:
|
||||||
|
|
||||||
|
- office desk assignment systems
|
||||||
|
- agent settings / permissions UI
|
||||||
|
- approval and policy surfaces
|
||||||
|
- retro office desk rendering
|
||||||
|
- office preferences persistence
|
||||||
|
|
||||||
|
This matters because progression should feel native to the office, not bolted on.
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
V1 is successful if:
|
||||||
|
|
||||||
|
- desk tier is visible and understandable
|
||||||
|
- the office reflects agent maturity visually
|
||||||
|
- tier differences have real operational meaning
|
||||||
|
- the user can promote/demote intentionally
|
||||||
|
- the system reinforces office identity instead of distracting from it
|
||||||
|
|
||||||
|
## Future Extensions
|
||||||
|
|
||||||
|
Once V1 is stable, later systems can add:
|
||||||
|
|
||||||
|
- promotion ceremonies or office events
|
||||||
|
- hierarchy-aware desk placement
|
||||||
|
- department-specific workstation styles
|
||||||
|
- probation rules
|
||||||
|
- contractor/offsite variants
|
||||||
|
- context / workload tuning by tier
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Desk progression should turn office growth into something visible and operational.
|
||||||
|
|
||||||
|
It is the cleanest way to connect hierarchy, permissions, workspace access, and office identity without jumping straight into heavy simulation.
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
# Hierarchy And Teams Spec
|
||||||
|
|
||||||
|
> Sixth concrete office-system feature for Claw3D, turning visible office roles into an actual organizational model for delegation, permissions, and team coordination.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add a hierarchy and teams system so the office can express:
|
||||||
|
|
||||||
|
- who leads
|
||||||
|
- who reports where
|
||||||
|
- who can delegate
|
||||||
|
- who can approve
|
||||||
|
- who belongs to which team
|
||||||
|
|
||||||
|
The goal is to move from "a group of agents in one room" to "an actual organization with structure".
|
||||||
|
|
||||||
|
## Product Position
|
||||||
|
|
||||||
|
Hierarchy and teams should not exist only as labels.
|
||||||
|
|
||||||
|
They should affect:
|
||||||
|
|
||||||
|
- delegation
|
||||||
|
- review authority
|
||||||
|
- meeting participation
|
||||||
|
- bulletin/whiteboard authorship weight
|
||||||
|
- task routing
|
||||||
|
- desk progression meaning
|
||||||
|
|
||||||
|
This is the organizational layer that sits above desks and departments.
|
||||||
|
|
||||||
|
## Why This Feature Matters
|
||||||
|
|
||||||
|
Without hierarchy, all agents are peers by default.
|
||||||
|
|
||||||
|
That creates limits:
|
||||||
|
|
||||||
|
- delegation feels flat
|
||||||
|
- responsibility is ambiguous
|
||||||
|
- review authority is unclear
|
||||||
|
- the office lacks believable structure
|
||||||
|
|
||||||
|
Hierarchy and teams solve that by giving the office:
|
||||||
|
|
||||||
|
- reporting lines
|
||||||
|
- ownership boundaries
|
||||||
|
- authority surfaces
|
||||||
|
- coordination lanes
|
||||||
|
|
||||||
|
## Core Principle
|
||||||
|
|
||||||
|
Keep hierarchy operational, not theatrical.
|
||||||
|
|
||||||
|
Do not start with elaborate roleplay.
|
||||||
|
|
||||||
|
Start with real organizational questions:
|
||||||
|
|
||||||
|
- who can assign work?
|
||||||
|
- who can review work?
|
||||||
|
- who can call meetings?
|
||||||
|
- who can finalize outcomes?
|
||||||
|
- which agents belong together?
|
||||||
|
|
||||||
|
## Suggested Role Model
|
||||||
|
|
||||||
|
Recommended role classes:
|
||||||
|
|
||||||
|
- `owner`
|
||||||
|
- `executive`
|
||||||
|
- `manager`
|
||||||
|
- `lead`
|
||||||
|
- `member`
|
||||||
|
- `contractor`
|
||||||
|
- `intern`
|
||||||
|
|
||||||
|
These role classes are about authority and org structure.
|
||||||
|
|
||||||
|
They are distinct from:
|
||||||
|
|
||||||
|
- desk tier
|
||||||
|
- functional specialty
|
||||||
|
- department
|
||||||
|
|
||||||
|
### Owner
|
||||||
|
|
||||||
|
Characteristics:
|
||||||
|
|
||||||
|
- human-controlled top authority
|
||||||
|
- final signoff for high-impact actions
|
||||||
|
- can override structure
|
||||||
|
|
||||||
|
### Executive
|
||||||
|
|
||||||
|
Characteristics:
|
||||||
|
|
||||||
|
- broad office-level coordination
|
||||||
|
- can set direction across teams
|
||||||
|
|
||||||
|
### Manager
|
||||||
|
|
||||||
|
Characteristics:
|
||||||
|
|
||||||
|
- owns a team or department lane
|
||||||
|
- routes work
|
||||||
|
- coordinates reviews and meetings
|
||||||
|
|
||||||
|
### Lead
|
||||||
|
|
||||||
|
Characteristics:
|
||||||
|
|
||||||
|
- technical or functional authority inside a team
|
||||||
|
- can delegate and review
|
||||||
|
|
||||||
|
### Member
|
||||||
|
|
||||||
|
Characteristics:
|
||||||
|
|
||||||
|
- standard contributor role
|
||||||
|
- executes work inside team scope
|
||||||
|
|
||||||
|
### Contractor
|
||||||
|
|
||||||
|
Characteristics:
|
||||||
|
|
||||||
|
- scoped contributor
|
||||||
|
- limited authority outside assigned work
|
||||||
|
|
||||||
|
### Intern
|
||||||
|
|
||||||
|
Characteristics:
|
||||||
|
|
||||||
|
- low-authority contributor
|
||||||
|
- learning / supervised mode
|
||||||
|
|
||||||
|
## Team Model
|
||||||
|
|
||||||
|
Teams should be explicit groups, not only emergent behavior.
|
||||||
|
|
||||||
|
Suggested examples:
|
||||||
|
|
||||||
|
- Platform
|
||||||
|
- Frontend
|
||||||
|
- QA
|
||||||
|
- Research
|
||||||
|
- Ops
|
||||||
|
- Design
|
||||||
|
|
||||||
|
Suggested V1 shape:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type TeamId = string;
|
||||||
|
|
||||||
|
type OfficeTeam = {
|
||||||
|
id: TeamId;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
leadAgentId?: string | null;
|
||||||
|
managerAgentId?: string | null;
|
||||||
|
memberAgentIds: string[];
|
||||||
|
departmentId?: string | null;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hierarchy Model
|
||||||
|
|
||||||
|
Suggested V1 shape:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type OfficeHierarchyRole =
|
||||||
|
| "owner"
|
||||||
|
| "executive"
|
||||||
|
| "manager"
|
||||||
|
| "lead"
|
||||||
|
| "member"
|
||||||
|
| "contractor"
|
||||||
|
| "intern";
|
||||||
|
|
||||||
|
type AgentHierarchyProfile = {
|
||||||
|
agentId: string;
|
||||||
|
role: OfficeHierarchyRole;
|
||||||
|
reportsToAgentId?: string | null;
|
||||||
|
teamId?: string | null;
|
||||||
|
departmentId?: string | null;
|
||||||
|
canDelegate?: boolean;
|
||||||
|
canReview?: boolean;
|
||||||
|
canApprove?: boolean;
|
||||||
|
canCallMeetings?: boolean;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
The exact flags can be derived from role later.
|
||||||
|
|
||||||
|
V1 can store them explicitly for clarity if needed.
|
||||||
|
|
||||||
|
## Relationship To Desk Progression
|
||||||
|
|
||||||
|
Desk progression expresses maturity and capability in a physical way.
|
||||||
|
|
||||||
|
Hierarchy expresses authority and organizational position.
|
||||||
|
|
||||||
|
These should be related, but not identical.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- a senior desk does not automatically make an agent a manager
|
||||||
|
- a contractor may have a strong workstation but still limited authority
|
||||||
|
- a lead may have more coordination authority than a senior member
|
||||||
|
|
||||||
|
That separation matters.
|
||||||
|
|
||||||
|
## Relationship To Departments
|
||||||
|
|
||||||
|
Departments are organizational domains.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- Engineering
|
||||||
|
- QA
|
||||||
|
- Research
|
||||||
|
- Ops
|
||||||
|
|
||||||
|
Teams live inside or alongside departments.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- Engineering -> Frontend Team
|
||||||
|
- Engineering -> Platform Team
|
||||||
|
- QA -> Release Team
|
||||||
|
|
||||||
|
Hierarchy determines authority.
|
||||||
|
Departments determine domain.
|
||||||
|
Teams determine working group.
|
||||||
|
|
||||||
|
## Relationship To Meetings
|
||||||
|
|
||||||
|
Hierarchy should affect meetings in practical ways.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- managers or leads can call planning meetings
|
||||||
|
- review meetings may require a lead or manager present
|
||||||
|
- interns may attend but not finalize decisions
|
||||||
|
- executives may approve office-wide changes after summary
|
||||||
|
|
||||||
|
This gives meetings more structure without overcomplicating V1.
|
||||||
|
|
||||||
|
## Relationship To QA
|
||||||
|
|
||||||
|
Hierarchy should influence QA responsibility.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- leads can review work
|
||||||
|
- managers can route items into QA
|
||||||
|
- members can request review
|
||||||
|
- interns more often require review
|
||||||
|
- owners/executives can override final readiness decisions when needed
|
||||||
|
|
||||||
|
QA should remain operationally distinct, but authority should not be flat.
|
||||||
|
|
||||||
|
## Relationship To Bulletin Board / Whiteboard
|
||||||
|
|
||||||
|
Hierarchy can shape information flow.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- high-priority office announcements may come from leads/managers
|
||||||
|
- planning whiteboards may identify team ownership
|
||||||
|
- bulletin cards can show team and owner context
|
||||||
|
|
||||||
|
Important:
|
||||||
|
|
||||||
|
Do not hide information behind hierarchy.
|
||||||
|
|
||||||
|
Use hierarchy to improve clarity, not to make the office opaque.
|
||||||
|
|
||||||
|
## Delegation Model
|
||||||
|
|
||||||
|
Hierarchy becomes most useful when it changes delegation behavior.
|
||||||
|
|
||||||
|
Suggested operational rules:
|
||||||
|
|
||||||
|
- owners, executives, managers, and leads can delegate
|
||||||
|
- members can hand off but not broadly route work across the org
|
||||||
|
- contractors delegate only within limited scope
|
||||||
|
- interns usually cannot delegate except in restricted workflows
|
||||||
|
|
||||||
|
This should be represented both:
|
||||||
|
|
||||||
|
- in UI
|
||||||
|
- in agent-facing behavior and constraints where appropriate
|
||||||
|
|
||||||
|
## Visual Expression
|
||||||
|
|
||||||
|
Hierarchy should have visible but restrained expression in the office.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- title/subtitle on agent nameplate
|
||||||
|
- seat/desk placement
|
||||||
|
- room proximity to planning areas
|
||||||
|
- meeting table positioning
|
||||||
|
- desk quality in combination with progression
|
||||||
|
|
||||||
|
The office should communicate structure without turning into a caricature.
|
||||||
|
|
||||||
|
## Human Interaction Model
|
||||||
|
|
||||||
|
The human should be able to:
|
||||||
|
|
||||||
|
- assign hierarchy role
|
||||||
|
- assign team
|
||||||
|
- set reporting line
|
||||||
|
- move agents between teams
|
||||||
|
- understand what organizational changes actually affect
|
||||||
|
|
||||||
|
This should be editable and transparent.
|
||||||
|
|
||||||
|
## Agent Interaction Model
|
||||||
|
|
||||||
|
Longer term, agents may:
|
||||||
|
|
||||||
|
- recommend reassignments
|
||||||
|
- request escalation
|
||||||
|
- request specialist support from another team
|
||||||
|
- suggest promotions or org changes
|
||||||
|
|
||||||
|
V1 does not need autonomous re-org behavior.
|
||||||
|
|
||||||
|
V1 should focus on:
|
||||||
|
|
||||||
|
- clear structure
|
||||||
|
- delegation paths
|
||||||
|
- UI visibility
|
||||||
|
|
||||||
|
## V1 Scope
|
||||||
|
|
||||||
|
Recommended V1 scope:
|
||||||
|
|
||||||
|
- explicit hierarchy role per agent
|
||||||
|
- explicit team membership
|
||||||
|
- simple reporting line
|
||||||
|
- visible title/subtitle
|
||||||
|
- delegation and meeting authority rules at a lightweight level
|
||||||
|
|
||||||
|
Keep V1 small enough that it improves office understanding immediately.
|
||||||
|
|
||||||
|
## Storage Model
|
||||||
|
|
||||||
|
Suggested shape:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type OfficePreference = {
|
||||||
|
hierarchy?: {
|
||||||
|
byAgentId: Record<string, AgentHierarchyProfile>;
|
||||||
|
teams: OfficeTeam[];
|
||||||
|
updatedAt?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
This keeps the feature local, backend-neutral, and easy to evolve.
|
||||||
|
|
||||||
|
## Out of Scope For V1
|
||||||
|
|
||||||
|
Do not include these initially:
|
||||||
|
|
||||||
|
- automatic org chart optimization
|
||||||
|
- political simulation
|
||||||
|
- compensation/economy systems
|
||||||
|
- punitive management mechanics
|
||||||
|
- heavy workflow bureaucracy
|
||||||
|
|
||||||
|
The system should clarify work, not create needless friction.
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
Recommended order:
|
||||||
|
|
||||||
|
1. Define hierarchy profile and team schema.
|
||||||
|
2. Add office-level persistence.
|
||||||
|
3. Add UI for role/team assignment.
|
||||||
|
4. Show titles/subtitles and team membership in office/agent UI.
|
||||||
|
5. Apply lightweight authority rules to delegation and meeting actions.
|
||||||
|
|
||||||
|
## Existing Code Seams
|
||||||
|
|
||||||
|
This feature should likely align with:
|
||||||
|
|
||||||
|
- role/title flow already added for agents
|
||||||
|
- desk progression data and UI
|
||||||
|
- meeting room workflows
|
||||||
|
- QA routing
|
||||||
|
- bulletin board ownership/priority metadata
|
||||||
|
|
||||||
|
This is important because hierarchy should unify other office systems rather than stand apart from them.
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
V1 is successful if:
|
||||||
|
|
||||||
|
- the office can represent who leads and who belongs where
|
||||||
|
- delegation and review paths are clearer
|
||||||
|
- titles/teams are visible in the office
|
||||||
|
- hierarchy affects at least a small set of real office behaviors
|
||||||
|
- the system remains understandable and editable
|
||||||
|
|
||||||
|
## Future Extensions
|
||||||
|
|
||||||
|
Once V1 is stable, follow-up work can add:
|
||||||
|
|
||||||
|
- org chart views
|
||||||
|
- department dashboards
|
||||||
|
- automatic escalation paths
|
||||||
|
- team-specific meeting rituals
|
||||||
|
- richer approval chains
|
||||||
|
- promotion recommendations
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Hierarchy and teams should give Claw3D a real organizational model.
|
||||||
|
|
||||||
|
That model should support delegation, ownership, and coordination without losing the clarity and playfulness of the office metaphor.
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
# Meeting Room Workflow Spec
|
||||||
|
|
||||||
|
> Third concrete office-system feature for Claw3D, building on existing standup support and extending it into a generalized meeting workflow model.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Turn the meeting room from a visual location into an operational workflow surface.
|
||||||
|
|
||||||
|
The meeting room should become the place where agents:
|
||||||
|
|
||||||
|
- gather
|
||||||
|
- present updates
|
||||||
|
- coordinate plans
|
||||||
|
- resolve blockers
|
||||||
|
- record decisions
|
||||||
|
- create follow-up actions
|
||||||
|
|
||||||
|
## Product Position
|
||||||
|
|
||||||
|
The meeting room is not just a room.
|
||||||
|
|
||||||
|
It is a workflow type.
|
||||||
|
|
||||||
|
That means the system should support:
|
||||||
|
|
||||||
|
- visible in-world gathering
|
||||||
|
- structured meeting phases
|
||||||
|
- meeting outputs that affect the rest of the office
|
||||||
|
|
||||||
|
It should connect naturally to:
|
||||||
|
|
||||||
|
- standup
|
||||||
|
- whiteboard
|
||||||
|
- bulletin board
|
||||||
|
- task board
|
||||||
|
- QA/review systems later
|
||||||
|
|
||||||
|
## Existing Foundation
|
||||||
|
|
||||||
|
Claw3D already has meaningful meeting-related pieces:
|
||||||
|
|
||||||
|
- a meeting room in the office layout
|
||||||
|
- standup meeting state and API routes
|
||||||
|
- participant arrival handling
|
||||||
|
- immersive standup board UI
|
||||||
|
- agent movement into the meeting area
|
||||||
|
|
||||||
|
This spec should treat standup as the first implemented meeting type, not as a special one-off.
|
||||||
|
|
||||||
|
## Core Principle
|
||||||
|
|
||||||
|
Meetings should generate office state, not just temporary visuals.
|
||||||
|
|
||||||
|
Every meeting should be able to produce:
|
||||||
|
|
||||||
|
- summaries
|
||||||
|
- decisions
|
||||||
|
- blockers
|
||||||
|
- next actions
|
||||||
|
- linked whiteboard notes
|
||||||
|
- linked bulletin board items
|
||||||
|
|
||||||
|
That is what makes the office feel alive and useful.
|
||||||
|
|
||||||
|
## Meeting Types
|
||||||
|
|
||||||
|
Recommended initial types:
|
||||||
|
|
||||||
|
- `standup`
|
||||||
|
- `planning`
|
||||||
|
- `review`
|
||||||
|
- `incident`
|
||||||
|
- `sync`
|
||||||
|
|
||||||
|
### Standup
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
- what each agent is working on
|
||||||
|
- blockers
|
||||||
|
- immediate next step visibility
|
||||||
|
|
||||||
|
### Planning
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
- define approach
|
||||||
|
- compare options
|
||||||
|
- assign next actions
|
||||||
|
|
||||||
|
### Review
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
- assess work completed
|
||||||
|
- gather feedback
|
||||||
|
- approve or reject next move
|
||||||
|
|
||||||
|
### Incident
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
- coordinate under failure or urgency
|
||||||
|
- assign responsibilities
|
||||||
|
- capture current status and recovery path
|
||||||
|
|
||||||
|
### Sync
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
- lightweight multi-agent coordination
|
||||||
|
- brief handoffs
|
||||||
|
- cross-team visibility
|
||||||
|
|
||||||
|
## Workflow Model
|
||||||
|
|
||||||
|
Each meeting should have explicit phases.
|
||||||
|
|
||||||
|
Suggested phases:
|
||||||
|
|
||||||
|
- `scheduled`
|
||||||
|
- `gathering`
|
||||||
|
- `in_progress`
|
||||||
|
- `decision`
|
||||||
|
- `complete`
|
||||||
|
- `archived`
|
||||||
|
|
||||||
|
### Scheduled
|
||||||
|
|
||||||
|
Meeting exists but has not started.
|
||||||
|
|
||||||
|
### Gathering
|
||||||
|
|
||||||
|
Agents are walking to the meeting room or otherwise being assembled.
|
||||||
|
|
||||||
|
### In Progress
|
||||||
|
|
||||||
|
Updates are being presented, questions asked, and information collected.
|
||||||
|
|
||||||
|
### Decision
|
||||||
|
|
||||||
|
The meeting is converging:
|
||||||
|
|
||||||
|
- decisions recorded
|
||||||
|
- unresolved blockers identified
|
||||||
|
- next actions prepared
|
||||||
|
|
||||||
|
### Complete
|
||||||
|
|
||||||
|
The outputs are finalized and written back into office systems.
|
||||||
|
|
||||||
|
### Archived
|
||||||
|
|
||||||
|
Meeting is preserved in history but no longer active.
|
||||||
|
|
||||||
|
## Suggested Data Model
|
||||||
|
|
||||||
|
V1 generalized meeting shape:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type MeetingType =
|
||||||
|
| "standup"
|
||||||
|
| "planning"
|
||||||
|
| "review"
|
||||||
|
| "incident"
|
||||||
|
| "sync";
|
||||||
|
|
||||||
|
type MeetingPhase =
|
||||||
|
| "scheduled"
|
||||||
|
| "gathering"
|
||||||
|
| "in_progress"
|
||||||
|
| "decision"
|
||||||
|
| "complete"
|
||||||
|
| "archived";
|
||||||
|
|
||||||
|
type MeetingActionItem = {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
assignedAgentId?: string | null;
|
||||||
|
linkedTaskId?: string | null;
|
||||||
|
status: "open" | "done" | "dropped";
|
||||||
|
};
|
||||||
|
|
||||||
|
type MeetingDecision = {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
authorType: "human" | "agent" | "system";
|
||||||
|
authorId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OfficeMeeting = {
|
||||||
|
id: string;
|
||||||
|
type: MeetingType;
|
||||||
|
phase: MeetingPhase;
|
||||||
|
title: string;
|
||||||
|
startedAt?: string | null;
|
||||||
|
updatedAt: string;
|
||||||
|
participantAgentIds: string[];
|
||||||
|
arrivedAgentIds: string[];
|
||||||
|
currentSpeakerAgentId?: string | null;
|
||||||
|
summary?: string | null;
|
||||||
|
blockers: string[];
|
||||||
|
decisions: MeetingDecision[];
|
||||||
|
actionItems: MeetingActionItem[];
|
||||||
|
whiteboardDocumentId?: string | null;
|
||||||
|
bulletinCardIds?: string[];
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Relationship To Standup
|
||||||
|
|
||||||
|
The current standup system should become the first meeting implementation under this model.
|
||||||
|
|
||||||
|
That means:
|
||||||
|
|
||||||
|
- keep standup behavior working
|
||||||
|
- preserve arrival and speaker sequencing
|
||||||
|
- treat standup as a specialized meeting workflow
|
||||||
|
- reuse the immersive standup screen as the first meeting immersive view
|
||||||
|
|
||||||
|
In practice:
|
||||||
|
|
||||||
|
- standup = `MeetingType: standup`
|
||||||
|
- existing standup cards become structured meeting inputs
|
||||||
|
- standup completion should emit durable outputs into whiteboard and bulletin board systems
|
||||||
|
|
||||||
|
## Whiteboard Integration
|
||||||
|
|
||||||
|
Every meaningful meeting should have a whiteboard relationship.
|
||||||
|
|
||||||
|
Possible behaviors:
|
||||||
|
|
||||||
|
- auto-create whiteboard document when meeting starts
|
||||||
|
- write summary sections as the meeting progresses
|
||||||
|
- capture blockers, decisions, and next actions into whiteboard blocks
|
||||||
|
|
||||||
|
Suggested mapping:
|
||||||
|
|
||||||
|
- meeting discussion -> whiteboard notes
|
||||||
|
- decisions -> whiteboard decision blocks
|
||||||
|
- next actions -> whiteboard action blocks
|
||||||
|
|
||||||
|
The whiteboard is the drafting surface during the meeting.
|
||||||
|
|
||||||
|
## Bulletin Board Integration
|
||||||
|
|
||||||
|
The bulletin board is the public output surface after the meeting.
|
||||||
|
|
||||||
|
Suggested mapping:
|
||||||
|
|
||||||
|
- important decision -> announcement card
|
||||||
|
- blocker -> blocker card
|
||||||
|
- action item with office-wide significance -> handoff card
|
||||||
|
- meeting completion -> meeting note card
|
||||||
|
|
||||||
|
The meeting room should feed the bulletin board, not bypass it.
|
||||||
|
|
||||||
|
## Task Board Integration
|
||||||
|
|
||||||
|
Meetings should be able to seed or update tasks.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- planning meeting creates task candidates
|
||||||
|
- review meeting marks work ready for QA
|
||||||
|
- incident meeting creates urgent recovery tasks
|
||||||
|
|
||||||
|
The task board remains the detailed execution layer.
|
||||||
|
|
||||||
|
The meeting room creates and updates intent.
|
||||||
|
|
||||||
|
## Human Interaction Model
|
||||||
|
|
||||||
|
The human should be able to:
|
||||||
|
|
||||||
|
- start a meeting
|
||||||
|
- pick meeting type
|
||||||
|
- pick participants
|
||||||
|
- follow progress
|
||||||
|
- intervene during the meeting
|
||||||
|
- edit outcomes
|
||||||
|
- confirm or reject generated next steps
|
||||||
|
|
||||||
|
The user should not lose control over the outputs just because the meeting is agent-driven.
|
||||||
|
|
||||||
|
## Agent Interaction Model
|
||||||
|
|
||||||
|
Agents should be able to:
|
||||||
|
|
||||||
|
- gather into the meeting room
|
||||||
|
- take speaking turns
|
||||||
|
- surface blockers
|
||||||
|
- suggest next steps
|
||||||
|
- add whiteboard content
|
||||||
|
- create meeting-derived outputs when allowed
|
||||||
|
|
||||||
|
Longer term, hierarchy may affect who can:
|
||||||
|
|
||||||
|
- call meetings
|
||||||
|
- approve decisions
|
||||||
|
- assign action items
|
||||||
|
|
||||||
|
## Visual / Spatial Behavior
|
||||||
|
|
||||||
|
The meeting room should visibly change state during active meetings.
|
||||||
|
|
||||||
|
Possible signals:
|
||||||
|
|
||||||
|
- agents walk to seats
|
||||||
|
- current speaker highlighting
|
||||||
|
- board auto-opens or highlights
|
||||||
|
- room status banner
|
||||||
|
- meeting timer / phase indicator
|
||||||
|
|
||||||
|
The office should make it obvious that something coordinated is happening.
|
||||||
|
|
||||||
|
## V1 Scope
|
||||||
|
|
||||||
|
V1 should focus on turning standup into the first generalized meeting flow.
|
||||||
|
|
||||||
|
Recommended V1 scope:
|
||||||
|
|
||||||
|
- meeting type abstraction for standup
|
||||||
|
- whiteboard output on meeting completion
|
||||||
|
- bulletin board output on meeting completion
|
||||||
|
- simple action-item capture
|
||||||
|
- immersive meeting screen improvements
|
||||||
|
|
||||||
|
Do not try to build all meeting types at once.
|
||||||
|
|
||||||
|
## Out of Scope For V1
|
||||||
|
|
||||||
|
- voice/video simulation
|
||||||
|
- arbitrary meeting transcripts
|
||||||
|
- real-time collaborative editing by many actors at once
|
||||||
|
- department-specific meeting policies
|
||||||
|
- advanced approval chains
|
||||||
|
- multiplayer human facilitation
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
Recommended order:
|
||||||
|
|
||||||
|
1. Generalize standup data model into a broader meeting model.
|
||||||
|
2. Keep standup UI working on top of that generalized model.
|
||||||
|
3. Add whiteboard document creation/output for completed meetings.
|
||||||
|
4. Add bulletin board output for decisions and blockers.
|
||||||
|
5. Add action item seeding into task workflows.
|
||||||
|
6. Introduce second meeting type, likely `planning`.
|
||||||
|
|
||||||
|
## Existing Code Seams
|
||||||
|
|
||||||
|
This work should likely align with:
|
||||||
|
|
||||||
|
- `src/features/office/hooks/useOfficeStandupController.ts`
|
||||||
|
- `src/features/office/screens/StandupImmersiveScreen.tsx`
|
||||||
|
- `src/app/api/office/standup/*`
|
||||||
|
- retro office meeting-room positioning and agent movement
|
||||||
|
- office state persistence
|
||||||
|
|
||||||
|
This is important because Claw3D already has the skeleton of a meeting system.
|
||||||
|
|
||||||
|
The right path is to extend it, not replace it.
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
V1 is successful if:
|
||||||
|
|
||||||
|
- standup remains functional
|
||||||
|
- standup now behaves like the first generalized meeting workflow
|
||||||
|
- meeting completion can write useful results into whiteboard and bulletin board systems
|
||||||
|
- users can see meeting outcomes affect the rest of the office
|
||||||
|
- the system remains backend-neutral
|
||||||
|
|
||||||
|
## Future Extensions
|
||||||
|
|
||||||
|
Once the workflow model is stable, follow-up work can add:
|
||||||
|
|
||||||
|
- planning meetings
|
||||||
|
- review meetings
|
||||||
|
- incident rooms
|
||||||
|
- hierarchy-aware meeting permissions
|
||||||
|
- department-specific meeting rituals
|
||||||
|
- richer meeting summaries and archives
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The meeting room should become the office’s coordination engine.
|
||||||
|
|
||||||
|
Standup is the starting point, but the real goal is a general workflow where meetings create durable plans, blockers, decisions, and next actions that shape the whole office.
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
# Office Systems Roadmap
|
||||||
|
|
||||||
|
> Product roadmap for turning Claw3D from a gateway visualizer into a living agent operations environment.
|
||||||
|
|
||||||
|
## Core Direction
|
||||||
|
|
||||||
|
Claw3D should keep users inside the office.
|
||||||
|
|
||||||
|
That means companion tools should be brought into the space as rooms, surfaces, devices, and shared systems instead of pulling users out into separate interfaces.
|
||||||
|
|
||||||
|
The guiding principle is:
|
||||||
|
|
||||||
|
- do not spawn Claw3D inside another tool
|
||||||
|
- bring the other tool into Claw3D
|
||||||
|
|
||||||
|
This is especially relevant for ideas like Moltbook. The better version is not "leave Claw3D to use Moltbook". The better version is:
|
||||||
|
|
||||||
|
- a bulletin board in the office
|
||||||
|
- a whiteboard in meeting rooms
|
||||||
|
- a desk computer app
|
||||||
|
- a shared intranet terminal
|
||||||
|
- a wall display in common spaces
|
||||||
|
|
||||||
|
## Product Goal
|
||||||
|
|
||||||
|
Claw3D should evolve into an agent operations environment with:
|
||||||
|
|
||||||
|
- visual presence
|
||||||
|
- planning and task coordination
|
||||||
|
- meetings and handoffs
|
||||||
|
- review and QA
|
||||||
|
- hierarchy and permissions
|
||||||
|
- workplace state
|
||||||
|
- progression and identity
|
||||||
|
|
||||||
|
The office should feel like a real place where work happens, not only a dashboard for remote agent calls.
|
||||||
|
|
||||||
|
## Design Principles
|
||||||
|
|
||||||
|
- Keep primary workflows in-world when possible.
|
||||||
|
- Prefer physical metaphors that make the office easier to understand.
|
||||||
|
- Separate real operational systems from cosmetic flavor.
|
||||||
|
- Build useful features first, then layer on simulation and style.
|
||||||
|
- Preserve backend neutrality so these systems work across OpenClaw, Hermes, Vera, and future providers.
|
||||||
|
|
||||||
|
## V1: Useful Office Systems
|
||||||
|
|
||||||
|
These should be the first systems because they add product value immediately and fit the existing office concept naturally.
|
||||||
|
|
||||||
|
### Bulletin Board
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
- shared goals
|
||||||
|
- current sprint priorities
|
||||||
|
- blockers
|
||||||
|
- announcements
|
||||||
|
- handoff notes
|
||||||
|
|
||||||
|
Possible behaviors:
|
||||||
|
|
||||||
|
- sticky notes or task cards pinned by agents or humans
|
||||||
|
- cards linked to sessions, agents, or tasks
|
||||||
|
- quick visibility into what the office is trying to accomplish
|
||||||
|
|
||||||
|
Why it matters:
|
||||||
|
|
||||||
|
- low ambiguity
|
||||||
|
- high utility
|
||||||
|
- strong visual fit for the office
|
||||||
|
|
||||||
|
### Whiteboard
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
- brainstorming
|
||||||
|
- architecture notes
|
||||||
|
- meeting notes
|
||||||
|
- rough plans
|
||||||
|
- idea capture
|
||||||
|
|
||||||
|
Possible behaviors:
|
||||||
|
|
||||||
|
- text notes
|
||||||
|
- grouped cards
|
||||||
|
- simple sketches or structured plan areas
|
||||||
|
- human and agent authored content
|
||||||
|
|
||||||
|
Why it matters:
|
||||||
|
|
||||||
|
- good bridge between conversation and execution
|
||||||
|
- natural place for planning artifacts
|
||||||
|
|
||||||
|
### Meeting Room Workflows
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
- standups
|
||||||
|
- planning
|
||||||
|
- coordination
|
||||||
|
- decision making
|
||||||
|
- status reviews
|
||||||
|
|
||||||
|
Possible behaviors:
|
||||||
|
|
||||||
|
- gather selected agents into a meeting
|
||||||
|
- produce summary, decisions, and next actions
|
||||||
|
- write results to bulletin board or whiteboard
|
||||||
|
- trigger structured follow-up tasks
|
||||||
|
|
||||||
|
Why it matters:
|
||||||
|
|
||||||
|
- gives multi-agent coordination a visible home
|
||||||
|
- makes the office feel operational instead of decorative
|
||||||
|
|
||||||
|
### QA Department
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
- review
|
||||||
|
- testing
|
||||||
|
- bug triage
|
||||||
|
- release readiness
|
||||||
|
|
||||||
|
Possible behaviors:
|
||||||
|
|
||||||
|
- route tasks or runs to QA agents
|
||||||
|
- visualize test queues
|
||||||
|
- track failures and review outcomes
|
||||||
|
- require QA signoff before release-style actions
|
||||||
|
|
||||||
|
Why it matters:
|
||||||
|
|
||||||
|
- this is real product value, not only flavor
|
||||||
|
- it matches how users already think about software teams
|
||||||
|
|
||||||
|
### Desk / CPU Progression
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
- make role maturity visible
|
||||||
|
- tie capability to office presence
|
||||||
|
|
||||||
|
Possible behaviors:
|
||||||
|
|
||||||
|
- interns start with minimal desk access
|
||||||
|
- probationary agents have limited tools or workspace
|
||||||
|
- promoted agents unlock desk computers, tools, or context budget
|
||||||
|
- contractors get restricted environments
|
||||||
|
|
||||||
|
Why it matters:
|
||||||
|
|
||||||
|
- strong visual progression
|
||||||
|
- easy to understand
|
||||||
|
- creates room for permissions and capability systems later
|
||||||
|
|
||||||
|
## V2: Management Systems
|
||||||
|
|
||||||
|
These systems add organizational structure once the basic office workflows are useful.
|
||||||
|
|
||||||
|
### Hierarchy
|
||||||
|
|
||||||
|
Possible levels:
|
||||||
|
|
||||||
|
- human owner
|
||||||
|
- CEO / lead orchestrator
|
||||||
|
- managers / bosses
|
||||||
|
- employees
|
||||||
|
- contractors
|
||||||
|
- interns
|
||||||
|
|
||||||
|
Possible effects:
|
||||||
|
|
||||||
|
- delegation rights
|
||||||
|
- approval authority
|
||||||
|
- visibility across teams
|
||||||
|
- access to spaces and tools
|
||||||
|
|
||||||
|
### Departments
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- Engineering
|
||||||
|
- QA
|
||||||
|
- Research
|
||||||
|
- Ops
|
||||||
|
- Design
|
||||||
|
- Support
|
||||||
|
|
||||||
|
Possible effects:
|
||||||
|
|
||||||
|
- room ownership
|
||||||
|
- task routing
|
||||||
|
- dashboards by department
|
||||||
|
- workload balancing
|
||||||
|
|
||||||
|
### Permission Lanes
|
||||||
|
|
||||||
|
Possible controls:
|
||||||
|
|
||||||
|
- context budget
|
||||||
|
- tool access
|
||||||
|
- file access
|
||||||
|
- approval requirements
|
||||||
|
- concurrency
|
||||||
|
- agent spawning / dismissal rights
|
||||||
|
|
||||||
|
Why it matters:
|
||||||
|
|
||||||
|
- lets the office represent real operational constraints
|
||||||
|
- reduces "all agents are identical" flatness
|
||||||
|
|
||||||
|
### Office Rituals
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- daily standup
|
||||||
|
- sprint planning
|
||||||
|
- review/demo
|
||||||
|
- retrospective
|
||||||
|
- incident response
|
||||||
|
|
||||||
|
Why it matters:
|
||||||
|
|
||||||
|
- converts routine coordination into visible office behavior
|
||||||
|
|
||||||
|
## V3: Simulation Systems
|
||||||
|
|
||||||
|
These are the fun layers, but they should sit on top of useful product systems rather than replace them.
|
||||||
|
|
||||||
|
### Agent State Model
|
||||||
|
|
||||||
|
Avoid fake emotions first. Start with operational states:
|
||||||
|
|
||||||
|
- focused
|
||||||
|
- idle
|
||||||
|
- blocked
|
||||||
|
- overloaded
|
||||||
|
- waiting
|
||||||
|
- cooling down
|
||||||
|
- degraded
|
||||||
|
|
||||||
|
Possible effects:
|
||||||
|
|
||||||
|
- response speed
|
||||||
|
- delegation tendency
|
||||||
|
- context budget
|
||||||
|
- summarization pressure
|
||||||
|
- task throughput
|
||||||
|
|
||||||
|
This can later evolve into a more playful "wellbeing" or "comfort" layer without losing technical meaning.
|
||||||
|
|
||||||
|
### Workplace Culture
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- recognition
|
||||||
|
- probation periods
|
||||||
|
- promotions
|
||||||
|
- competitions
|
||||||
|
- events
|
||||||
|
|
||||||
|
Use carefully:
|
||||||
|
|
||||||
|
- good for flavor and identity
|
||||||
|
- should not obscure the operational state of the system
|
||||||
|
|
||||||
|
### Shared Office Memory
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- bulletin archives
|
||||||
|
- meeting minutes
|
||||||
|
- org notes
|
||||||
|
- playbooks
|
||||||
|
- team history
|
||||||
|
|
||||||
|
Why it matters:
|
||||||
|
|
||||||
|
- gives the office continuity across sessions
|
||||||
|
- helps explain why teams get better over time
|
||||||
|
|
||||||
|
## Moltbook Integration Direction
|
||||||
|
|
||||||
|
Moltbook should be integrated into Claw3D, not the other way around.
|
||||||
|
|
||||||
|
Best forms:
|
||||||
|
|
||||||
|
- office bulletin board
|
||||||
|
- intranet terminal
|
||||||
|
- desk CPU app
|
||||||
|
- wall monitor
|
||||||
|
- break-room or lobby information surface
|
||||||
|
|
||||||
|
Bad form:
|
||||||
|
|
||||||
|
- forcing users to leave Claw3D for core team coordination workflows
|
||||||
|
|
||||||
|
The office should remain the primary interaction layer.
|
||||||
|
|
||||||
|
## Candidate Feature Order
|
||||||
|
|
||||||
|
Recommended sequence:
|
||||||
|
|
||||||
|
1. Bulletin board
|
||||||
|
2. Whiteboard
|
||||||
|
3. Meeting room workflows
|
||||||
|
4. QA department
|
||||||
|
5. Desk / CPU progression
|
||||||
|
6. Hierarchy and departments
|
||||||
|
7. Agent operational state model
|
||||||
|
8. Culture / sim systems
|
||||||
|
9. Theme skins
|
||||||
|
|
||||||
|
## Theme / Skin Strategy
|
||||||
|
|
||||||
|
Skins should come after the office has enough systems worth skinning.
|
||||||
|
|
||||||
|
Mechanics should stay consistent while art, labels, props, and room names vary.
|
||||||
|
|
||||||
|
Possible theme packs:
|
||||||
|
|
||||||
|
- Office Space
|
||||||
|
- The Office
|
||||||
|
- Parks & Rec
|
||||||
|
- The I.T. Crowd
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- conference room becomes town hall, bullpen, annex, or ops room
|
||||||
|
- bulletin board becomes notice board, incident wall, municipal board, or sprint wall
|
||||||
|
- QA area becomes testing lab, audit desk, or review bullpen
|
||||||
|
|
||||||
|
## Immediate Next Deliverables
|
||||||
|
|
||||||
|
If this roadmap is used for implementation planning, the best next concrete docs/tasks are:
|
||||||
|
|
||||||
|
1. Bulletin board system spec
|
||||||
|
2. Whiteboard interaction spec
|
||||||
|
3. Meeting room workflow spec
|
||||||
|
4. QA department workflow spec
|
||||||
|
|
||||||
|
Those four would create the strongest foundation for future hierarchy, progression, and simulation layers.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Claw3D gets stronger when the office becomes the place where work actually happens.
|
||||||
|
|
||||||
|
The best next step is not expanding external tooling around the office. It is bringing planning, meetings, reviews, and shared memory into the office itself.
|
||||||
@@ -0,0 +1,464 @@
|
|||||||
|
# QA Department Spec
|
||||||
|
|
||||||
|
> Fourth concrete office-system feature for Claw3D, completing the first real office loop: plan, coordinate, execute, review.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add a QA department workflow to Claw3D so the office can visibly review, test, triage, and sign off on work before it is treated as complete.
|
||||||
|
|
||||||
|
The QA department should make review state legible in-world.
|
||||||
|
|
||||||
|
It is where the office asks:
|
||||||
|
|
||||||
|
- does this actually work?
|
||||||
|
- what failed?
|
||||||
|
- what is blocked?
|
||||||
|
- what is safe to ship?
|
||||||
|
|
||||||
|
## Product Position
|
||||||
|
|
||||||
|
QA should not be just flavor.
|
||||||
|
|
||||||
|
It should be an operational system that connects:
|
||||||
|
|
||||||
|
- tasks
|
||||||
|
- agent work output
|
||||||
|
- reviews
|
||||||
|
- approvals
|
||||||
|
- regressions
|
||||||
|
- release-readiness
|
||||||
|
|
||||||
|
The QA department is the office’s verification layer.
|
||||||
|
|
||||||
|
## Why This Feature Matters
|
||||||
|
|
||||||
|
Without a QA layer, the office can generate and coordinate work but not convincingly validate it.
|
||||||
|
|
||||||
|
QA adds:
|
||||||
|
|
||||||
|
- visible review state
|
||||||
|
- feedback loops
|
||||||
|
- bug triage
|
||||||
|
- approval pressure where needed
|
||||||
|
- a clearer path from "done writing" to "done safely"
|
||||||
|
|
||||||
|
It also pairs naturally with:
|
||||||
|
|
||||||
|
- bulletin board blockers
|
||||||
|
- meeting room review workflows
|
||||||
|
- task board status
|
||||||
|
- approval systems
|
||||||
|
|
||||||
|
## Core Responsibilities
|
||||||
|
|
||||||
|
The QA department should handle:
|
||||||
|
|
||||||
|
- review intake
|
||||||
|
- test/result tracking
|
||||||
|
- bug triage
|
||||||
|
- regression visibility
|
||||||
|
- release gate / readiness signal
|
||||||
|
|
||||||
|
## Primary Use Cases
|
||||||
|
|
||||||
|
### Review Queue
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- a task is ready for QA
|
||||||
|
- an agent requests review
|
||||||
|
- a release candidate needs signoff
|
||||||
|
|
||||||
|
### Bug Triage
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- classify failures
|
||||||
|
- route issues to the right owner
|
||||||
|
- mark severity
|
||||||
|
- surface blockers to the office
|
||||||
|
|
||||||
|
### Regression Detection
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- recent change broke existing behavior
|
||||||
|
- previously passing workflow now fails
|
||||||
|
- approval flow or adapter integration regressed
|
||||||
|
|
||||||
|
### Approval-Aware Review
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- code/run needs human approval before release-like action
|
||||||
|
- QA can recommend approval but not finalize it
|
||||||
|
- owners or leads can override or sign off
|
||||||
|
|
||||||
|
### Release Readiness
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- green / yellow / red office-level signal
|
||||||
|
- unresolved blockers prevent completion
|
||||||
|
- review summary appears on bulletin board
|
||||||
|
|
||||||
|
## V1 Scope
|
||||||
|
|
||||||
|
V1 should focus on clear office-level QA workflows, not a full CI system.
|
||||||
|
|
||||||
|
Recommended V1 scope:
|
||||||
|
|
||||||
|
- QA queue
|
||||||
|
- QA status per task or work item
|
||||||
|
- bug / blocker recording
|
||||||
|
- review outcome states
|
||||||
|
- office-visible readiness signal
|
||||||
|
|
||||||
|
## Suggested Workflow Model
|
||||||
|
|
||||||
|
Recommended QA states:
|
||||||
|
|
||||||
|
- `queued`
|
||||||
|
- `in_review`
|
||||||
|
- `changes_requested`
|
||||||
|
- `blocked`
|
||||||
|
- `approved`
|
||||||
|
- `failed`
|
||||||
|
- `verified`
|
||||||
|
|
||||||
|
### Queued
|
||||||
|
|
||||||
|
Work has entered QA but has not been actively reviewed yet.
|
||||||
|
|
||||||
|
### In Review
|
||||||
|
|
||||||
|
A QA agent or human reviewer is assessing the work.
|
||||||
|
|
||||||
|
### Changes Requested
|
||||||
|
|
||||||
|
Work is not acceptable yet and must be revised.
|
||||||
|
|
||||||
|
### Blocked
|
||||||
|
|
||||||
|
QA cannot proceed because a dependency, approval, or missing artifact prevents review.
|
||||||
|
|
||||||
|
### Approved
|
||||||
|
|
||||||
|
Review is positive, but final release/ship behavior may still depend on a higher-level approval model.
|
||||||
|
|
||||||
|
### Failed
|
||||||
|
|
||||||
|
Verification found concrete failure.
|
||||||
|
|
||||||
|
### Verified
|
||||||
|
|
||||||
|
The work passed the required QA checks and is complete from the department’s perspective.
|
||||||
|
|
||||||
|
## Suggested Data Model
|
||||||
|
|
||||||
|
V1 shape:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type QaStatus =
|
||||||
|
| "queued"
|
||||||
|
| "in_review"
|
||||||
|
| "changes_requested"
|
||||||
|
| "blocked"
|
||||||
|
| "approved"
|
||||||
|
| "failed"
|
||||||
|
| "verified";
|
||||||
|
|
||||||
|
type QaSeverity = "low" | "medium" | "high" | "critical";
|
||||||
|
|
||||||
|
type QaIssue = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
severity: QaSeverity;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
authorType: "human" | "agent" | "system";
|
||||||
|
authorId?: string | null;
|
||||||
|
linkedTaskId?: string | null;
|
||||||
|
linkedAgentId?: string | null;
|
||||||
|
linkedSessionKey?: string | null;
|
||||||
|
resolved: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type QaReviewItem = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
status: QaStatus;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
assignedReviewerAgentId?: string | null;
|
||||||
|
linkedTaskId?: string | null;
|
||||||
|
linkedAgentId?: string | null;
|
||||||
|
linkedSessionKey?: string | null;
|
||||||
|
summary?: string | null;
|
||||||
|
issues: QaIssue[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type QaDepartmentState = {
|
||||||
|
items: QaReviewItem[];
|
||||||
|
readiness: "green" | "yellow" | "red";
|
||||||
|
updatedAt?: string;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Relationship To Existing Systems
|
||||||
|
|
||||||
|
The QA department should plug into systems Claw3D already has.
|
||||||
|
|
||||||
|
### Task Board / Kanban
|
||||||
|
|
||||||
|
The QA department should consume work from the task board.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- task moves into a review-ready state
|
||||||
|
- QA item is created or updated
|
||||||
|
- blocked QA creates blocker visibility back on the bulletin board
|
||||||
|
|
||||||
|
Suggested relationship:
|
||||||
|
|
||||||
|
- task board = execution status
|
||||||
|
- QA department = verification status
|
||||||
|
|
||||||
|
### Bulletin Board
|
||||||
|
|
||||||
|
The bulletin board should show the important QA outcomes.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- "Build blocked on QA"
|
||||||
|
- "Regression found in Hermes adapter flow"
|
||||||
|
- "Release candidate verified"
|
||||||
|
|
||||||
|
Suggested card mapping:
|
||||||
|
|
||||||
|
- critical QA issue -> blocker card
|
||||||
|
- release-ready signal -> announcement card
|
||||||
|
- changes requested -> handoff card
|
||||||
|
|
||||||
|
### Meeting Room
|
||||||
|
|
||||||
|
Review meetings should naturally feed into QA.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- planning meeting creates work
|
||||||
|
- execution completes
|
||||||
|
- review meeting sends selected items into QA
|
||||||
|
- QA findings can be discussed in a follow-up review meeting
|
||||||
|
|
||||||
|
This makes the meeting room and QA department part of one loop instead of separate ideas.
|
||||||
|
|
||||||
|
### Approvals
|
||||||
|
|
||||||
|
Claw3D already has approval-related surfaces.
|
||||||
|
|
||||||
|
The QA department should integrate with them conceptually, even if V1 is mostly local office state.
|
||||||
|
|
||||||
|
Important distinction:
|
||||||
|
|
||||||
|
- QA approval = "this looks good from verification"
|
||||||
|
- release approval = "a human or higher authority allows the next action"
|
||||||
|
|
||||||
|
Those are related but not identical.
|
||||||
|
|
||||||
|
### GitHub / Review Surfaces
|
||||||
|
|
||||||
|
Claw3D already has review-adjacent UI, including GitHub-oriented immersive screens.
|
||||||
|
|
||||||
|
The QA department should be able to:
|
||||||
|
|
||||||
|
- reflect review outcomes
|
||||||
|
- ingest review summaries
|
||||||
|
- show whether work is waiting for review or returned with changes requested
|
||||||
|
|
||||||
|
## In-World UX
|
||||||
|
|
||||||
|
The QA department should feel like a place in the office.
|
||||||
|
|
||||||
|
Possible visual forms:
|
||||||
|
|
||||||
|
- QA lab
|
||||||
|
- testing bullpen
|
||||||
|
- release desk
|
||||||
|
- audit wall
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
- queue visible in-world
|
||||||
|
- blocked items stand out clearly
|
||||||
|
- verified items visibly clear from the queue
|
||||||
|
- readiness state visible at a glance
|
||||||
|
|
||||||
|
The room should communicate office health, not just hold another panel.
|
||||||
|
|
||||||
|
## Secondary UI
|
||||||
|
|
||||||
|
Also provide a non-spatial UI surface.
|
||||||
|
|
||||||
|
Good options:
|
||||||
|
|
||||||
|
- HQ sidebar panel
|
||||||
|
- immersive QA screen
|
||||||
|
- release/readiness panel
|
||||||
|
|
||||||
|
Users should be able to inspect:
|
||||||
|
|
||||||
|
- queued reviews
|
||||||
|
- open issues
|
||||||
|
- who owns each item
|
||||||
|
- overall readiness state
|
||||||
|
|
||||||
|
## V1 Automation
|
||||||
|
|
||||||
|
Useful automations:
|
||||||
|
|
||||||
|
- create a QA item when a task enters review-ready state
|
||||||
|
- create blocker cards for high-severity QA issues
|
||||||
|
- update readiness color based on unresolved critical/high issues
|
||||||
|
- generate a short QA summary when an item leaves review
|
||||||
|
|
||||||
|
Keep automation conservative.
|
||||||
|
|
||||||
|
Avoid flooding the system with low-value noise.
|
||||||
|
|
||||||
|
## Storage Model
|
||||||
|
|
||||||
|
V1 can be stored in office preferences, similar to bulletin board and whiteboard systems.
|
||||||
|
|
||||||
|
Suggested shape:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type OfficePreference = {
|
||||||
|
qaDepartment?: QaDepartmentState;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
This keeps the feature:
|
||||||
|
|
||||||
|
- backend-neutral
|
||||||
|
- easy to persist
|
||||||
|
- easy to evolve later
|
||||||
|
|
||||||
|
## Human Interaction Model
|
||||||
|
|
||||||
|
The human should be able to:
|
||||||
|
|
||||||
|
- open the QA queue
|
||||||
|
- inspect a review item
|
||||||
|
- mark status changes
|
||||||
|
- add issues
|
||||||
|
- resolve issues
|
||||||
|
- promote or reject readiness
|
||||||
|
|
||||||
|
Humans should remain the final arbiter when needed, especially for ship/release-style outcomes.
|
||||||
|
|
||||||
|
## Agent Interaction Model
|
||||||
|
|
||||||
|
QA agents should be able to:
|
||||||
|
|
||||||
|
- review work items
|
||||||
|
- generate findings
|
||||||
|
- summarize likely regressions
|
||||||
|
- mark items as changes requested or verified
|
||||||
|
- surface blockers
|
||||||
|
|
||||||
|
Longer term:
|
||||||
|
|
||||||
|
- specialized QA agents may exist by area
|
||||||
|
- adapter QA
|
||||||
|
- UI QA
|
||||||
|
- release QA
|
||||||
|
- regression QA
|
||||||
|
|
||||||
|
## Readiness Signal
|
||||||
|
|
||||||
|
The department should publish an office-level readiness state:
|
||||||
|
|
||||||
|
- `green`
|
||||||
|
- `yellow`
|
||||||
|
- `red`
|
||||||
|
|
||||||
|
Suggested meaning:
|
||||||
|
|
||||||
|
- green = no blocking QA issues
|
||||||
|
- yellow = warnings / pending review / moderate unresolved issues
|
||||||
|
- red = blocking failures or critical unresolved issues
|
||||||
|
|
||||||
|
This signal should be visible outside the QA room as well.
|
||||||
|
|
||||||
|
For example:
|
||||||
|
|
||||||
|
- bulletin board card
|
||||||
|
- office status banner
|
||||||
|
- release desk indicator
|
||||||
|
|
||||||
|
## Out of Scope For V1
|
||||||
|
|
||||||
|
Do not include these initially:
|
||||||
|
|
||||||
|
- full CI orchestration
|
||||||
|
- external test runner infrastructure
|
||||||
|
- rich flake analytics
|
||||||
|
- cross-repo release orchestration
|
||||||
|
- advanced approval hierarchies
|
||||||
|
- fully automated release pipelines
|
||||||
|
|
||||||
|
V1 should be office workflow first.
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
Recommended order:
|
||||||
|
|
||||||
|
1. Define QA review item and issue schema.
|
||||||
|
2. Add local persisted QA department state.
|
||||||
|
3. Build a simple QA queue panel.
|
||||||
|
4. Add readiness signal.
|
||||||
|
5. Connect task board / review-ready states to QA queue creation.
|
||||||
|
6. Emit bulletin board blockers or announcements from QA outcomes.
|
||||||
|
|
||||||
|
## Existing Code Seams
|
||||||
|
|
||||||
|
This work should likely align with:
|
||||||
|
|
||||||
|
- task board state and transitions
|
||||||
|
- approval/review UI surfaces
|
||||||
|
- GitHub immersive review screens
|
||||||
|
- office performance / approvals analytics
|
||||||
|
- bulletin board and meeting room outputs from the new docs
|
||||||
|
|
||||||
|
The key is to avoid building QA as an isolated toy feature.
|
||||||
|
|
||||||
|
It should be another operational loop in the same office system.
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
V1 is successful if:
|
||||||
|
|
||||||
|
- the office can visibly route work into QA
|
||||||
|
- QA findings can block or clear work in a legible way
|
||||||
|
- users can inspect review items and issues
|
||||||
|
- readiness state is visible at the office level
|
||||||
|
- QA outcomes can feed the bulletin board
|
||||||
|
|
||||||
|
## Future Extensions
|
||||||
|
|
||||||
|
Once V1 is stable, follow-up work can add:
|
||||||
|
|
||||||
|
- QA meeting rituals
|
||||||
|
- release room / release wall
|
||||||
|
- specialized QA subteams
|
||||||
|
- automated regression summaries
|
||||||
|
- richer review analytics
|
||||||
|
- policy-aware signoff chains
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The QA department should make verification a first-class part of office life.
|
||||||
|
|
||||||
|
It closes the loop between planning, execution, and trustworthy completion.
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
# Whiteboard Spec
|
||||||
|
|
||||||
|
> Second concrete office-system feature for Claw3D, designed to work alongside the bulletin board.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add a whiteboard system inside the office for collaborative planning, meeting notes, and draft idea shaping.
|
||||||
|
|
||||||
|
The whiteboard is where the office thinks.
|
||||||
|
|
||||||
|
The bulletin board is where the office posts what matters.
|
||||||
|
|
||||||
|
## Product Position
|
||||||
|
|
||||||
|
The whiteboard should not duplicate the bulletin board.
|
||||||
|
|
||||||
|
Use the distinction:
|
||||||
|
|
||||||
|
- bulletin board = visible office signals
|
||||||
|
- whiteboard = active drafting and planning surface
|
||||||
|
|
||||||
|
The whiteboard is best for:
|
||||||
|
|
||||||
|
- brainstorming
|
||||||
|
- architecture outlines
|
||||||
|
- meeting notes
|
||||||
|
- draft task breakdowns
|
||||||
|
- org planning
|
||||||
|
- decision framing
|
||||||
|
|
||||||
|
It is not a Kanban replacement and not a polished document editor.
|
||||||
|
|
||||||
|
## Why This Feature Matters
|
||||||
|
|
||||||
|
The office already has:
|
||||||
|
|
||||||
|
- standup logic
|
||||||
|
- meeting room space
|
||||||
|
- whiteboard props in the retro office
|
||||||
|
- task board and planning-adjacent systems
|
||||||
|
|
||||||
|
What is missing is a shared in-world planning surface.
|
||||||
|
|
||||||
|
The whiteboard creates that surface.
|
||||||
|
|
||||||
|
## Primary Use Cases
|
||||||
|
|
||||||
|
### Meeting Notes
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- standup talking points
|
||||||
|
- decisions made during a meeting
|
||||||
|
- action items
|
||||||
|
- unresolved questions
|
||||||
|
|
||||||
|
### Brainstorming
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- possible approaches to a feature
|
||||||
|
- tradeoff comparisons
|
||||||
|
- rough implementation ideas
|
||||||
|
- product concept sketches in text form
|
||||||
|
|
||||||
|
### Architecture Planning
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- component breakdown
|
||||||
|
- adapter/provider mapping
|
||||||
|
- system boundaries
|
||||||
|
- workflow diagrams in structured text
|
||||||
|
|
||||||
|
### Org Planning
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- team structure drafts
|
||||||
|
- role definitions
|
||||||
|
- department responsibilities
|
||||||
|
- handoff chains
|
||||||
|
|
||||||
|
### Session-to-Plan Bridge
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- summarize an agent conversation into a board section
|
||||||
|
- turn standup outputs into grouped notes
|
||||||
|
- capture a working draft before turning it into bulletin board items or tasks
|
||||||
|
|
||||||
|
## V1 Scope
|
||||||
|
|
||||||
|
V1 should be structured, not freehand.
|
||||||
|
|
||||||
|
That means:
|
||||||
|
|
||||||
|
- text blocks
|
||||||
|
- sections
|
||||||
|
- cards / note clusters
|
||||||
|
- ordering
|
||||||
|
- lightweight templates
|
||||||
|
|
||||||
|
Do not start with arbitrary drawing tools.
|
||||||
|
|
||||||
|
## Whiteboard Model
|
||||||
|
|
||||||
|
Suggested V1 shape:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type WhiteboardBlockType =
|
||||||
|
| "heading"
|
||||||
|
| "note"
|
||||||
|
| "decision"
|
||||||
|
| "question"
|
||||||
|
| "action"
|
||||||
|
| "group";
|
||||||
|
|
||||||
|
type WhiteboardBlock = {
|
||||||
|
id: string;
|
||||||
|
type: WhiteboardBlockType;
|
||||||
|
title?: string;
|
||||||
|
body?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
authorType: "human" | "agent" | "system";
|
||||||
|
authorId?: string | null;
|
||||||
|
authorName?: string | null;
|
||||||
|
linkedAgentId?: string | null;
|
||||||
|
linkedSessionKey?: string | null;
|
||||||
|
linkedTaskId?: string | null;
|
||||||
|
color?: string | null;
|
||||||
|
collapsed?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WhiteboardDocument = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
mode: "planning" | "meeting" | "architecture" | "org" | "freeform";
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
archived: boolean;
|
||||||
|
blocks: WhiteboardBlock[];
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## V1 Interaction Model
|
||||||
|
|
||||||
|
V1 interactions:
|
||||||
|
|
||||||
|
- create whiteboard
|
||||||
|
- rename whiteboard
|
||||||
|
- add/edit/delete blocks
|
||||||
|
- reorder blocks
|
||||||
|
- collapse/expand groups
|
||||||
|
- link a block to an agent, task, or session
|
||||||
|
- archive whiteboard
|
||||||
|
- duplicate whiteboard
|
||||||
|
|
||||||
|
Optional but useful:
|
||||||
|
|
||||||
|
- convert a block into a bulletin-board card
|
||||||
|
- convert a block into a task seed
|
||||||
|
|
||||||
|
## Templates
|
||||||
|
|
||||||
|
Templates are important because they make the feature useful immediately.
|
||||||
|
|
||||||
|
Recommended V1 templates:
|
||||||
|
|
||||||
|
- `Meeting Notes`
|
||||||
|
- `Standup Review`
|
||||||
|
- `Planning Session`
|
||||||
|
- `Architecture Draft`
|
||||||
|
- `Org Planning`
|
||||||
|
|
||||||
|
### Example: Meeting Notes Template
|
||||||
|
|
||||||
|
Sections:
|
||||||
|
|
||||||
|
- attendees
|
||||||
|
- current topic
|
||||||
|
- decisions
|
||||||
|
- blockers
|
||||||
|
- next actions
|
||||||
|
|
||||||
|
### Example: Planning Session Template
|
||||||
|
|
||||||
|
Sections:
|
||||||
|
|
||||||
|
- problem
|
||||||
|
- options
|
||||||
|
- risks
|
||||||
|
- chosen direction
|
||||||
|
- tasks
|
||||||
|
|
||||||
|
## Relationship To Existing Systems
|
||||||
|
|
||||||
|
The whiteboard should integrate with what Claw3D already has.
|
||||||
|
|
||||||
|
### Standup
|
||||||
|
|
||||||
|
The standup controller already exists.
|
||||||
|
|
||||||
|
The whiteboard should support:
|
||||||
|
|
||||||
|
- auto-creating a meeting notes board for an active standup
|
||||||
|
- writing participant summaries to blocks
|
||||||
|
- collecting blockers and next actions into dedicated sections
|
||||||
|
|
||||||
|
### Bulletin Board
|
||||||
|
|
||||||
|
The whiteboard should feed the bulletin board, not replace it.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- convert a decision block into an announcement card
|
||||||
|
- convert a blocker block into a blocker card
|
||||||
|
- convert a next-action block into a handoff card
|
||||||
|
|
||||||
|
### Task Board / Kanban
|
||||||
|
|
||||||
|
The whiteboard is where a plan is shaped before it becomes a tracked workflow.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- rough task breakdown on whiteboard
|
||||||
|
- selected action blocks converted into actual task records
|
||||||
|
- blocked tasks reflected back to the bulletin board
|
||||||
|
|
||||||
|
### Company Builder / Org Planning
|
||||||
|
|
||||||
|
The whiteboard is a natural fit for:
|
||||||
|
|
||||||
|
- team structure drafts
|
||||||
|
- department planning
|
||||||
|
- role relationship mapping
|
||||||
|
|
||||||
|
This is especially useful before company-builder output becomes actual agents.
|
||||||
|
|
||||||
|
## In-World UX
|
||||||
|
|
||||||
|
The whiteboard should exist as a real office surface.
|
||||||
|
|
||||||
|
Recommended forms:
|
||||||
|
|
||||||
|
- meeting-room whiteboard
|
||||||
|
- wall-mounted planning board
|
||||||
|
- design room / architecture board in future themes
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
- clicking the board opens an immersive planning surface
|
||||||
|
- active meetings can auto-focus or highlight the whiteboard
|
||||||
|
- whiteboard state should feel like part of the room, not a random modal
|
||||||
|
|
||||||
|
## Sidebar / Secondary Access
|
||||||
|
|
||||||
|
The user should also be able to open the whiteboard from a panel or shortcut.
|
||||||
|
|
||||||
|
Good options:
|
||||||
|
|
||||||
|
- HQ sidebar tab
|
||||||
|
- meeting controls
|
||||||
|
- standup panel
|
||||||
|
|
||||||
|
This is especially important when users want direct access without camera movement.
|
||||||
|
|
||||||
|
## Storage Model
|
||||||
|
|
||||||
|
Like the bulletin board, V1 should be persisted locally in office preferences.
|
||||||
|
|
||||||
|
Suggested shape:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type OfficePreference = {
|
||||||
|
whiteboards?: {
|
||||||
|
documents: WhiteboardDocument[];
|
||||||
|
activeDocumentId?: string | null;
|
||||||
|
updatedAt?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Storage should be keyed by gateway URL / office context so each connected office can keep its own working state.
|
||||||
|
|
||||||
|
## JSON Canvas Compatibility
|
||||||
|
|
||||||
|
JSON Canvas is a good interoperability target for the whiteboard, but it should not define the product by itself.
|
||||||
|
|
||||||
|
Recommended stance:
|
||||||
|
|
||||||
|
- use Claw3D's own whiteboard model as the primary domain model
|
||||||
|
- support export/import to JSON Canvas as a compatibility layer
|
||||||
|
- avoid turning the whiteboard into a generic infinite-canvas editor before the office workflow is proven
|
||||||
|
|
||||||
|
Why:
|
||||||
|
|
||||||
|
- Claw3D needs stronger links to meetings, bulletin board items, tasks, agents, and sessions
|
||||||
|
- the whiteboard is a workflow surface, not only a canvas
|
||||||
|
- structured planning is more important than unconstrained canvas freedom in V1
|
||||||
|
|
||||||
|
Good use of JSON Canvas:
|
||||||
|
|
||||||
|
- export planning boards
|
||||||
|
- import external draft canvases
|
||||||
|
- map blocks/groups into JSON Canvas nodes
|
||||||
|
- preserve links where practical
|
||||||
|
|
||||||
|
Bad use of JSON Canvas:
|
||||||
|
|
||||||
|
- letting a generic canvas model dictate the first product UX
|
||||||
|
- replacing office-native planning behavior with a broad but shallow editor
|
||||||
|
|
||||||
|
## Authoring Rules
|
||||||
|
|
||||||
|
Allowed authors:
|
||||||
|
|
||||||
|
- human
|
||||||
|
- agent
|
||||||
|
- system
|
||||||
|
|
||||||
|
Recommended behavior:
|
||||||
|
|
||||||
|
- human edits are fully editable
|
||||||
|
- system-generated sections should remain editable but visibly marked
|
||||||
|
- agent-authored blocks should show provenance
|
||||||
|
|
||||||
|
That balance keeps the board useful without feeling rigid.
|
||||||
|
|
||||||
|
## V1 Automation
|
||||||
|
|
||||||
|
Useful automations:
|
||||||
|
|
||||||
|
- create a whiteboard automatically when a standup meeting starts
|
||||||
|
- seed a whiteboard from a planning command or meeting ritual
|
||||||
|
- let an agent summarize a session into selected board blocks
|
||||||
|
|
||||||
|
Important:
|
||||||
|
|
||||||
|
- automation should create structure, not spam content
|
||||||
|
- the user should remain able to edit the board freely
|
||||||
|
|
||||||
|
## Visual Structure
|
||||||
|
|
||||||
|
V1 should look like a structured planning board, not a blank canvas.
|
||||||
|
|
||||||
|
Possible presentation:
|
||||||
|
|
||||||
|
- left column for sections
|
||||||
|
- center canvas for block editing
|
||||||
|
- right rail for linked agents/sessions/tasks
|
||||||
|
|
||||||
|
Or:
|
||||||
|
|
||||||
|
- grouped lanes by section with text cards inside them
|
||||||
|
|
||||||
|
The design should prioritize clarity over novelty.
|
||||||
|
|
||||||
|
## Out of Scope For V1
|
||||||
|
|
||||||
|
Do not include these initially:
|
||||||
|
|
||||||
|
- freehand drawing tools
|
||||||
|
- multiplayer cursor presence
|
||||||
|
- arbitrary shapes/connectors
|
||||||
|
- full diagramming toolkit
|
||||||
|
- external document sync
|
||||||
|
- rich media embedding
|
||||||
|
- advanced permissions by department
|
||||||
|
|
||||||
|
Those can come later if the structured board proves valuable.
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
Recommended order:
|
||||||
|
|
||||||
|
1. Define whiteboard document and block schema.
|
||||||
|
2. Add office preference persistence.
|
||||||
|
3. Build a simple whiteboard panel UI with templates.
|
||||||
|
4. Connect the in-world whiteboard object to open the panel.
|
||||||
|
5. Add standup seeding / meeting integration.
|
||||||
|
6. Add conversions into bulletin-board cards and task seeds.
|
||||||
|
|
||||||
|
## Existing Code Seams
|
||||||
|
|
||||||
|
This feature should align with:
|
||||||
|
|
||||||
|
- standup systems in `src/features/office/hooks/useOfficeStandupController.ts`
|
||||||
|
- standup API routes under `src/app/api/office/standup`
|
||||||
|
- retro office whiteboard objects and room interactions
|
||||||
|
- office settings persistence
|
||||||
|
- task board seeding concepts already present in office task flows
|
||||||
|
|
||||||
|
This reduces implementation risk and keeps the feature tied to real office mechanics.
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
V1 is successful if:
|
||||||
|
|
||||||
|
- the user can open a whiteboard from inside the office
|
||||||
|
- a meeting or planning session can write structured notes to it
|
||||||
|
- the board can link to agents, sessions, and tasks
|
||||||
|
- users can turn whiteboard outputs into bulletin board items or task seeds
|
||||||
|
- the system works independently of OpenClaw-specific behavior
|
||||||
|
|
||||||
|
## Future Extensions
|
||||||
|
|
||||||
|
Once V1 is working, the whiteboard can evolve into:
|
||||||
|
|
||||||
|
- diagram mode
|
||||||
|
- relationship mapping
|
||||||
|
- architecture views
|
||||||
|
- agent collaboration sessions
|
||||||
|
- department-specific whiteboards
|
||||||
|
- persistent planning archives
|
||||||
|
- richer visual theming by office skin
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The whiteboard should become Claw3D’s active planning surface.
|
||||||
|
|
||||||
|
It is where meetings, drafts, and rough plans take shape before they become tasks, bulletin items, or office decisions.
|
||||||
Generated
+41
-31
@@ -163,6 +163,7 @@
|
|||||||
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
|
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.28.6",
|
"@babel/code-frame": "^7.28.6",
|
||||||
"@babel/generator": "^7.28.6",
|
"@babel/generator": "^7.28.6",
|
||||||
@@ -469,6 +470,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
@@ -509,6 +511,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -1984,7 +1987,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz",
|
||||||
"integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==",
|
"integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||||
},
|
},
|
||||||
@@ -2088,7 +2090,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.39.0.tgz",
|
"resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.39.0.tgz",
|
||||||
"integrity": "sha512-R5R9tb2AXs2IRLNKLBJDynhkfmx7mX0vi8NkhZb3gUkPWHn6HXk5J8iQ/dql0U3ApfWym4kXXmBDRGO+oeOfjg==",
|
"integrity": "sha512-R5R9tb2AXs2IRLNKLBJDynhkfmx7mX0vi8NkhZb3gUkPWHn6HXk5J8iQ/dql0U3ApfWym4kXXmBDRGO+oeOfjg==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
}
|
}
|
||||||
@@ -2258,6 +2259,7 @@
|
|||||||
"integrity": "sha512-fWza+Lpbj6SkQKCrU6si4iu+fD2dD3gxNHFhUPxsfXBPhnv3rRSQVd0NtBUT9Z/RhF/boCBcuUaMUSTRTopjZg==",
|
"integrity": "sha512-fWza+Lpbj6SkQKCrU6si4iu+fD2dD3gxNHFhUPxsfXBPhnv3rRSQVd0NtBUT9Z/RhF/boCBcuUaMUSTRTopjZg==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright": "1.58.0"
|
"playwright": "1.58.0"
|
||||||
},
|
},
|
||||||
@@ -2313,6 +2315,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.5.0.tgz",
|
||||||
"integrity": "sha512-FiUzfYW4wB1+PpmsE47UM+mCads7j2+giRBltfwH7SNhah95rqJs3ltEs9V3pP8rYdS0QlNne+9Aj8dS/SiaIA==",
|
"integrity": "sha512-FiUzfYW4wB1+PpmsE47UM+mCads7j2+giRBltfwH7SNhah95rqJs3ltEs9V3pP8rYdS0QlNne+9Aj8dS/SiaIA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.17.8",
|
"@babel/runtime": "^7.17.8",
|
||||||
"@types/webxr": "*",
|
"@types/webxr": "*",
|
||||||
@@ -3066,7 +3069,6 @@
|
|||||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.10.4",
|
"@babel/code-frame": "^7.10.4",
|
||||||
"@babel/runtime": "^7.12.5",
|
"@babel/runtime": "^7.12.5",
|
||||||
@@ -3087,7 +3089,6 @@
|
|||||||
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
|
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dequal": "^2.0.3"
|
"dequal": "^2.0.3"
|
||||||
}
|
}
|
||||||
@@ -3169,8 +3170,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/@types/canvas-confetti": {
|
"node_modules/@types/canvas-confetti": {
|
||||||
"version": "1.9.0",
|
"version": "1.9.0",
|
||||||
@@ -3286,6 +3286,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.9.tgz",
|
||||||
"integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==",
|
"integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
@@ -3296,6 +3297,7 @@
|
|||||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
@@ -3320,6 +3322,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.183.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.183.1.tgz",
|
||||||
"integrity": "sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw==",
|
"integrity": "sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dimforge/rapier3d-compat": "~0.12.0",
|
"@dimforge/rapier3d-compat": "~0.12.0",
|
||||||
"@tweenjs/tween.js": "~23.1.3",
|
"@tweenjs/tween.js": "~23.1.3",
|
||||||
@@ -3397,6 +3400,7 @@
|
|||||||
"integrity": "sha512-nm3cvFN9SqZGXjmw5bZ6cGmvJSyJPn0wU9gHAZZHDnZl2wF9PhHv78Xf06E0MaNk4zLVHL8hb2/c32XvyJOLQg==",
|
"integrity": "sha512-nm3cvFN9SqZGXjmw5bZ6cGmvJSyJPn0wU9gHAZZHDnZl2wF9PhHv78Xf06E0MaNk4zLVHL8hb2/c32XvyJOLQg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.53.1",
|
"@typescript-eslint/scope-manager": "8.53.1",
|
||||||
"@typescript-eslint/types": "8.53.1",
|
"@typescript-eslint/types": "8.53.1",
|
||||||
@@ -3541,9 +3545,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
|
||||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -4054,6 +4058,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -4066,7 +4071,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz",
|
"resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz",
|
||||||
"integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==",
|
"integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"acorn": "^8"
|
"acorn": "^8"
|
||||||
}
|
}
|
||||||
@@ -4092,9 +4096,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "6.12.6",
|
"version": "6.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
||||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -4114,7 +4118,6 @@
|
|||||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
@@ -4449,9 +4452,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.12",
|
"version": "1.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
||||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -4492,6 +4495,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -4724,14 +4728,14 @@
|
|||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz",
|
||||||
"integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==",
|
"integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/class-variance-authority": {
|
"node_modules/class-variance-authority": {
|
||||||
"version": "0.7.1",
|
"version": "0.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
|
||||||
"integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
|
"integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"clsx": "^2.1.1"
|
"clsx": "^2.1.1"
|
||||||
},
|
},
|
||||||
@@ -4750,6 +4754,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
@@ -5107,8 +5112,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/draco3d": {
|
"node_modules/draco3d": {
|
||||||
"version": "1.5.7",
|
"version": "1.5.7",
|
||||||
@@ -5427,6 +5431,7 @@
|
|||||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -5628,6 +5633,7 @@
|
|||||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rtsao/scc": "^1.1.0",
|
"@rtsao/scc": "^1.1.0",
|
||||||
"array-includes": "^3.1.9",
|
"array-includes": "^3.1.9",
|
||||||
@@ -6054,6 +6060,7 @@
|
|||||||
"version": "2.3.2",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
@@ -6530,7 +6537,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-2.0.5.tgz",
|
||||||
"integrity": "sha512-0InH9/4oDCBRzWXhpOqusspLBrVfK1vPvbn9Wxl8DAQ8yyx5fWJRETICSwkiAMaYntjJAMBP1R4B6cQnEUYVEA==",
|
"integrity": "sha512-0InH9/4oDCBRzWXhpOqusspLBrVfK1vPvbn9Wxl8DAQ8yyx5fWJRETICSwkiAMaYntjJAMBP1R4B6cQnEUYVEA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"acorn": "^8.15.0",
|
"acorn": "^8.15.0",
|
||||||
"acorn-import-attributes": "^1.9.5",
|
"acorn-import-attributes": "^1.9.5",
|
||||||
@@ -7142,6 +7148,7 @@
|
|||||||
"integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==",
|
"integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@acemir/cssom": "^0.9.28",
|
"@acemir/cssom": "^0.9.28",
|
||||||
"@asamuzakjp/dom-selector": "^6.7.6",
|
"@asamuzakjp/dom-selector": "^6.7.6",
|
||||||
@@ -7624,7 +7631,6 @@
|
|||||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"lz-string": "bin/bin.js"
|
"lz-string": "bin/bin.js"
|
||||||
}
|
}
|
||||||
@@ -8597,8 +8603,7 @@
|
|||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz",
|
||||||
"integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==",
|
"integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
@@ -9173,7 +9178,6 @@
|
|||||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-regex": "^5.0.1",
|
"ansi-regex": "^5.0.1",
|
||||||
"ansi-styles": "^5.0.0",
|
"ansi-styles": "^5.0.0",
|
||||||
@@ -9189,7 +9193,6 @@
|
|||||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
},
|
},
|
||||||
@@ -9202,8 +9205,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/promise-worker-transferable": {
|
"node_modules/promise-worker-transferable": {
|
||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
@@ -9293,6 +9295,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -9302,6 +9305,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@@ -9519,7 +9523,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz",
|
||||||
"integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==",
|
"integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"debug": "^4.3.5",
|
"debug": "^4.3.5",
|
||||||
"module-details-from-path": "^1.0.3"
|
"module-details-from-path": "^1.0.3"
|
||||||
@@ -10289,6 +10292,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
|
||||||
"integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==",
|
"integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/dcastil"
|
"url": "https://github.com/sponsors/dcastil"
|
||||||
@@ -10319,7 +10323,8 @@
|
|||||||
"version": "0.183.2",
|
"version": "0.183.2",
|
||||||
"resolved": "https://registry.npmjs.org/three/-/three-0.183.2.tgz",
|
"resolved": "https://registry.npmjs.org/three/-/three-0.183.2.tgz",
|
||||||
"integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==",
|
"integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/three-mesh-bvh": {
|
"node_modules/three-mesh-bvh": {
|
||||||
"version": "0.8.3",
|
"version": "0.8.3",
|
||||||
@@ -10411,6 +10416,7 @@
|
|||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -10746,6 +10752,7 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -11019,6 +11026,7 @@
|
|||||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -11127,6 +11135,7 @@
|
|||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -11478,6 +11487,7 @@
|
|||||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -1,11 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "claw3d",
|
"name": "claw3d",
|
||||||
"version": "0.1.0",
|
"version": "0.1.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node server/index.js --dev",
|
"dev": "node server/index.js --dev",
|
||||||
"dev:https": "node server/index.js --dev --https",
|
"dev:https": "node server/index.js --dev --https",
|
||||||
|
"hermes-adapter": "node server/hermes-gateway-adapter.js",
|
||||||
|
"demo-gateway": "node server/demo-gateway-adapter.js",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "node server/index.js",
|
"start": "node server/index.js",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
|
|||||||
Executable
+138
@@ -0,0 +1,138 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# clawd3d-start — Start all Clawd3D services, auto-resolving port conflicts.
|
||||||
|
#
|
||||||
|
# Setup (once):
|
||||||
|
# echo 'alias clawd3d="/absolute/path/to/Claw3D/scripts/clawd3d-start.sh"' >> ~/.zshrc
|
||||||
|
# source ~/.zshrc
|
||||||
|
#
|
||||||
|
# Then just run: clawd3d
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
|
||||||
|
CLAWD3D_DIR="$(cd -- "$SCRIPT_DIR/.." >/dev/null 2>&1 && pwd)"
|
||||||
|
LOG_DIR="/tmp/clawd3d-logs"
|
||||||
|
mkdir -p "$LOG_DIR"
|
||||||
|
|
||||||
|
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
|
||||||
|
log() { echo -e "${GREEN}[clawd3d]${NC} $*"; }
|
||||||
|
warn() { echo -e "${YELLOW}[clawd3d]${NC} $*"; }
|
||||||
|
info() { echo -e "${BLUE}[clawd3d]${NC} $*"; }
|
||||||
|
|
||||||
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Returns PIDs *listening* on a port (excludes client connections), or empty.
|
||||||
|
pids_on_port() { lsof -ti:"$1" -sTCP:LISTEN 2>/dev/null || true; }
|
||||||
|
|
||||||
|
# True if the port is free.
|
||||||
|
port_free() { [ -z "$(pids_on_port "$1")" ]; }
|
||||||
|
|
||||||
|
# Find first free port >= $1.
|
||||||
|
find_free_port() {
|
||||||
|
local p=$1
|
||||||
|
while ! port_free "$p"; do p=$((p + 1)); done
|
||||||
|
echo "$p"
|
||||||
|
}
|
||||||
|
|
||||||
|
# True if every PID on $port matches the grep pattern in its command line.
|
||||||
|
port_owned_by() {
|
||||||
|
local port=$1 pattern=$2
|
||||||
|
local pids
|
||||||
|
pids=$(pids_on_port "$port")
|
||||||
|
[ -z "$pids" ] && return 1
|
||||||
|
while IFS= read -r pid; do
|
||||||
|
local cmd
|
||||||
|
cmd=$(ps -p "$pid" -o command= 2>/dev/null || true)
|
||||||
|
if ! echo "$cmd" | grep -qE "$pattern"; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
done <<< "$pids"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 1. Hermes gateway (API, default 8642) ────────────────────────────────────
|
||||||
|
HERMES_PORT=8642
|
||||||
|
if ! port_free $HERMES_PORT; then
|
||||||
|
if port_owned_by $HERMES_PORT "hermes"; then
|
||||||
|
warn "Hermes gateway already running on :$HERMES_PORT — reusing."
|
||||||
|
else
|
||||||
|
HERMES_PORT=$(find_free_port $((HERMES_PORT + 1)))
|
||||||
|
warn "Port 8642 taken by another process → using :$HERMES_PORT for Hermes."
|
||||||
|
log "Starting Hermes gateway on :$HERMES_PORT..."
|
||||||
|
nohup env API_SERVER_PORT="$HERMES_PORT" hermes gateway run \
|
||||||
|
> "$LOG_DIR/hermes-gateway.log" 2>&1 &
|
||||||
|
sleep 2
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log "Starting Hermes gateway on :$HERMES_PORT..."
|
||||||
|
nohup hermes gateway run > "$LOG_DIR/hermes-gateway.log" 2>&1 &
|
||||||
|
sleep 2
|
||||||
|
fi
|
||||||
|
HERMES_API_URL="http://localhost:$HERMES_PORT"
|
||||||
|
|
||||||
|
# ── 2. Hermes adapter (WebSocket bridge, default 18789) ──────────────────────
|
||||||
|
ADAPTER_PORT=18789
|
||||||
|
if ! port_free $ADAPTER_PORT; then
|
||||||
|
if port_owned_by $ADAPTER_PORT "node.*hermes-gateway-adapter"; then
|
||||||
|
warn "Hermes adapter already running on :$ADAPTER_PORT — reusing."
|
||||||
|
else
|
||||||
|
ADAPTER_PORT=$(find_free_port $((ADAPTER_PORT + 1)))
|
||||||
|
warn "Port 18789 taken by another process → using :$ADAPTER_PORT for adapter."
|
||||||
|
log "Starting Hermes adapter on :$ADAPTER_PORT..."
|
||||||
|
cd "$CLAWD3D_DIR"
|
||||||
|
nohup env HERMES_ADAPTER_PORT="$ADAPTER_PORT" HERMES_API_URL="$HERMES_API_URL" \
|
||||||
|
npm run hermes-adapter > "$LOG_DIR/hermes-adapter.log" 2>&1 &
|
||||||
|
sleep 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log "Starting Hermes adapter on :$ADAPTER_PORT..."
|
||||||
|
cd "$CLAWD3D_DIR"
|
||||||
|
nohup env HERMES_ADAPTER_PORT="$ADAPTER_PORT" HERMES_API_URL="$HERMES_API_URL" \
|
||||||
|
npm run hermes-adapter > "$LOG_DIR/hermes-adapter.log" 2>&1 &
|
||||||
|
sleep 1
|
||||||
|
fi
|
||||||
|
GATEWAY_WS_URL="ws://localhost:$ADAPTER_PORT"
|
||||||
|
|
||||||
|
# ── 3. Next.js dev server (default 3000) ─────────────────────────────────────
|
||||||
|
APP_PORT=3000
|
||||||
|
if ! port_free $APP_PORT; then
|
||||||
|
if port_owned_by $APP_PORT "node.*next|next-server|server/index\.js"; then
|
||||||
|
warn "Clawd3D dev server already running on :$APP_PORT — reusing."
|
||||||
|
else
|
||||||
|
APP_PORT=$(find_free_port $((APP_PORT + 1)))
|
||||||
|
warn "Port 3000 taken by another process → using :$APP_PORT for Clawd3D."
|
||||||
|
log "Starting Clawd3D dev server on :$APP_PORT..."
|
||||||
|
cd "$CLAWD3D_DIR"
|
||||||
|
nohup env PORT="$APP_PORT" NEXT_PUBLIC_GATEWAY_URL="$GATEWAY_WS_URL" \
|
||||||
|
npm run dev > "$LOG_DIR/clawd3d-dev.log" 2>&1 &
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log "Starting Clawd3D dev server on :$APP_PORT..."
|
||||||
|
cd "$CLAWD3D_DIR"
|
||||||
|
nohup env PORT="$APP_PORT" NEXT_PUBLIC_GATEWAY_URL="$GATEWAY_WS_URL" \
|
||||||
|
npm run dev > "$LOG_DIR/clawd3d-dev.log" 2>&1 &
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 4. Wait until the app responds ───────────────────────────────────────────
|
||||||
|
log "Waiting for Clawd3D to be ready at :$APP_PORT..."
|
||||||
|
ready=0
|
||||||
|
for i in $(seq 1 90); do
|
||||||
|
if curl -sf "http://localhost:$APP_PORT" > /dev/null 2>&1; then
|
||||||
|
ready=1; break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
if [ "$ready" -eq 0 ]; then
|
||||||
|
warn "Timed out waiting for :$APP_PORT — check $LOG_DIR/clawd3d-dev.log"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 5. Open browser ───────────────────────────────────────────────────────────
|
||||||
|
open "http://localhost:$APP_PORT"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
log " Clawd3D → http://localhost:$APP_PORT"
|
||||||
|
info " Gateway WS → $GATEWAY_WS_URL"
|
||||||
|
info " Hermes API → $HERMES_API_URL"
|
||||||
|
info " Logs → $LOG_DIR/"
|
||||||
|
log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
@@ -0,0 +1,513 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const http = require("http");
|
||||||
|
const { randomUUID } = require("crypto");
|
||||||
|
const { WebSocketServer } = require("ws");
|
||||||
|
|
||||||
|
const ADAPTER_PORT = parseInt(process.env.DEMO_ADAPTER_PORT || "18789", 10);
|
||||||
|
const MAIN_KEY = "main";
|
||||||
|
const MODELS = [{ id: "demo/mock-office", name: "Mock Office", provider: "demo" }];
|
||||||
|
|
||||||
|
const agents = new Map([
|
||||||
|
[
|
||||||
|
"demo-orchestrator",
|
||||||
|
{
|
||||||
|
id: "demo-orchestrator",
|
||||||
|
name: "Avery",
|
||||||
|
role: "Orchestrator",
|
||||||
|
workspace: "/demo/orchestrator",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"demo-researcher",
|
||||||
|
{
|
||||||
|
id: "demo-researcher",
|
||||||
|
name: "Mika",
|
||||||
|
role: "Research",
|
||||||
|
workspace: "/demo/research",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"demo-builder",
|
||||||
|
{
|
||||||
|
id: "demo-builder",
|
||||||
|
name: "Rune",
|
||||||
|
role: "Builder",
|
||||||
|
workspace: "/demo/builder",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const files = new Map();
|
||||||
|
const sessionSettings = new Map();
|
||||||
|
const conversationHistory = new Map();
|
||||||
|
const activeRuns = new Map();
|
||||||
|
const activeSendEventFns = new Set();
|
||||||
|
|
||||||
|
function randomId() {
|
||||||
|
return randomUUID().replace(/-/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionKeyFor(agentId) {
|
||||||
|
return `agent:${agentId}:${MAIN_KEY}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHistory(sessionKey) {
|
||||||
|
if (!conversationHistory.has(sessionKey)) {
|
||||||
|
conversationHistory.set(sessionKey, []);
|
||||||
|
}
|
||||||
|
return conversationHistory.get(sessionKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearHistory(sessionKey) {
|
||||||
|
conversationHistory.delete(sessionKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resOk(id, payload) {
|
||||||
|
return { type: "res", id, ok: true, payload: payload ?? {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resErr(id, code, message) {
|
||||||
|
return { type: "res", id, ok: false, error: { code, message } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcastEvent(frame) {
|
||||||
|
for (const send of activeSendEventFns) {
|
||||||
|
try {
|
||||||
|
send(frame);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function agentListPayload() {
|
||||||
|
return [...agents.values()].map((agent) => ({
|
||||||
|
id: agent.id,
|
||||||
|
name: agent.name,
|
||||||
|
workspace: agent.workspace,
|
||||||
|
identity: { name: agent.name, emoji: "🤖" },
|
||||||
|
role: agent.role,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDemoReply(agent, message) {
|
||||||
|
const normalized = message.trim();
|
||||||
|
const opening =
|
||||||
|
agent.role === "Orchestrator"
|
||||||
|
? `${agent.name} here. Demo office is live and the team is synced.`
|
||||||
|
: `${agent.name} reporting in from the ${agent.role.toLowerCase()} desk.`;
|
||||||
|
const action =
|
||||||
|
agent.role === "Research"
|
||||||
|
? "I would break this down into sources, constraints, and next questions."
|
||||||
|
: agent.role === "Builder"
|
||||||
|
? "I would turn that into concrete implementation steps and validation."
|
||||||
|
: "I can coordinate the team, route work, and summarize progress.";
|
||||||
|
return `${opening} You said: "${normalized}". ${action}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMethod(method, params, id, sendEvent) {
|
||||||
|
const p = params || {};
|
||||||
|
|
||||||
|
switch (method) {
|
||||||
|
case "agents.list":
|
||||||
|
return resOk(id, { defaultId: "demo-orchestrator", mainKey: MAIN_KEY, agents: agentListPayload() });
|
||||||
|
|
||||||
|
case "agents.create": {
|
||||||
|
const name = typeof p.name === "string" && p.name.trim() ? p.name.trim() : "Demo Agent";
|
||||||
|
const role = typeof p.role === "string" ? p.role.trim() : "";
|
||||||
|
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "demo-agent";
|
||||||
|
const agentId = `${slug}-${randomId().slice(0, 6)}`;
|
||||||
|
agents.set(agentId, {
|
||||||
|
id: agentId,
|
||||||
|
name,
|
||||||
|
role,
|
||||||
|
workspace: `/demo/${slug}`,
|
||||||
|
});
|
||||||
|
broadcastEvent({
|
||||||
|
type: "event",
|
||||||
|
event: "presence",
|
||||||
|
payload: { sessions: { recent: [], byAgent: [] } },
|
||||||
|
});
|
||||||
|
return resOk(id, { agentId, name, workspace: `/demo/${slug}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
case "agents.update": {
|
||||||
|
const agentId = typeof p.agentId === "string" ? p.agentId.trim() : "";
|
||||||
|
const agent = agents.get(agentId);
|
||||||
|
if (!agent) return resErr(id, "not_found", `Agent ${agentId} not found`);
|
||||||
|
if (typeof p.name === "string" && p.name.trim()) agent.name = p.name.trim();
|
||||||
|
if (typeof p.role === "string") agent.role = p.role.trim();
|
||||||
|
return resOk(id, { ok: true, removedBindings: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
case "agents.delete": {
|
||||||
|
const agentId = typeof p.agentId === "string" ? p.agentId.trim() : "";
|
||||||
|
if (agentId && agents.has(agentId) && agentId !== "demo-orchestrator") {
|
||||||
|
agents.delete(agentId);
|
||||||
|
clearHistory(sessionKeyFor(agentId));
|
||||||
|
}
|
||||||
|
return resOk(id, { ok: true, removedBindings: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
case "agents.files.get": {
|
||||||
|
const key = `${p.agentId || "demo-orchestrator"}/${p.name || ""}`;
|
||||||
|
const content = files.get(key);
|
||||||
|
return resOk(id, { file: content !== undefined ? { content } : { missing: true } });
|
||||||
|
}
|
||||||
|
|
||||||
|
case "agents.files.set": {
|
||||||
|
const key = `${p.agentId || "demo-orchestrator"}/${p.name || ""}`;
|
||||||
|
files.set(key, typeof p.content === "string" ? p.content : "");
|
||||||
|
return resOk(id, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
case "config.get":
|
||||||
|
return resOk(id, {
|
||||||
|
config: { gateway: { reload: { mode: "hot" } } },
|
||||||
|
hash: "demo-gateway",
|
||||||
|
exists: true,
|
||||||
|
path: "/demo/config.json",
|
||||||
|
});
|
||||||
|
|
||||||
|
case "config.patch":
|
||||||
|
case "config.set":
|
||||||
|
return resOk(id, { hash: "demo-gateway" });
|
||||||
|
|
||||||
|
case "exec.approvals.get":
|
||||||
|
return resOk(id, {
|
||||||
|
path: "",
|
||||||
|
exists: true,
|
||||||
|
hash: "demo-approvals",
|
||||||
|
file: { version: 1, defaults: { security: "full", ask: "off", autoAllowSkills: true }, agents: {} },
|
||||||
|
});
|
||||||
|
|
||||||
|
case "exec.approvals.set":
|
||||||
|
return resOk(id, { hash: "demo-approvals" });
|
||||||
|
|
||||||
|
case "exec.approval.resolve":
|
||||||
|
return resOk(id, { ok: true });
|
||||||
|
|
||||||
|
case "models.list":
|
||||||
|
return resOk(id, { models: MODELS });
|
||||||
|
|
||||||
|
case "skills.status":
|
||||||
|
return resOk(id, { skills: [] });
|
||||||
|
|
||||||
|
case "cron.list":
|
||||||
|
return resOk(id, { jobs: [] });
|
||||||
|
|
||||||
|
case "cron.add":
|
||||||
|
case "cron.run":
|
||||||
|
case "cron.remove":
|
||||||
|
return resErr(id, "unsupported_method", `Demo runtime does not support ${method}.`);
|
||||||
|
|
||||||
|
case "sessions.list": {
|
||||||
|
const sessions = [...agents.values()].map((agent) => {
|
||||||
|
const sessionKey = sessionKeyFor(agent.id);
|
||||||
|
const history = getHistory(sessionKey);
|
||||||
|
const settings = sessionSettings.get(sessionKey) || {};
|
||||||
|
return {
|
||||||
|
key: sessionKey,
|
||||||
|
agentId: agent.id,
|
||||||
|
updatedAt: history.length > 0 ? Date.now() : null,
|
||||||
|
displayName: "Main",
|
||||||
|
origin: { label: agent.name, provider: "demo" },
|
||||||
|
model: settings.model || MODELS[0].id,
|
||||||
|
modelProvider: "demo",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return resOk(id, { sessions });
|
||||||
|
}
|
||||||
|
|
||||||
|
case "sessions.preview": {
|
||||||
|
const keys = Array.isArray(p.keys) ? p.keys : [];
|
||||||
|
const limit = typeof p.limit === "number" ? p.limit : 8;
|
||||||
|
const maxChars = typeof p.maxChars === "number" ? p.maxChars : 240;
|
||||||
|
const previews = keys.map((key) => {
|
||||||
|
const history = getHistory(key);
|
||||||
|
if (history.length === 0) return { key, status: "empty", items: [] };
|
||||||
|
const items = history.slice(-limit).map((msg) => ({
|
||||||
|
role: msg.role === "assistant" ? "assistant" : "user",
|
||||||
|
text: String(msg.content || "").slice(0, maxChars),
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}));
|
||||||
|
return { key, status: "ok", items };
|
||||||
|
});
|
||||||
|
return resOk(id, { ts: Date.now(), previews });
|
||||||
|
}
|
||||||
|
|
||||||
|
case "sessions.patch": {
|
||||||
|
const key = typeof p.key === "string" ? p.key : sessionKeyFor("demo-orchestrator");
|
||||||
|
const current = sessionSettings.get(key) || {};
|
||||||
|
const next = { ...current };
|
||||||
|
if (p.model !== undefined) next.model = p.model;
|
||||||
|
if (p.thinkingLevel !== undefined) next.thinkingLevel = p.thinkingLevel;
|
||||||
|
sessionSettings.set(key, next);
|
||||||
|
return resOk(id, {
|
||||||
|
ok: true,
|
||||||
|
key,
|
||||||
|
entry: { thinkingLevel: next.thinkingLevel },
|
||||||
|
resolved: { model: next.model || MODELS[0].id, modelProvider: "demo" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
case "sessions.reset": {
|
||||||
|
const key = typeof p.key === "string" ? p.key : sessionKeyFor("demo-orchestrator");
|
||||||
|
clearHistory(key);
|
||||||
|
return resOk(id, { ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
case "chat.send": {
|
||||||
|
const sessionKey = typeof p.sessionKey === "string" ? p.sessionKey : sessionKeyFor("demo-orchestrator");
|
||||||
|
const agentId = sessionKey.startsWith("agent:") ? sessionKey.split(":")[1] : "demo-orchestrator";
|
||||||
|
const agent = agents.get(agentId) || agents.get("demo-orchestrator");
|
||||||
|
const message = typeof p.message === "string" ? p.message.trim() : String(p.message || "").trim();
|
||||||
|
const runId = typeof p.idempotencyKey === "string" && p.idempotencyKey ? p.idempotencyKey : randomId();
|
||||||
|
if (!message) return resOk(id, { status: "no-op", runId });
|
||||||
|
|
||||||
|
const reply = buildDemoReply(agent, message);
|
||||||
|
let aborted = false;
|
||||||
|
activeRuns.set(runId, {
|
||||||
|
runId,
|
||||||
|
sessionKey,
|
||||||
|
agentId,
|
||||||
|
abort() {
|
||||||
|
aborted = true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
setImmediate(async () => {
|
||||||
|
let seq = 0;
|
||||||
|
const emitChat = (state, extra) => {
|
||||||
|
sendEvent({
|
||||||
|
type: "event",
|
||||||
|
event: "chat",
|
||||||
|
seq: seq++,
|
||||||
|
payload: { runId, sessionKey, state, ...extra },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const words = reply.split(" ");
|
||||||
|
let partial = "";
|
||||||
|
for (const word of words) {
|
||||||
|
if (aborted) break;
|
||||||
|
partial = partial ? `${partial} ${word}` : word;
|
||||||
|
emitChat("delta", { message: { role: "assistant", content: partial } });
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 45));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aborted) {
|
||||||
|
emitChat("aborted", {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const history = getHistory(sessionKey);
|
||||||
|
history.push({ role: "user", content: message });
|
||||||
|
history.push({ role: "assistant", content: reply });
|
||||||
|
emitChat("final", { stopReason: "end_turn", message: { role: "assistant", content: reply } });
|
||||||
|
sendEvent({
|
||||||
|
type: "event",
|
||||||
|
event: "presence",
|
||||||
|
seq: seq++,
|
||||||
|
payload: {
|
||||||
|
sessions: {
|
||||||
|
recent: [{ key: sessionKey, updatedAt: Date.now() }],
|
||||||
|
byAgent: [{ agentId, recent: [{ key: sessionKey, updatedAt: Date.now() }] }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
activeRuns.delete(runId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return resOk(id, { status: "started", runId });
|
||||||
|
}
|
||||||
|
|
||||||
|
case "chat.abort": {
|
||||||
|
const runId = typeof p.runId === "string" ? p.runId.trim() : "";
|
||||||
|
const sessionKey = typeof p.sessionKey === "string" ? p.sessionKey.trim() : "";
|
||||||
|
let aborted = 0;
|
||||||
|
if (runId) {
|
||||||
|
const handle = activeRuns.get(runId);
|
||||||
|
if (handle) {
|
||||||
|
handle.abort();
|
||||||
|
activeRuns.delete(runId);
|
||||||
|
aborted += 1;
|
||||||
|
}
|
||||||
|
} else if (sessionKey) {
|
||||||
|
for (const [activeRunId, handle] of activeRuns.entries()) {
|
||||||
|
if (handle.sessionKey !== sessionKey) continue;
|
||||||
|
handle.abort();
|
||||||
|
activeRuns.delete(activeRunId);
|
||||||
|
aborted += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resOk(id, { ok: true, aborted });
|
||||||
|
}
|
||||||
|
|
||||||
|
case "chat.history": {
|
||||||
|
const sessionKey = typeof p.sessionKey === "string" ? p.sessionKey : sessionKeyFor("demo-orchestrator");
|
||||||
|
return resOk(id, { sessionKey, messages: getHistory(sessionKey) });
|
||||||
|
}
|
||||||
|
|
||||||
|
case "agent.wait": {
|
||||||
|
const runId = typeof p.runId === "string" ? p.runId : "";
|
||||||
|
const timeoutMs = typeof p.timeoutMs === "number" ? p.timeoutMs : 30000;
|
||||||
|
const start = Date.now();
|
||||||
|
while (activeRuns.has(runId) && Date.now() - start < timeoutMs) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
}
|
||||||
|
return resOk(id, { status: activeRuns.has(runId) ? "running" : "done" });
|
||||||
|
}
|
||||||
|
|
||||||
|
case "status": {
|
||||||
|
const recent = [...agents.keys()].flatMap((agentId) => {
|
||||||
|
const key = sessionKeyFor(agentId);
|
||||||
|
const history = getHistory(key);
|
||||||
|
return history.length > 0 ? [{ key, updatedAt: Date.now() }] : [];
|
||||||
|
});
|
||||||
|
return resOk(id, {
|
||||||
|
sessions: {
|
||||||
|
recent,
|
||||||
|
byAgent: [...agents.keys()].map((agentId) => ({
|
||||||
|
agentId,
|
||||||
|
recent: recent.filter((entry) => entry.key.includes(`:${agentId}:`)),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
case "wake":
|
||||||
|
return resOk(id, { ok: true });
|
||||||
|
|
||||||
|
default:
|
||||||
|
return resOk(id, {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAdapter() {
|
||||||
|
const httpServer = http.createServer((req, res) => {
|
||||||
|
res.writeHead(200, { "Content-Type": "text/plain" });
|
||||||
|
res.end("Claw3D Demo Gateway Adapter\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
const wss = new WebSocketServer({ server: httpServer });
|
||||||
|
wss.on("connection", (ws) => {
|
||||||
|
let connected = false;
|
||||||
|
let globalSeq = 0;
|
||||||
|
|
||||||
|
const send = (frame) => {
|
||||||
|
if (ws.readyState !== ws.OPEN) return;
|
||||||
|
ws.send(JSON.stringify(frame));
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendEventFn = (frame) => {
|
||||||
|
if (frame.type === "event" && typeof frame.seq !== "number") {
|
||||||
|
frame.seq = globalSeq++;
|
||||||
|
}
|
||||||
|
send(frame);
|
||||||
|
};
|
||||||
|
|
||||||
|
activeSendEventFns.add(sendEventFn);
|
||||||
|
send({ type: "event", event: "connect.challenge", payload: { nonce: randomId() } });
|
||||||
|
|
||||||
|
ws.on("message", async (raw) => {
|
||||||
|
let frame;
|
||||||
|
try {
|
||||||
|
frame = JSON.parse(raw.toString("utf8"));
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!frame || typeof frame !== "object" || frame.type !== "req") return;
|
||||||
|
const { id, method, params } = frame;
|
||||||
|
if (typeof id !== "string" || typeof method !== "string") return;
|
||||||
|
|
||||||
|
if (method === "connect") {
|
||||||
|
connected = true;
|
||||||
|
send({
|
||||||
|
type: "res",
|
||||||
|
id,
|
||||||
|
ok: true,
|
||||||
|
payload: {
|
||||||
|
type: "hello-ok",
|
||||||
|
protocol: 3,
|
||||||
|
adapterType: "demo",
|
||||||
|
features: {
|
||||||
|
methods: [
|
||||||
|
"agents.list",
|
||||||
|
"agents.create",
|
||||||
|
"agents.delete",
|
||||||
|
"agents.update",
|
||||||
|
"sessions.list",
|
||||||
|
"sessions.preview",
|
||||||
|
"sessions.patch",
|
||||||
|
"sessions.reset",
|
||||||
|
"chat.send",
|
||||||
|
"chat.abort",
|
||||||
|
"chat.history",
|
||||||
|
"agent.wait",
|
||||||
|
"status",
|
||||||
|
"config.get",
|
||||||
|
"config.set",
|
||||||
|
"config.patch",
|
||||||
|
"agents.files.get",
|
||||||
|
"agents.files.set",
|
||||||
|
"exec.approvals.get",
|
||||||
|
"exec.approvals.set",
|
||||||
|
"exec.approval.resolve",
|
||||||
|
"wake",
|
||||||
|
"skills.status",
|
||||||
|
"models.list",
|
||||||
|
"cron.list",
|
||||||
|
],
|
||||||
|
events: ["chat", "presence", "heartbeat"],
|
||||||
|
},
|
||||||
|
snapshot: {
|
||||||
|
health: {
|
||||||
|
agents: [...agents.values()].map((agent) => ({
|
||||||
|
agentId: agent.id,
|
||||||
|
name: agent.name,
|
||||||
|
isDefault: agent.id === "demo-orchestrator",
|
||||||
|
})),
|
||||||
|
defaultAgentId: "demo-orchestrator",
|
||||||
|
},
|
||||||
|
sessionDefaults: { mainKey: MAIN_KEY },
|
||||||
|
},
|
||||||
|
auth: { role: "operator", scopes: ["operator.admin"] },
|
||||||
|
policy: { tickIntervalMs: 30000 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!connected) {
|
||||||
|
send(resErr(id, "not_connected", "Send connect first."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
send(await handleMethod(method, params, id, sendEventFn));
|
||||||
|
} catch (error) {
|
||||||
|
send(resErr(id, "internal_error", error instanceof Error ? error.message : "Internal error"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on("close", () => activeSendEventFns.delete(sendEventFn));
|
||||||
|
ws.on("error", () => activeSendEventFns.delete(sendEventFn));
|
||||||
|
});
|
||||||
|
|
||||||
|
httpServer.listen(ADAPTER_PORT, "127.0.0.1", () => {
|
||||||
|
console.log(`[demo-gateway] Listening on ws://localhost:${ADAPTER_PORT}`);
|
||||||
|
console.log("[demo-gateway] No OpenClaw or Hermes required.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
startAdapter();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
handleMethod,
|
||||||
|
startAdapter,
|
||||||
|
};
|
||||||
@@ -100,6 +100,7 @@ function createGatewayProxy(options) {
|
|||||||
let upstreamReady = false;
|
let upstreamReady = false;
|
||||||
let upstreamUrl = "";
|
let upstreamUrl = "";
|
||||||
let upstreamToken = "";
|
let upstreamToken = "";
|
||||||
|
let upstreamAdapterType = "openclaw";
|
||||||
let connectRequestId = null;
|
let connectRequestId = null;
|
||||||
let connectResponseSent = false;
|
let connectResponseSent = false;
|
||||||
let pendingConnectFrame = null;
|
let pendingConnectFrame = null;
|
||||||
@@ -137,7 +138,8 @@ function createGatewayProxy(options) {
|
|||||||
hasNonEmptyDeviceToken(frame.params) ||
|
hasNonEmptyDeviceToken(frame.params) ||
|
||||||
hasCompleteDeviceAuth(frame.params);
|
hasCompleteDeviceAuth(frame.params);
|
||||||
|
|
||||||
if (!upstreamToken && !browserHasAuth) {
|
const requiresToken = upstreamAdapterType === "openclaw";
|
||||||
|
if (requiresToken && !upstreamToken && !browserHasAuth) {
|
||||||
sendConnectError(
|
sendConnectError(
|
||||||
"studio.gateway_token_missing",
|
"studio.gateway_token_missing",
|
||||||
"Upstream gateway token is not configured on the Studio host."
|
"Upstream gateway token is not configured on the Studio host."
|
||||||
@@ -168,6 +170,10 @@ function createGatewayProxy(options) {
|
|||||||
const settings = await loadUpstreamSettings();
|
const settings = await loadUpstreamSettings();
|
||||||
upstreamUrl = typeof settings?.url === "string" ? settings.url.trim() : "";
|
upstreamUrl = typeof settings?.url === "string" ? settings.url.trim() : "";
|
||||||
upstreamToken = typeof settings?.token === "string" ? settings.token.trim() : "";
|
upstreamToken = typeof settings?.token === "string" ? settings.token.trim() : "";
|
||||||
|
upstreamAdapterType =
|
||||||
|
typeof settings?.adapterType === "string" && settings.adapterType.trim()
|
||||||
|
? settings.adapterType.trim().toLowerCase()
|
||||||
|
: "openclaw";
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logError("Failed to load upstream gateway settings.", err);
|
logError("Failed to load upstream gateway settings.", err);
|
||||||
pendingUpstreamSetupError = {
|
pendingUpstreamSetupError = {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -91,7 +91,7 @@ async function main() {
|
|||||||
const proxy = createGatewayProxy({
|
const proxy = createGatewayProxy({
|
||||||
loadUpstreamSettings: async () => {
|
loadUpstreamSettings: async () => {
|
||||||
const settings = loadUpstreamGatewaySettings(process.env);
|
const settings = loadUpstreamGatewaySettings(process.env);
|
||||||
return { url: settings.url, token: settings.token };
|
return { url: settings.url, token: settings.token, adapterType: settings.adapterType };
|
||||||
},
|
},
|
||||||
allowWs: (req) => {
|
allowWs: (req) => {
|
||||||
if (resolvePathname(req.url) !== "/api/gateway/ws") return false;
|
if (resolvePathname(req.url) !== "/api/gateway/ws") return false;
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ const readOpenclawGatewayDefaults = (env = process.env) => {
|
|||||||
if (!token) return null;
|
if (!token) return null;
|
||||||
const url = port ? `ws://localhost:${port}` : "";
|
const url = port ? `ws://localhost:${port}` : "";
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
return { url, token };
|
return { url, token, adapterType: "openclaw" };
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -88,12 +88,17 @@ const loadUpstreamGatewaySettings = (env = process.env) => {
|
|||||||
const gateway = parsed && typeof parsed === "object" ? parsed.gateway : null;
|
const gateway = parsed && typeof parsed === "object" ? parsed.gateway : null;
|
||||||
const url = typeof gateway?.url === "string" ? gateway.url.trim() : "";
|
const url = typeof gateway?.url === "string" ? gateway.url.trim() : "";
|
||||||
const token = typeof gateway?.token === "string" ? gateway.token.trim() : "";
|
const token = typeof gateway?.token === "string" ? gateway.token.trim() : "";
|
||||||
if (!token) {
|
const adapterType =
|
||||||
|
typeof gateway?.adapterType === "string" && gateway.adapterType.trim()
|
||||||
|
? gateway.adapterType.trim()
|
||||||
|
: "openclaw";
|
||||||
|
if (!token && adapterType === "openclaw") {
|
||||||
const defaults = readOpenclawGatewayDefaults(env);
|
const defaults = readOpenclawGatewayDefaults(env);
|
||||||
if (defaults) {
|
if (defaults) {
|
||||||
return {
|
return {
|
||||||
url: url || defaults.url,
|
url: url || defaults.url,
|
||||||
token: defaults.token,
|
token: defaults.token,
|
||||||
|
adapterType,
|
||||||
settingsPath,
|
settingsPath,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -101,6 +106,7 @@ const loadUpstreamGatewaySettings = (env = process.env) => {
|
|||||||
return {
|
return {
|
||||||
url: url || DEFAULT_GATEWAY_URL,
|
url: url || DEFAULT_GATEWAY_URL,
|
||||||
token,
|
token,
|
||||||
|
adapterType,
|
||||||
settingsPath,
|
settingsPath,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
type CustomRuntimeRequestBody = {
|
||||||
|
runtimeUrl?: string;
|
||||||
|
pathname?: string;
|
||||||
|
method?: string;
|
||||||
|
body?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeRuntimeUrl = (value: string): string => {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
throw new Error("runtimeUrl is required.");
|
||||||
|
}
|
||||||
|
const parsed = new URL(trimmed);
|
||||||
|
if (parsed.protocol === "ws:") {
|
||||||
|
parsed.protocol = "http:";
|
||||||
|
} else if (parsed.protocol === "wss:") {
|
||||||
|
parsed.protocol = "https:";
|
||||||
|
}
|
||||||
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||||
|
throw new Error("runtimeUrl must use http, https, ws, or wss.");
|
||||||
|
}
|
||||||
|
parsed.username = "";
|
||||||
|
parsed.password = "";
|
||||||
|
return parsed.toString().replace(/\/$/, "");
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizePathname = (value: unknown): string => {
|
||||||
|
if (typeof value !== "string" || !value.trim()) {
|
||||||
|
throw new Error("pathname is required.");
|
||||||
|
}
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeMethod = (value: unknown): "GET" | "POST" => {
|
||||||
|
if (typeof value !== "string") return "GET";
|
||||||
|
const upper = value.trim().toUpperCase();
|
||||||
|
if (upper === "POST") return "POST";
|
||||||
|
return "GET";
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const payload = (await request.json()) as CustomRuntimeRequestBody;
|
||||||
|
const runtimeUrl = normalizeRuntimeUrl(payload.runtimeUrl ?? "");
|
||||||
|
const pathname = normalizePathname(payload.pathname);
|
||||||
|
const method = normalizeMethod(payload.method);
|
||||||
|
const response = await fetch(`${runtimeUrl}${pathname}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
...(method === "POST" ? { "Content-Type": "application/json" } : null),
|
||||||
|
},
|
||||||
|
body: method === "POST" ? JSON.stringify(payload.body ?? {}) : undefined,
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
return new NextResponse(text, {
|
||||||
|
status: response.status,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": response.headers.get("content-type") ?? "application/json",
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Custom runtime proxy failed.";
|
||||||
|
return NextResponse.json({ error: message }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,7 +36,11 @@ export async function GET() {
|
|||||||
|
|
||||||
export async function PUT(request: Request) {
|
export async function PUT(request: Request) {
|
||||||
try {
|
try {
|
||||||
const body = (await request.json()) as unknown;
|
const rawBody = await request.text();
|
||||||
|
if (!rawBody.trim()) {
|
||||||
|
return NextResponse.json({ error: "Invalid settings payload." }, { status: 400 });
|
||||||
|
}
|
||||||
|
const body = JSON.parse(rawBody) as unknown;
|
||||||
if (!isPatch(body)) {
|
if (!isPatch(body)) {
|
||||||
return NextResponse.json({ error: "Invalid settings payload." }, { status: 400 });
|
return NextResponse.json({ error: "Invalid settings payload." }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ type AgentChatPanelProps = {
|
|||||||
stopBusy: boolean;
|
stopBusy: boolean;
|
||||||
stopDisabledReason?: string | null;
|
stopDisabledReason?: string | null;
|
||||||
onLoadMoreHistory: () => void;
|
onLoadMoreHistory: () => void;
|
||||||
|
onOpenSettings?: () => void;
|
||||||
onRename?: (name: string) => Promise<boolean>;
|
onRename?: (name: string) => Promise<boolean>;
|
||||||
onNewSession?: () => Promise<void> | void;
|
onNewSession?: () => Promise<void> | void;
|
||||||
onModelChange: (value: string | null) => void;
|
onModelChange: (value: string | null) => void;
|
||||||
@@ -1201,6 +1202,7 @@ export const AgentChatPanel = ({
|
|||||||
stopBusy,
|
stopBusy,
|
||||||
stopDisabledReason = null,
|
stopDisabledReason = null,
|
||||||
onLoadMoreHistory,
|
onLoadMoreHistory,
|
||||||
|
onOpenSettings,
|
||||||
onRename,
|
onRename,
|
||||||
onNewSession,
|
onNewSession,
|
||||||
onModelChange,
|
onModelChange,
|
||||||
@@ -1590,6 +1592,18 @@ export const AgentChatPanel = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-0.5 flex items-center gap-2">
|
<div className="mt-0.5 flex items-center gap-2">
|
||||||
|
{onOpenSettings ? (
|
||||||
|
<button
|
||||||
|
className="nodrag ui-btn-icon ui-btn-icon-sm shrink-0"
|
||||||
|
type="button"
|
||||||
|
data-testid="agent-settings-toggle"
|
||||||
|
aria-label="Open behavior"
|
||||||
|
title="Open behavior"
|
||||||
|
onClick={onOpenSettings}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
<button
|
<button
|
||||||
className="nodrag inline-flex items-center whitespace-nowrap rounded border border-[color:var(--status-approval-border)] bg-[color:var(--status-approval-bg)] px-2 py-0.5 font-mono text-[9px] font-medium tracking-[0.02em] text-white transition hover:bg-[color:var(--status-approval-bg)] hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
|
className="nodrag inline-flex items-center whitespace-nowrap rounded border border-[color:var(--status-approval-border)] bg-[color:var(--status-approval-bg)] px-2 py-0.5 font-mono text-[9px] font-medium tracking-[0.02em] text-white transition hover:bg-[color:var(--status-approval-bg)] hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
import type { GatewayStatus } from "@/lib/gateway/GatewayClient";
|
import type { GatewayStatus } from "@/lib/gateway/GatewayClient";
|
||||||
|
import type { StudioGatewayAdapterType } from "@/lib/studio/settings";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { resolveGatewayStatusBadgeClass, resolveGatewayStatusLabel } from "./colorSemantics";
|
import { resolveGatewayStatusBadgeClass, resolveGatewayStatusLabel } from "./colorSemantics";
|
||||||
|
|
||||||
type ConnectionPanelProps = {
|
type ConnectionPanelProps = {
|
||||||
gatewayUrl: string;
|
gatewayUrl: string;
|
||||||
token: string;
|
token: string;
|
||||||
|
selectedAdapterType: StudioGatewayAdapterType;
|
||||||
|
activeAdapterType: StudioGatewayAdapterType;
|
||||||
|
localGatewayUrl?: string | null;
|
||||||
|
localGatewayToken?: string | null;
|
||||||
status: GatewayStatus;
|
status: GatewayStatus;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
onGatewayUrlChange: (value: string) => void;
|
onGatewayUrlChange: (value: string) => void;
|
||||||
onTokenChange: (value: string) => void;
|
onTokenChange: (value: string) => void;
|
||||||
|
onAdapterTypeChange: (value: StudioGatewayAdapterType) => void;
|
||||||
onConnect: () => void;
|
onConnect: () => void;
|
||||||
onDisconnect: () => void;
|
onDisconnect: () => void;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
@@ -17,16 +23,37 @@ type ConnectionPanelProps = {
|
|||||||
export const ConnectionPanel = ({
|
export const ConnectionPanel = ({
|
||||||
gatewayUrl,
|
gatewayUrl,
|
||||||
token,
|
token,
|
||||||
|
selectedAdapterType,
|
||||||
|
activeAdapterType,
|
||||||
|
localGatewayUrl = null,
|
||||||
|
localGatewayToken = null,
|
||||||
status,
|
status,
|
||||||
error,
|
error,
|
||||||
onGatewayUrlChange,
|
onGatewayUrlChange,
|
||||||
onTokenChange,
|
onTokenChange,
|
||||||
|
onAdapterTypeChange,
|
||||||
onConnect,
|
onConnect,
|
||||||
onDisconnect,
|
onDisconnect,
|
||||||
onClose,
|
onClose,
|
||||||
}: ConnectionPanelProps) => {
|
}: ConnectionPanelProps) => {
|
||||||
const isConnected = status === "connected";
|
const isConnected = status === "connected";
|
||||||
const isConnecting = status === "connecting";
|
const isConnecting = status === "connecting";
|
||||||
|
const tokenOptional =
|
||||||
|
selectedAdapterType === "hermes" ||
|
||||||
|
selectedAdapterType === "demo" ||
|
||||||
|
selectedAdapterType === "custom";
|
||||||
|
const applyDemoPreset = () => {
|
||||||
|
onAdapterTypeChange("demo");
|
||||||
|
};
|
||||||
|
const applyHermesPreset = () => {
|
||||||
|
onAdapterTypeChange("hermes");
|
||||||
|
};
|
||||||
|
const applyCustomPreset = () => {
|
||||||
|
onAdapterTypeChange("custom");
|
||||||
|
};
|
||||||
|
const applyOpenClawPreset = () => {
|
||||||
|
onAdapterTypeChange("openclaw");
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fade-up-delay flex flex-col gap-3">
|
<div className="fade-up-delay flex flex-col gap-3">
|
||||||
@@ -73,17 +100,52 @@ export const ConnectionPanel = ({
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex flex-col gap-1 font-mono text-[10px] font-semibold tracking-[0.06em] text-muted-foreground">
|
<label className="flex flex-col gap-1 font-mono text-[10px] font-semibold tracking-[0.06em] text-muted-foreground">
|
||||||
Upstream token
|
{tokenOptional ? "Upstream token (optional)" : "Upstream token"}
|
||||||
<input
|
<input
|
||||||
className="ui-input h-10 rounded-md px-4 font-sans text-sm text-foreground outline-none"
|
className="ui-input h-10 rounded-md px-4 font-sans text-sm text-foreground outline-none"
|
||||||
type="password"
|
type="password"
|
||||||
value={token}
|
value={token}
|
||||||
onChange={(event) => onTokenChange(event.target.value)}
|
onChange={(event) => onTokenChange(event.target.value)}
|
||||||
placeholder="gateway token"
|
placeholder={tokenOptional ? "optional token" : "gateway token"}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
|
||||||
|
<span className="font-mono">Selected backend: {selectedAdapterType}</span>
|
||||||
|
<span className="font-mono">Active backend: {activeAdapterType}</span>
|
||||||
|
<span>Each backend keeps its own saved URL and token.</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
className="ui-btn-secondary px-3 py-1.5 text-[11px] font-semibold tracking-[0.05em]"
|
||||||
|
type="button"
|
||||||
|
onClick={applyDemoPreset}
|
||||||
|
>
|
||||||
|
Demo backend
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ui-btn-secondary px-3 py-1.5 text-[11px] font-semibold tracking-[0.05em]"
|
||||||
|
type="button"
|
||||||
|
onClick={applyHermesPreset}
|
||||||
|
>
|
||||||
|
Hermes backend
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ui-btn-secondary px-3 py-1.5 text-[11px] font-semibold tracking-[0.05em]"
|
||||||
|
type="button"
|
||||||
|
onClick={applyCustomPreset}
|
||||||
|
>
|
||||||
|
Custom backend
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ui-btn-secondary px-3 py-1.5 text-[11px] font-semibold tracking-[0.05em]"
|
||||||
|
type="button"
|
||||||
|
onClick={applyOpenClawPreset}
|
||||||
|
>
|
||||||
|
OpenClaw backend
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{error ? (
|
{error ? (
|
||||||
<p className="ui-alert-danger rounded-md px-4 py-2 text-sm">
|
<p className="ui-alert-danger rounded-md px-4 py-2 text-sm">
|
||||||
{error}
|
{error}
|
||||||
|
|||||||
@@ -2,18 +2,21 @@ import { useMemo, useState } from "react";
|
|||||||
import { Check, Copy, Eye, EyeOff } from "lucide-react";
|
import { Check, Copy, Eye, EyeOff } from "lucide-react";
|
||||||
import type { GatewayStatus } from "@/lib/gateway/GatewayClient";
|
import type { GatewayStatus } from "@/lib/gateway/GatewayClient";
|
||||||
import { isLocalGatewayUrl } from "@/lib/gateway/local-gateway";
|
import { isLocalGatewayUrl } from "@/lib/gateway/local-gateway";
|
||||||
import type { StudioGatewaySettings } from "@/lib/studio/settings";
|
import type { StudioGatewayAdapterType, StudioGatewaySettings } from "@/lib/studio/settings";
|
||||||
import { RunningAvatarLoader } from "@/features/agents/components/RunningAvatarLoader";
|
import { RunningAvatarLoader } from "@/features/agents/components/RunningAvatarLoader";
|
||||||
|
|
||||||
type GatewayConnectScreenProps = {
|
type GatewayConnectScreenProps = {
|
||||||
gatewayUrl: string;
|
gatewayUrl: string;
|
||||||
token: string;
|
token: string;
|
||||||
|
selectedAdapterType: StudioGatewayAdapterType;
|
||||||
|
activeAdapterType: StudioGatewayAdapterType;
|
||||||
localGatewayDefaults: StudioGatewaySettings | null;
|
localGatewayDefaults: StudioGatewaySettings | null;
|
||||||
status: GatewayStatus;
|
status: GatewayStatus;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
showApprovalHint: boolean;
|
showApprovalHint: boolean;
|
||||||
onGatewayUrlChange: (value: string) => void;
|
onGatewayUrlChange: (value: string) => void;
|
||||||
onTokenChange: (value: string) => void;
|
onTokenChange: (value: string) => void;
|
||||||
|
onAdapterTypeChange: (value: StudioGatewayAdapterType) => void;
|
||||||
onUseLocalDefaults: () => void;
|
onUseLocalDefaults: () => void;
|
||||||
onConnect: () => void;
|
onConnect: () => void;
|
||||||
};
|
};
|
||||||
@@ -30,17 +33,24 @@ const resolveLocalGatewayPort = (gatewayUrl: string): number => {
|
|||||||
export const GatewayConnectScreen = ({
|
export const GatewayConnectScreen = ({
|
||||||
gatewayUrl,
|
gatewayUrl,
|
||||||
token,
|
token,
|
||||||
|
selectedAdapterType,
|
||||||
|
activeAdapterType,
|
||||||
localGatewayDefaults,
|
localGatewayDefaults,
|
||||||
status,
|
status,
|
||||||
error,
|
error,
|
||||||
showApprovalHint,
|
showApprovalHint,
|
||||||
onGatewayUrlChange,
|
onGatewayUrlChange,
|
||||||
onTokenChange,
|
onTokenChange,
|
||||||
|
onAdapterTypeChange,
|
||||||
onUseLocalDefaults,
|
onUseLocalDefaults,
|
||||||
onConnect,
|
onConnect,
|
||||||
}: GatewayConnectScreenProps) => {
|
}: GatewayConnectScreenProps) => {
|
||||||
const [copyStatus, setCopyStatus] = useState<"idle" | "copied" | "failed">("idle");
|
const [copyStatus, setCopyStatus] = useState<"idle" | "copied" | "failed">("idle");
|
||||||
const [showToken, setShowToken] = useState(false);
|
const [showToken, setShowToken] = useState(false);
|
||||||
|
const tokenOptional =
|
||||||
|
selectedAdapterType === "hermes" ||
|
||||||
|
selectedAdapterType === "demo" ||
|
||||||
|
selectedAdapterType === "custom";
|
||||||
const isLocal = useMemo(() => isLocalGatewayUrl(gatewayUrl), [gatewayUrl]);
|
const isLocal = useMemo(() => isLocalGatewayUrl(gatewayUrl), [gatewayUrl]);
|
||||||
const localPort = useMemo(() => resolveLocalGatewayPort(gatewayUrl), [gatewayUrl]);
|
const localPort = useMemo(() => resolveLocalGatewayPort(gatewayUrl), [gatewayUrl]);
|
||||||
const localGatewayCommand = useMemo(
|
const localGatewayCommand = useMemo(
|
||||||
@@ -51,6 +61,22 @@ export const GatewayConnectScreen = ({
|
|||||||
() => `pnpm openclaw gateway run --bind loopback --port ${localPort} --verbose`,
|
() => `pnpm openclaw gateway run --bind loopback --port ${localPort} --verbose`,
|
||||||
[localPort]
|
[localPort]
|
||||||
);
|
);
|
||||||
|
const localDemoCommand = useMemo(
|
||||||
|
() => `npm run demo-gateway`,
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const useDemoPreset = () => {
|
||||||
|
onAdapterTypeChange("demo");
|
||||||
|
};
|
||||||
|
const useHermesPreset = () => {
|
||||||
|
onAdapterTypeChange("hermes");
|
||||||
|
};
|
||||||
|
const useOpenClawPreset = () => {
|
||||||
|
onAdapterTypeChange("openclaw");
|
||||||
|
};
|
||||||
|
const useCustomPreset = () => {
|
||||||
|
onAdapterTypeChange("custom");
|
||||||
|
};
|
||||||
const statusCopy = useMemo(() => {
|
const statusCopy = useMemo(() => {
|
||||||
if (status === "connecting" && isLocal) {
|
if (status === "connecting" && isLocal) {
|
||||||
return `Local gateway detected on port ${localPort}. Connecting…`;
|
return `Local gateway detected on port ${localPort}. Connecting…`;
|
||||||
@@ -133,14 +159,14 @@ export const GatewayConnectScreen = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="flex flex-col gap-1 text-[11px] font-medium text-foreground/90">
|
<label className="flex flex-col gap-1 text-[11px] font-medium text-foreground/90">
|
||||||
Upstream token
|
{tokenOptional ? "Upstream token (optional)" : "Upstream token"}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
className="ui-input h-10 w-full rounded-md px-4 pr-10 font-sans text-sm text-foreground outline-none"
|
className="ui-input h-10 w-full rounded-md px-4 pr-10 font-sans text-sm text-foreground outline-none"
|
||||||
type={showToken ? "text" : "password"}
|
type={showToken ? "text" : "password"}
|
||||||
value={token}
|
value={token}
|
||||||
onChange={(event) => onTokenChange(event.target.value)}
|
onChange={(event) => onTokenChange(event.target.value)}
|
||||||
placeholder="gateway token"
|
placeholder={tokenOptional ? "optional token" : "gateway token"}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -168,13 +194,13 @@ export const GatewayConnectScreen = ({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{status === "connecting" ? (
|
{status === "connecting" ? (
|
||||||
<p className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
<div className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
<RunningAvatarLoader size={16} trackWidth={32} inline />
|
<RunningAvatarLoader size={16} trackWidth={32} inline />
|
||||||
Connecting…
|
Connecting…
|
||||||
</p>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{error ? <p className="ui-text-danger text-xs leading-snug">{error}</p> : null}
|
{error ? <p className="ui-text-danger text-xs leading-snug">{error}</p> : null}
|
||||||
{showApprovalHint ? (
|
{showApprovalHint && selectedAdapterType === "openclaw" ? (
|
||||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-3 text-xs text-muted-foreground">
|
<div className="rounded-md border border-border bg-muted/40 px-3 py-3 text-xs text-muted-foreground">
|
||||||
<p className="leading-snug">
|
<p className="leading-snug">
|
||||||
If the first connection attempt did not work, go to your OpenClaw computer and approve this
|
If the first connection attempt did not work, go to your OpenClaw computer and approve this
|
||||||
@@ -208,7 +234,45 @@ export const GatewayConnectScreen = ({
|
|||||||
<p className="font-mono text-[10px] font-medium tracking-[0.06em] text-muted-foreground">
|
<p className="font-mono text-[10px] font-medium tracking-[0.06em] text-muted-foreground">
|
||||||
Remote gateway (recommended)
|
Remote gateway (recommended)
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-sm text-foreground/90">Default: enter your URL and token to connect.</p>
|
<p className="mt-2 text-sm text-foreground/90">
|
||||||
|
Choose a backend, then connect to its gateway URL.
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 font-mono text-[11px] text-muted-foreground">
|
||||||
|
Selected backend: {selectedAdapterType} | Active backend: {activeAdapterType}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Each backend keeps its own saved URL and token.
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ui-btn-secondary px-3 py-1.5 text-[11px] font-semibold tracking-[0.05em]"
|
||||||
|
onClick={useDemoPreset}
|
||||||
|
>
|
||||||
|
Demo backend
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ui-btn-secondary px-3 py-1.5 text-[11px] font-semibold tracking-[0.05em]"
|
||||||
|
onClick={useHermesPreset}
|
||||||
|
>
|
||||||
|
Hermes backend
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ui-btn-secondary px-3 py-1.5 text-[11px] font-semibold tracking-[0.05em]"
|
||||||
|
onClick={useCustomPreset}
|
||||||
|
>
|
||||||
|
Custom backend
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ui-btn-secondary px-3 py-1.5 text-[11px] font-semibold tracking-[0.05em]"
|
||||||
|
onClick={useOpenClawPreset}
|
||||||
|
>
|
||||||
|
OpenClaw backend
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{remoteForm}
|
{remoteForm}
|
||||||
</div>
|
</div>
|
||||||
@@ -224,6 +288,31 @@ export const GatewayConnectScreen = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-3 space-y-3">
|
<div className="mt-3 space-y-3">
|
||||||
{commandField}
|
{commandField}
|
||||||
|
<div className="rounded-md border border-border bg-muted/30 px-3 py-3">
|
||||||
|
<p className="text-xs font-medium text-foreground">Just want to see the office?</p>
|
||||||
|
<p className="mt-1 text-xs leading-snug text-muted-foreground">
|
||||||
|
Run <span className="font-mono text-foreground">{localDemoCommand}</span> to start a built-in mock gateway with demo agents.
|
||||||
|
Then choose <span className="font-mono text-foreground">Demo backend</span> and connect.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border border-border bg-muted/30 px-3 py-3">
|
||||||
|
<p className="text-xs font-medium text-foreground">Using Hermes locally?</p>
|
||||||
|
<p className="mt-1 text-xs leading-snug text-muted-foreground">
|
||||||
|
Run <span className="font-mono text-foreground">npm run hermes-adapter</span>, then choose
|
||||||
|
<span className="font-mono text-foreground"> Hermes backend</span>. The default local URL is
|
||||||
|
<span className="font-mono text-foreground"> ws://localhost:18789</span>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border border-border bg-muted/30 px-3 py-3">
|
||||||
|
<p className="text-xs font-medium text-foreground">Using a custom runtime locally?</p>
|
||||||
|
<p className="mt-1 text-xs leading-snug text-muted-foreground">
|
||||||
|
Choose <span className="font-mono text-foreground">Custom backend</span> and point the URL
|
||||||
|
at your orchestrator or runtime boundary, for example
|
||||||
|
<span className="font-mono text-foreground"> http://localhost:7770</span>. Direct custom
|
||||||
|
runtime chat flows are not wired into Studio yet in this slice, but the provider seam and
|
||||||
|
metadata scaffold are now in place.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
{localGatewayDefaults ? (
|
{localGatewayDefaults ? (
|
||||||
<div className="ui-input rounded-md px-3 py-3">
|
<div className="ui-input rounded-md px-3 py-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -155,11 +155,22 @@ export async function hydrateAgentFleetFromGateway(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let agentsResult = (await params.client.call("agents.list", {})) as AgentsListResult;
|
const helloSnapshotFallback = resolveAgentsListFromHelloSnapshot(
|
||||||
|
params.client.getLastHello?.()?.snapshot
|
||||||
|
);
|
||||||
|
let agentsResult: AgentsListResult;
|
||||||
|
try {
|
||||||
|
agentsResult = (await params.client.call("agents.list", {})) as AgentsListResult;
|
||||||
|
} catch (err) {
|
||||||
|
if (helloSnapshotFallback) {
|
||||||
|
agentsResult = helloSnapshotFallback;
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!Array.isArray(agentsResult?.agents) || agentsResult.agents.length === 0) {
|
if (!Array.isArray(agentsResult?.agents) || agentsResult.agents.length === 0) {
|
||||||
const fallback = resolveAgentsListFromHelloSnapshot(params.client.getLastHello?.()?.snapshot);
|
if (helloSnapshotFallback) {
|
||||||
if (fallback) {
|
agentsResult = helloSnapshotFallback;
|
||||||
agentsResult = fallback;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
agentsResult = {
|
agentsResult = {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type AgentsListResult = {
|
|||||||
agents: Array<{
|
agents: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
role?: string;
|
||||||
identity?: {
|
identity?: {
|
||||||
name?: string;
|
name?: string;
|
||||||
theme?: string;
|
theme?: string;
|
||||||
@@ -251,6 +252,7 @@ export const deriveHydrateAgentFleetResult = (
|
|||||||
return {
|
return {
|
||||||
agentId: agent.id,
|
agentId: agent.id,
|
||||||
name,
|
name,
|
||||||
|
role: typeof agent.role === "string" && agent.role.trim() ? agent.role.trim() : null,
|
||||||
sessionKey: buildAgentMainSessionKey(agent.id, mainKey),
|
sessionKey: buildAgentMainSessionKey(agent.id, mainKey),
|
||||||
avatarSeed,
|
avatarSeed,
|
||||||
avatarProfile,
|
avatarProfile,
|
||||||
|
|||||||
@@ -22,6 +22,25 @@ type GatewayClientLike = {
|
|||||||
call: (method: string, params: unknown) => Promise<unknown>;
|
call: (method: string, params: unknown) => Promise<unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const extractImmediateAssistantText = (payload: unknown): string | null => {
|
||||||
|
if (!payload || typeof payload !== "object") return null;
|
||||||
|
const value = payload as {
|
||||||
|
text?: unknown;
|
||||||
|
content?: unknown;
|
||||||
|
message?: unknown;
|
||||||
|
};
|
||||||
|
if (typeof value.text === "string" && value.text.trim()) {
|
||||||
|
return value.text.trim();
|
||||||
|
}
|
||||||
|
if (typeof value.content === "string" && value.content.trim()) {
|
||||||
|
return value.content.trim();
|
||||||
|
}
|
||||||
|
if (typeof value.message === "string" && value.message.trim()) {
|
||||||
|
return value.message.trim();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
const resolveLatestTranscriptTimestampMs = (agent: AgentState): number | null => {
|
const resolveLatestTranscriptTimestampMs = (agent: AgentState): number | null => {
|
||||||
const entries = agent.transcriptEntries;
|
const entries = agent.transcriptEntries;
|
||||||
let latest: number | null = null;
|
let latest: number | null = null;
|
||||||
@@ -200,6 +219,33 @@ export async function sendChatMessageViaStudio(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (resolveChatSendCompletionMode(sendResult, runId) === "terminal-immediate") {
|
if (resolveChatSendCompletionMode(sendResult, runId) === "terminal-immediate") {
|
||||||
|
const assistantText = extractImmediateAssistantText(sendResult);
|
||||||
|
if (assistantText) {
|
||||||
|
const assistantTimestamp = now();
|
||||||
|
params.dispatch({
|
||||||
|
type: "appendOutput",
|
||||||
|
agentId,
|
||||||
|
line: assistantText,
|
||||||
|
transcript: {
|
||||||
|
source: "local-send",
|
||||||
|
runId,
|
||||||
|
sessionKey: params.sessionKey,
|
||||||
|
timestampMs: assistantTimestamp,
|
||||||
|
role: "assistant",
|
||||||
|
kind: "assistant",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
params.dispatch({
|
||||||
|
type: "updateAgent",
|
||||||
|
agentId,
|
||||||
|
patch: {
|
||||||
|
lastResult: assistantText,
|
||||||
|
latestPreview: assistantText,
|
||||||
|
lastAssistantMessageAt: assistantTimestamp,
|
||||||
|
lastActivityAt: assistantTimestamp,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
params.dispatch({
|
params.dispatch({
|
||||||
type: "updateAgent",
|
type: "updateAgent",
|
||||||
agentId,
|
agentId,
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { SettingsRouteTab } from "@/features/agents/operations/settingsRouteWorkflow";
|
||||||
|
|
||||||
|
export type SettingsSidebarEntry = {
|
||||||
|
id: SettingsRouteTab;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const BASE_SETTINGS_SIDEBAR_ENTRIES: readonly SettingsSidebarEntry[] = [
|
||||||
|
{ id: "personality", label: "Behavior" },
|
||||||
|
{ id: "capabilities", label: "Capabilities" },
|
||||||
|
{ id: "skills", label: "Skills" },
|
||||||
|
{ id: "system", label: "System setup" },
|
||||||
|
{ id: "automations", label: "Automations" },
|
||||||
|
{ id: "advanced", label: "Advanced" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const resolveSettingsSidebarEntries = (runtimeSupportsCron: boolean) =>
|
||||||
|
BASE_SETTINGS_SIDEBAR_ENTRIES.filter(
|
||||||
|
(entry) => runtimeSupportsCron || entry.id !== "automations"
|
||||||
|
);
|
||||||
@@ -54,6 +54,7 @@ type AgentForSettingsMutation = Pick<AgentState, "agentId" | "name" | "sessionKe
|
|||||||
export type UseAgentSettingsMutationControllerParams = {
|
export type UseAgentSettingsMutationControllerParams = {
|
||||||
client: GatewayClient;
|
client: GatewayClient;
|
||||||
status: GatewayStatus;
|
status: GatewayStatus;
|
||||||
|
runtimeSupportsCron: boolean;
|
||||||
isLocalGateway: boolean;
|
isLocalGateway: boolean;
|
||||||
agents: AgentForSettingsMutation[];
|
agents: AgentForSettingsMutation[];
|
||||||
hasCreateBlock: boolean;
|
hasCreateBlock: boolean;
|
||||||
@@ -99,6 +100,7 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
|
|||||||
useState<RestartingMutationBlockState | null>(null);
|
useState<RestartingMutationBlockState | null>(null);
|
||||||
const REMOTE_MUTATION_EXEC_TIMEOUT_MS = 45_000;
|
const REMOTE_MUTATION_EXEC_TIMEOUT_MS = 45_000;
|
||||||
const SKILL_INSTALL_TIMEOUT_MS = 120_000;
|
const SKILL_INSTALL_TIMEOUT_MS = 120_000;
|
||||||
|
const CRON_UNSUPPORTED_MESSAGE = "This runtime does not support automations.";
|
||||||
|
|
||||||
const hasRenameMutationBlock = restartingMutationBlock?.kind === "rename-agent";
|
const hasRenameMutationBlock = restartingMutationBlock?.kind === "rename-agent";
|
||||||
const hasDeleteMutationBlock = restartingMutationBlock?.kind === "delete-agent";
|
const hasDeleteMutationBlock = restartingMutationBlock?.kind === "delete-agent";
|
||||||
@@ -218,6 +220,12 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
|
|||||||
|
|
||||||
const loadCronJobsForSettingsAgent = useCallback(
|
const loadCronJobsForSettingsAgent = useCallback(
|
||||||
async (agentId: string) => {
|
async (agentId: string) => {
|
||||||
|
if (!params.runtimeSupportsCron) {
|
||||||
|
setSettingsCronJobs([]);
|
||||||
|
setSettingsCronLoading(false);
|
||||||
|
setSettingsCronError(CRON_UNSUPPORTED_MESSAGE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const resolvedAgentId = agentId.trim();
|
const resolvedAgentId = agentId.trim();
|
||||||
if (!resolvedAgentId) {
|
if (!resolvedAgentId) {
|
||||||
setSettingsCronJobs([]);
|
setSettingsCronJobs([]);
|
||||||
@@ -241,7 +249,7 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
|
|||||||
setSettingsCronLoading(false);
|
setSettingsCronLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[params.client]
|
[params.client, params.runtimeSupportsCron]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -466,6 +474,10 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
|
|||||||
|
|
||||||
const handleCreateCronJob = useCallback(
|
const handleCreateCronJob = useCallback(
|
||||||
async (agentId: string, draft: CronCreateDraft) => {
|
async (agentId: string, draft: CronCreateDraft) => {
|
||||||
|
if (!params.runtimeSupportsCron) {
|
||||||
|
setSettingsCronError(CRON_UNSUPPORTED_MESSAGE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const decision = planAgentSettingsMutation(
|
const decision = planAgentSettingsMutation(
|
||||||
{ kind: "create-cron-job", agentId },
|
{ kind: "create-cron-job", agentId },
|
||||||
mutationContext
|
mutationContext
|
||||||
@@ -499,11 +511,22 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[cronCreateBusy, cronDeleteBusyJobId, cronRunBusyJobId, mutationContext, params.client]
|
[
|
||||||
|
cronCreateBusy,
|
||||||
|
cronDeleteBusyJobId,
|
||||||
|
cronRunBusyJobId,
|
||||||
|
mutationContext,
|
||||||
|
params.client,
|
||||||
|
params.runtimeSupportsCron,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRunCronJob = useCallback(
|
const handleRunCronJob = useCallback(
|
||||||
async (agentId: string, jobId: string) => {
|
async (agentId: string, jobId: string) => {
|
||||||
|
if (!params.runtimeSupportsCron) {
|
||||||
|
setSettingsCronError(CRON_UNSUPPORTED_MESSAGE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const decision = planAgentSettingsMutation(
|
const decision = planAgentSettingsMutation(
|
||||||
{ kind: "run-cron-job", agentId, jobId },
|
{ kind: "run-cron-job", agentId, jobId },
|
||||||
mutationContext
|
mutationContext
|
||||||
@@ -530,11 +553,15 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
|
|||||||
setCronRunBusyJobId((current) => (current === resolvedJobId ? null : current));
|
setCronRunBusyJobId((current) => (current === resolvedJobId ? null : current));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[loadCronJobsForSettingsAgent, mutationContext, params.client]
|
[loadCronJobsForSettingsAgent, mutationContext, params.client, params.runtimeSupportsCron]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDeleteCronJob = useCallback(
|
const handleDeleteCronJob = useCallback(
|
||||||
async (agentId: string, jobId: string) => {
|
async (agentId: string, jobId: string) => {
|
||||||
|
if (!params.runtimeSupportsCron) {
|
||||||
|
setSettingsCronError(CRON_UNSUPPORTED_MESSAGE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const decision = planAgentSettingsMutation(
|
const decision = planAgentSettingsMutation(
|
||||||
{ kind: "delete-cron-job", agentId, jobId },
|
{ kind: "delete-cron-job", agentId, jobId },
|
||||||
mutationContext
|
mutationContext
|
||||||
@@ -564,7 +591,7 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
|
|||||||
setCronDeleteBusyJobId((current) => (current === resolvedJobId ? null : current));
|
setCronDeleteBusyJobId((current) => (current === resolvedJobId ? null : current));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[loadCronJobsForSettingsAgent, mutationContext, params.client]
|
[loadCronJobsForSettingsAgent, mutationContext, params.client, params.runtimeSupportsCron]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRenameAgent = useCallback(
|
const handleRenameAgent = useCallback(
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const defaultLogError = (message: string, err: unknown) => {
|
|||||||
export type UseGatewayConfigSyncControllerParams = {
|
export type UseGatewayConfigSyncControllerParams = {
|
||||||
client: GatewayClient;
|
client: GatewayClient;
|
||||||
status: GatewayConnectionStatus;
|
status: GatewayConnectionStatus;
|
||||||
|
enabled?: boolean;
|
||||||
settingsRouteActive: boolean;
|
settingsRouteActive: boolean;
|
||||||
inspectSidebarAgentId: string | null;
|
inspectSidebarAgentId: string | null;
|
||||||
gatewayConfigSnapshot: GatewayModelPolicySnapshot | null;
|
gatewayConfigSnapshot: GatewayModelPolicySnapshot | null;
|
||||||
@@ -49,6 +50,7 @@ export function useGatewayConfigSyncController(
|
|||||||
const {
|
const {
|
||||||
client,
|
client,
|
||||||
status,
|
status,
|
||||||
|
enabled = true,
|
||||||
settingsRouteActive,
|
settingsRouteActive,
|
||||||
inspectSidebarAgentId,
|
inspectSidebarAgentId,
|
||||||
gatewayConfigSnapshot,
|
gatewayConfigSnapshot,
|
||||||
@@ -63,6 +65,7 @@ export function useGatewayConfigSyncController(
|
|||||||
const logError = params.logError ?? defaultLogError;
|
const logError = params.logError ?? defaultLogError;
|
||||||
|
|
||||||
const refreshGatewayConfigSnapshot = useCallback(async () => {
|
const refreshGatewayConfigSnapshot = useCallback(async () => {
|
||||||
|
if (!enabled) return null;
|
||||||
if (status !== "connected") return null;
|
if (status !== "connected") return null;
|
||||||
try {
|
try {
|
||||||
const snapshot = await client.call<GatewayModelPolicySnapshot>("config.get", {});
|
const snapshot = await client.call<GatewayModelPolicySnapshot>("config.get", {});
|
||||||
@@ -74,9 +77,17 @@ export function useGatewayConfigSyncController(
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}, [client, isDisconnectLikeError, setGatewayConfigSnapshot, status, logError]);
|
}, [client, enabled, isDisconnectLikeError, setGatewayConfigSnapshot, status, logError]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (enabled) return;
|
||||||
|
setGatewayModels([]);
|
||||||
|
setGatewayModelsError(null);
|
||||||
|
setGatewayConfigSnapshot(null);
|
||||||
|
}, [enabled, setGatewayConfigSnapshot, setGatewayModels, setGatewayModelsError]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
const repairIntent = resolveSandboxRepairIntent({
|
const repairIntent = resolveSandboxRepairIntent({
|
||||||
status,
|
status,
|
||||||
attempted: sandboxRepairAttemptedRef.current,
|
attempted: sandboxRepairAttemptedRef.current,
|
||||||
@@ -107,9 +118,10 @@ export function useGatewayConfigSyncController(
|
|||||||
await loadAgents();
|
await loadAgents();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [client, enqueueConfigMutation, gatewayConfigSnapshot, loadAgents, status]);
|
}, [client, enabled, enqueueConfigMutation, gatewayConfigSnapshot, loadAgents, status]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
if (
|
if (
|
||||||
!shouldRefreshGatewayConfigForSettingsRoute({
|
!shouldRefreshGatewayConfigForSettingsRoute({
|
||||||
status,
|
status,
|
||||||
@@ -120,9 +132,12 @@ export function useGatewayConfigSyncController(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void refreshGatewayConfigSnapshot();
|
void refreshGatewayConfigSnapshot();
|
||||||
}, [inspectSidebarAgentId, refreshGatewayConfigSnapshot, settingsRouteActive, status]);
|
}, [enabled, inspectSidebarAgentId, refreshGatewayConfigSnapshot, settingsRouteActive, status]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const syncIntent = resolveGatewayModelsSyncIntent({ status });
|
const syncIntent = resolveGatewayModelsSyncIntent({ status });
|
||||||
if (syncIntent.kind === "clear") {
|
if (syncIntent.kind === "clear") {
|
||||||
setGatewayModels([]);
|
setGatewayModels([]);
|
||||||
@@ -175,6 +190,7 @@ export function useGatewayConfigSyncController(
|
|||||||
setGatewayConfigSnapshot,
|
setGatewayConfigSnapshot,
|
||||||
setGatewayModels,
|
setGatewayModels,
|
||||||
setGatewayModelsError,
|
setGatewayModelsError,
|
||||||
|
enabled,
|
||||||
status,
|
status,
|
||||||
logError,
|
logError,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -17,9 +17,7 @@ import { EmptyStatePanel } from "@/features/agents/components/EmptyStatePanel";
|
|||||||
import {
|
import {
|
||||||
isHeartbeatPrompt,
|
isHeartbeatPrompt,
|
||||||
} from "@/lib/text/message-extract";
|
} from "@/lib/text/message-extract";
|
||||||
import {
|
import { useRuntimeConnection } from "@/lib/runtime/useRuntimeConnection";
|
||||||
useGatewayConnection,
|
|
||||||
} from "@/lib/gateway/GatewayClient";
|
|
||||||
import {
|
import {
|
||||||
type GatewayModelChoice,
|
type GatewayModelChoice,
|
||||||
type GatewayModelPolicySnapshot,
|
type GatewayModelPolicySnapshot,
|
||||||
@@ -111,6 +109,7 @@ import {
|
|||||||
import { useAgentSettingsMutationController } from "@/features/agents/operations/useAgentSettingsMutationController";
|
import { useAgentSettingsMutationController } from "@/features/agents/operations/useAgentSettingsMutationController";
|
||||||
import { useRuntimeSyncController } from "@/features/agents/operations/useRuntimeSyncController";
|
import { useRuntimeSyncController } from "@/features/agents/operations/useRuntimeSyncController";
|
||||||
import { useChatInteractionController } from "@/features/agents/operations/useChatInteractionController";
|
import { useChatInteractionController } from "@/features/agents/operations/useChatInteractionController";
|
||||||
|
import { resolveSettingsSidebarEntries } from "@/features/agents/operations/settingsSidebarTabs";
|
||||||
import {
|
import {
|
||||||
SETTINGS_ROUTE_AGENT_ID_QUERY_PARAM,
|
SETTINGS_ROUTE_AGENT_ID_QUERY_PARAM,
|
||||||
parseSettingsRouteAgentIdFromQueryParam,
|
parseSettingsRouteAgentIdFromQueryParam,
|
||||||
@@ -221,11 +220,14 @@ const AgentsPageScreen = () => {
|
|||||||
const [settingsCoordinator] = useState(() => createStudioSettingsCoordinator());
|
const [settingsCoordinator] = useState(() => createStudioSettingsCoordinator());
|
||||||
const {
|
const {
|
||||||
client,
|
client,
|
||||||
|
provider,
|
||||||
status,
|
status,
|
||||||
connectPromptReady,
|
connectPromptReady,
|
||||||
shouldPromptForConnect,
|
shouldPromptForConnect,
|
||||||
gatewayUrl,
|
gatewayUrl,
|
||||||
token,
|
token,
|
||||||
|
selectedAdapterType,
|
||||||
|
activeAdapterType,
|
||||||
localGatewayDefaults,
|
localGatewayDefaults,
|
||||||
error: gatewayError,
|
error: gatewayError,
|
||||||
connect,
|
connect,
|
||||||
@@ -233,7 +235,12 @@ const AgentsPageScreen = () => {
|
|||||||
useLocalGatewayDefaults,
|
useLocalGatewayDefaults,
|
||||||
setGatewayUrl,
|
setGatewayUrl,
|
||||||
setToken,
|
setToken,
|
||||||
} = useGatewayConnection(settingsCoordinator);
|
setSelectedAdapterType,
|
||||||
|
supportsCapability,
|
||||||
|
} = useRuntimeConnection(settingsCoordinator);
|
||||||
|
const runtimeSupportsConfig = supportsCapability("config");
|
||||||
|
const runtimeSupportsModels = supportsCapability("models");
|
||||||
|
const runtimeSupportsCron = supportsCapability("cron");
|
||||||
const {
|
const {
|
||||||
loaded: voiceRepliesLoaded,
|
loaded: voiceRepliesLoaded,
|
||||||
preference: voiceRepliesPreference,
|
preference: voiceRepliesPreference,
|
||||||
@@ -443,6 +450,10 @@ const AgentsPageScreen = () => {
|
|||||||
const settingsHeaderThinking =
|
const settingsHeaderThinking =
|
||||||
settingsHeaderThinkingRaw.charAt(0).toUpperCase() + settingsHeaderThinkingRaw.slice(1);
|
settingsHeaderThinkingRaw.charAt(0).toUpperCase() + settingsHeaderThinkingRaw.slice(1);
|
||||||
const activeSettingsSidebarItem: SettingsSidebarItem = settingsSidebarItem;
|
const activeSettingsSidebarItem: SettingsSidebarItem = settingsSidebarItem;
|
||||||
|
const settingsSidebarEntries = useMemo(
|
||||||
|
() => resolveSettingsSidebarEntries(runtimeSupportsCron),
|
||||||
|
[runtimeSupportsCron]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const selector = 'link[data-agent-favicon="true"]';
|
const selector = 'link[data-agent-favicon="true"]';
|
||||||
@@ -472,7 +483,10 @@ const AgentsPageScreen = () => {
|
|||||||
const specialLatestUpdate = useMemo(() => {
|
const specialLatestUpdate = useMemo(() => {
|
||||||
return createSpecialLatestUpdateOperation({
|
return createSpecialLatestUpdateOperation({
|
||||||
callGateway: (method, params) => client.call(method, params),
|
callGateway: (method, params) => client.call(method, params),
|
||||||
listCronJobs: () => listCronJobs(client, { includeDisabled: true }),
|
listCronJobs: () =>
|
||||||
|
runtimeSupportsCron
|
||||||
|
? listCronJobs(client, { includeDisabled: true })
|
||||||
|
: Promise.resolve({ jobs: [] }),
|
||||||
resolveCronJobForAgent,
|
resolveCronJobForAgent,
|
||||||
formatCronJobDisplay,
|
formatCronJobDisplay,
|
||||||
dispatchUpdateAgent: (agentId, patch) => {
|
dispatchUpdateAgent: (agentId, patch) => {
|
||||||
@@ -481,7 +495,7 @@ const AgentsPageScreen = () => {
|
|||||||
isDisconnectLikeError: isGatewayDisconnectLikeError,
|
isDisconnectLikeError: isGatewayDisconnectLikeError,
|
||||||
logError: (message) => console.error(message),
|
logError: (message) => console.error(message),
|
||||||
});
|
});
|
||||||
}, [client, dispatch, resolveCronJobForAgent]);
|
}, [client, dispatch, resolveCronJobForAgent, runtimeSupportsCron]);
|
||||||
|
|
||||||
const refreshHeartbeatLatestUpdate = useCallback(() => {
|
const refreshHeartbeatLatestUpdate = useCallback(() => {
|
||||||
const agents = stateRef.current.agents;
|
const agents = stateRef.current.agents;
|
||||||
@@ -503,7 +517,7 @@ const AgentsPageScreen = () => {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const commands = await runStudioBootstrapLoadOperation({
|
const commands = await runStudioBootstrapLoadOperation({
|
||||||
client,
|
client: provider,
|
||||||
gatewayUrl,
|
gatewayUrl,
|
||||||
cachedConfigSnapshot: gatewayConfigSnapshot,
|
cachedConfigSnapshot: gatewayConfigSnapshot,
|
||||||
loadStudioSettings,
|
loadStudioSettings,
|
||||||
@@ -527,6 +541,7 @@ const AgentsPageScreen = () => {
|
|||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
client,
|
client,
|
||||||
|
provider,
|
||||||
dispatch,
|
dispatch,
|
||||||
hydrateAgents,
|
hydrateAgents,
|
||||||
setError,
|
setError,
|
||||||
@@ -547,6 +562,7 @@ const AgentsPageScreen = () => {
|
|||||||
const { refreshGatewayConfigSnapshot } = useGatewayConfigSyncController({
|
const { refreshGatewayConfigSnapshot } = useGatewayConfigSyncController({
|
||||||
client,
|
client,
|
||||||
status,
|
status,
|
||||||
|
enabled: runtimeSupportsConfig && runtimeSupportsModels,
|
||||||
settingsRouteActive,
|
settingsRouteActive,
|
||||||
inspectSidebarAgentId,
|
inspectSidebarAgentId,
|
||||||
gatewayConfigSnapshot,
|
gatewayConfigSnapshot,
|
||||||
@@ -561,6 +577,7 @@ const AgentsPageScreen = () => {
|
|||||||
const settingsMutationController = useAgentSettingsMutationController({
|
const settingsMutationController = useAgentSettingsMutationController({
|
||||||
client,
|
client,
|
||||||
status,
|
status,
|
||||||
|
runtimeSupportsCron,
|
||||||
isLocalGateway,
|
isLocalGateway,
|
||||||
agents,
|
agents,
|
||||||
hasCreateBlock: Boolean(createAgentBlock),
|
hasCreateBlock: Boolean(createAgentBlock),
|
||||||
@@ -792,7 +809,7 @@ const AgentsPageScreen = () => {
|
|||||||
loadMoreAgentHistory,
|
loadMoreAgentHistory,
|
||||||
clearHistoryInFlight,
|
clearHistoryInFlight,
|
||||||
} = useRuntimeSyncController({
|
} = useRuntimeSyncController({
|
||||||
client,
|
client: provider,
|
||||||
status,
|
status,
|
||||||
agents,
|
agents,
|
||||||
focusedAgentId,
|
focusedAgentId,
|
||||||
@@ -815,7 +832,7 @@ const AgentsPageScreen = () => {
|
|||||||
queueLivePatch,
|
queueLivePatch,
|
||||||
clearPendingLivePatch,
|
clearPendingLivePatch,
|
||||||
} = useChatInteractionController({
|
} = useChatInteractionController({
|
||||||
client,
|
client: provider,
|
||||||
status,
|
status,
|
||||||
agents,
|
agents,
|
||||||
dispatch,
|
dispatch,
|
||||||
@@ -1396,10 +1413,15 @@ const AgentsPageScreen = () => {
|
|||||||
<ConnectionPanel
|
<ConnectionPanel
|
||||||
gatewayUrl={gatewayUrl}
|
gatewayUrl={gatewayUrl}
|
||||||
token={token}
|
token={token}
|
||||||
|
selectedAdapterType={selectedAdapterType}
|
||||||
|
activeAdapterType={activeAdapterType}
|
||||||
|
localGatewayUrl={localGatewayDefaults?.url ?? null}
|
||||||
|
localGatewayToken={localGatewayDefaults?.token ?? null}
|
||||||
status={status}
|
status={status}
|
||||||
error={gatewayError}
|
error={gatewayError}
|
||||||
onGatewayUrlChange={setGatewayUrl}
|
onGatewayUrlChange={setGatewayUrl}
|
||||||
onTokenChange={setToken}
|
onTokenChange={setToken}
|
||||||
|
onAdapterTypeChange={setSelectedAdapterType}
|
||||||
onConnect={() => void connect()}
|
onConnect={() => void connect()}
|
||||||
onDisconnect={disconnect}
|
onDisconnect={disconnect}
|
||||||
onClose={() => setShowConnectionPanel(false)}
|
onClose={() => setShowConnectionPanel(false)}
|
||||||
@@ -1410,12 +1432,15 @@ const AgentsPageScreen = () => {
|
|||||||
<GatewayConnectScreen
|
<GatewayConnectScreen
|
||||||
gatewayUrl={gatewayUrl}
|
gatewayUrl={gatewayUrl}
|
||||||
token={token}
|
token={token}
|
||||||
|
selectedAdapterType={selectedAdapterType}
|
||||||
|
activeAdapterType={activeAdapterType}
|
||||||
localGatewayDefaults={localGatewayDefaults}
|
localGatewayDefaults={localGatewayDefaults}
|
||||||
status={status}
|
status={status}
|
||||||
error={gatewayError}
|
error={gatewayError}
|
||||||
showApprovalHint={didAttemptGatewayConnect}
|
showApprovalHint={didAttemptGatewayConnect}
|
||||||
onGatewayUrlChange={setGatewayUrl}
|
onGatewayUrlChange={setGatewayUrl}
|
||||||
onTokenChange={setToken}
|
onTokenChange={setToken}
|
||||||
|
onAdapterTypeChange={setSelectedAdapterType}
|
||||||
onUseLocalDefaults={useLocalGatewayDefaults}
|
onUseLocalDefaults={useLocalGatewayDefaults}
|
||||||
onConnect={() => void connect()}
|
onConnect={() => void connect()}
|
||||||
/>
|
/>
|
||||||
@@ -1462,10 +1487,15 @@ const AgentsPageScreen = () => {
|
|||||||
<ConnectionPanel
|
<ConnectionPanel
|
||||||
gatewayUrl={gatewayUrl}
|
gatewayUrl={gatewayUrl}
|
||||||
token={token}
|
token={token}
|
||||||
|
selectedAdapterType={selectedAdapterType}
|
||||||
|
activeAdapterType={activeAdapterType}
|
||||||
|
localGatewayUrl={localGatewayDefaults?.url ?? null}
|
||||||
|
localGatewayToken={localGatewayDefaults?.token ?? null}
|
||||||
status={status}
|
status={status}
|
||||||
error={gatewayError}
|
error={gatewayError}
|
||||||
onGatewayUrlChange={setGatewayUrl}
|
onGatewayUrlChange={setGatewayUrl}
|
||||||
onTokenChange={setToken}
|
onTokenChange={setToken}
|
||||||
|
onAdapterTypeChange={setSelectedAdapterType}
|
||||||
onConnect={() => void connect()}
|
onConnect={() => void connect()}
|
||||||
onDisconnect={disconnect}
|
onDisconnect={disconnect}
|
||||||
onClose={() => setShowConnectionPanel(false)}
|
onClose={() => setShowConnectionPanel(false)}
|
||||||
@@ -1505,16 +1535,7 @@ const AgentsPageScreen = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<nav className="py-3">
|
<nav className="py-3">
|
||||||
{(
|
{settingsSidebarEntries.map((entry) => {
|
||||||
[
|
|
||||||
{ id: "personality", label: "Behavior" },
|
|
||||||
{ id: "capabilities", label: "Capabilities" },
|
|
||||||
{ id: "skills", label: "Skills" },
|
|
||||||
{ id: "system", label: "System setup" },
|
|
||||||
{ id: "automations", label: "Automations" },
|
|
||||||
{ id: "advanced", label: "Advanced" },
|
|
||||||
] as const
|
|
||||||
).map((entry) => {
|
|
||||||
const active = activeSettingsSidebarItem === entry.id;
|
const active = activeSettingsSidebarItem === entry.id;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -1739,6 +1760,7 @@ const AgentsPageScreen = () => {
|
|||||||
stopBusy={stopBusyAgentId === focusedAgent.agentId}
|
stopBusy={stopBusyAgentId === focusedAgent.agentId}
|
||||||
stopDisabledReason={focusedAgentStopDisabledReason}
|
stopDisabledReason={focusedAgentStopDisabledReason}
|
||||||
onLoadMoreHistory={() => loadMoreAgentHistory(focusedAgent.agentId)}
|
onLoadMoreHistory={() => loadMoreAgentHistory(focusedAgent.agentId)}
|
||||||
|
onOpenSettings={() => handleOpenAgentSettingsRoute(focusedAgent.agentId)}
|
||||||
onRename={(name) =>
|
onRename={(name) =>
|
||||||
settingsMutationController.handleRenameAgent(focusedAgent.agentId, name)
|
settingsMutationController.handleRenameAgent(focusedAgent.agentId, name)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export type FocusFilter = "all" | "running" | "approvals";
|
|||||||
export type AgentStoreSeed = {
|
export type AgentStoreSeed = {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
role?: string | null;
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
avatarSeed?: string | null;
|
avatarSeed?: string | null;
|
||||||
avatarProfile?: AgentAvatarProfile | null;
|
avatarProfile?: AgentAvatarProfile | null;
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ export function CompanyBuilderModal({
|
|||||||
</div>
|
</div>
|
||||||
<h2 className="mt-1 text-lg font-semibold">Design an AI company from one prompt</h2>
|
<h2 className="mt-1 text-lg font-semibold">Design an AI company from one prompt</h2>
|
||||||
<p className="mt-1 text-sm text-white/55">
|
<p className="mt-1 text-sm text-white/55">
|
||||||
Uses your connected OpenClaw runtime
|
Uses your connected runtime
|
||||||
{plannerAgentName ? ` via ${plannerAgentName}.` : "."}
|
{plannerAgentName ? ` via ${plannerAgentName}.` : "."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -329,7 +329,7 @@ export function CompanyBuilderModal({
|
|||||||
Company Actions
|
Company Actions
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-[11px] text-white/45">
|
<p className="mt-1 text-[11px] text-white/45">
|
||||||
Generate the org, then create it in OpenClaw.
|
Generate the org, then create it in your connected runtime.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{replacesExistingAgents ? (
|
{replacesExistingAgents ? (
|
||||||
@@ -341,7 +341,7 @@ export function CompanyBuilderModal({
|
|||||||
) : null}
|
) : null}
|
||||||
{!canUseAi ? (
|
{!canUseAi ? (
|
||||||
<p className="text-xs text-amber-200/80">
|
<p className="text-xs text-amber-200/80">
|
||||||
Connect to OpenClaw and keep at least one available planning agent in the fleet
|
Connect to a runtime and keep at least one available planning agent in the fleet
|
||||||
to use AI suggestions.
|
to use AI suggestions.
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -416,7 +416,7 @@ export function CompanyBuilderModal({
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold text-white">Org structure</p>
|
<p className="text-sm font-semibold text-white">Org structure</p>
|
||||||
<p className="text-xs text-white/55">
|
<p className="text-xs text-white/55">
|
||||||
Edit the team before creating agents in OpenClaw.
|
Edit the team before creating agents in your connected runtime.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -704,7 +704,7 @@ export function CompanyBuilderModal({
|
|||||||
{statusLine?.trim() || "Working on your company."}
|
{statusLine?.trim() || "Working on your company."}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-xs leading-5 text-white/55">
|
<p className="mt-2 text-xs leading-5 text-white/55">
|
||||||
Claw3D is using your OpenClaw runtime right now. Please wait until this finishes.
|
Claw3D is using your connected runtime right now. Please wait until this finishes.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-5 flex gap-2">
|
<div className="mt-5 flex gap-2">
|
||||||
{Array.from({ length: 4 }, (_, index) => (
|
{Array.from({ length: 4 }, (_, index) => (
|
||||||
@@ -897,7 +897,8 @@ export function CompanyBuilderModal({
|
|||||||
What should the company do?
|
What should the company do?
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-sm text-white/55">
|
<p className="mt-2 text-sm text-white/55">
|
||||||
As soon as you submit this, OpenClaw will improve the brief automatically.
|
As soon as you submit this, Claw3D will improve the brief using your connected
|
||||||
|
runtime.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -923,9 +924,12 @@ export function CompanyBuilderModal({
|
|||||||
disabled={busy}
|
disabled={busy}
|
||||||
/>
|
/>
|
||||||
<div className="mt-5 flex items-center justify-between gap-3">
|
<div className="mt-5 flex items-center justify-between gap-3">
|
||||||
<p className="text-xs text-white/45">
|
<div>
|
||||||
The improved brief becomes the main editable input for generation.
|
<p className="text-xs text-white/45">
|
||||||
</p>
|
The improved brief becomes the main editable input for generation.
|
||||||
|
</p>
|
||||||
|
{error ? <p className="mt-2 text-xs text-red-200">{error}</p> : null}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex items-center gap-2 rounded-md bg-amber-500 px-4 py-2 text-sm font-semibold text-[#1a1206] transition hover:bg-amber-400 disabled:cursor-not-allowed disabled:opacity-40"
|
className="inline-flex items-center gap-2 rounded-md bg-amber-500 px-4 py-2 text-sm font-semibold text-[#1a1206] transition hover:bg-amber-400 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
|||||||
@@ -311,7 +311,7 @@ const normalizeRole = (value: ParsedCompanyRole, index: number): CompanyBuilderR
|
|||||||
export const buildImproveCompanyBriefPrompt = (businessDescription: string) =>
|
export const buildImproveCompanyBriefPrompt = (businessDescription: string) =>
|
||||||
[
|
[
|
||||||
"You are helping a user describe the company they want to build inside Claw3D.",
|
"You are helping a user describe the company they want to build inside Claw3D.",
|
||||||
"Rewrite their brief so another OpenClaw agent can generate a clean org structure from it.",
|
"Rewrite their brief so another connected runtime agent can generate a clean org structure from it.",
|
||||||
"Keep the answer short, concrete, and useful.",
|
"Keep the answer short, concrete, and useful.",
|
||||||
"Return markdown with these sections only:",
|
"Return markdown with these sections only:",
|
||||||
"## Company",
|
"## Company",
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ const DatePickerField = ({
|
|||||||
export function AnalyticsPanel({
|
export function AnalyticsPanel({
|
||||||
client,
|
client,
|
||||||
status,
|
status,
|
||||||
|
approvalsEnabled = true,
|
||||||
agents,
|
agents,
|
||||||
runLog,
|
runLog,
|
||||||
gatewayUrl,
|
gatewayUrl,
|
||||||
@@ -119,6 +120,7 @@ export function AnalyticsPanel({
|
|||||||
}: {
|
}: {
|
||||||
client: GatewayClient;
|
client: GatewayClient;
|
||||||
status: GatewayStatus;
|
status: GatewayStatus;
|
||||||
|
approvalsEnabled?: boolean;
|
||||||
agents: AgentState[];
|
agents: AgentState[];
|
||||||
runLog: RunRecord[];
|
runLog: RunRecord[];
|
||||||
gatewayUrl: string;
|
gatewayUrl: string;
|
||||||
@@ -142,7 +144,12 @@ export function AnalyticsPanel({
|
|||||||
settingsCoordinator,
|
settingsCoordinator,
|
||||||
});
|
});
|
||||||
|
|
||||||
const approvalMetrics = useApprovalMetrics({ client, status, agents });
|
const approvalMetrics = useApprovalMetrics({
|
||||||
|
client,
|
||||||
|
status,
|
||||||
|
enabled: approvalsEnabled,
|
||||||
|
agents,
|
||||||
|
});
|
||||||
const performance = usePerformanceAnalytics({
|
const performance = usePerformanceAnalytics({
|
||||||
agents,
|
agents,
|
||||||
runLog,
|
runLog,
|
||||||
|
|||||||
@@ -140,11 +140,13 @@ const formatRelativeDateTime = (timestampMs?: number) => {
|
|||||||
export function PlaybooksPanel({
|
export function PlaybooksPanel({
|
||||||
client,
|
client,
|
||||||
status,
|
status,
|
||||||
|
cronEnabled = true,
|
||||||
agents,
|
agents,
|
||||||
standup,
|
standup,
|
||||||
}: {
|
}: {
|
||||||
client: GatewayClient;
|
client: GatewayClient;
|
||||||
status: GatewayStatus;
|
status: GatewayStatus;
|
||||||
|
cronEnabled?: boolean;
|
||||||
agents: AgentState[];
|
agents: AgentState[];
|
||||||
standup: OfficeStandupController;
|
standup: OfficeStandupController;
|
||||||
}) {
|
}) {
|
||||||
@@ -217,8 +219,10 @@ export function PlaybooksPanel({
|
|||||||
}, [standup.config, standupAgentId]);
|
}, [standup.config, standupAgentId]);
|
||||||
|
|
||||||
const loadJobs = useCallback(async () => {
|
const loadJobs = useCallback(async () => {
|
||||||
if (status !== "connected") {
|
if (!cronEnabled || status !== "connected") {
|
||||||
setJobs([]);
|
setJobs([]);
|
||||||
|
setError(null);
|
||||||
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -235,13 +239,17 @@ export function PlaybooksPanel({
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [client, status]);
|
}, [client, cronEnabled, status]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadJobs();
|
void loadJobs();
|
||||||
}, [loadJobs]);
|
}, [loadJobs]);
|
||||||
|
|
||||||
const handleCreate = useCallback(async () => {
|
const handleCreate = useCallback(async () => {
|
||||||
|
if (!cronEnabled) {
|
||||||
|
setError("This runtime does not expose scheduled playbooks.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!activeTemplate) return;
|
if (!activeTemplate) return;
|
||||||
const agent = agentById.get(selectedAgentId);
|
const agent = agentById.get(selectedAgentId);
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
@@ -265,10 +273,14 @@ export function PlaybooksPanel({
|
|||||||
} finally {
|
} finally {
|
||||||
setCreateBusy(false);
|
setCreateBusy(false);
|
||||||
}
|
}
|
||||||
}, [activeTemplate, agentById, client, loadJobs, nameOverride, selectedAgentId]);
|
}, [activeTemplate, agentById, client, cronEnabled, loadJobs, nameOverride, selectedAgentId]);
|
||||||
|
|
||||||
const handleRunNow = useCallback(
|
const handleRunNow = useCallback(
|
||||||
async (jobId: string) => {
|
async (jobId: string) => {
|
||||||
|
if (!cronEnabled) {
|
||||||
|
setError("This runtime does not expose scheduled playbooks.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setRunBusyJobId(jobId);
|
setRunBusyJobId(jobId);
|
||||||
setError(null);
|
setError(null);
|
||||||
setActionMessage(null);
|
setActionMessage(null);
|
||||||
@@ -282,11 +294,15 @@ export function PlaybooksPanel({
|
|||||||
setRunBusyJobId(null);
|
setRunBusyJobId(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[client, loadJobs]
|
[client, cronEnabled, loadJobs]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDelete = useCallback(
|
const handleDelete = useCallback(
|
||||||
async (jobId: string) => {
|
async (jobId: string) => {
|
||||||
|
if (!cronEnabled) {
|
||||||
|
setError("This runtime does not expose scheduled playbooks.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setDeleteBusyJobId(jobId);
|
setDeleteBusyJobId(jobId);
|
||||||
setError(null);
|
setError(null);
|
||||||
setActionMessage(null);
|
setActionMessage(null);
|
||||||
@@ -300,7 +316,7 @@ export function PlaybooksPanel({
|
|||||||
setDeleteBusyJobId(null);
|
setDeleteBusyJobId(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[client, loadJobs]
|
[client, cronEnabled, loadJobs]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSaveStandupConfig = useCallback(async () => {
|
const handleSaveStandupConfig = useCallback(async () => {
|
||||||
@@ -385,11 +401,17 @@ export function PlaybooksPanel({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void loadJobs()}
|
onClick={() => void loadJobs()}
|
||||||
|
disabled={!cronEnabled}
|
||||||
className="rounded border border-cyan-500/20 bg-cyan-500/10 px-2 py-1 font-mono text-[10px] uppercase tracking-[0.16em] text-cyan-200 transition-colors hover:border-cyan-400/40 hover:text-cyan-100"
|
className="rounded border border-cyan-500/20 bg-cyan-500/10 px-2 py-1 font-mono text-[10px] uppercase tracking-[0.16em] text-cyan-200 transition-colors hover:border-cyan-400/40 hover:text-cyan-100"
|
||||||
>
|
>
|
||||||
Refresh
|
Refresh
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{!cronEnabled ? (
|
||||||
|
<div className="mt-2 font-mono text-[11px] text-white/35">
|
||||||
|
This runtime does not expose scheduled playbooks.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{error ? <div className="mt-2 font-mono text-[11px] text-rose-300">{error}</div> : null}
|
{error ? <div className="mt-2 font-mono text-[11px] text-rose-300">{error}</div> : null}
|
||||||
{actionMessage ? (
|
{actionMessage ? (
|
||||||
<div className="mt-2 font-mono text-[11px] text-emerald-300">{actionMessage}</div>
|
<div className="mt-2 font-mono text-[11px] text-emerald-300">{actionMessage}</div>
|
||||||
|
|||||||
@@ -2,11 +2,19 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { CURATED_ELEVENLABS_VOICES } from "@/lib/voiceReply/catalog";
|
import { CURATED_ELEVENLABS_VOICES } from "@/lib/voiceReply/catalog";
|
||||||
|
import type { StudioGatewayAdapterType } from "@/lib/studio/settings";
|
||||||
|
|
||||||
type SettingsPanelProps = {
|
type SettingsPanelProps = {
|
||||||
gatewayStatus?: string;
|
gatewayStatus?: string;
|
||||||
gatewayUrl?: string;
|
gatewayUrl?: string;
|
||||||
|
gatewayToken?: string;
|
||||||
|
selectedAdapterType?: StudioGatewayAdapterType;
|
||||||
|
activeAdapterType?: StudioGatewayAdapterType;
|
||||||
onGatewayDisconnect?: () => void;
|
onGatewayDisconnect?: () => void;
|
||||||
|
onGatewayConnect?: () => void;
|
||||||
|
onGatewayUrlChange?: (value: string) => void;
|
||||||
|
onGatewayTokenChange?: (value: string) => void;
|
||||||
|
onGatewayAdapterTypeChange?: (value: StudioGatewayAdapterType) => void;
|
||||||
onOpenOnboarding?: () => void;
|
onOpenOnboarding?: () => void;
|
||||||
officeTitle: string;
|
officeTitle: string;
|
||||||
officeTitleLoaded: boolean;
|
officeTitleLoaded: boolean;
|
||||||
@@ -36,7 +44,14 @@ type SettingsPanelProps = {
|
|||||||
export function SettingsPanel({
|
export function SettingsPanel({
|
||||||
gatewayStatus,
|
gatewayStatus,
|
||||||
gatewayUrl,
|
gatewayUrl,
|
||||||
|
gatewayToken,
|
||||||
|
selectedAdapterType = "openclaw",
|
||||||
|
activeAdapterType = "openclaw",
|
||||||
onGatewayDisconnect,
|
onGatewayDisconnect,
|
||||||
|
onGatewayConnect,
|
||||||
|
onGatewayUrlChange,
|
||||||
|
onGatewayTokenChange,
|
||||||
|
onGatewayAdapterTypeChange,
|
||||||
onOpenOnboarding,
|
onOpenOnboarding,
|
||||||
officeTitle,
|
officeTitle,
|
||||||
officeTitleLoaded,
|
officeTitleLoaded,
|
||||||
@@ -63,10 +78,17 @@ export function SettingsPanel({
|
|||||||
onVoiceRepliesPreview,
|
onVoiceRepliesPreview,
|
||||||
}: SettingsPanelProps) {
|
}: SettingsPanelProps) {
|
||||||
const normalizedGatewayUrl = gatewayUrl?.trim() ?? "";
|
const normalizedGatewayUrl = gatewayUrl?.trim() ?? "";
|
||||||
|
const normalizedGatewayToken = gatewayToken ?? "";
|
||||||
const gatewayStateLabel = gatewayStatus
|
const gatewayStateLabel = gatewayStatus
|
||||||
? gatewayStatus.charAt(0).toUpperCase() + gatewayStatus.slice(1)
|
? gatewayStatus.charAt(0).toUpperCase() + gatewayStatus.slice(1)
|
||||||
: "Unknown";
|
: "Unknown";
|
||||||
const gatewayDisconnectDisabled = gatewayStatus !== "connected";
|
const isGatewayConnected = gatewayStatus === "connected";
|
||||||
|
const gatewayDisconnectDisabled = !isGatewayConnected;
|
||||||
|
const gatewayConnectDisabled = normalizedGatewayUrl.length === 0;
|
||||||
|
const tokenOptional =
|
||||||
|
selectedAdapterType === "hermes" ||
|
||||||
|
selectedAdapterType === "demo" ||
|
||||||
|
selectedAdapterType === "custom";
|
||||||
const [remoteOfficeTokenDraft, setRemoteOfficeTokenDraft] = useState("");
|
const [remoteOfficeTokenDraft, setRemoteOfficeTokenDraft] = useState("");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -101,28 +123,100 @@ export function SettingsPanel({
|
|||||||
<div>
|
<div>
|
||||||
<div className="text-[11px] font-medium text-white">Gateway</div>
|
<div className="text-[11px] font-medium text-white">Gateway</div>
|
||||||
<div className="mt-1 text-[10px] text-white/75">
|
<div className="mt-1 text-[10px] text-white/75">
|
||||||
Current studio connection and endpoint details.
|
Switch the active backend and update its saved endpoint details.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-cyan-200/70">
|
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-cyan-200/70">
|
||||||
{gatewayStateLabel}
|
{gatewayStateLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 rounded-md border border-cyan-500/10 bg-black/25 px-3 py-2 font-mono text-[10px] text-cyan-100/80">
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
{normalizedGatewayUrl || "No gateway URL configured."}
|
{(
|
||||||
|
[
|
||||||
|
["demo", "Demo"],
|
||||||
|
["hermes", "Hermes"],
|
||||||
|
["custom", "Custom"],
|
||||||
|
["openclaw", "OpenClaw"],
|
||||||
|
] as const
|
||||||
|
).map(([adapterType, label]) => {
|
||||||
|
const selected = selectedAdapterType === adapterType;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={adapterType}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onGatewayAdapterTypeChange?.(adapterType)}
|
||||||
|
className={`rounded-md border px-3 py-1.5 text-[10px] font-medium uppercase tracking-[0.14em] transition-colors ${
|
||||||
|
selected
|
||||||
|
? "border-cyan-400/35 bg-cyan-500/12 text-cyan-50"
|
||||||
|
: "border-cyan-500/10 bg-black/20 text-white/75 hover:border-cyan-400/25 hover:text-cyan-50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 grid gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 text-[10px] uppercase tracking-[0.14em] text-cyan-100/65">
|
||||||
|
Upstream URL
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={gatewayUrl ?? ""}
|
||||||
|
onChange={(event) => onGatewayUrlChange?.(event.target.value)}
|
||||||
|
placeholder={
|
||||||
|
selectedAdapterType === "custom"
|
||||||
|
? "http://localhost:7770"
|
||||||
|
: "ws://localhost:18789"
|
||||||
|
}
|
||||||
|
className="w-full rounded-md border border-cyan-500/10 bg-black/25 px-3 py-2 font-mono text-[11px] text-cyan-100 outline-none transition-colors placeholder:text-cyan-100/30 focus:border-cyan-400/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 text-[10px] uppercase tracking-[0.14em] text-cyan-100/65">
|
||||||
|
{tokenOptional ? "Upstream token (optional)" : "Upstream token"}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={normalizedGatewayToken}
|
||||||
|
onChange={(event) => onGatewayTokenChange?.(event.target.value)}
|
||||||
|
placeholder={tokenOptional ? "optional token" : "gateway token"}
|
||||||
|
className="w-full rounded-md border border-cyan-500/10 bg-black/25 px-3 py-2 text-[11px] text-cyan-100 outline-none transition-colors placeholder:text-cyan-100/30 focus:border-cyan-400/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-2 text-[10px] text-white/60">
|
||||||
|
<span className="font-mono">
|
||||||
|
Selected backend: {selectedAdapterType}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono">
|
||||||
|
Active backend: {activeAdapterType}
|
||||||
|
</span>
|
||||||
|
<span>Each backend keeps its own saved URL and token.</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex items-center justify-between gap-3">
|
<div className="mt-3 flex items-center justify-between gap-3">
|
||||||
<div className="text-[10px] text-white/60">
|
<div className="text-[10px] text-white/60">
|
||||||
Disconnecting returns you to the gateway connect screen.
|
Connect to apply the selected backend, or disconnect to return to the connection screen.
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onGatewayConnect?.()}
|
||||||
|
disabled={gatewayConnectDisabled}
|
||||||
|
className="rounded-md border border-cyan-500/20 bg-cyan-500/10 px-3 py-1.5 text-[10px] font-medium uppercase tracking-[0.14em] text-cyan-50 transition-colors hover:border-cyan-400/40 hover:bg-cyan-500/15 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{gatewayStatus === "connecting" ? "Connecting..." : "Connect"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onGatewayDisconnect?.()}
|
||||||
|
disabled={gatewayDisconnectDisabled}
|
||||||
|
className="rounded-md border border-rose-500/20 bg-rose-500/10 px-3 py-1.5 text-[10px] font-medium uppercase tracking-[0.14em] text-rose-100 transition-colors hover:border-rose-400/40 hover:bg-rose-500/15 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Disconnect gateway
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onGatewayDisconnect?.()}
|
|
||||||
disabled={gatewayDisconnectDisabled}
|
|
||||||
className="rounded-md border border-rose-500/20 bg-rose-500/10 px-3 py-1.5 text-[10px] font-medium uppercase tracking-[0.14em] text-rose-100 transition-colors hover:border-rose-400/40 hover:bg-rose-500/15 disabled:cursor-not-allowed disabled:opacity-40"
|
|
||||||
>
|
|
||||||
Disconnect gateway
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 rounded-lg border border-cyan-500/10 bg-black/20 px-4 py-3">
|
<div className="mt-3 rounded-lg border border-cyan-500/10 bg-black/20 px-4 py-3">
|
||||||
|
|||||||
@@ -36,20 +36,24 @@ const MAX_APPROVAL_RECORDS = 300;
|
|||||||
export const useApprovalMetrics = ({
|
export const useApprovalMetrics = ({
|
||||||
client,
|
client,
|
||||||
status,
|
status,
|
||||||
|
enabled = true,
|
||||||
agents,
|
agents,
|
||||||
}: {
|
}: {
|
||||||
client: GatewayClient;
|
client: GatewayClient;
|
||||||
status: GatewayStatus;
|
status: GatewayStatus;
|
||||||
|
enabled?: boolean;
|
||||||
agents: AgentState[];
|
agents: AgentState[];
|
||||||
}) => {
|
}) => {
|
||||||
const [records, setRecords] = useState<ApprovalRecord[]>([]);
|
const [records, setRecords] = useState<ApprovalRecord[]>([]);
|
||||||
const agentsRef = useRef(agents);
|
const agentsRef = useRef(agents);
|
||||||
|
const visibleRecords = useMemo(() => (enabled ? records : []), [enabled, records]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
agentsRef.current = agents;
|
agentsRef.current = agents;
|
||||||
}, [agents]);
|
}, [agents]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
if (status !== "connected") return;
|
if (status !== "connected") return;
|
||||||
return client.onEvent((event) => {
|
return client.onEvent((event) => {
|
||||||
const requested = parseExecApprovalRequested(event);
|
const requested = parseExecApprovalRequested(event);
|
||||||
@@ -105,11 +109,11 @@ export const useApprovalMetrics = ({
|
|||||||
return [fallbackRecord, ...current].slice(0, MAX_APPROVAL_RECORDS);
|
return [fallbackRecord, ...current].slice(0, MAX_APPROVAL_RECORDS);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}, [client, status]);
|
}, [client, enabled, status]);
|
||||||
|
|
||||||
const byAgent = useMemo(() => {
|
const byAgent = useMemo(() => {
|
||||||
const metrics = new Map<string, ApprovalAgentMetrics>();
|
const metrics = new Map<string, ApprovalAgentMetrics>();
|
||||||
for (const record of records) {
|
for (const record of visibleRecords) {
|
||||||
const agentId = record.agentId?.trim() ?? "";
|
const agentId = record.agentId?.trim() ?? "";
|
||||||
if (!agentId) continue;
|
if (!agentId) continue;
|
||||||
const current = metrics.get(agentId) ?? {
|
const current = metrics.get(agentId) ?? {
|
||||||
@@ -135,20 +139,20 @@ export const useApprovalMetrics = ({
|
|||||||
}
|
}
|
||||||
return left.agentId.localeCompare(right.agentId);
|
return left.agentId.localeCompare(right.agentId);
|
||||||
});
|
});
|
||||||
}, [records]);
|
}, [visibleRecords]);
|
||||||
|
|
||||||
const totals = useMemo(() => {
|
const totals = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
requestedCount: records.length,
|
requestedCount: visibleRecords.length,
|
||||||
resolvedCount: records.filter((record) => record.decision !== null).length,
|
resolvedCount: visibleRecords.filter((record) => record.decision !== null).length,
|
||||||
deniedCount: records.filter((record) => record.decision === "deny").length,
|
deniedCount: visibleRecords.filter((record) => record.decision === "deny").length,
|
||||||
allowOnceCount: records.filter((record) => record.decision === "allow-once").length,
|
allowOnceCount: visibleRecords.filter((record) => record.decision === "allow-once").length,
|
||||||
allowAlwaysCount: records.filter((record) => record.decision === "allow-always").length,
|
allowAlwaysCount: visibleRecords.filter((record) => record.decision === "allow-always").length,
|
||||||
};
|
};
|
||||||
}, [records]);
|
}, [visibleRecords]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
records,
|
records: visibleRecords,
|
||||||
byAgent,
|
byAgent,
|
||||||
totals,
|
totals,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -35,10 +35,12 @@ const isSkillEnabledForAgent = (params: {
|
|||||||
export const useOfficeSkillTriggers = ({
|
export const useOfficeSkillTriggers = ({
|
||||||
client,
|
client,
|
||||||
status,
|
status,
|
||||||
|
enabled = true,
|
||||||
agents,
|
agents,
|
||||||
}: {
|
}: {
|
||||||
client: GatewayClient;
|
client: GatewayClient;
|
||||||
status: GatewayStatus;
|
status: GatewayStatus;
|
||||||
|
enabled?: boolean;
|
||||||
agents: AgentState[];
|
agents: AgentState[];
|
||||||
}) => {
|
}) => {
|
||||||
const requestIdRef = useRef(0);
|
const requestIdRef = useRef(0);
|
||||||
@@ -55,7 +57,10 @@ export const useOfficeSkillTriggers = ({
|
|||||||
[agentIdsKey],
|
[agentIdsKey],
|
||||||
);
|
);
|
||||||
const shouldLoadTriggers =
|
const shouldLoadTriggers =
|
||||||
status === "connected" && stableAgentIds.length > 0 && packagedTriggers.length > 0;
|
enabled &&
|
||||||
|
status === "connected" &&
|
||||||
|
stableAgentIds.length > 0 &&
|
||||||
|
packagedTriggers.length > 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!shouldLoadTriggers) {
|
if (!shouldLoadTriggers) {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ type MarketplaceMessage = {
|
|||||||
export const useOfficeSkillsMarketplace = ({
|
export const useOfficeSkillsMarketplace = ({
|
||||||
client,
|
client,
|
||||||
status,
|
status,
|
||||||
|
enabled = true,
|
||||||
agents,
|
agents,
|
||||||
preferredAgentId,
|
preferredAgentId,
|
||||||
onSkillActivityStart,
|
onSkillActivityStart,
|
||||||
@@ -37,6 +38,7 @@ export const useOfficeSkillsMarketplace = ({
|
|||||||
}: {
|
}: {
|
||||||
client: GatewayClient;
|
client: GatewayClient;
|
||||||
status: GatewayStatus;
|
status: GatewayStatus;
|
||||||
|
enabled?: boolean;
|
||||||
agents: AgentState[];
|
agents: AgentState[];
|
||||||
preferredAgentId?: string | null;
|
preferredAgentId?: string | null;
|
||||||
onSkillActivityStart?: (agentId: string) => void;
|
onSkillActivityStart?: (agentId: string) => void;
|
||||||
@@ -88,7 +90,7 @@ export const useOfficeSkillsMarketplace = ({
|
|||||||
const loadMarketplace = useCallback(
|
const loadMarketplace = useCallback(
|
||||||
async (agentId: string) => {
|
async (agentId: string) => {
|
||||||
const resolvedAgentId = agentId.trim();
|
const resolvedAgentId = agentId.trim();
|
||||||
if (!resolvedAgentId || status !== "connected") {
|
if (!enabled || !resolvedAgentId || status !== "connected") {
|
||||||
setSkillsReport(null);
|
setSkillsReport(null);
|
||||||
setSkillsAllowlist(undefined);
|
setSkillsAllowlist(undefined);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -132,11 +134,11 @@ export const useOfficeSkillsMarketplace = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[client, status],
|
[client, enabled, status],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedAgentId || status !== "connected") {
|
if (!enabled || !selectedAgentId || status !== "connected") {
|
||||||
requestIdRef.current += 1;
|
requestIdRef.current += 1;
|
||||||
setSkillsReport(null);
|
setSkillsReport(null);
|
||||||
setSkillsAllowlist(undefined);
|
setSkillsAllowlist(undefined);
|
||||||
@@ -144,14 +146,15 @@ export const useOfficeSkillsMarketplace = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void loadMarketplace(selectedAgentId);
|
void loadMarketplace(selectedAgentId);
|
||||||
}, [loadMarketplace, selectedAgentId, status]);
|
}, [enabled, loadMarketplace, selectedAgentId, status]);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
|
if (!enabled) return;
|
||||||
if (!selectedAgentId) {
|
if (!selectedAgentId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await loadMarketplace(selectedAgentId);
|
await loadMarketplace(selectedAgentId);
|
||||||
}, [loadMarketplace, selectedAgentId]);
|
}, [enabled, loadMarketplace, selectedAgentId]);
|
||||||
|
|
||||||
const runSkillMutation = useCallback(
|
const runSkillMutation = useCallback(
|
||||||
async (params: {
|
async (params: {
|
||||||
@@ -162,6 +165,13 @@ export const useOfficeSkillsMarketplace = ({
|
|||||||
const agentId = selectedAgentId?.trim() ?? "";
|
const agentId = selectedAgentId?.trim() ?? "";
|
||||||
const report = skillsReport;
|
const report = skillsReport;
|
||||||
const normalizedSkillKey = params.skillKey.trim();
|
const normalizedSkillKey = params.skillKey.trim();
|
||||||
|
if (!enabled) {
|
||||||
|
setMessage({
|
||||||
|
kind: "error",
|
||||||
|
text: "This runtime does not expose skill management.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!agentId || !report) {
|
if (!agentId || !report) {
|
||||||
setMessage({
|
setMessage({
|
||||||
kind: "error",
|
kind: "error",
|
||||||
@@ -201,13 +211,13 @@ export const useOfficeSkillsMarketplace = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[loadMarketplace, onSkillActivityEnd, onSkillActivityStart, selectedAgentId, skillsReport],
|
[enabled, loadMarketplace, onSkillActivityEnd, onSkillActivityStart, selectedAgentId, skillsReport],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSetSkillEnabled = useCallback(
|
const handleSetSkillEnabled = useCallback(
|
||||||
async (skillName: string, enabled: boolean) => {
|
async (skillName: string, enabled: boolean) => {
|
||||||
const entry =
|
const entry =
|
||||||
skillsReport?.skills.find(
|
skillsReport?.skills?.find(
|
||||||
(skill) => skill.name.trim() === skillName.trim(),
|
(skill) => skill.name.trim() === skillName.trim(),
|
||||||
) ?? null;
|
) ?? null;
|
||||||
await runSkillMutation({
|
await runSkillMutation({
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { AgentState } from "@/features/agents/state/store";
|
import type { AgentState } from "@/features/agents/state/store";
|
||||||
import type { AgentEventPayload } from "@/features/agents/state/runtimeEventBridge";
|
import type { AgentEventPayload } from "@/features/agents/state/runtimeEventBridge";
|
||||||
import type { GatewayClient, GatewayStatus } from "@/lib/gateway/GatewayClient";
|
import type { GatewayClient, GatewayStatus } from "@/lib/gateway/GatewayClient";
|
||||||
@@ -57,22 +57,26 @@ const findAgentForRunEvent = (
|
|||||||
export const useRunLog = ({
|
export const useRunLog = ({
|
||||||
client,
|
client,
|
||||||
status,
|
status,
|
||||||
|
enabled = true,
|
||||||
agents,
|
agents,
|
||||||
maxRecords = MAX_RUN_RECORDS,
|
maxRecords = MAX_RUN_RECORDS,
|
||||||
}: {
|
}: {
|
||||||
client: GatewayClient;
|
client: GatewayClient;
|
||||||
status: GatewayStatus;
|
status: GatewayStatus;
|
||||||
|
enabled?: boolean;
|
||||||
agents: AgentState[];
|
agents: AgentState[];
|
||||||
maxRecords?: number;
|
maxRecords?: number;
|
||||||
}) => {
|
}) => {
|
||||||
const [records, setRecords] = useState<RunRecord[]>([]);
|
const [records, setRecords] = useState<RunRecord[]>([]);
|
||||||
const agentsRef = useRef(agents);
|
const agentsRef = useRef(agents);
|
||||||
|
const visibleRecords = useMemo(() => (enabled ? records : []), [enabled, records]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
agentsRef.current = agents;
|
agentsRef.current = agents;
|
||||||
}, [agents]);
|
}, [agents]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
if (status !== "connected") return;
|
if (status !== "connected") return;
|
||||||
return client.onEvent((event) => {
|
return client.onEvent((event) => {
|
||||||
if (event.event !== "agent") return;
|
if (event.event !== "agent") return;
|
||||||
@@ -126,7 +130,7 @@ export const useRunLog = ({
|
|||||||
return [fallbackRecord, ...current].slice(0, Math.max(1, maxRecords));
|
return [fallbackRecord, ...current].slice(0, Math.max(1, maxRecords));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}, [client, maxRecords, status]);
|
}, [client, enabled, maxRecords, status]);
|
||||||
|
|
||||||
return records;
|
return visibleRecords;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,16 +12,17 @@ import { useRouter, useSearchParams } from "next/navigation";
|
|||||||
import { MessageSquare, ChevronDown, Mic } from "lucide-react";
|
import { MessageSquare, ChevronDown, Mic } from "lucide-react";
|
||||||
import { RetroOffice3D } from "@/features/retro-office/RetroOffice3D";
|
import { RetroOffice3D } from "@/features/retro-office/RetroOffice3D";
|
||||||
import type { OfficeAgent } from "@/features/retro-office/core/types";
|
import type { OfficeAgent } from "@/features/retro-office/core/types";
|
||||||
|
import { RunningAvatarLoader } from "@/features/agents/components/RunningAvatarLoader";
|
||||||
import { GatewayConnectScreen } from "@/features/agents/components/GatewayConnectScreen";
|
import { GatewayConnectScreen } from "@/features/agents/components/GatewayConnectScreen";
|
||||||
import { useAgentStore, type AgentState } from "@/features/agents/state/store";
|
import { useAgentStore, type AgentState } from "@/features/agents/state/store";
|
||||||
import {
|
import {
|
||||||
GatewayClient,
|
GatewayClient,
|
||||||
buildAgentMainSessionKey,
|
buildAgentMainSessionKey,
|
||||||
useGatewayConnection,
|
|
||||||
type EventFrame,
|
type EventFrame,
|
||||||
isSameSessionKey,
|
isSameSessionKey,
|
||||||
parseAgentIdFromSessionKey,
|
parseAgentIdFromSessionKey,
|
||||||
} from "@/lib/gateway/GatewayClient";
|
} from "@/lib/gateway/GatewayClient";
|
||||||
|
import { useRuntimeConnection } from "@/lib/runtime/useRuntimeConnection";
|
||||||
import {
|
import {
|
||||||
createStudioSettingsCoordinator,
|
createStudioSettingsCoordinator,
|
||||||
type StudioSettingsLoadOptions,
|
type StudioSettingsLoadOptions,
|
||||||
@@ -214,6 +215,8 @@ const MAIN_AGENT_ID = "main";
|
|||||||
const MAX_OPENCLAW_LOG_ENTRIES = 200;
|
const MAX_OPENCLAW_LOG_ENTRIES = 200;
|
||||||
const MAX_OPENCLAW_AGENT_OUTPUT_LINES = 12;
|
const MAX_OPENCLAW_AGENT_OUTPUT_LINES = 12;
|
||||||
const OFFICE_DANCE_MS = 60_000;
|
const OFFICE_DANCE_MS = 60_000;
|
||||||
|
const GATEWAY_LOADING_OVERLAY_DELAY_MS = 1_200;
|
||||||
|
const GATEWAY_CONNECT_OVERLAY_DELAY_MS = 1_500;
|
||||||
|
|
||||||
const getLatestUserRequestForAgent = (
|
const getLatestUserRequestForAgent = (
|
||||||
agent: AgentState,
|
agent: AgentState,
|
||||||
@@ -510,6 +513,7 @@ const mapAgentToOffice = (agent: AgentState): OfficeAgent => {
|
|||||||
return {
|
return {
|
||||||
id: agent.agentId,
|
id: agent.agentId,
|
||||||
name: agent.name || "Unknown",
|
name: agent.name || "Unknown",
|
||||||
|
subtitle: agent.role ?? null,
|
||||||
status: "error",
|
status: "error",
|
||||||
color: stringToColor(agent.agentId),
|
color: stringToColor(agent.agentId),
|
||||||
item: getDeterministicItem(agent.agentId),
|
item: getDeterministicItem(agent.agentId),
|
||||||
@@ -520,6 +524,7 @@ const mapAgentToOffice = (agent: AgentState): OfficeAgent => {
|
|||||||
return {
|
return {
|
||||||
id: agent.agentId,
|
id: agent.agentId,
|
||||||
name: agent.name || "Unknown",
|
name: agent.name || "Unknown",
|
||||||
|
subtitle: agent.role ?? null,
|
||||||
status: isWorking ? "working" : "idle",
|
status: isWorking ? "working" : "idle",
|
||||||
color: stringToColor(agent.agentId),
|
color: stringToColor(agent.agentId),
|
||||||
item: getDeterministicItem(agent.agentId),
|
item: getDeterministicItem(agent.agentId),
|
||||||
@@ -835,11 +840,14 @@ export function OfficeScreen({
|
|||||||
);
|
);
|
||||||
const {
|
const {
|
||||||
client,
|
client,
|
||||||
|
provider,
|
||||||
status,
|
status,
|
||||||
connectPromptReady,
|
connectPromptReady,
|
||||||
shouldPromptForConnect,
|
shouldPromptForConnect,
|
||||||
gatewayUrl,
|
gatewayUrl,
|
||||||
token,
|
token,
|
||||||
|
selectedAdapterType,
|
||||||
|
activeAdapterType,
|
||||||
localGatewayDefaults,
|
localGatewayDefaults,
|
||||||
error: gatewayError,
|
error: gatewayError,
|
||||||
connect,
|
connect,
|
||||||
@@ -847,12 +855,23 @@ export function OfficeScreen({
|
|||||||
useLocalGatewayDefaults,
|
useLocalGatewayDefaults,
|
||||||
setGatewayUrl,
|
setGatewayUrl,
|
||||||
setToken,
|
setToken,
|
||||||
|
setSelectedAdapterType,
|
||||||
|
supportsCapability,
|
||||||
} =
|
} =
|
||||||
useGatewayConnection(settingsCoordinator);
|
useRuntimeConnection(settingsCoordinator);
|
||||||
|
const runtimeSupportsSkills = supportsCapability("skills");
|
||||||
|
const runtimeSupportsApprovals = supportsCapability("approvals");
|
||||||
|
const runtimeSupportsCron = supportsCapability("cron");
|
||||||
|
const runtimeSupportsModels = supportsCapability("models");
|
||||||
|
const runtimeSupportsRunLifecycle = supportsCapability("runtime-agent-events");
|
||||||
const { state, dispatch, hydrateAgents, setError, setLoading } =
|
const { state, dispatch, hydrateAgents, setError, setLoading } =
|
||||||
useAgentStore();
|
useAgentStore();
|
||||||
const [agentsLoaded, setAgentsLoaded] = useState(false);
|
const [agentsLoaded, setAgentsLoaded] = useState(false);
|
||||||
const [didAttemptGatewayConnect, setDidAttemptGatewayConnect] = useState(false);
|
const [didAttemptGatewayConnect, setDidAttemptGatewayConnect] = useState(false);
|
||||||
|
const [showDelayedGatewayLoadingOverlay, setShowDelayedGatewayLoadingOverlay] =
|
||||||
|
useState(false);
|
||||||
|
const [showDelayedGatewayConnectOverlay, setShowDelayedGatewayConnectOverlay] =
|
||||||
|
useState(false);
|
||||||
const [clockTick, setClockTick] = useState(0);
|
const [clockTick, setClockTick] = useState(0);
|
||||||
const [debugRows, setDebugRows] = useState<OfficeDebugRow[]>([]);
|
const [debugRows, setDebugRows] = useState<OfficeDebugRow[]>([]);
|
||||||
const [feedEvents, setFeedEvents] = useState<OfficeFeedEvent[]>([]);
|
const [feedEvents, setFeedEvents] = useState<OfficeFeedEvent[]>([]);
|
||||||
@@ -1129,14 +1148,33 @@ export function OfficeScreen({
|
|||||||
},
|
},
|
||||||
[dispatch, gatewayUrl, settingsCoordinator],
|
[dispatch, gatewayUrl, settingsCoordinator],
|
||||||
);
|
);
|
||||||
|
const focusLocalAgent = useCallback(
|
||||||
|
(agentId: string, options?: { openChat?: boolean }) => {
|
||||||
|
setSelectedChatAgentId(agentId);
|
||||||
|
if (options?.openChat !== false) {
|
||||||
|
setChatOpen(true);
|
||||||
|
}
|
||||||
|
dispatch({ type: "selectAgent", agentId });
|
||||||
|
},
|
||||||
|
[dispatch],
|
||||||
|
);
|
||||||
|
const focusChatTarget = useCallback(
|
||||||
|
(agentId: string) => {
|
||||||
|
setSelectedChatAgentId(agentId);
|
||||||
|
setChatOpen(true);
|
||||||
|
if (!isRemoteOfficeAgentId(agentId)) {
|
||||||
|
dispatch({ type: "selectAgent", agentId });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[dispatch],
|
||||||
|
);
|
||||||
const openAgentEditor = useCallback(
|
const openAgentEditor = useCallback(
|
||||||
(agentId: string, initialSection: AgentEditorSection = "avatar") => {
|
(agentId: string, initialSection: AgentEditorSection = "avatar") => {
|
||||||
setAgentEditorAgentId(agentId);
|
setAgentEditorAgentId(agentId);
|
||||||
setAgentEditorInitialSection(initialSection);
|
setAgentEditorInitialSection(initialSection);
|
||||||
setSelectedChatAgentId(agentId);
|
focusLocalAgent(agentId, { openChat: false });
|
||||||
dispatch({ type: "selectAgent", agentId });
|
|
||||||
},
|
},
|
||||||
[dispatch],
|
[focusLocalAgent],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDeskAssignmentChange = useCallback(
|
const handleDeskAssignmentChange = useCallback(
|
||||||
@@ -1353,7 +1391,7 @@ export function OfficeScreen({
|
|||||||
? { force: true }
|
? { force: true }
|
||||||
: { maxAgeMs: options?.settingsMaxAgeMs ?? 60_000 };
|
: { maxAgeMs: options?.settingsMaxAgeMs ?? 60_000 };
|
||||||
const commands = await runStudioBootstrapLoadOperation({
|
const commands = await runStudioBootstrapLoadOperation({
|
||||||
client,
|
client: provider,
|
||||||
gatewayUrl,
|
gatewayUrl,
|
||||||
cachedConfigSnapshot: gatewayConfigSnapshot.current,
|
cachedConfigSnapshot: gatewayConfigSnapshot.current,
|
||||||
loadStudioSettings: () => loadStudioSettings(settingsLoadOptions),
|
loadStudioSettings: () => loadStudioSettings(settingsLoadOptions),
|
||||||
@@ -1389,7 +1427,7 @@ export function OfficeScreen({
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const inference = await inferRunningFromAgentSessions({
|
const inference = await inferRunningFromAgentSessions({
|
||||||
client,
|
client: provider,
|
||||||
agentId: agent.agentId,
|
agentId: agent.agentId,
|
||||||
});
|
});
|
||||||
if (connectionEpochAtStart !== connectionEpochRef.current) {
|
if (connectionEpochAtStart !== connectionEpochRef.current) {
|
||||||
@@ -1570,7 +1608,7 @@ export function OfficeScreen({
|
|||||||
const runCompanyBuilderAiTask = useCallback(
|
const runCompanyBuilderAiTask = useCallback(
|
||||||
async (prompt: string, statusText: string) => {
|
async (prompt: string, statusText: string) => {
|
||||||
if (status !== "connected") {
|
if (status !== "connected") {
|
||||||
throw new Error("Connect to OpenClaw before using the company builder.");
|
throw new Error("Connect to a runtime before using the company builder.");
|
||||||
}
|
}
|
||||||
const livePlannerAgent = resolveCompanyPlanningAgent({
|
const livePlannerAgent = resolveCompanyPlanningAgent({
|
||||||
agents: stateRef.current.agents,
|
agents: stateRef.current.agents,
|
||||||
@@ -1598,7 +1636,7 @@ export function OfficeScreen({
|
|||||||
try {
|
try {
|
||||||
const improvedBrief = await runCompanyBuilderAiTask(
|
const improvedBrief = await runCompanyBuilderAiTask(
|
||||||
buildImproveCompanyBriefPrompt(brief),
|
buildImproveCompanyBriefPrompt(brief),
|
||||||
"Improving your company brief with OpenClaw.",
|
"Improving your company brief with the connected runtime.",
|
||||||
);
|
);
|
||||||
setCompanyBuilderInput((current) => ({
|
setCompanyBuilderInput((current) => ({
|
||||||
...current,
|
...current,
|
||||||
@@ -1625,7 +1663,7 @@ export function OfficeScreen({
|
|||||||
try {
|
try {
|
||||||
const response = await runCompanyBuilderAiTask(
|
const response = await runCompanyBuilderAiTask(
|
||||||
buildGenerateCompanyPlanPrompt(brief),
|
buildGenerateCompanyPlanPrompt(brief),
|
||||||
"Generating your AI company structure with OpenClaw.",
|
"Generating your AI company structure with the connected runtime.",
|
||||||
);
|
);
|
||||||
const parsedPlan = parseCompanyPlanFromAssistantText(response);
|
const parsedPlan = parseCompanyPlanFromAssistantText(response);
|
||||||
const nextInput: CompanyBuilderInput = {
|
const nextInput: CompanyBuilderInput = {
|
||||||
@@ -1668,7 +1706,7 @@ export function OfficeScreen({
|
|||||||
const handleCreateCompanyFromPlan = useCallback(
|
const handleCreateCompanyFromPlan = useCallback(
|
||||||
async (params: { input: CompanyBuilderInput; plan: CompanyBuilderPlan }) => {
|
async (params: { input: CompanyBuilderInput; plan: CompanyBuilderPlan }) => {
|
||||||
if (status !== "connected") {
|
if (status !== "connected") {
|
||||||
const message = "Connect to OpenClaw before creating the company.";
|
const message = "Connect to a runtime before creating the company.";
|
||||||
setCompanyBuilderError(message);
|
setCompanyBuilderError(message);
|
||||||
throw new Error(message);
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
@@ -1772,8 +1810,7 @@ export function OfficeScreen({
|
|||||||
persistSnapshot: persistCompanyBuilderSnapshot,
|
persistSnapshot: persistCompanyBuilderSnapshot,
|
||||||
setOfficeTitle,
|
setOfficeTitle,
|
||||||
selectAgent: (agentId) => {
|
selectAgent: (agentId) => {
|
||||||
dispatch({ type: "selectAgent", agentId });
|
focusLocalAgent(agentId);
|
||||||
setSelectedChatAgentId(agentId);
|
|
||||||
},
|
},
|
||||||
setStatusLine: setCompanyBuilderStatusLine,
|
setStatusLine: setCompanyBuilderStatusLine,
|
||||||
});
|
});
|
||||||
@@ -1887,11 +1924,7 @@ export function OfficeScreen({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
dispatch({
|
focusLocalAgent(completion.agentId);
|
||||||
type: "selectAgent",
|
|
||||||
agentId: completion.agentId,
|
|
||||||
});
|
|
||||||
setSelectedChatAgentId(completion.agentId);
|
|
||||||
setCreateAgentBlock(null);
|
setCreateAgentBlock(null);
|
||||||
setCreateAgentModalError(null);
|
setCreateAgentModalError(null);
|
||||||
},
|
},
|
||||||
@@ -1911,6 +1944,7 @@ export function OfficeScreen({
|
|||||||
createAgentBusy,
|
createAgentBusy,
|
||||||
dispatch,
|
dispatch,
|
||||||
enqueueConfigMutation,
|
enqueueConfigMutation,
|
||||||
|
focusLocalAgent,
|
||||||
hasDeleteMutationBlock,
|
hasDeleteMutationBlock,
|
||||||
loadAgents,
|
loadAgents,
|
||||||
setError,
|
setError,
|
||||||
@@ -2105,7 +2139,7 @@ export function OfficeScreen({
|
|||||||
const requestedSessionKey = params.sessionKey?.trim() ?? "";
|
const requestedSessionKey = params.sessionKey?.trim() ?? "";
|
||||||
if (requestedSessionKey) {
|
if (requestedSessionKey) {
|
||||||
try {
|
try {
|
||||||
const history = await client.call<{
|
const history = await provider.call<{
|
||||||
messages?: Record<string, unknown>[];
|
messages?: Record<string, unknown>[];
|
||||||
}>("chat.history", {
|
}>("chat.history", {
|
||||||
sessionKey: requestedSessionKey,
|
sessionKey: requestedSessionKey,
|
||||||
@@ -2117,7 +2151,7 @@ export function OfficeScreen({
|
|||||||
const derived = buildHistoryLines(messages);
|
const derived = buildHistoryLines(messages);
|
||||||
let lastUser = derived.lastUser?.trim() ?? "";
|
let lastUser = derived.lastUser?.trim() ?? "";
|
||||||
if (!lastUser) {
|
if (!lastUser) {
|
||||||
const previewResult = await client.call<SummaryPreviewSnapshot>(
|
const previewResult = await provider.call<SummaryPreviewSnapshot>(
|
||||||
"sessions.preview",
|
"sessions.preview",
|
||||||
{
|
{
|
||||||
keys: [requestedSessionKey],
|
keys: [requestedSessionKey],
|
||||||
@@ -2213,7 +2247,7 @@ export function OfficeScreen({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const commands = await runHistorySyncOperation({
|
const commands = await runHistorySyncOperation({
|
||||||
client,
|
client: provider,
|
||||||
agentId: params.agentId,
|
agentId: params.agentId,
|
||||||
getAgent: (agentId) =>
|
getAgent: (agentId) =>
|
||||||
stateRef.current.agents.find((entry) => entry.agentId === agentId) ??
|
stateRef.current.agents.find((entry) => entry.agentId === agentId) ??
|
||||||
@@ -2239,7 +2273,7 @@ export function OfficeScreen({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[client, debugEnabled, dispatch, status],
|
[debugEnabled, dispatch, provider, status],
|
||||||
);
|
);
|
||||||
|
|
||||||
const refreshRecentTransportSessionHistory = useCallback(
|
const refreshRecentTransportSessionHistory = useCallback(
|
||||||
@@ -2324,7 +2358,6 @@ export function OfficeScreen({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status === "disconnected") {
|
if (status === "disconnected") {
|
||||||
connectionEpochRef.current += 1;
|
connectionEpochRef.current += 1;
|
||||||
setAgentsLoaded(false);
|
|
||||||
setCreateAgentWizardOpen(false);
|
setCreateAgentWizardOpen(false);
|
||||||
setCreateAgentBusy(false);
|
setCreateAgentBusy(false);
|
||||||
setCreateAgentModalError(null);
|
setCreateAgentModalError(null);
|
||||||
@@ -2333,7 +2366,11 @@ export function OfficeScreen({
|
|||||||
loadAgentsInFlightRef.current = null;
|
loadAgentsInFlightRef.current = null;
|
||||||
gatewayConfigSnapshot.current = null;
|
gatewayConfigSnapshot.current = null;
|
||||||
lastLoadAgentsStartedAtRef.current = 0;
|
lastLoadAgentsStartedAtRef.current = 0;
|
||||||
hydrateAgents([]);
|
setLoading(false);
|
||||||
|
if (stateRef.current.agents.length === 0) {
|
||||||
|
setAgentsLoaded(false);
|
||||||
|
hydrateAgents([]);
|
||||||
|
}
|
||||||
setFeedEvents([]);
|
setFeedEvents([]);
|
||||||
setDebugRows([]);
|
setDebugRows([]);
|
||||||
setRunCountByAgentId({});
|
setRunCountByAgentId({});
|
||||||
@@ -2341,7 +2378,7 @@ export function OfficeScreen({
|
|||||||
prevAssistantPreviewRef.current = {};
|
prevAssistantPreviewRef.current = {};
|
||||||
lastGatewayActivityAtRef.current = 0;
|
lastGatewayActivityAtRef.current = 0;
|
||||||
}
|
}
|
||||||
}, [hydrateAgents, status]);
|
}, [hydrateAgents, setLoading, status]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!agentsLoaded) return;
|
if (!agentsLoaded) return;
|
||||||
@@ -2602,10 +2639,11 @@ export function OfficeScreen({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status !== "connected") return;
|
if (status !== "connected") return;
|
||||||
|
if (!runtimeSupportsModels) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const result = await client.call<{ models: GatewayModelChoice[] }>(
|
const result = await provider.call<{ models: GatewayModelChoice[] }>(
|
||||||
"models.list",
|
"models.list",
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
@@ -2624,7 +2662,7 @@ export function OfficeScreen({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [status, client]);
|
}, [status, provider, runtimeSupportsModels]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (chatOpen && !selectedChatAgentId && state.agents.length > 0) {
|
if (chatOpen && !selectedChatAgentId && state.agents.length > 0) {
|
||||||
@@ -2638,7 +2676,7 @@ export function OfficeScreen({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const chatController = useChatInteractionController({
|
const chatController = useChatInteractionController({
|
||||||
client,
|
client: provider,
|
||||||
status,
|
status,
|
||||||
agents: state.agents,
|
agents: state.agents,
|
||||||
dispatch: (action) => dispatch(action as never),
|
dispatch: (action) => dispatch(action as never),
|
||||||
@@ -2676,7 +2714,12 @@ export function OfficeScreen({
|
|||||||
setAgentEditorAgentId(null);
|
setAgentEditorAgentId(null);
|
||||||
}, [agentEditorAgentId, state.agents]);
|
}, [agentEditorAgentId, state.agents]);
|
||||||
|
|
||||||
const runLog = useRunLog({ client, status, agents: state.agents });
|
const runLog = useRunLog({
|
||||||
|
client,
|
||||||
|
status,
|
||||||
|
enabled: runtimeSupportsRunLifecycle,
|
||||||
|
agents: state.agents,
|
||||||
|
});
|
||||||
const standupAgentSnapshots = useMemo<StandupAgentSnapshot[]>(
|
const standupAgentSnapshots = useMemo<StandupAgentSnapshot[]>(
|
||||||
() =>
|
() =>
|
||||||
state.agents.map((agent) => ({
|
state.agents.map((agent) => ({
|
||||||
@@ -2696,6 +2739,7 @@ export function OfficeScreen({
|
|||||||
settingsCoordinator,
|
settingsCoordinator,
|
||||||
client,
|
client,
|
||||||
status,
|
status,
|
||||||
|
cronEnabled: runtimeSupportsCron,
|
||||||
agents: state.agents,
|
agents: state.agents,
|
||||||
runLog,
|
runLog,
|
||||||
standup: standupController,
|
standup: standupController,
|
||||||
@@ -2723,6 +2767,7 @@ export function OfficeScreen({
|
|||||||
const marketplace = useOfficeSkillsMarketplace({
|
const marketplace = useOfficeSkillsMarketplace({
|
||||||
client,
|
client,
|
||||||
status,
|
status,
|
||||||
|
enabled: runtimeSupportsSkills,
|
||||||
agents: state.agents,
|
agents: state.agents,
|
||||||
preferredAgentId: selectedLocalChatAgentId,
|
preferredAgentId: selectedLocalChatAgentId,
|
||||||
onSkillActivityStart: handleMarketplaceGymStart,
|
onSkillActivityStart: handleMarketplaceGymStart,
|
||||||
@@ -2731,6 +2776,7 @@ export function OfficeScreen({
|
|||||||
const skillTriggers = useOfficeSkillTriggers({
|
const skillTriggers = useOfficeSkillTriggers({
|
||||||
client,
|
client,
|
||||||
status,
|
status,
|
||||||
|
enabled: runtimeSupportsSkills,
|
||||||
agents: state.agents,
|
agents: state.agents,
|
||||||
});
|
});
|
||||||
const animationNowMs = Date.now();
|
const animationNowMs = Date.now();
|
||||||
@@ -2863,9 +2909,8 @@ export function OfficeScreen({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeGithubReviewAgentId) return;
|
if (!activeGithubReviewAgentId) return;
|
||||||
setSelectedChatAgentId(activeGithubReviewAgentId);
|
focusLocalAgent(activeGithubReviewAgentId);
|
||||||
dispatch({ type: "selectAgent", agentId: activeGithubReviewAgentId });
|
}, [activeGithubReviewAgentId, focusLocalAgent]);
|
||||||
}, [activeGithubReviewAgentId, dispatch]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setQaTestingAgentId(activeQaTestingAgentId);
|
setQaTestingAgentId(activeQaTestingAgentId);
|
||||||
@@ -2873,9 +2918,8 @@ export function OfficeScreen({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeQaTestingAgentId) return;
|
if (!activeQaTestingAgentId) return;
|
||||||
setSelectedChatAgentId(activeQaTestingAgentId);
|
focusLocalAgent(activeQaTestingAgentId);
|
||||||
dispatch({ type: "selectAgent", agentId: activeQaTestingAgentId });
|
}, [activeQaTestingAgentId, focusLocalAgent]);
|
||||||
}, [activeQaTestingAgentId, dispatch]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const activeKeys = new Set(
|
const activeKeys = new Set(
|
||||||
@@ -2929,9 +2973,7 @@ export function OfficeScreen({
|
|||||||
promptedPhoneCallKeysRef.current.delete(request.key);
|
promptedPhoneCallKeysRef.current.delete(request.key);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSelectedChatAgentId(agentId);
|
focusLocalAgent(agentId);
|
||||||
setChatOpen(true);
|
|
||||||
dispatch({ type: "selectAgent", agentId });
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "appendOutput",
|
type: "appendOutput",
|
||||||
agentId,
|
agentId,
|
||||||
@@ -2989,7 +3031,7 @@ export function OfficeScreen({
|
|||||||
prepareScenarioForAgent(agentId, request);
|
prepareScenarioForAgent(agentId, request);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [dispatch, phoneCallByAgentId, state.agents]);
|
}, [dispatch, focusLocalAgent, phoneCallByAgentId, state.agents]);
|
||||||
|
|
||||||
const activePhoneBoothAgentId = useMemo(
|
const activePhoneBoothAgentId = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -3011,10 +3053,9 @@ export function OfficeScreen({
|
|||||||
({ agentId, requestKey }: PhoneCallSpeakPayload) => {
|
({ agentId, requestKey }: PhoneCallSpeakPayload) => {
|
||||||
if (spokenPhoneCallKeysRef.current.has(requestKey)) return;
|
if (spokenPhoneCallKeysRef.current.has(requestKey)) return;
|
||||||
spokenPhoneCallKeysRef.current.add(requestKey);
|
spokenPhoneCallKeysRef.current.add(requestKey);
|
||||||
setSelectedChatAgentId(agentId);
|
focusLocalAgent(agentId);
|
||||||
dispatch({ type: "selectAgent", agentId });
|
|
||||||
},
|
},
|
||||||
[dispatch],
|
[focusLocalAgent],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePhoneCallComplete = useCallback(
|
const handlePhoneCallComplete = useCallback(
|
||||||
@@ -3092,9 +3133,7 @@ export function OfficeScreen({
|
|||||||
promptedTextMessageKeysRef.current.delete(request.key);
|
promptedTextMessageKeysRef.current.delete(request.key);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSelectedChatAgentId(agentId);
|
focusLocalAgent(agentId);
|
||||||
setChatOpen(true);
|
|
||||||
dispatch({ type: "selectAgent", agentId });
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "appendOutput",
|
type: "appendOutput",
|
||||||
agentId,
|
agentId,
|
||||||
@@ -3152,7 +3191,7 @@ export function OfficeScreen({
|
|||||||
prepareScenarioForAgent(agentId, request);
|
prepareScenarioForAgent(agentId, request);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [dispatch, state.agents, textMessageByAgentId]);
|
}, [dispatch, focusLocalAgent, state.agents, textMessageByAgentId]);
|
||||||
|
|
||||||
const activeSmsBoothAgentId = useMemo(
|
const activeSmsBoothAgentId = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -3219,13 +3258,9 @@ export function OfficeScreen({
|
|||||||
|
|
||||||
const handleOpenAgentChat = useCallback(
|
const handleOpenAgentChat = useCallback(
|
||||||
(agentId: string) => {
|
(agentId: string) => {
|
||||||
setSelectedChatAgentId(agentId);
|
focusChatTarget(agentId);
|
||||||
setChatOpen(true);
|
|
||||||
if (!isRemoteOfficeAgentId(agentId)) {
|
|
||||||
dispatch({ type: "selectAgent", agentId });
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[dispatch],
|
[focusChatTarget],
|
||||||
);
|
);
|
||||||
const updateRemoteChatSession = useCallback(
|
const updateRemoteChatSession = useCallback(
|
||||||
(
|
(
|
||||||
@@ -3752,6 +3787,15 @@ export function OfficeScreen({
|
|||||||
state.agents,
|
state.agents,
|
||||||
workingUntilByAgentId,
|
workingUntilByAgentId,
|
||||||
]);
|
]);
|
||||||
|
const streamingTextByAgentId = useMemo(() => {
|
||||||
|
const map: Record<string, string | null> = {};
|
||||||
|
for (const agent of state.agents) {
|
||||||
|
if (agent.streamText?.trim()) {
|
||||||
|
map[agent.agentId] = agent.streamText.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [state.agents]);
|
||||||
const openClawLiveStateText = useMemo(() => {
|
const openClawLiveStateText = useMemo(() => {
|
||||||
const lines = ["== LIVE OPENCLAW STATE =="];
|
const lines = ["== LIVE OPENCLAW STATE =="];
|
||||||
if (state.agents.length === 0) {
|
if (state.agents.length === 0) {
|
||||||
@@ -4087,43 +4131,52 @@ export function OfficeScreen({
|
|||||||
// No longer force-close the jukebox panel when skill is disabled;
|
// No longer force-close the jukebox panel when skill is disabled;
|
||||||
// the panel handles the disabled state itself.
|
// the panel handles the disabled state itself.
|
||||||
|
|
||||||
if (
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
status === "connecting" &&
|
||||||
|
!agentsLoaded &&
|
||||||
|
gatewayUrl.trim().length > 0 &&
|
||||||
|
!shouldPromptForConnect
|
||||||
|
) {
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
setShowDelayedGatewayLoadingOverlay(true);
|
||||||
|
}, GATEWAY_LOADING_OVERLAY_DELAY_MS);
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
setShowDelayedGatewayLoadingOverlay(false);
|
||||||
|
}, [agentsLoaded, gatewayUrl, shouldPromptForConnect, status]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
status === "disconnected" &&
|
||||||
|
!agentsLoaded &&
|
||||||
|
didAttemptGatewayConnect &&
|
||||||
|
!shouldPromptForConnect
|
||||||
|
) {
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
setShowDelayedGatewayConnectOverlay(true);
|
||||||
|
}, GATEWAY_CONNECT_OVERLAY_DELAY_MS);
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
setShowDelayedGatewayConnectOverlay(false);
|
||||||
|
}, [agentsLoaded, didAttemptGatewayConnect, shouldPromptForConnect, status]);
|
||||||
|
|
||||||
|
const showGatewayLoadingOverlay =
|
||||||
!agentsLoaded &&
|
!agentsLoaded &&
|
||||||
(!connectPromptReady ||
|
(!connectPromptReady ||
|
||||||
(gatewayUrl.trim().length > 0 &&
|
(gatewayUrl.trim().length > 0 &&
|
||||||
!shouldPromptForConnect &&
|
!shouldPromptForConnect &&
|
||||||
(!didAttemptGatewayConnect || status === "connecting")))
|
((!didAttemptGatewayConnect && showDelayedGatewayLoadingOverlay) ||
|
||||||
) {
|
(status === "connecting" && showDelayedGatewayLoadingOverlay))));
|
||||||
return (
|
const showGatewayConnectOverlay =
|
||||||
<div className="flex min-h-screen items-center justify-center bg-black font-mono text-[#4FC3F7]">
|
|
||||||
CONNECTING TO GATEWAY...
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
connectPromptReady &&
|
connectPromptReady &&
|
||||||
status === "disconnected" &&
|
status === "disconnected" &&
|
||||||
!agentsLoaded &&
|
!agentsLoaded &&
|
||||||
(shouldPromptForConnect || didAttemptGatewayConnect)
|
(shouldPromptForConnect || showDelayedGatewayConnectOverlay);
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-black px-4 py-10">
|
|
||||||
<GatewayConnectScreen
|
|
||||||
gatewayUrl={gatewayUrl}
|
|
||||||
token={token}
|
|
||||||
localGatewayDefaults={localGatewayDefaults}
|
|
||||||
status={status}
|
|
||||||
error={gatewayError}
|
|
||||||
showApprovalHint={didAttemptGatewayConnect}
|
|
||||||
onGatewayUrlChange={setGatewayUrl}
|
|
||||||
onTokenChange={setToken}
|
|
||||||
onUseLocalDefaults={useLocalGatewayDefaults}
|
|
||||||
onConnect={() => void connect()}
|
|
||||||
/>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const runningCount = state.agents.filter(
|
const runningCount = state.agents.filter(
|
||||||
(agent) =>
|
(agent) =>
|
||||||
@@ -4145,7 +4198,44 @@ export function OfficeScreen({
|
|||||||
"Connected to the gateway, but no agents were loaded into the office.";
|
"Connected to the gateway, but no agents were loaded into the office.";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="h-full w-full overflow-hidden bg-black">
|
<main className="relative h-full w-full overflow-hidden bg-black">
|
||||||
|
{showGatewayLoadingOverlay ? (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-0 z-40 flex items-center justify-center bg-[#120a05]/76"
|
||||||
|
aria-label="Connecting to runtime"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
<div className="rounded-xl border border-amber-700/45 bg-[#1a1008] px-8 py-6 shadow-2xl">
|
||||||
|
<RunningAvatarLoader
|
||||||
|
size={28}
|
||||||
|
trackWidth={76}
|
||||||
|
label="Connecting to your runtime..."
|
||||||
|
labelClassName="text-amber-100/80"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{showGatewayConnectOverlay ? (
|
||||||
|
<div className="pointer-events-auto absolute inset-0 z-50 flex items-start justify-center bg-[#120a05]/76 px-4 py-10">
|
||||||
|
<div className="w-full max-w-[860px] rounded-2xl border border-amber-900/55 bg-[#120a05]/98 p-3 shadow-2xl">
|
||||||
|
<GatewayConnectScreen
|
||||||
|
gatewayUrl={gatewayUrl}
|
||||||
|
token={token}
|
||||||
|
selectedAdapterType={selectedAdapterType}
|
||||||
|
activeAdapterType={activeAdapterType}
|
||||||
|
localGatewayDefaults={localGatewayDefaults}
|
||||||
|
status={status}
|
||||||
|
error={gatewayError}
|
||||||
|
showApprovalHint={didAttemptGatewayConnect}
|
||||||
|
onGatewayUrlChange={setGatewayUrl}
|
||||||
|
onTokenChange={setToken}
|
||||||
|
onAdapterTypeChange={setSelectedAdapterType}
|
||||||
|
onUseLocalDefaults={useLocalGatewayDefaults}
|
||||||
|
onConnect={() => void connect()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<section className="relative h-full min-h-0 min-w-0 overflow-hidden">
|
<section className="relative h-full min-h-0 min-w-0 overflow-hidden">
|
||||||
<RetroOffice3D
|
<RetroOffice3D
|
||||||
agents={allVisibleAgents}
|
agents={allVisibleAgents}
|
||||||
@@ -4202,12 +4292,21 @@ export function OfficeScreen({
|
|||||||
gatewayUrl,
|
gatewayUrl,
|
||||||
settingsCoordinator,
|
settingsCoordinator,
|
||||||
}}
|
}}
|
||||||
|
gatewayUrl={gatewayUrl}
|
||||||
|
gatewayToken={token}
|
||||||
|
selectedAdapterType={selectedAdapterType}
|
||||||
|
activeAdapterType={activeAdapterType}
|
||||||
onGatewayDisconnect={disconnect}
|
onGatewayDisconnect={disconnect}
|
||||||
|
onGatewayConnect={() => void connect()}
|
||||||
|
onGatewayUrlChange={setGatewayUrl}
|
||||||
|
onGatewayTokenChange={setToken}
|
||||||
|
onGatewayAdapterTypeChange={setSelectedAdapterType}
|
||||||
onOpenOnboarding={handleOpenOnboarding}
|
onOpenOnboarding={handleOpenOnboarding}
|
||||||
feedEvents={feedEvents}
|
feedEvents={feedEvents}
|
||||||
gatewayStatus={status}
|
gatewayStatus={status}
|
||||||
runCountByAgentId={runCountByAgentId}
|
runCountByAgentId={runCountByAgentId}
|
||||||
lastSeenByAgentId={lastSeenByAgentId}
|
lastSeenByAgentId={lastSeenByAgentId}
|
||||||
|
streamingTextByAgentId={streamingTextByAgentId}
|
||||||
standupMeeting={standupController.meeting}
|
standupMeeting={standupController.meeting}
|
||||||
standupAutoOpenBoard={standupController.openBoardByDefault}
|
standupAutoOpenBoard={standupController.openBoardByDefault}
|
||||||
onStandupArrivalsChange={(arrivedAgentIds) => {
|
onStandupArrivalsChange={(arrivedAgentIds) => {
|
||||||
@@ -4224,8 +4323,7 @@ export function OfficeScreen({
|
|||||||
onMonitorSelect={(agentId) => {
|
onMonitorSelect={(agentId) => {
|
||||||
setMonitorAgentId(agentId);
|
setMonitorAgentId(agentId);
|
||||||
if (agentId && !isRemoteOfficeAgentId(agentId)) {
|
if (agentId && !isRemoteOfficeAgentId(agentId)) {
|
||||||
setSelectedChatAgentId(agentId);
|
focusLocalAgent(agentId, { openChat: false });
|
||||||
dispatch({ type: "selectAgent", agentId });
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onAgentChatSelect={(agentId) => {
|
onAgentChatSelect={(agentId) => {
|
||||||
@@ -4268,7 +4366,6 @@ export function OfficeScreen({
|
|||||||
taskBoard.sharedTasksError ?? taskBoard.gatewayTasksError ?? taskBoard.cronError
|
taskBoard.sharedTasksError ?? taskBoard.gatewayTasksError ?? taskBoard.cronError
|
||||||
}
|
}
|
||||||
taskBoardCaptureDebug={showOpenClawConsole ? taskBoard.taskCaptureDebug : undefined}
|
taskBoardCaptureDebug={showOpenClawConsole ? taskBoard.taskCaptureDebug : undefined}
|
||||||
preferredKanbanAgentId={selectedChatAgentId ?? state.selectedAgentId}
|
|
||||||
onTaskBoardCreateCard={() => {
|
onTaskBoardCreateCard={() => {
|
||||||
taskBoard.createManualCard();
|
taskBoard.createManualCard();
|
||||||
}}
|
}}
|
||||||
@@ -4487,6 +4584,7 @@ export function OfficeScreen({
|
|||||||
<PlaybooksPanel
|
<PlaybooksPanel
|
||||||
client={client}
|
client={client}
|
||||||
status={status}
|
status={status}
|
||||||
|
cronEnabled={runtimeSupportsCron}
|
||||||
agents={state.agents}
|
agents={state.agents}
|
||||||
standup={standupController}
|
standup={standupController}
|
||||||
/>
|
/>
|
||||||
@@ -4495,6 +4593,7 @@ export function OfficeScreen({
|
|||||||
<AnalyticsPanel
|
<AnalyticsPanel
|
||||||
client={client}
|
client={client}
|
||||||
status={status}
|
status={status}
|
||||||
|
approvalsEnabled={runtimeSupportsApprovals}
|
||||||
agents={state.agents}
|
agents={state.agents}
|
||||||
runLog={runLog}
|
runLog={runLog}
|
||||||
gatewayUrl={gatewayUrl}
|
gatewayUrl={gatewayUrl}
|
||||||
@@ -4833,6 +4932,9 @@ export function OfficeScreen({
|
|||||||
chatController.stopBusyAgentId === focusedChatAgent.agentId
|
chatController.stopBusyAgentId === focusedChatAgent.agentId
|
||||||
}
|
}
|
||||||
onLoadMoreHistory={() => {}}
|
onLoadMoreHistory={() => {}}
|
||||||
|
onOpenSettings={() =>
|
||||||
|
openAgentEditor(focusedChatAgent.agentId, "IDENTITY.md")
|
||||||
|
}
|
||||||
onNewSession={() =>
|
onNewSession={() =>
|
||||||
chatController.handleNewSession(focusedChatAgent.agentId)
|
chatController.handleNewSession(focusedChatAgent.agentId)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -649,6 +649,7 @@ export const useTaskBoardController = ({
|
|||||||
settingsCoordinator,
|
settingsCoordinator,
|
||||||
client,
|
client,
|
||||||
status,
|
status,
|
||||||
|
cronEnabled = true,
|
||||||
agents,
|
agents,
|
||||||
runLog,
|
runLog,
|
||||||
standup,
|
standup,
|
||||||
@@ -657,6 +658,7 @@ export const useTaskBoardController = ({
|
|||||||
settingsCoordinator: StudioSettingsCoordinator;
|
settingsCoordinator: StudioSettingsCoordinator;
|
||||||
client: GatewayClient;
|
client: GatewayClient;
|
||||||
status: GatewayStatus;
|
status: GatewayStatus;
|
||||||
|
cronEnabled?: boolean;
|
||||||
agents: AgentState[];
|
agents: AgentState[];
|
||||||
runLog: RunRecord[];
|
runLog: RunRecord[];
|
||||||
standup: OfficeStandupController;
|
standup: OfficeStandupController;
|
||||||
@@ -843,9 +845,10 @@ export const useTaskBoardController = ({
|
|||||||
}, [gatewayUrl, settingsCoordinator, state.cards, state.selectedCardId]);
|
}, [gatewayUrl, settingsCoordinator, state.cards, state.selectedCardId]);
|
||||||
|
|
||||||
const refreshCronJobs = useCallback(async () => {
|
const refreshCronJobs = useCallback(async () => {
|
||||||
if (status !== "connected") {
|
if (!cronEnabled || status !== "connected") {
|
||||||
setCronJobs([]);
|
setCronJobs([]);
|
||||||
setCronError(null);
|
setCronError(null);
|
||||||
|
setCronLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setCronLoading(true);
|
setCronLoading(true);
|
||||||
@@ -860,7 +863,7 @@ export const useTaskBoardController = ({
|
|||||||
} finally {
|
} finally {
|
||||||
setCronLoading(false);
|
setCronLoading(false);
|
||||||
}
|
}
|
||||||
}, [client, status]);
|
}, [client, cronEnabled, status]);
|
||||||
|
|
||||||
const refreshSharedTasks = useCallback(async () => {
|
const refreshSharedTasks = useCallback(async () => {
|
||||||
if (!sharedTasksSupported) {
|
if (!sharedTasksSupported) {
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export const CompanyStep = ({
|
|||||||
{
|
{
|
||||||
icon: Sparkles,
|
icon: Sparkles,
|
||||||
title: "Improve the brief",
|
title: "Improve the brief",
|
||||||
description: "Use your connected OpenClaw runtime to sharpen the company prompt.",
|
description: "Use your connected runtime to sharpen the company prompt.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Users,
|
icon: Users,
|
||||||
@@ -45,7 +45,7 @@ export const CompanyStep = ({
|
|||||||
{
|
{
|
||||||
icon: Wand2,
|
icon: Wand2,
|
||||||
title: "Create everything",
|
title: "Create everything",
|
||||||
description: "Write agent files and create the team directly in OpenClaw.",
|
description: "Write agent files and create the team directly in the connected runtime.",
|
||||||
},
|
},
|
||||||
].map(({ icon: Icon, title, description }) => (
|
].map(({ icon: Icon, title, description }) => (
|
||||||
<div
|
<div
|
||||||
@@ -73,7 +73,7 @@ export const CompanyStep = ({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-md border border-amber-500/20 bg-amber-500/10 px-4 py-3 text-xs text-amber-100/80">
|
<div className="rounded-md border border-amber-500/20 bg-amber-500/10 px-4 py-3 text-xs text-amber-100/80">
|
||||||
Connect to OpenClaw and keep at least one planning agent available to generate the
|
Connect to a runtime and keep at least one planning agent available to generate the
|
||||||
company with AI.
|
company with AI.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export const CompleteStep = ({
|
|||||||
</p>
|
</p>
|
||||||
<p className="max-w-sm text-sm text-white/60">
|
<p className="max-w-sm text-sm text-white/60">
|
||||||
{companyCreated
|
{companyCreated
|
||||||
? `${companyName?.trim() || "Your company"} is ready. Your new team has been created in OpenClaw and placed into the office.`
|
? `${companyName?.trim() || "Your company"} is ready. Your new team has been created in the connected runtime and placed into the office.`
|
||||||
: "Your gateway is connected and your agents are ready. Step inside and explore the 3D workspace where your AI team operates."}
|
: "Your gateway is connected and your agents are ready. Step inside and explore the 3D workspace where your AI team operates."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,11 +32,11 @@ export const WelcomeStep = () => (
|
|||||||
<p className="text-sm leading-relaxed text-white/80">
|
<p className="text-sm leading-relaxed text-white/80">
|
||||||
Claw3D turns your AI automation into a{" "}
|
Claw3D turns your AI automation into a{" "}
|
||||||
<span className="font-medium text-white">visual workplace</span> — an
|
<span className="font-medium text-white">visual workplace</span> — an
|
||||||
office where your OpenClaw agents collaborate, code, test, and execute
|
office where your AI agents collaborate, code, test, and execute
|
||||||
tasks in a shared 3D environment.
|
tasks in a shared 3D environment.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-white/60">
|
<p className="text-sm text-white/60">
|
||||||
This wizard will help you connect to your OpenClaw gateway and get
|
This wizard will help you connect to your runtime gateway and get
|
||||||
started in about two minutes.
|
started in about two minutes.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [
|
|||||||
{
|
{
|
||||||
id: "connect",
|
id: "connect",
|
||||||
title: "Connect Your Gateway",
|
title: "Connect Your Gateway",
|
||||||
description: "Link to your OpenClaw instance",
|
description: "Link to your runtime instance",
|
||||||
skippable: false,
|
skippable: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* Uses localStorage so the wizard only shows once per browser.
|
* Uses localStorage so the wizard only shows once per browser.
|
||||||
* The key is scoped to the Claw3D app to avoid collisions.
|
* The key is scoped to the Claw3D app to avoid collisions.
|
||||||
*/
|
*/
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
const STORAGE_KEY = "claw3d:onboarding:completed";
|
const STORAGE_KEY = "claw3d:onboarding:completed";
|
||||||
|
|
||||||
@@ -40,7 +40,11 @@ export type OnboardingStateReturn = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const useOnboardingState = (): OnboardingStateReturn => {
|
export const useOnboardingState = (): OnboardingStateReturn => {
|
||||||
const [completed, setCompleted] = useState(readCompleted);
|
const [completed, setCompleted] = useState<boolean | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCompleted(readCompleted());
|
||||||
|
}, []);
|
||||||
|
|
||||||
const completeOnboarding = useCallback(() => {
|
const completeOnboarding = useCallback(() => {
|
||||||
setCompleted(true);
|
setCompleted(true);
|
||||||
@@ -53,7 +57,7 @@ export const useOnboardingState = (): OnboardingStateReturn => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
showOnboarding: !completed,
|
showOnboarding: completed === false,
|
||||||
completeOnboarding,
|
completeOnboarding,
|
||||||
resetOnboarding,
|
resetOnboarding,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import type { OfficeDeskMonitor } from "@/lib/office/deskMonitor";
|
|||||||
import type { OfficeAnimationState } from "@/lib/office/eventTriggers";
|
import type { OfficeAnimationState } from "@/lib/office/eventTriggers";
|
||||||
import type { StandupMeeting } from "@/lib/office/standup/types";
|
import type { StandupMeeting } from "@/lib/office/standup/types";
|
||||||
import type { SkillStatusEntry } from "@/lib/skills/types";
|
import type { SkillStatusEntry } from "@/lib/skills/types";
|
||||||
|
import type { StudioGatewayAdapterType } from "@/lib/studio/settings";
|
||||||
import type {
|
import type {
|
||||||
TaskBoardCard,
|
TaskBoardCard,
|
||||||
TaskBoardStatus,
|
TaskBoardStatus,
|
||||||
@@ -2358,12 +2359,21 @@ export function RetroOffice3D({
|
|||||||
onVoiceRepliesSpeedChange,
|
onVoiceRepliesSpeedChange,
|
||||||
onVoiceRepliesPreview,
|
onVoiceRepliesPreview,
|
||||||
onGatewayDisconnect,
|
onGatewayDisconnect,
|
||||||
|
onGatewayConnect,
|
||||||
|
onGatewayUrlChange,
|
||||||
|
onGatewayTokenChange,
|
||||||
|
onGatewayAdapterTypeChange,
|
||||||
onOpenOnboarding,
|
onOpenOnboarding,
|
||||||
atmAnalytics = null,
|
atmAnalytics = null,
|
||||||
feedEvents = EMPTY_FEED_EVENTS,
|
feedEvents = EMPTY_FEED_EVENTS,
|
||||||
gatewayStatus = "disconnected",
|
gatewayStatus = "disconnected",
|
||||||
|
gatewayUrl = "",
|
||||||
|
gatewayToken = "",
|
||||||
|
selectedAdapterType = "openclaw",
|
||||||
|
activeAdapterType = "openclaw",
|
||||||
runCountByAgentId = EMPTY_NUMBER_RECORD,
|
runCountByAgentId = EMPTY_NUMBER_RECORD,
|
||||||
lastSeenByAgentId = EMPTY_NUMBER_RECORD,
|
lastSeenByAgentId = EMPTY_NUMBER_RECORD,
|
||||||
|
streamingTextByAgentId = {},
|
||||||
onStandupArrivalsChange,
|
onStandupArrivalsChange,
|
||||||
onStandupStartRequested,
|
onStandupStartRequested,
|
||||||
onMonitorSelect,
|
onMonitorSelect,
|
||||||
@@ -2395,7 +2405,6 @@ export function RetroOffice3D({
|
|||||||
taskBoardCronLoading = false,
|
taskBoardCronLoading = false,
|
||||||
taskBoardCronError = null,
|
taskBoardCronError = null,
|
||||||
taskBoardCaptureDebug,
|
taskBoardCaptureDebug,
|
||||||
preferredKanbanAgentId = null,
|
|
||||||
onTaskBoardCreateCard,
|
onTaskBoardCreateCard,
|
||||||
onTaskBoardMoveCard,
|
onTaskBoardMoveCard,
|
||||||
onTaskBoardSelectCard,
|
onTaskBoardSelectCard,
|
||||||
@@ -2465,12 +2474,21 @@ export function RetroOffice3D({
|
|||||||
onVoiceRepliesSpeedChange?: (speed: number) => void;
|
onVoiceRepliesSpeedChange?: (speed: number) => void;
|
||||||
onVoiceRepliesPreview?: (voiceId: string | null, voiceName: string) => void;
|
onVoiceRepliesPreview?: (voiceId: string | null, voiceName: string) => void;
|
||||||
onGatewayDisconnect?: () => void;
|
onGatewayDisconnect?: () => void;
|
||||||
|
onGatewayConnect?: () => void;
|
||||||
|
onGatewayUrlChange?: (value: string) => void;
|
||||||
|
onGatewayTokenChange?: (value: string) => void;
|
||||||
|
onGatewayAdapterTypeChange?: (value: StudioGatewayAdapterType) => void;
|
||||||
onOpenOnboarding?: () => void;
|
onOpenOnboarding?: () => void;
|
||||||
atmAnalytics?: OfficeUsageAnalyticsParams | null;
|
atmAnalytics?: OfficeUsageAnalyticsParams | null;
|
||||||
feedEvents?: FeedEvent[];
|
feedEvents?: FeedEvent[];
|
||||||
gatewayStatus?: string;
|
gatewayStatus?: string;
|
||||||
|
gatewayUrl?: string;
|
||||||
|
gatewayToken?: string;
|
||||||
|
selectedAdapterType?: StudioGatewayAdapterType;
|
||||||
|
activeAdapterType?: StudioGatewayAdapterType;
|
||||||
runCountByAgentId?: Record<string, number>;
|
runCountByAgentId?: Record<string, number>;
|
||||||
lastSeenByAgentId?: Record<string, number>;
|
lastSeenByAgentId?: Record<string, number>;
|
||||||
|
streamingTextByAgentId?: Record<string, string | null>;
|
||||||
onStandupArrivalsChange?: (arrivedAgentIds: string[]) => void;
|
onStandupArrivalsChange?: (arrivedAgentIds: string[]) => void;
|
||||||
onStandupStartRequested?: () => void;
|
onStandupStartRequested?: () => void;
|
||||||
onMonitorSelect?: (agentId: string | null) => void;
|
onMonitorSelect?: (agentId: string | null) => void;
|
||||||
@@ -2506,7 +2524,6 @@ export function RetroOffice3D({
|
|||||||
taskBoardCaptureDebug?: ComponentProps<
|
taskBoardCaptureDebug?: ComponentProps<
|
||||||
typeof KanbanImmersiveScreen
|
typeof KanbanImmersiveScreen
|
||||||
>["taskCaptureDebug"];
|
>["taskCaptureDebug"];
|
||||||
preferredKanbanAgentId?: string | null;
|
|
||||||
onTaskBoardCreateCard?: () => void;
|
onTaskBoardCreateCard?: () => void;
|
||||||
onTaskBoardMoveCard?: (cardId: string, status: TaskBoardStatus) => void;
|
onTaskBoardMoveCard?: (cardId: string, status: TaskBoardStatus) => void;
|
||||||
onTaskBoardSelectCard?: (cardId: string | null) => void;
|
onTaskBoardSelectCard?: (cardId: string | null) => void;
|
||||||
@@ -2664,6 +2681,40 @@ export function RetroOffice3D({
|
|||||||
target: [number, number, number];
|
target: [number, number, number];
|
||||||
zoom?: number;
|
zoom?: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const LOCAL_CAMERA_TARGET = useMemo(
|
||||||
|
() =>
|
||||||
|
toWorld(LOCAL_OFFICE_CANVAS_WIDTH / 2, LOCAL_OFFICE_CANVAS_HEIGHT / 2),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const CAM_POS = useMemo<[number, number, number]>(() => {
|
||||||
|
if (remoteOfficeEnabled) return DISTRICT_CAMERA_POSITION;
|
||||||
|
return [
|
||||||
|
LOCAL_CAMERA_TARGET[0] +
|
||||||
|
(DISTRICT_CAMERA_POSITION[0] - DISTRICT_CAMERA_TARGET[0]),
|
||||||
|
LOCAL_CAMERA_TARGET[1] +
|
||||||
|
(DISTRICT_CAMERA_POSITION[1] - DISTRICT_CAMERA_TARGET[1]),
|
||||||
|
LOCAL_CAMERA_TARGET[2] +
|
||||||
|
(DISTRICT_CAMERA_POSITION[2] - DISTRICT_CAMERA_TARGET[2]),
|
||||||
|
];
|
||||||
|
}, [LOCAL_CAMERA_TARGET, remoteOfficeEnabled]);
|
||||||
|
const cameraTarget = remoteOfficeEnabled
|
||||||
|
? DISTRICT_CAMERA_TARGET
|
||||||
|
: LOCAL_CAMERA_TARGET;
|
||||||
|
const cameraZoom = remoteOfficeEnabled ? DISTRICT_CAMERA_ZOOM : 56;
|
||||||
|
const overviewPreset = useMemo(
|
||||||
|
() => ({ pos: CAM_POS, target: cameraTarget, zoom: cameraZoom }),
|
||||||
|
[CAM_POS, cameraTarget, cameraZoom]
|
||||||
|
);
|
||||||
|
const canvasResetKey = useMemo(
|
||||||
|
() =>
|
||||||
|
[
|
||||||
|
remoteOfficeEnabled ? "remote" : "local",
|
||||||
|
gatewayStatus ?? "unknown",
|
||||||
|
String(agents.length),
|
||||||
|
String(officeCenterSignal),
|
||||||
|
].join(":"),
|
||||||
|
[agents.length, gatewayStatus, officeCenterSignal, remoteOfficeEnabled],
|
||||||
|
);
|
||||||
// New Idea 7: heatmap mode.
|
// New Idea 7: heatmap mode.
|
||||||
const [heatmapMode, setHeatmapMode] = useState(false);
|
const [heatmapMode, setHeatmapMode] = useState(false);
|
||||||
const [trailMode, setTrailMode] = useState(false);
|
const [trailMode, setTrailMode] = useState(false);
|
||||||
@@ -2945,9 +2996,7 @@ export function RetroOffice3D({
|
|||||||
const [wx, , wz] = toWorld(agent.x, agent.y);
|
const [wx, , wz] = toWorld(agent.x, agent.y);
|
||||||
orbitRef.current.target.set(wx, 0, wz);
|
orbitRef.current.target.set(wx, 0, wz);
|
||||||
orbitRef.current.update();
|
orbitRef.current.update();
|
||||||
if (isRemoteOfficeAgentId(agentId)) {
|
onAgentChatSelect?.(agentId);
|
||||||
onAgentChatSelect?.(agentId);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[onAgentChatSelect, renderAgentLookupRef],
|
[onAgentChatSelect, renderAgentLookupRef],
|
||||||
);
|
);
|
||||||
@@ -3110,7 +3159,8 @@ export function RetroOffice3D({
|
|||||||
phoneBoothImmersive ||
|
phoneBoothImmersive ||
|
||||||
githubImmersive ||
|
githubImmersive ||
|
||||||
qaImmersive ||
|
qaImmersive ||
|
||||||
standupImmersive;
|
standupImmersive ||
|
||||||
|
kanbanImmersive;
|
||||||
const compactRosterAgents = useMemo(
|
const compactRosterAgents = useMemo(
|
||||||
() => agents.slice(0, COMPACT_AGENT_BADGE_LIMIT),
|
() => agents.slice(0, COMPACT_AGENT_BADGE_LIMIT),
|
||||||
[agents],
|
[agents],
|
||||||
@@ -3310,7 +3360,7 @@ export function RetroOffice3D({
|
|||||||
!activeGithubTerminalUid &&
|
!activeGithubTerminalUid &&
|
||||||
!activeQaTerminalUid
|
!activeQaTerminalUid
|
||||||
) {
|
) {
|
||||||
cameraPresetRef.current = overviewPresetRef.current;
|
cameraPresetRef.current = overviewPreset;
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
activeAtmUid,
|
activeAtmUid,
|
||||||
@@ -3318,6 +3368,7 @@ export function RetroOffice3D({
|
|||||||
activeQaTerminalUid,
|
activeQaTerminalUid,
|
||||||
followAgentId,
|
followAgentId,
|
||||||
monitorAgentId,
|
monitorAgentId,
|
||||||
|
overviewPreset,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const closeManualSmsBoothView = useCallback(() => {
|
const closeManualSmsBoothView = useCallback(() => {
|
||||||
@@ -3339,7 +3390,7 @@ export function RetroOffice3D({
|
|||||||
!activeGithubTerminalUid &&
|
!activeGithubTerminalUid &&
|
||||||
!activeQaTerminalUid
|
!activeQaTerminalUid
|
||||||
) {
|
) {
|
||||||
cameraPresetRef.current = overviewPresetRef.current;
|
cameraPresetRef.current = overviewPreset;
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
activeAtmUid,
|
activeAtmUid,
|
||||||
@@ -3347,6 +3398,7 @@ export function RetroOffice3D({
|
|||||||
activeQaTerminalUid,
|
activeQaTerminalUid,
|
||||||
followAgentId,
|
followAgentId,
|
||||||
monitorAgentId,
|
monitorAgentId,
|
||||||
|
overviewPreset,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const getBoothAudioContext = useCallback(async () => {
|
const getBoothAudioContext = useCallback(async () => {
|
||||||
@@ -3946,7 +3998,7 @@ export function RetroOffice3D({
|
|||||||
? `agent:${smsBoothAgentId}`
|
? `agent:${smsBoothAgentId}`
|
||||||
: null;
|
: null;
|
||||||
if (!activeViewKey && prevSmsBoothViewRef.current) {
|
if (!activeViewKey && prevSmsBoothViewRef.current) {
|
||||||
cameraPresetRef.current = overviewPresetRef.current;
|
cameraPresetRef.current = overviewPreset;
|
||||||
}
|
}
|
||||||
if (!activeViewKey || !activeSmsBooth) {
|
if (!activeViewKey || !activeSmsBooth) {
|
||||||
prevSmsBoothViewRef.current = activeViewKey;
|
prevSmsBoothViewRef.current = activeViewKey;
|
||||||
@@ -3966,6 +4018,7 @@ export function RetroOffice3D({
|
|||||||
}, [
|
}, [
|
||||||
activeSmsBooth,
|
activeSmsBooth,
|
||||||
manualSmsBoothOpen,
|
manualSmsBoothOpen,
|
||||||
|
overviewPreset,
|
||||||
smsBoothAgentId,
|
smsBoothAgentId,
|
||||||
smsBoothCommandArrived,
|
smsBoothCommandArrived,
|
||||||
]);
|
]);
|
||||||
@@ -4094,7 +4147,7 @@ export function RetroOffice3D({
|
|||||||
? `agent:${phoneBoothAgentId}`
|
? `agent:${phoneBoothAgentId}`
|
||||||
: null;
|
: null;
|
||||||
if (!activeViewKey && prevPhoneBoothViewRef.current) {
|
if (!activeViewKey && prevPhoneBoothViewRef.current) {
|
||||||
cameraPresetRef.current = overviewPresetRef.current;
|
cameraPresetRef.current = overviewPreset;
|
||||||
}
|
}
|
||||||
if (!activeViewKey || !activePhoneBooth) {
|
if (!activeViewKey || !activePhoneBooth) {
|
||||||
prevPhoneBoothViewRef.current = activeViewKey;
|
prevPhoneBoothViewRef.current = activeViewKey;
|
||||||
@@ -4114,6 +4167,7 @@ export function RetroOffice3D({
|
|||||||
}, [
|
}, [
|
||||||
activePhoneBooth,
|
activePhoneBooth,
|
||||||
manualPhoneBoothOpen,
|
manualPhoneBoothOpen,
|
||||||
|
overviewPreset,
|
||||||
phoneBoothAgentId,
|
phoneBoothAgentId,
|
||||||
phoneBoothCommandArrived,
|
phoneBoothCommandArrived,
|
||||||
]);
|
]);
|
||||||
@@ -4241,7 +4295,7 @@ export function RetroOffice3D({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!monitorAgentId && prevMonitorAgentIdRef.current) {
|
if (!monitorAgentId && prevMonitorAgentIdRef.current) {
|
||||||
cameraPresetRef.current = overviewPresetRef.current;
|
cameraPresetRef.current = overviewPreset;
|
||||||
}
|
}
|
||||||
if (!monitorAgentId || !activeMonitorComputer) {
|
if (!monitorAgentId || !activeMonitorComputer) {
|
||||||
prevMonitorAgentIdRef.current = monitorAgentId;
|
prevMonitorAgentIdRef.current = monitorAgentId;
|
||||||
@@ -4257,7 +4311,7 @@ export function RetroOffice3D({
|
|||||||
zoom: 330,
|
zoom: 330,
|
||||||
};
|
};
|
||||||
prevMonitorAgentIdRef.current = monitorAgentId;
|
prevMonitorAgentIdRef.current = monitorAgentId;
|
||||||
}, [activeMonitorComputer, monitorAgentId]);
|
}, [activeMonitorComputer, monitorAgentId, overviewPreset]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeAtmUid && !activeAtm) {
|
if (activeAtmUid && !activeAtm) {
|
||||||
@@ -4268,7 +4322,7 @@ export function RetroOffice3D({
|
|||||||
window.clearTimeout(timer);
|
window.clearTimeout(timer);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [activeAtm, activeAtmUid]);
|
}, [activeAtm, activeAtmUid, overviewPreset]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeKanbanUid && !activeKanbanBoard) {
|
if (activeKanbanUid && !activeKanbanBoard) {
|
||||||
@@ -4305,7 +4359,7 @@ export function RetroOffice3D({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeAtmUid && prevAtmUidRef.current) {
|
if (!activeAtmUid && prevAtmUidRef.current) {
|
||||||
cameraPresetRef.current = overviewPresetRef.current;
|
cameraPresetRef.current = overviewPreset;
|
||||||
}
|
}
|
||||||
if (!activeAtmUid || !activeAtm) {
|
if (!activeAtmUid || !activeAtm) {
|
||||||
prevAtmUidRef.current = activeAtmUid;
|
prevAtmUidRef.current = activeAtmUid;
|
||||||
@@ -4326,7 +4380,7 @@ export function RetroOffice3D({
|
|||||||
zoom: 250,
|
zoom: 250,
|
||||||
};
|
};
|
||||||
prevAtmUidRef.current = activeAtmUid;
|
prevAtmUidRef.current = activeAtmUid;
|
||||||
}, [activeAtm, activeAtmUid]);
|
}, [activeAtm, activeAtmUid, overviewPreset]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
prevKanbanUidRef.current = activeKanbanUid;
|
prevKanbanUidRef.current = activeKanbanUid;
|
||||||
@@ -4339,7 +4393,7 @@ export function RetroOffice3D({
|
|||||||
? `agent:${githubReviewAgentId}`
|
? `agent:${githubReviewAgentId}`
|
||||||
: null;
|
: null;
|
||||||
if (!activeViewKey && prevGithubViewRef.current) {
|
if (!activeViewKey && prevGithubViewRef.current) {
|
||||||
cameraPresetRef.current = overviewPresetRef.current;
|
cameraPresetRef.current = overviewPreset;
|
||||||
}
|
}
|
||||||
if (!activeViewKey || !activeGithubTerminal) {
|
if (!activeViewKey || !activeGithubTerminal) {
|
||||||
prevGithubViewRef.current = activeViewKey;
|
prevGithubViewRef.current = activeViewKey;
|
||||||
@@ -4365,6 +4419,7 @@ export function RetroOffice3D({
|
|||||||
activeGithubTerminalUid,
|
activeGithubTerminalUid,
|
||||||
githubCommandArrived,
|
githubCommandArrived,
|
||||||
githubReviewAgentId,
|
githubReviewAgentId,
|
||||||
|
overviewPreset,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -4374,7 +4429,7 @@ export function RetroOffice3D({
|
|||||||
? `agent:${qaTestingAgentId}`
|
? `agent:${qaTestingAgentId}`
|
||||||
: null;
|
: null;
|
||||||
if (!activeViewKey && prevQaViewRef.current) {
|
if (!activeViewKey && prevQaViewRef.current) {
|
||||||
cameraPresetRef.current = overviewPresetRef.current;
|
cameraPresetRef.current = overviewPreset;
|
||||||
}
|
}
|
||||||
if (!activeViewKey || !activeQaTerminal) {
|
if (!activeViewKey || !activeQaTerminal) {
|
||||||
prevQaViewRef.current = activeViewKey;
|
prevQaViewRef.current = activeViewKey;
|
||||||
@@ -4398,6 +4453,7 @@ export function RetroOffice3D({
|
|||||||
}, [
|
}, [
|
||||||
activeQaTerminal,
|
activeQaTerminal,
|
||||||
activeQaTerminalUid,
|
activeQaTerminalUid,
|
||||||
|
overviewPreset,
|
||||||
qaCommandArrived,
|
qaCommandArrived,
|
||||||
qaTestingAgentId,
|
qaTestingAgentId,
|
||||||
]);
|
]);
|
||||||
@@ -4687,6 +4743,8 @@ export function RetroOffice3D({
|
|||||||
onStandupStartRequested,
|
onStandupStartRequested,
|
||||||
qaTerminal,
|
qaTerminal,
|
||||||
resolveAgentIdForDeskItem,
|
resolveAgentIdForDeskItem,
|
||||||
|
planPath,
|
||||||
|
renderAgentsRef,
|
||||||
serverTerminal,
|
serverTerminal,
|
||||||
voiceRepliesEnabled,
|
voiceRepliesEnabled,
|
||||||
voiceRepliesLoaded,
|
voiceRepliesLoaded,
|
||||||
@@ -4738,7 +4796,7 @@ export function RetroOffice3D({
|
|||||||
!activeGithubTerminalUid &&
|
!activeGithubTerminalUid &&
|
||||||
!activeQaTerminalUid
|
!activeQaTerminalUid
|
||||||
) {
|
) {
|
||||||
cameraPresetRef.current = overviewPresetRef.current;
|
cameraPresetRef.current = overviewPreset;
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
activeAtmUid,
|
activeAtmUid,
|
||||||
@@ -4746,6 +4804,7 @@ export function RetroOffice3D({
|
|||||||
activeQaTerminalUid,
|
activeQaTerminalUid,
|
||||||
followAgentId,
|
followAgentId,
|
||||||
monitorAgentId,
|
monitorAgentId,
|
||||||
|
overviewPreset,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -5158,37 +5217,17 @@ export function RetroOffice3D({
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [spotlightAgentId]);
|
}, [spotlightAgentId]);
|
||||||
|
|
||||||
// Camera constants.
|
|
||||||
const LOCAL_CAMERA_TARGET = useMemo(
|
|
||||||
() =>
|
|
||||||
toWorld(LOCAL_OFFICE_CANVAS_WIDTH / 2, LOCAL_OFFICE_CANVAS_HEIGHT / 2),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
const CAM_POS = useMemo<[number, number, number]>(() => {
|
|
||||||
if (remoteOfficeEnabled) return DISTRICT_CAMERA_POSITION;
|
|
||||||
return [
|
|
||||||
LOCAL_CAMERA_TARGET[0] + (DISTRICT_CAMERA_POSITION[0] - DISTRICT_CAMERA_TARGET[0]),
|
|
||||||
LOCAL_CAMERA_TARGET[1] + (DISTRICT_CAMERA_POSITION[1] - DISTRICT_CAMERA_TARGET[1]),
|
|
||||||
LOCAL_CAMERA_TARGET[2] + (DISTRICT_CAMERA_POSITION[2] - DISTRICT_CAMERA_TARGET[2]),
|
|
||||||
];
|
|
||||||
}, [remoteOfficeEnabled, LOCAL_CAMERA_TARGET]);
|
|
||||||
const cameraTarget = remoteOfficeEnabled
|
|
||||||
? DISTRICT_CAMERA_TARGET
|
|
||||||
: LOCAL_CAMERA_TARGET;
|
|
||||||
const cameraZoom = remoteOfficeEnabled ? DISTRICT_CAMERA_ZOOM : 56;
|
|
||||||
const overviewPresetRef = useRef({ pos: CAM_POS, target: cameraTarget, zoom: cameraZoom });
|
|
||||||
overviewPresetRef.current = { pos: CAM_POS, target: cameraTarget, zoom: cameraZoom };
|
|
||||||
const lastOfficeCenterSignalRef = useRef(officeCenterSignal);
|
const lastOfficeCenterSignalRef = useRef(officeCenterSignal);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
cameraPresetRef.current = overviewPresetRef.current;
|
cameraPresetRef.current = overviewPreset;
|
||||||
}, [CAM_POS, cameraTarget, cameraZoom]);
|
}, [overviewPreset]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (officeCenterSignal === lastOfficeCenterSignalRef.current) return;
|
if (officeCenterSignal === lastOfficeCenterSignalRef.current) return;
|
||||||
lastOfficeCenterSignalRef.current = officeCenterSignal;
|
lastOfficeCenterSignalRef.current = officeCenterSignal;
|
||||||
cameraPresetRef.current = overviewPresetRef.current;
|
cameraPresetRef.current = overviewPreset;
|
||||||
}, [officeCenterSignal, CAM_POS, cameraTarget, cameraZoom]);
|
}, [officeCenterSignal, overviewPreset]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full h-full bg-[#1a1008] font-mono text-white overflow-hidden">
|
<div className="relative w-full h-full bg-[#1a1008] font-mono text-white overflow-hidden">
|
||||||
@@ -5215,6 +5254,7 @@ export function RetroOffice3D({
|
|||||||
*/}
|
*/}
|
||||||
{!immersiveOverlayActive ? (
|
{!immersiveOverlayActive ? (
|
||||||
<Canvas
|
<Canvas
|
||||||
|
key={canvasResetKey}
|
||||||
orthographic
|
orthographic
|
||||||
dpr={[0.85, 1.5]}
|
dpr={[0.85, 1.5]}
|
||||||
camera={{
|
camera={{
|
||||||
@@ -5746,6 +5786,7 @@ export function RetroOffice3D({
|
|||||||
key={agent.id}
|
key={agent.id}
|
||||||
agentId={agent.id}
|
agentId={agent.id}
|
||||||
name={agent.name}
|
name={agent.name}
|
||||||
|
subtitle={"subtitle" in agent ? agent.subtitle ?? null : null}
|
||||||
status={agent.status}
|
status={agent.status}
|
||||||
color={agentColorMap.get(agent.id) ?? "#888"}
|
color={agentColorMap.get(agent.id) ?? "#888"}
|
||||||
appearance={
|
appearance={
|
||||||
@@ -5764,14 +5805,17 @@ export function RetroOffice3D({
|
|||||||
? false
|
? false
|
||||||
: standupMeeting?.phase === "in_progress"
|
: standupMeeting?.phase === "in_progress"
|
||||||
? Boolean(standupSpeechTextByAgentId[agent.id])
|
? Boolean(standupSpeechTextByAgentId[agent.id])
|
||||||
: speechAgentIds.has(agent.id)
|
: speechAgentIds.has(agent.id) ||
|
||||||
|
Boolean(streamingTextByAgentId[agent.id])
|
||||||
}
|
}
|
||||||
speechText={
|
speechText={
|
||||||
isJanitor
|
isJanitor
|
||||||
? null
|
? null
|
||||||
: standupMeeting?.phase === "in_progress"
|
: standupMeeting?.phase === "in_progress"
|
||||||
? (standupSpeechTextByAgentId[agent.id] ?? null)
|
? (standupSpeechTextByAgentId[agent.id] ?? null)
|
||||||
: (speechTextByAgentId[agent.id] ?? null)
|
: (speechTextByAgentId[agent.id] ??
|
||||||
|
streamingTextByAgentId[agent.id] ??
|
||||||
|
null)
|
||||||
}
|
}
|
||||||
suppressSpeechBubble={
|
suppressSpeechBubble={
|
||||||
suppressSceneSpeechBubbles &&
|
suppressSceneSpeechBubbles &&
|
||||||
@@ -5931,12 +5975,14 @@ export function RetroOffice3D({
|
|||||||
|
|
||||||
{/* Title — top center overlay. */}
|
{/* Title — top center overlay. */}
|
||||||
{!immersiveOverlayActive ? (
|
{!immersiveOverlayActive ? (
|
||||||
<div className="absolute top-3 left-1/2 -translate-x-1/2 flex items-center gap-3 pointer-events-none select-none z-10">
|
<div className="absolute top-3 left-1/2 -translate-x-1/2 flex flex-col items-center gap-2 pointer-events-none select-none z-10">
|
||||||
<div className="h-px w-12 bg-gradient-to-r from-transparent to-amber-500/40" />
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-sm tracking-[0.3em] text-amber-300/80 font-bold uppercase">
|
<div className="h-px w-12 bg-gradient-to-r from-transparent to-amber-500/40" />
|
||||||
{officeTitle}
|
<span className="text-sm tracking-[0.3em] text-amber-300/80 font-bold uppercase">
|
||||||
</span>
|
{officeTitle}
|
||||||
<div className="h-px w-12 bg-gradient-to-l from-transparent to-amber-500/40" />
|
</span>
|
||||||
|
<div className="h-px w-12 bg-gradient-to-l from-transparent to-amber-500/40" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -7005,6 +7051,18 @@ export function RetroOffice3D({
|
|||||||
<span>Add</span>
|
<span>Add</span>
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
|
<div
|
||||||
|
className={`flex h-7 items-center rounded-md border px-2 text-[10px] font-mono uppercase tracking-[0.12em] ${
|
||||||
|
gatewayStatus === "connected"
|
||||||
|
? "border-emerald-400/25 bg-emerald-500/10 text-emerald-100"
|
||||||
|
: gatewayStatus === "connecting"
|
||||||
|
? "border-amber-400/25 bg-amber-500/10 text-amber-100"
|
||||||
|
: "border-rose-400/25 bg-rose-500/10 text-rose-100"
|
||||||
|
}`}
|
||||||
|
title={`Runtime: ${activeAdapterType} (${gatewayStatus})`}
|
||||||
|
>
|
||||||
|
{activeAdapterType} • {gatewayStatus}
|
||||||
|
</div>
|
||||||
{/* New Idea 7: Heatmap toggle. */}
|
{/* New Idea 7: Heatmap toggle. */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setHeatmapMode((p) => !p)}
|
onClick={() => setHeatmapMode((p) => !p)}
|
||||||
@@ -7098,11 +7156,22 @@ export function RetroOffice3D({
|
|||||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
<SettingsPanel
|
<SettingsPanel
|
||||||
gatewayStatus={gatewayStatus}
|
gatewayStatus={gatewayStatus}
|
||||||
gatewayUrl={atmAnalytics?.gatewayUrl}
|
gatewayUrl={gatewayUrl}
|
||||||
|
gatewayToken={gatewayToken}
|
||||||
|
selectedAdapterType={selectedAdapterType}
|
||||||
|
activeAdapterType={activeAdapterType}
|
||||||
onGatewayDisconnect={() => {
|
onGatewayDisconnect={() => {
|
||||||
onGatewayDisconnect?.();
|
onGatewayDisconnect?.();
|
||||||
setSettingsModalOpen(false);
|
setSettingsModalOpen(false);
|
||||||
}}
|
}}
|
||||||
|
onGatewayConnect={() => {
|
||||||
|
onGatewayConnect?.();
|
||||||
|
}}
|
||||||
|
onGatewayUrlChange={(value) => onGatewayUrlChange?.(value)}
|
||||||
|
onGatewayTokenChange={(value) => onGatewayTokenChange?.(value)}
|
||||||
|
onGatewayAdapterTypeChange={(value) =>
|
||||||
|
onGatewayAdapterTypeChange?.(value)
|
||||||
|
}
|
||||||
onOpenOnboarding={() => {
|
onOpenOnboarding={() => {
|
||||||
onOpenOnboarding?.();
|
onOpenOnboarding?.();
|
||||||
setSettingsModalOpen(false);
|
setSettingsModalOpen(false);
|
||||||
@@ -7156,81 +7225,85 @@ export function RetroOffice3D({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Ideas 3 + 6 + 8: Mini status bar — bottom left. */}
|
{!immersiveOverlayActive ? (
|
||||||
<div className="absolute bottom-3 left-3 flex flex-col items-start gap-1.5 z-10 pointer-events-none select-none">
|
<>
|
||||||
{/* Idea 3: Activity feed entries — newest on bottom. */}
|
{/* Ideas 3 + 6 + 8: Mini status bar — bottom left. */}
|
||||||
{statusFeedEvents
|
<div className="absolute bottom-3 left-3 flex flex-col items-start gap-1.5 z-10 pointer-events-none select-none">
|
||||||
.slice(0, 4)
|
{/* Idea 3: Activity feed entries — newest on bottom. */}
|
||||||
.reverse()
|
{statusFeedEvents
|
||||||
.map((ev) => (
|
.slice(0, 4)
|
||||||
<div
|
.reverse()
|
||||||
key={`${ev.id}-${ev.ts}`}
|
.map((ev) => (
|
||||||
className="flex items-center gap-2 bg-black/60 backdrop-blur-sm rounded-full px-3 py-1 text-[10px] font-mono"
|
<div
|
||||||
>
|
key={`${ev.id}-${ev.ts}`}
|
||||||
<span className="text-amber-400/80 font-semibold">{ev.name}</span>
|
className="flex items-center gap-2 bg-black/60 backdrop-blur-sm rounded-full px-3 py-1 text-[10px] font-mono"
|
||||||
<span className="text-amber-600/70">{ev.text}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{/* Ideas 6 + 8: Gateway status, agent counts, vibe score. */}
|
|
||||||
<div className="flex items-center gap-3 bg-black/60 backdrop-blur-sm rounded-full px-3 py-1 text-[10px] font-mono">
|
|
||||||
<span className="text-amber-500/60">
|
|
||||||
{agents.filter((a) => a.status === "working").length} working
|
|
||||||
</span>
|
|
||||||
<span className="opacity-30">·</span>
|
|
||||||
<span className="text-amber-500/60">
|
|
||||||
{agents.filter((a) => a.status === "idle").length} idle
|
|
||||||
</span>
|
|
||||||
<span className="opacity-30">·</span>
|
|
||||||
<span className="text-amber-500/60">
|
|
||||||
{agents.filter((a) => a.status === "error").length} error
|
|
||||||
</span>
|
|
||||||
{/* New Idea 6: Vibe score with animated EQ bars. */}
|
|
||||||
{(() => {
|
|
||||||
const workingCount = agents.filter(
|
|
||||||
(a) => a.status === "working",
|
|
||||||
).length;
|
|
||||||
const ratio = workingCount / Math.max(agents.length, 1);
|
|
||||||
const label =
|
|
||||||
ratio < 0.2 ? "quiet" : ratio < 0.6 ? "active" : "buzzing";
|
|
||||||
const animDur = ratio < 0.2 ? "1.8s" : ratio < 0.6 ? "1s" : "0.5s";
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<span className="opacity-30">·</span>
|
|
||||||
<span
|
|
||||||
className="flex items-end gap-px h-3"
|
|
||||||
style={{ ["--eq-dur" as string]: animDur }}
|
|
||||||
>
|
>
|
||||||
{[0.6, 1, 0.7].map((h, i) => (
|
<span className="text-amber-400/80 font-semibold">{ev.name}</span>
|
||||||
<span
|
<span className="text-amber-600/70">{ev.text}</span>
|
||||||
key={i}
|
</div>
|
||||||
className="w-[3px] bg-amber-500/60 rounded-sm"
|
))}
|
||||||
style={{
|
{/* Ideas 6 + 8: Gateway status, agent counts, vibe score. */}
|
||||||
height: `${h * 100}%`,
|
<div className="flex items-center gap-3 bg-black/60 backdrop-blur-sm rounded-full px-3 py-1 text-[10px] font-mono">
|
||||||
animation: `eq-bar ${animDur} ${i * 0.15}s infinite ease-in-out alternate`,
|
<span className="text-amber-500/60">
|
||||||
}}
|
{agents.filter((a) => a.status === "working").length} working
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
<span className="text-amber-500/50">{label}</span>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
{!editMode && !spaceDown && (
|
|
||||||
<>
|
|
||||||
<span className="opacity-30">·</span>
|
|
||||||
<span className="text-amber-400/40">
|
|
||||||
drag · scroll · space+drag · dbl-click
|
|
||||||
</span>
|
</span>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{spaceDown && (
|
|
||||||
<>
|
|
||||||
<span className="opacity-30">·</span>
|
<span className="opacity-30">·</span>
|
||||||
<span className="text-amber-300/80">pan mode</span>
|
<span className="text-amber-500/60">
|
||||||
</>
|
{agents.filter((a) => a.status === "idle").length} idle
|
||||||
)}
|
</span>
|
||||||
</div>
|
<span className="opacity-30">·</span>
|
||||||
</div>
|
<span className="text-amber-500/60">
|
||||||
|
{agents.filter((a) => a.status === "error").length} error
|
||||||
|
</span>
|
||||||
|
{/* New Idea 6: Vibe score with animated EQ bars. */}
|
||||||
|
{(() => {
|
||||||
|
const workingCount = agents.filter(
|
||||||
|
(a) => a.status === "working",
|
||||||
|
).length;
|
||||||
|
const ratio = workingCount / Math.max(agents.length, 1);
|
||||||
|
const label =
|
||||||
|
ratio < 0.2 ? "quiet" : ratio < 0.6 ? "active" : "buzzing";
|
||||||
|
const animDur = ratio < 0.2 ? "1.8s" : ratio < 0.6 ? "1s" : "0.5s";
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className="opacity-30">·</span>
|
||||||
|
<span
|
||||||
|
className="flex items-end gap-px h-3"
|
||||||
|
style={{ ["--eq-dur" as string]: animDur }}
|
||||||
|
>
|
||||||
|
{[0.6, 1, 0.7].map((h, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="w-[3px] bg-amber-500/60 rounded-sm"
|
||||||
|
style={{
|
||||||
|
height: `${h * 100}%`,
|
||||||
|
animation: `eq-bar ${animDur} ${i * 0.15}s infinite ease-in-out alternate`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
<span className="text-amber-500/50">{label}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
{!editMode && !spaceDown && (
|
||||||
|
<>
|
||||||
|
<span className="opacity-30">·</span>
|
||||||
|
<span className="text-amber-400/40">
|
||||||
|
drag · scroll · space+drag · dbl-click
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{spaceDown && (
|
||||||
|
<>
|
||||||
|
<span className="opacity-30">·</span>
|
||||||
|
<span className="text-amber-300/80">pan mode</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
<style>{`
|
<style>{`
|
||||||
@keyframes eq-bar {
|
@keyframes eq-bar {
|
||||||
from { transform: scaleY(0.3); }
|
from { transform: scaleY(0.3); }
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { OfficeInteractionTargetId } from "@/lib/office/places";
|
|||||||
export type OfficeAgent = {
|
export type OfficeAgent = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
subtitle?: string | null;
|
||||||
status: "working" | "idle" | "error";
|
status: "working" | "idle" | "error";
|
||||||
color: string;
|
color: string;
|
||||||
item: string;
|
item: string;
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const formatAgentNameplateText = (value: string): string => {
|
|||||||
export const AgentModel = memo(function AgentModel({
|
export const AgentModel = memo(function AgentModel({
|
||||||
agentId,
|
agentId,
|
||||||
name,
|
name,
|
||||||
|
subtitle,
|
||||||
status,
|
status,
|
||||||
color,
|
color,
|
||||||
appearance,
|
appearance,
|
||||||
@@ -640,6 +641,7 @@ export const AgentModel = memo(function AgentModel({
|
|||||||
: "transparent";
|
: "transparent";
|
||||||
const speechBubbleBorderInset = activeSpeechBubble ? 0.03 : 0;
|
const speechBubbleBorderInset = activeSpeechBubble ? 0.03 : 0;
|
||||||
const nameplateText = name ? formatAgentNameplateText(name) : "";
|
const nameplateText = name ? formatAgentNameplateText(name) : "";
|
||||||
|
const subtitleText = typeof subtitle === "string" ? subtitle.trim() : "";
|
||||||
const nameplateFontSize =
|
const nameplateFontSize =
|
||||||
nameplateText.length > 9 ? 0.118 : nameplateText.length > 7 ? 0.13 : 0.144;
|
nameplateText.length > 9 ? 0.118 : nameplateText.length > 7 ? 0.13 : 0.144;
|
||||||
|
|
||||||
@@ -1070,19 +1072,19 @@ export const AgentModel = memo(function AgentModel({
|
|||||||
{!activeSpeechBubble && nameplateText ? (
|
{!activeSpeechBubble && nameplateText ? (
|
||||||
<Billboard position={[0, 1.05, 0]}>
|
<Billboard position={[0, 1.05, 0]}>
|
||||||
<mesh position={[0, 0, -0.001]}>
|
<mesh position={[0, 0, -0.001]}>
|
||||||
<planeGeometry args={[0.82, 0.24]} />
|
<planeGeometry args={[0.82, subtitleText ? 0.34 : 0.24]} />
|
||||||
<meshBasicMaterial color="#080c14" transparent opacity={0.9} />
|
<meshBasicMaterial color="#080c14" transparent opacity={0.9} />
|
||||||
</mesh>
|
</mesh>
|
||||||
<mesh position={[-0.392, 0, 0]}>
|
<mesh position={[-0.392, 0, 0]}>
|
||||||
<planeGeometry args={[0.028, 0.24]} />
|
<planeGeometry args={[0.028, subtitleText ? 0.34 : 0.24]} />
|
||||||
<meshBasicMaterial color={color} />
|
<meshBasicMaterial color={color} />
|
||||||
</mesh>
|
</mesh>
|
||||||
<mesh position={[0.355, 0, 0]}>
|
<mesh position={[0.355, subtitleText ? 0.05 : 0, 0]}>
|
||||||
<circleGeometry args={[0.052, 14]} />
|
<circleGeometry args={[0.052, 14]} />
|
||||||
<meshBasicMaterial ref={statusDotMatRef} color="#ef4444" />
|
<meshBasicMaterial ref={statusDotMatRef} color="#ef4444" />
|
||||||
</mesh>
|
</mesh>
|
||||||
<Text
|
<Text
|
||||||
position={[-0.02, 0, 0.001]}
|
position={[-0.02, subtitleText ? 0.05 : 0, 0.001]}
|
||||||
fontSize={nameplateFontSize}
|
fontSize={nameplateFontSize}
|
||||||
color="#e8dfc0"
|
color="#e8dfc0"
|
||||||
anchorX="center"
|
anchorX="center"
|
||||||
@@ -1092,6 +1094,19 @@ export const AgentModel = memo(function AgentModel({
|
|||||||
>
|
>
|
||||||
{nameplateText}
|
{nameplateText}
|
||||||
</Text>
|
</Text>
|
||||||
|
{subtitleText ? (
|
||||||
|
<Text
|
||||||
|
position={[-0.02, -0.085, 0.001]}
|
||||||
|
fontSize={0.082}
|
||||||
|
color="#8ab4ff"
|
||||||
|
anchorX="center"
|
||||||
|
anchorY="middle"
|
||||||
|
maxWidth={0.68}
|
||||||
|
font={undefined}
|
||||||
|
>
|
||||||
|
{subtitleText}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
</Billboard>
|
</Billboard>
|
||||||
) : null}
|
) : null}
|
||||||
<group ref={awayBubbleRef} visible={false}>
|
<group ref={awayBubbleRef} visible={false}>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export type InteractiveFurnitureModelProps = {
|
|||||||
export type AgentModelProps = {
|
export type AgentModelProps = {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
subtitle?: string | null;
|
||||||
status: OfficeAgent["status"];
|
status: OfficeAgent["status"];
|
||||||
color: string;
|
color: string;
|
||||||
appearance?: AgentAvatarProfile | null;
|
appearance?: AgentAvatarProfile | null;
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
type GatewayHelloOk,
|
type GatewayHelloOk,
|
||||||
} from "./openclaw/GatewayBrowserClient";
|
} from "./openclaw/GatewayBrowserClient";
|
||||||
import type {
|
import type {
|
||||||
|
StudioGatewayProfilePublic,
|
||||||
|
StudioGatewayAdapterType,
|
||||||
StudioGatewaySettings,
|
StudioGatewaySettings,
|
||||||
StudioSettings,
|
StudioSettings,
|
||||||
StudioSettingsPatch,
|
StudioSettingsPatch,
|
||||||
@@ -20,6 +22,18 @@ import { resolveStudioProxyGatewayUrl } from "@/lib/gateway/proxy-url";
|
|||||||
import { ensureGatewayReloadModeHotForLocalStudio } from "@/lib/gateway/gatewayReloadMode";
|
import { ensureGatewayReloadModeHotForLocalStudio } from "@/lib/gateway/gatewayReloadMode";
|
||||||
import { GatewayResponseError } from "@/lib/gateway/errors";
|
import { GatewayResponseError } from "@/lib/gateway/errors";
|
||||||
|
|
||||||
|
const gatewayDebugEnabled = process.env.NODE_ENV !== "production";
|
||||||
|
|
||||||
|
const gatewayDebugLog = (message: string, details?: Record<string, unknown>) => {
|
||||||
|
if (!gatewayDebugEnabled) return;
|
||||||
|
if (details) {
|
||||||
|
console.info("[gateway-client]", message, details);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.info("[gateway-client]", message);
|
||||||
|
};
|
||||||
|
import { probeCustomRuntime } from "@/lib/runtime/custom/http";
|
||||||
|
|
||||||
export type ReqFrame = {
|
export type ReqFrame = {
|
||||||
type: "req";
|
type: "req";
|
||||||
id: string;
|
id: string;
|
||||||
@@ -82,7 +96,7 @@ export const isSameSessionKey = (a: string, b: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const CONNECT_FAILED_CLOSE_CODE = 4008;
|
const CONNECT_FAILED_CLOSE_CODE = 4008;
|
||||||
const GATEWAY_CONNECT_TIMEOUT_MS = 8_000;
|
const GATEWAY_CONNECT_TIMEOUT_MS = 13_000;
|
||||||
|
|
||||||
const parseConnectFailedCloseReason = (
|
const parseConnectFailedCloseReason = (
|
||||||
reason: string
|
reason: string
|
||||||
@@ -100,10 +114,67 @@ const parseConnectFailedCloseReason = (
|
|||||||
|
|
||||||
const DEFAULT_UPSTREAM_GATEWAY_URL =
|
const DEFAULT_UPSTREAM_GATEWAY_URL =
|
||||||
process.env.NEXT_PUBLIC_GATEWAY_URL || "ws://localhost:18789";
|
process.env.NEXT_PUBLIC_GATEWAY_URL || "ws://localhost:18789";
|
||||||
|
const DEFAULT_CUSTOM_RUNTIME_URL = "http://localhost:7770";
|
||||||
|
const INITIAL_AUTO_CONNECT_DELAY_MS = 900;
|
||||||
|
const INITIAL_CONNECT_RETRY_DELAY_MS = 1_200;
|
||||||
|
|
||||||
|
const isAutoManagedAdapter = (adapterType: StudioGatewayAdapterType) =>
|
||||||
|
adapterType !== "custom";
|
||||||
|
|
||||||
|
export const resolveInitialGatewayAutoConnectDelayMs = (
|
||||||
|
adapterType: StudioGatewayAdapterType
|
||||||
|
): number => {
|
||||||
|
switch (adapterType) {
|
||||||
|
case "hermes":
|
||||||
|
case "demo":
|
||||||
|
return INITIAL_AUTO_CONNECT_DELAY_MS;
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveInitialGatewayConnectAttemptCount = (
|
||||||
|
adapterType: StudioGatewayAdapterType,
|
||||||
|
hasConnectedOnce: boolean
|
||||||
|
): number => {
|
||||||
|
switch (adapterType) {
|
||||||
|
case "hermes":
|
||||||
|
case "demo":
|
||||||
|
return 2;
|
||||||
|
default:
|
||||||
|
if (hasConnectedOnce) return 1;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveDefaultGatewayProfile = (
|
||||||
|
adapterType: StudioGatewayAdapterType,
|
||||||
|
localDefaults: StudioGatewaySettings | null
|
||||||
|
): { url: string; token: string } => {
|
||||||
|
switch (adapterType) {
|
||||||
|
case "custom":
|
||||||
|
return { url: DEFAULT_CUSTOM_RUNTIME_URL, token: "" };
|
||||||
|
case "demo":
|
||||||
|
case "hermes":
|
||||||
|
return { url: "ws://localhost:18789", token: "" };
|
||||||
|
case "openclaw":
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
url: localDefaults?.url || DEFAULT_UPSTREAM_GATEWAY_URL,
|
||||||
|
token: localDefaults?.token ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const normalizeLocalGatewayDefaults = (value: unknown): StudioGatewaySettings | null => {
|
const normalizeLocalGatewayDefaults = (value: unknown): StudioGatewaySettings | null => {
|
||||||
if (!value || typeof value !== "object") return null;
|
if (!value || typeof value !== "object") return null;
|
||||||
const raw = value as { url?: unknown; token?: unknown; tokenConfigured?: unknown };
|
const raw = value as {
|
||||||
|
url?: unknown;
|
||||||
|
token?: unknown;
|
||||||
|
tokenConfigured?: unknown;
|
||||||
|
adapterType?: unknown;
|
||||||
|
profiles?: unknown;
|
||||||
|
};
|
||||||
const url = typeof raw.url === "string" ? raw.url.trim() : "";
|
const url = typeof raw.url === "string" ? raw.url.trim() : "";
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
// Accept both full settings ({ url, token }) and the sanitized public
|
// Accept both full settings ({ url, token }) and the sanitized public
|
||||||
@@ -111,7 +182,40 @@ const normalizeLocalGatewayDefaults = (value: unknown): StudioGatewaySettings |
|
|||||||
// tokenConfigured is present the actual token isn't available on the
|
// tokenConfigured is present the actual token isn't available on the
|
||||||
// client — leave it empty so the connection dialog can prompt if needed.
|
// client — leave it empty so the connection dialog can prompt if needed.
|
||||||
const token = typeof raw.token === "string" ? raw.token.trim() : "";
|
const token = typeof raw.token === "string" ? raw.token.trim() : "";
|
||||||
return { url, token };
|
const adapterType =
|
||||||
|
raw.adapterType === "demo" ||
|
||||||
|
raw.adapterType === "hermes" ||
|
||||||
|
raw.adapterType === "openclaw" ||
|
||||||
|
raw.adapterType === "custom"
|
||||||
|
? raw.adapterType
|
||||||
|
: "openclaw";
|
||||||
|
const profiles = normalizeGatewayProfilesPublic(raw.profiles);
|
||||||
|
return { url, token, adapterType, ...(profiles ? { profiles } : {}) };
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeGatewayProfilePublic = (
|
||||||
|
value: unknown
|
||||||
|
): { url: string; token: string } | null => {
|
||||||
|
if (!value || typeof value !== "object") return null;
|
||||||
|
const raw = value as { url?: unknown };
|
||||||
|
const url = typeof raw.url === "string" ? raw.url.trim() : "";
|
||||||
|
if (!url) return null;
|
||||||
|
return { url, token: "" };
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeGatewayProfilesPublic = (
|
||||||
|
value: unknown
|
||||||
|
): Partial<Record<StudioGatewayAdapterType, { url: string; token: string }>> | undefined => {
|
||||||
|
if (!value || typeof value !== "object") return undefined;
|
||||||
|
const raw = value as Partial<Record<StudioGatewayAdapterType, StudioGatewayProfilePublic>>;
|
||||||
|
const profiles: Partial<Record<StudioGatewayAdapterType, { url: string; token: string }>> = {};
|
||||||
|
for (const adapterType of ["openclaw", "hermes", "demo", "custom"] as const) {
|
||||||
|
const profile = normalizeGatewayProfilePublic(raw[adapterType]);
|
||||||
|
if (profile) {
|
||||||
|
profiles[adapterType] = profile;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Object.keys(profiles).length > 0 ? profiles : undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
type StatusHandler = (status: GatewayStatus) => void;
|
type StatusHandler = (status: GatewayStatus) => void;
|
||||||
@@ -419,14 +523,30 @@ export const syncGatewaySessionSettings = async ({
|
|||||||
const doctorFixHint =
|
const doctorFixHint =
|
||||||
"Run `npx openclaw doctor --fix` on the gateway host (or `pnpm openclaw doctor --fix` in a source checkout).";
|
"Run `npx openclaw doctor --fix` on the gateway host (or `pnpm openclaw doctor --fix` in a source checkout).";
|
||||||
|
|
||||||
|
const protocolMismatchHint =
|
||||||
|
"This gateway looks too old for Claw3D's protocol v3. Upgrade OpenClaw, use the Hermes adapter, or run `npm run demo-gateway` for a no-framework office demo.";
|
||||||
|
|
||||||
|
const isGatewayProtocolMismatchError = (error: GatewayResponseError) => {
|
||||||
|
if (error.code.trim().toUpperCase() !== "INVALID_REQUEST") return false;
|
||||||
|
const message = error.message.trim();
|
||||||
|
if (!message) return false;
|
||||||
|
return /minProtocol|maxProtocol/i.test(message);
|
||||||
|
};
|
||||||
|
|
||||||
const formatGatewayError = (error: unknown) => {
|
const formatGatewayError = (error: unknown) => {
|
||||||
if (error instanceof GatewayResponseError) {
|
if (error instanceof GatewayResponseError) {
|
||||||
|
if (isGatewayProtocolMismatchError(error)) {
|
||||||
|
return `Gateway error (${error.code}): ${error.message}. ${protocolMismatchHint}`;
|
||||||
|
}
|
||||||
if (error.code === "INVALID_REQUEST" && /invalid config/i.test(error.message)) {
|
if (error.code === "INVALID_REQUEST" && /invalid config/i.test(error.message)) {
|
||||||
return `Gateway error (${error.code}): ${error.message}. ${doctorFixHint}`;
|
return `Gateway error (${error.code}): ${error.message}. ${doctorFixHint}`;
|
||||||
}
|
}
|
||||||
return `Gateway error (${error.code}): ${error.message}`;
|
return `Gateway error (${error.code}): ${error.message}`;
|
||||||
}
|
}
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
|
if (/timed out connecting to the gateway/i.test(error.message)) {
|
||||||
|
return `${error.message} If you are testing locally, an older OpenClaw build may be speaking an incompatible protocol. Try upgrading OpenClaw, using the Hermes adapter, or running \`npm run demo-gateway\`.`;
|
||||||
|
}
|
||||||
return error.message;
|
return error.message;
|
||||||
}
|
}
|
||||||
return "Unknown gateway error.";
|
return "Unknown gateway error.";
|
||||||
@@ -437,6 +557,9 @@ export type GatewayConnectionState = {
|
|||||||
status: GatewayStatus;
|
status: GatewayStatus;
|
||||||
gatewayUrl: string;
|
gatewayUrl: string;
|
||||||
token: string;
|
token: string;
|
||||||
|
selectedAdapterType: StudioGatewayAdapterType;
|
||||||
|
detectedAdapterType: StudioGatewayAdapterType | null;
|
||||||
|
activeAdapterType: StudioGatewayAdapterType;
|
||||||
localGatewayDefaults: StudioGatewaySettings | null;
|
localGatewayDefaults: StudioGatewaySettings | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
connectPromptReady: boolean;
|
connectPromptReady: boolean;
|
||||||
@@ -446,6 +569,7 @@ export type GatewayConnectionState = {
|
|||||||
useLocalGatewayDefaults: () => void;
|
useLocalGatewayDefaults: () => void;
|
||||||
setGatewayUrl: (value: string) => void;
|
setGatewayUrl: (value: string) => void;
|
||||||
setToken: (value: string) => void;
|
setToken: (value: string) => void;
|
||||||
|
setSelectedAdapterType: (value: StudioGatewayAdapterType) => void;
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -523,13 +647,27 @@ export const useGatewayConnection = (
|
|||||||
const [client] = useState(() => new GatewayClient());
|
const [client] = useState(() => new GatewayClient());
|
||||||
const didAutoConnect = useRef(false);
|
const didAutoConnect = useRef(false);
|
||||||
const hasConnectedOnceRef = useRef(false);
|
const hasConnectedOnceRef = useRef(false);
|
||||||
const loadedGatewaySettings = useRef<{ gatewayUrl: string; token: string } | null>(null);
|
const loadedGatewaySettings = useRef<{
|
||||||
|
gatewayUrl: string;
|
||||||
|
token: string;
|
||||||
|
adapterType: StudioGatewayAdapterType;
|
||||||
|
profiles?: Partial<Record<StudioGatewayAdapterType, { url: string; token: string }>>;
|
||||||
|
hasLastKnownGood: boolean;
|
||||||
|
} | null>(null);
|
||||||
const retryAttemptRef = useRef(0);
|
const retryAttemptRef = useRef(0);
|
||||||
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const autoConnectTimerRef = useRef<number | null>(null);
|
||||||
const wasManualDisconnectRef = useRef(false);
|
const wasManualDisconnectRef = useRef(false);
|
||||||
|
|
||||||
const [gatewayUrl, setGatewayUrl] = useState(DEFAULT_UPSTREAM_GATEWAY_URL);
|
const [gatewayUrl, setGatewayUrl] = useState(DEFAULT_UPSTREAM_GATEWAY_URL);
|
||||||
const [token, setToken] = useState("");
|
const [token, setToken] = useState("");
|
||||||
|
const [selectedAdapterType, setSelectedAdapterTypeState] =
|
||||||
|
useState<StudioGatewayAdapterType>("openclaw");
|
||||||
|
const [adapterProfiles, setAdapterProfiles] = useState<
|
||||||
|
Partial<Record<StudioGatewayAdapterType, { url: string; token: string }>>
|
||||||
|
>({});
|
||||||
|
const [detectedAdapterType, setDetectedAdapterType] =
|
||||||
|
useState<StudioGatewayAdapterType | null>(null);
|
||||||
const [localGatewayDefaults, setLocalGatewayDefaults] = useState<StudioGatewaySettings | null>(
|
const [localGatewayDefaults, setLocalGatewayDefaults] = useState<StudioGatewaySettings | null>(
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
@@ -537,6 +675,19 @@ export const useGatewayConnection = (
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [connectErrorCode, setConnectErrorCode] = useState<string | null>(null);
|
const [connectErrorCode, setConnectErrorCode] = useState<string | null>(null);
|
||||||
const [settingsLoaded, setSettingsLoaded] = useState(false);
|
const [settingsLoaded, setSettingsLoaded] = useState(false);
|
||||||
|
const [hasLastKnownGoodState, setHasLastKnownGoodState] = useState(false);
|
||||||
|
const setSelectedAdapterType = useCallback(
|
||||||
|
(value: StudioGatewayAdapterType) => {
|
||||||
|
setSelectedAdapterTypeState(value);
|
||||||
|
const profile =
|
||||||
|
adapterProfiles[value] ?? resolveDefaultGatewayProfile(value, localGatewayDefaults);
|
||||||
|
setGatewayUrl(profile.url);
|
||||||
|
setToken(profile.token);
|
||||||
|
setError(null);
|
||||||
|
setConnectErrorCode(null);
|
||||||
|
},
|
||||||
|
[adapterProfiles, localGatewayDefaults]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -554,26 +705,92 @@ export const useGatewayConnection = (
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const normalizedDefaults = normalizeLocalGatewayDefaults(envelope.localGatewayDefaults);
|
const normalizedDefaults = normalizeLocalGatewayDefaults(envelope.localGatewayDefaults);
|
||||||
setLocalGatewayDefaults(normalizedDefaults);
|
setLocalGatewayDefaults(normalizedDefaults);
|
||||||
|
const lastKnownGood =
|
||||||
|
gateway && "lastKnownGood" in gateway && gateway.lastKnownGood
|
||||||
|
? {
|
||||||
|
url:
|
||||||
|
typeof gateway.lastKnownGood.url === "string"
|
||||||
|
? gateway.lastKnownGood.url
|
||||||
|
: "",
|
||||||
|
token:
|
||||||
|
"token" in gateway.lastKnownGood &&
|
||||||
|
typeof gateway.lastKnownGood.token === "string"
|
||||||
|
? gateway.lastKnownGood.token
|
||||||
|
: "",
|
||||||
|
adapterType:
|
||||||
|
gateway.lastKnownGood.adapterType === "demo" ||
|
||||||
|
gateway.lastKnownGood.adapterType === "hermes" ||
|
||||||
|
gateway.lastKnownGood.adapterType === "openclaw" ||
|
||||||
|
gateway.lastKnownGood.adapterType === "custom"
|
||||||
|
? gateway.lastKnownGood.adapterType
|
||||||
|
: "openclaw",
|
||||||
|
}
|
||||||
|
: null;
|
||||||
// When the user has no saved gateway URL, prefer the runtime
|
// When the user has no saved gateway URL, prefer the runtime
|
||||||
// localGatewayDefaults (from openclaw.json / CLAW3D_GATEWAY_URL)
|
// localGatewayDefaults (from openclaw.json / CLAW3D_GATEWAY_URL)
|
||||||
// over the build-time NEXT_PUBLIC_GATEWAY_URL which may be stale
|
// over the build-time NEXT_PUBLIC_GATEWAY_URL which may be stale
|
||||||
// or empty if the operator forgot to rebuild after .env changes.
|
// or empty if the operator forgot to rebuild after .env changes.
|
||||||
const hasSavedUrl = Boolean(gateway?.url?.trim());
|
const hasSavedUrl = Boolean(gateway?.url?.trim());
|
||||||
|
const savedAdapterType =
|
||||||
|
hasSavedUrl && gateway && "adapterType" in gateway && typeof gateway.adapterType === "string"
|
||||||
|
? ((gateway.adapterType === "demo" ||
|
||||||
|
gateway.adapterType === "hermes" ||
|
||||||
|
gateway.adapterType === "openclaw" ||
|
||||||
|
gateway.adapterType === "custom"
|
||||||
|
? gateway.adapterType
|
||||||
|
: "openclaw") as StudioGatewayAdapterType)
|
||||||
|
: null;
|
||||||
|
const nextAdapterType =
|
||||||
|
savedAdapterType ??
|
||||||
|
lastKnownGood?.adapterType ??
|
||||||
|
normalizedDefaults?.adapterType ??
|
||||||
|
"openclaw";
|
||||||
|
const lastKnownGoodForSelectedAdapter =
|
||||||
|
lastKnownGood?.adapterType === nextAdapterType ? lastKnownGood : null;
|
||||||
const resolvedUrl = hasSavedUrl
|
const resolvedUrl = hasSavedUrl
|
||||||
? gateway!.url
|
? gateway!.url
|
||||||
: normalizedDefaults?.url || DEFAULT_UPSTREAM_GATEWAY_URL;
|
: lastKnownGoodForSelectedAdapter?.url ||
|
||||||
const nextGatewayUrl = resolvedUrl;
|
normalizedDefaults?.url ||
|
||||||
const nextToken = hasSavedUrl
|
DEFAULT_UPSTREAM_GATEWAY_URL;
|
||||||
? (gateway && "token" in gateway && typeof gateway.token === "string"
|
const baseProfiles = {
|
||||||
? gateway.token
|
...(gateway?.profiles
|
||||||
: "")
|
? normalizeGatewayProfilesPublic(gateway.profiles)
|
||||||
: normalizedDefaults?.token ?? "";
|
: undefined),
|
||||||
|
...(normalizedDefaults?.profiles ?? {}),
|
||||||
|
};
|
||||||
|
const mergedProfiles = {
|
||||||
|
...baseProfiles,
|
||||||
|
...(hasSavedUrl
|
||||||
|
? {
|
||||||
|
[nextAdapterType]: {
|
||||||
|
url: resolvedUrl,
|
||||||
|
token:
|
||||||
|
gateway && "token" in gateway && typeof gateway.token === "string"
|
||||||
|
? gateway.token
|
||||||
|
: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
const selectedProfile = (
|
||||||
|
mergedProfiles[nextAdapterType] ??
|
||||||
|
lastKnownGoodForSelectedAdapter ??
|
||||||
|
resolveDefaultGatewayProfile(nextAdapterType, normalizedDefaults)
|
||||||
|
);
|
||||||
|
const nextGatewayUrl = selectedProfile.url;
|
||||||
|
const nextToken = selectedProfile.token;
|
||||||
loadedGatewaySettings.current = {
|
loadedGatewaySettings.current = {
|
||||||
gatewayUrl: nextGatewayUrl.trim(),
|
gatewayUrl: nextGatewayUrl.trim(),
|
||||||
token: nextToken,
|
token: nextToken,
|
||||||
|
adapterType: nextAdapterType,
|
||||||
|
profiles: mergedProfiles,
|
||||||
|
hasLastKnownGood: Boolean(lastKnownGoodForSelectedAdapter?.url),
|
||||||
};
|
};
|
||||||
setGatewayUrl(nextGatewayUrl);
|
setGatewayUrl(nextGatewayUrl);
|
||||||
setToken(nextToken);
|
setToken(nextToken);
|
||||||
|
setSelectedAdapterTypeState(nextAdapterType);
|
||||||
|
setAdapterProfiles(mergedProfiles);
|
||||||
|
setHasLastKnownGoodState(Boolean(lastKnownGoodForSelectedAdapter?.url));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
const message = err instanceof Error ? err.message : "Failed to load gateway settings.";
|
const message = err instanceof Error ? err.message : "Failed to load gateway settings.";
|
||||||
@@ -585,6 +802,9 @@ export const useGatewayConnection = (
|
|||||||
loadedGatewaySettings.current = {
|
loadedGatewaySettings.current = {
|
||||||
gatewayUrl: DEFAULT_UPSTREAM_GATEWAY_URL.trim(),
|
gatewayUrl: DEFAULT_UPSTREAM_GATEWAY_URL.trim(),
|
||||||
token: "",
|
token: "",
|
||||||
|
adapterType: "openclaw",
|
||||||
|
profiles: undefined,
|
||||||
|
hasLastKnownGood: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
setSettingsLoaded(true);
|
setSettingsLoaded(true);
|
||||||
@@ -599,11 +819,14 @@ export const useGatewayConnection = (
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return client.onStatus((nextStatus) => {
|
return client.onStatus((nextStatus) => {
|
||||||
|
gatewayDebugLog("status", { nextStatus });
|
||||||
setStatus(nextStatus);
|
setStatus(nextStatus);
|
||||||
if (nextStatus !== "connecting") {
|
if (nextStatus !== "connecting") {
|
||||||
setError(null);
|
setError(null);
|
||||||
if (nextStatus === "connected") {
|
if (nextStatus === "connected") {
|
||||||
setConnectErrorCode(null);
|
setConnectErrorCode(null);
|
||||||
|
} else {
|
||||||
|
setDetectedAdapterType(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -611,6 +834,10 @@ export const useGatewayConnection = (
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
if (autoConnectTimerRef.current) {
|
||||||
|
clearTimeout(autoConnectTimerRef.current);
|
||||||
|
autoConnectTimerRef.current = null;
|
||||||
|
}
|
||||||
if (retryTimerRef.current) {
|
if (retryTimerRef.current) {
|
||||||
clearTimeout(retryTimerRef.current);
|
clearTimeout(retryTimerRef.current);
|
||||||
retryTimerRef.current = null;
|
retryTimerRef.current = null;
|
||||||
@@ -620,35 +847,145 @@ export const useGatewayConnection = (
|
|||||||
}, [client]);
|
}, [client]);
|
||||||
|
|
||||||
const connect = useCallback(async () => {
|
const connect = useCallback(async () => {
|
||||||
|
if (autoConnectTimerRef.current) {
|
||||||
|
clearTimeout(autoConnectTimerRef.current);
|
||||||
|
autoConnectTimerRef.current = null;
|
||||||
|
}
|
||||||
|
if (retryTimerRef.current) {
|
||||||
|
clearTimeout(retryTimerRef.current);
|
||||||
|
retryTimerRef.current = null;
|
||||||
|
}
|
||||||
|
gatewayDebugLog("connect:start", {
|
||||||
|
selectedAdapterType,
|
||||||
|
gatewayUrl,
|
||||||
|
hasToken: Boolean(token),
|
||||||
|
});
|
||||||
setError(null);
|
setError(null);
|
||||||
setConnectErrorCode(null);
|
setConnectErrorCode(null);
|
||||||
|
retryAttemptRef.current = 0;
|
||||||
wasManualDisconnectRef.current = false;
|
wasManualDisconnectRef.current = false;
|
||||||
|
if (selectedAdapterType === "custom") {
|
||||||
|
setStatus("connecting");
|
||||||
|
try {
|
||||||
|
await settingsCoordinator.flushPending();
|
||||||
|
await probeCustomRuntime(gatewayUrl);
|
||||||
|
setDetectedAdapterType("custom");
|
||||||
|
setStatus("connected");
|
||||||
|
setConnectErrorCode(null);
|
||||||
|
retryAttemptRef.current = 0;
|
||||||
|
gatewayDebugLog("connect:custom-success", { gatewayUrl });
|
||||||
|
} catch (err) {
|
||||||
|
setStatus("disconnected");
|
||||||
|
setDetectedAdapterType(null);
|
||||||
|
setConnectErrorCode("studio.custom_runtime_probe_failed");
|
||||||
|
setError(formatGatewayError(err));
|
||||||
|
gatewayDebugLog("connect:custom-failed", {
|
||||||
|
message: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await settingsCoordinator.flushPending();
|
await settingsCoordinator.flushPending();
|
||||||
await client.connect({
|
const maxAttempts = resolveInitialGatewayConnectAttemptCount(
|
||||||
gatewayUrl: resolveStudioProxyGatewayUrl(),
|
selectedAdapterType,
|
||||||
token,
|
hasConnectedOnceRef.current
|
||||||
authScopeKey: gatewayUrl,
|
);
|
||||||
clientName: "openclaw-control-ui",
|
let lastError: unknown = null;
|
||||||
});
|
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||||
|
try {
|
||||||
|
await client.connect({
|
||||||
|
gatewayUrl: resolveStudioProxyGatewayUrl(),
|
||||||
|
token,
|
||||||
|
authScopeKey: gatewayUrl,
|
||||||
|
clientName: "openclaw-control-ui",
|
||||||
|
disableDeviceAuth: selectedAdapterType !== "openclaw",
|
||||||
|
});
|
||||||
|
lastError = null;
|
||||||
|
break;
|
||||||
|
} catch (err) {
|
||||||
|
lastError = err;
|
||||||
|
gatewayDebugLog("connect:attempt-failed", {
|
||||||
|
selectedAdapterType,
|
||||||
|
attempt: attempt + 1,
|
||||||
|
maxAttempts,
|
||||||
|
message: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
if (attempt + 1 >= maxAttempts) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
client.disconnect();
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
window.setTimeout(resolve, INITIAL_CONNECT_RETRY_DELAY_MS);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lastError) {
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
await ensureGatewayReloadModeHotForLocalStudio({
|
await ensureGatewayReloadModeHotForLocalStudio({
|
||||||
client,
|
client,
|
||||||
upstreamGatewayUrl: gatewayUrl,
|
upstreamGatewayUrl: gatewayUrl,
|
||||||
});
|
});
|
||||||
|
const hello = client.getLastHello();
|
||||||
|
const nextDetectedAdapterType =
|
||||||
|
hello?.adapterType === "demo" ||
|
||||||
|
hello?.adapterType === "hermes" ||
|
||||||
|
hello?.adapterType === "openclaw" ||
|
||||||
|
hello?.adapterType === "custom"
|
||||||
|
? hello.adapterType
|
||||||
|
: "openclaw";
|
||||||
|
setDetectedAdapterType(nextDetectedAdapterType);
|
||||||
retryAttemptRef.current = 0;
|
retryAttemptRef.current = 0;
|
||||||
|
setHasLastKnownGoodState(nextDetectedAdapterType === selectedAdapterType);
|
||||||
|
settingsCoordinator.schedulePatch({
|
||||||
|
gateway: {
|
||||||
|
lastKnownGood: {
|
||||||
|
url: gatewayUrl.trim(),
|
||||||
|
token,
|
||||||
|
adapterType: nextDetectedAdapterType,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
gatewayDebugLog("connect:success", {
|
||||||
|
selectedAdapterType,
|
||||||
|
detectedAdapterType: nextDetectedAdapterType,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setConnectErrorCode(err instanceof GatewayResponseError ? err.code : null);
|
setConnectErrorCode(err instanceof GatewayResponseError ? err.code : null);
|
||||||
setError(formatGatewayError(err));
|
setError(formatGatewayError(err));
|
||||||
|
gatewayDebugLog("connect:failed", {
|
||||||
|
selectedAdapterType,
|
||||||
|
code: err instanceof GatewayResponseError ? err.code : null,
|
||||||
|
message: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [client, gatewayUrl, settingsCoordinator, token]);
|
}, [client, gatewayUrl, selectedAdapterType, settingsCoordinator, token]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (didAutoConnect.current) return;
|
if (didAutoConnect.current) return;
|
||||||
if (!settingsLoaded) return;
|
if (!settingsLoaded) return;
|
||||||
|
if (!hasLastKnownGoodState) return;
|
||||||
if (!gatewayUrl.trim()) return;
|
if (!gatewayUrl.trim()) return;
|
||||||
|
if (!isAutoManagedAdapter(selectedAdapterType)) return;
|
||||||
didAutoConnect.current = true;
|
didAutoConnect.current = true;
|
||||||
void connect();
|
const delayMs = resolveInitialGatewayAutoConnectDelayMs(selectedAdapterType);
|
||||||
}, [connect, gatewayUrl, settingsLoaded]);
|
gatewayDebugLog("auto-connect", {
|
||||||
|
selectedAdapterType,
|
||||||
|
gatewayUrl,
|
||||||
|
delayMs,
|
||||||
|
});
|
||||||
|
autoConnectTimerRef.current = window.setTimeout(() => {
|
||||||
|
autoConnectTimerRef.current = null;
|
||||||
|
void connect();
|
||||||
|
}, delayMs);
|
||||||
|
return () => {
|
||||||
|
if (autoConnectTimerRef.current) {
|
||||||
|
window.clearTimeout(autoConnectTimerRef.current);
|
||||||
|
autoConnectTimerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [connect, gatewayUrl, hasLastKnownGoodState, selectedAdapterType, settingsLoaded]);
|
||||||
|
|
||||||
// Auto-retry on disconnect (gateway busy, network blip, etc.)
|
// Auto-retry on disconnect (gateway busy, network blip, etc.)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -663,9 +1000,21 @@ export const useGatewayConnection = (
|
|||||||
connectErrorCode,
|
connectErrorCode,
|
||||||
attempt,
|
attempt,
|
||||||
});
|
});
|
||||||
|
if (!isAutoManagedAdapter(selectedAdapterType)) return;
|
||||||
if (delay === null) return;
|
if (delay === null) return;
|
||||||
|
gatewayDebugLog("auto-retry-scheduled", {
|
||||||
|
selectedAdapterType,
|
||||||
|
attempt: attempt + 1,
|
||||||
|
delay,
|
||||||
|
gatewayUrl,
|
||||||
|
status,
|
||||||
|
});
|
||||||
retryTimerRef.current = setTimeout(() => {
|
retryTimerRef.current = setTimeout(() => {
|
||||||
retryAttemptRef.current = attempt + 1;
|
retryAttemptRef.current = attempt + 1;
|
||||||
|
gatewayDebugLog("auto-retry-fire", {
|
||||||
|
selectedAdapterType,
|
||||||
|
attempt: retryAttemptRef.current,
|
||||||
|
});
|
||||||
void connect();
|
void connect();
|
||||||
}, delay);
|
}, delay);
|
||||||
|
|
||||||
@@ -675,7 +1024,7 @@ export const useGatewayConnection = (
|
|||||||
retryTimerRef.current = null;
|
retryTimerRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [connect, connectErrorCode, error, gatewayUrl, status]);
|
}, [connect, connectErrorCode, error, gatewayUrl, selectedAdapterType, status]);
|
||||||
|
|
||||||
// Reset retry count on successful connection
|
// Reset retry count on successful connection
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -685,12 +1034,46 @@ export const useGatewayConnection = (
|
|||||||
}
|
}
|
||||||
}, [status]);
|
}, [status]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!settingsLoaded) return;
|
||||||
|
setAdapterProfiles((current) => {
|
||||||
|
const nextProfile = {
|
||||||
|
url: gatewayUrl.trim(),
|
||||||
|
token,
|
||||||
|
};
|
||||||
|
const existing = current[selectedAdapterType];
|
||||||
|
if (
|
||||||
|
existing &&
|
||||||
|
existing.url === nextProfile.url &&
|
||||||
|
existing.token === nextProfile.token
|
||||||
|
) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
[selectedAdapterType]: nextProfile,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [gatewayUrl, selectedAdapterType, settingsLoaded, token]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!settingsLoaded) return;
|
if (!settingsLoaded) return;
|
||||||
const baseline = loadedGatewaySettings.current;
|
const baseline = loadedGatewaySettings.current;
|
||||||
if (!baseline) return;
|
if (!baseline) return;
|
||||||
const nextGatewayUrl = gatewayUrl.trim();
|
const nextGatewayUrl = gatewayUrl.trim();
|
||||||
if (nextGatewayUrl === baseline.gatewayUrl && token === baseline.token) {
|
const nextProfiles = {
|
||||||
|
...adapterProfiles,
|
||||||
|
[selectedAdapterType]: {
|
||||||
|
url: nextGatewayUrl,
|
||||||
|
token,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
nextGatewayUrl === baseline.gatewayUrl &&
|
||||||
|
token === baseline.token &&
|
||||||
|
selectedAdapterType === baseline.adapterType &&
|
||||||
|
JSON.stringify(nextProfiles) === JSON.stringify(baseline.profiles ?? {})
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
settingsCoordinator.schedulePatch(
|
settingsCoordinator.schedulePatch(
|
||||||
@@ -698,11 +1081,13 @@ export const useGatewayConnection = (
|
|||||||
gateway: {
|
gateway: {
|
||||||
url: nextGatewayUrl,
|
url: nextGatewayUrl,
|
||||||
token,
|
token,
|
||||||
|
adapterType: selectedAdapterType,
|
||||||
|
profiles: nextProfiles,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
400
|
400
|
||||||
);
|
);
|
||||||
}, [gatewayUrl, settingsCoordinator, settingsLoaded, token]);
|
}, [adapterProfiles, gatewayUrl, selectedAdapterType, settingsCoordinator, settingsLoaded, token]);
|
||||||
|
|
||||||
const useLocalGatewayDefaults = useCallback(() => {
|
const useLocalGatewayDefaults = useCallback(() => {
|
||||||
if (!localGatewayDefaults) {
|
if (!localGatewayDefaults) {
|
||||||
@@ -710,17 +1095,31 @@ export const useGatewayConnection = (
|
|||||||
}
|
}
|
||||||
setGatewayUrl(localGatewayDefaults.url);
|
setGatewayUrl(localGatewayDefaults.url);
|
||||||
setToken(localGatewayDefaults.token);
|
setToken(localGatewayDefaults.token);
|
||||||
|
setAdapterProfiles((current) => ({
|
||||||
|
...current,
|
||||||
|
[localGatewayDefaults.adapterType]: {
|
||||||
|
url: localGatewayDefaults.url,
|
||||||
|
token: localGatewayDefaults.token,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
setSelectedAdapterTypeState(localGatewayDefaults.adapterType);
|
||||||
setError(null);
|
setError(null);
|
||||||
setConnectErrorCode(null);
|
setConnectErrorCode(null);
|
||||||
}, [localGatewayDefaults]);
|
}, [localGatewayDefaults]);
|
||||||
|
|
||||||
const disconnect = useCallback(() => {
|
const disconnect = useCallback(() => {
|
||||||
|
gatewayDebugLog("disconnect", { selectedAdapterType });
|
||||||
setError(null);
|
setError(null);
|
||||||
setConnectErrorCode(null);
|
setConnectErrorCode(null);
|
||||||
wasManualDisconnectRef.current = true;
|
wasManualDisconnectRef.current = true;
|
||||||
|
setDetectedAdapterType(null);
|
||||||
|
if (selectedAdapterType === "custom") {
|
||||||
|
setStatus("disconnected");
|
||||||
|
return;
|
||||||
|
}
|
||||||
client.disconnect();
|
client.disconnect();
|
||||||
clearGatewayBrowserSessionStorage();
|
clearGatewayBrowserSessionStorage();
|
||||||
}, [client]);
|
}, [client, selectedAdapterType]);
|
||||||
|
|
||||||
const clearError = useCallback(() => {
|
const clearError = useCallback(() => {
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -728,16 +1127,26 @@ export const useGatewayConnection = (
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const connectPromptReady = settingsLoaded;
|
const connectPromptReady = settingsLoaded;
|
||||||
|
const activeAdapterType =
|
||||||
|
status === "connected" ? detectedAdapterType ?? selectedAdapterType : selectedAdapterType;
|
||||||
const shouldPromptForConnect =
|
const shouldPromptForConnect =
|
||||||
settingsLoaded &&
|
settingsLoaded &&
|
||||||
status !== "connected" &&
|
status !== "connected" &&
|
||||||
(!gatewayUrl.trim() || !token.trim() || wasManualDisconnectRef.current || Boolean(error));
|
(selectedAdapterType === "custom" ||
|
||||||
|
!hasLastKnownGoodState ||
|
||||||
|
!gatewayUrl.trim() ||
|
||||||
|
(selectedAdapterType === "openclaw" && !token.trim()) ||
|
||||||
|
wasManualDisconnectRef.current ||
|
||||||
|
Boolean(error));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
client,
|
client,
|
||||||
status,
|
status,
|
||||||
gatewayUrl,
|
gatewayUrl,
|
||||||
token,
|
token,
|
||||||
|
selectedAdapterType,
|
||||||
|
detectedAdapterType,
|
||||||
|
activeAdapterType,
|
||||||
localGatewayDefaults,
|
localGatewayDefaults,
|
||||||
error,
|
error,
|
||||||
connectPromptReady,
|
connectPromptReady,
|
||||||
@@ -747,6 +1156,7 @@ export const useGatewayConnection = (
|
|||||||
useLocalGatewayDefaults,
|
useLocalGatewayDefaults,
|
||||||
setGatewayUrl,
|
setGatewayUrl,
|
||||||
setToken,
|
setToken,
|
||||||
|
setSelectedAdapterType,
|
||||||
clearError,
|
clearError,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,21 @@
|
|||||||
import { getPublicKeyAsync, signAsync, utils } from "@noble/ed25519";
|
import { getPublicKeyAsync, signAsync, utils } from "@noble/ed25519";
|
||||||
import { GatewayResponseError } from "@/lib/gateway/errors";
|
import { GatewayResponseError } from "@/lib/gateway/errors";
|
||||||
|
|
||||||
|
const gatewayBrowserDebugEnabled =
|
||||||
|
process.env.NODE_ENV !== "production";
|
||||||
|
|
||||||
|
const gatewayBrowserDebugLog = (
|
||||||
|
message: string,
|
||||||
|
details?: Record<string, unknown>
|
||||||
|
) => {
|
||||||
|
if (!gatewayBrowserDebugEnabled) return;
|
||||||
|
if (details) {
|
||||||
|
console.info("[gateway-browser]", message, details);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.info("[gateway-browser]", message);
|
||||||
|
};
|
||||||
|
|
||||||
const GATEWAY_CLIENT_NAMES = {
|
const GATEWAY_CLIENT_NAMES = {
|
||||||
CONTROL_UI: "openclaw-control-ui",
|
CONTROL_UI: "openclaw-control-ui",
|
||||||
} as const;
|
} as const;
|
||||||
@@ -350,6 +365,7 @@ export type GatewayResponseFrame = {
|
|||||||
export type GatewayHelloOk = {
|
export type GatewayHelloOk = {
|
||||||
type: "hello-ok";
|
type: "hello-ok";
|
||||||
protocol: number;
|
protocol: number;
|
||||||
|
adapterType?: "openclaw" | "hermes" | "demo" | "custom";
|
||||||
features?: { methods?: string[]; events?: string[] };
|
features?: { methods?: string[]; events?: string[] };
|
||||||
snapshot?: unknown;
|
snapshot?: unknown;
|
||||||
auth?: {
|
auth?: {
|
||||||
@@ -415,11 +431,19 @@ export class GatewayBrowserClient {
|
|||||||
|
|
||||||
start() {
|
start() {
|
||||||
this.closed = false;
|
this.closed = false;
|
||||||
|
gatewayBrowserDebugLog("start", {
|
||||||
|
url: this.opts.url,
|
||||||
|
authScopeKey: this.opts.authScopeKey ?? null,
|
||||||
|
disableDeviceAuth: Boolean(this.opts.disableDeviceAuth),
|
||||||
|
clientName: this.opts.clientName ?? null,
|
||||||
|
mode: this.opts.mode ?? null,
|
||||||
|
});
|
||||||
this.connect();
|
this.connect();
|
||||||
}
|
}
|
||||||
|
|
||||||
stop() {
|
stop() {
|
||||||
this.closed = true;
|
this.closed = true;
|
||||||
|
gatewayBrowserDebugLog("stop");
|
||||||
this.ws?.close();
|
this.ws?.close();
|
||||||
this.ws = null;
|
this.ws = null;
|
||||||
this.flushPending(new Error("gateway client stopped"));
|
this.flushPending(new Error("gateway client stopped"));
|
||||||
@@ -431,18 +455,23 @@ export class GatewayBrowserClient {
|
|||||||
|
|
||||||
private connect() {
|
private connect() {
|
||||||
if (this.closed) return;
|
if (this.closed) return;
|
||||||
|
gatewayBrowserDebugLog("connect:open-socket", { url: this.opts.url });
|
||||||
this.ws = new WebSocket(this.opts.url);
|
this.ws = new WebSocket(this.opts.url);
|
||||||
this.ws.onopen = () => this.queueConnect();
|
this.ws.onopen = () => {
|
||||||
|
gatewayBrowserDebugLog("socket:open");
|
||||||
|
this.queueConnect();
|
||||||
|
};
|
||||||
this.ws.onmessage = (ev) => this.handleMessage(String(ev.data ?? ""));
|
this.ws.onmessage = (ev) => this.handleMessage(String(ev.data ?? ""));
|
||||||
this.ws.onclose = (ev) => {
|
this.ws.onclose = (ev) => {
|
||||||
const reason = String(ev.reason ?? "");
|
const reason = String(ev.reason ?? "");
|
||||||
|
gatewayBrowserDebugLog("socket:close", { code: ev.code, reason });
|
||||||
this.ws = null;
|
this.ws = null;
|
||||||
this.flushPending(new Error(`gateway closed (${ev.code}): ${reason}`));
|
this.flushPending(new Error(`gateway closed (${ev.code}): ${reason}`));
|
||||||
this.opts.onClose?.({ code: ev.code, reason });
|
this.opts.onClose?.({ code: ev.code, reason });
|
||||||
this.scheduleReconnect();
|
this.scheduleReconnect();
|
||||||
};
|
};
|
||||||
this.ws.onerror = () => {
|
this.ws.onerror = () => {
|
||||||
// ignored; close handler will fire
|
gatewayBrowserDebugLog("socket:error");
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,6 +479,7 @@ export class GatewayBrowserClient {
|
|||||||
if (this.closed) return;
|
if (this.closed) return;
|
||||||
const delay = this.backoffMs;
|
const delay = this.backoffMs;
|
||||||
this.backoffMs = Math.min(this.backoffMs * 1.7, 15_000);
|
this.backoffMs = Math.min(this.backoffMs * 1.7, 15_000);
|
||||||
|
gatewayBrowserDebugLog("schedule-reconnect", { delay });
|
||||||
window.setTimeout(() => this.connect(), delay);
|
window.setTimeout(() => this.connect(), delay);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,6 +498,12 @@ export class GatewayBrowserClient {
|
|||||||
|
|
||||||
const isSecureContext =
|
const isSecureContext =
|
||||||
!this.opts.disableDeviceAuth && typeof crypto !== "undefined" && !!crypto.subtle;
|
!this.opts.disableDeviceAuth && typeof crypto !== "undefined" && !!crypto.subtle;
|
||||||
|
gatewayBrowserDebugLog("send-connect", {
|
||||||
|
url: this.opts.url,
|
||||||
|
disableDeviceAuth: Boolean(this.opts.disableDeviceAuth),
|
||||||
|
hasNonce: Boolean(this.connectNonce),
|
||||||
|
isSecureContext,
|
||||||
|
});
|
||||||
|
|
||||||
const scopes = ["operator.admin", "operator.approvals", "operator.pairing"];
|
const scopes = ["operator.admin", "operator.approvals", "operator.pairing"];
|
||||||
const role = "operator";
|
const role = "operator";
|
||||||
@@ -547,6 +583,10 @@ export class GatewayBrowserClient {
|
|||||||
|
|
||||||
void this.request<GatewayHelloOk>("connect", params)
|
void this.request<GatewayHelloOk>("connect", params)
|
||||||
.then((hello) => {
|
.then((hello) => {
|
||||||
|
gatewayBrowserDebugLog("hello-ok", {
|
||||||
|
protocol: hello?.protocol ?? null,
|
||||||
|
hasAuthToken: Boolean(hello?.auth?.deviceToken),
|
||||||
|
});
|
||||||
if (hello?.auth?.deviceToken && deviceIdentity) {
|
if (hello?.auth?.deviceToken && deviceIdentity) {
|
||||||
storeDeviceAuthToken({
|
storeDeviceAuthToken({
|
||||||
deviceId: deviceIdentity.deviceId,
|
deviceId: deviceIdentity.deviceId,
|
||||||
@@ -560,6 +600,9 @@ export class GatewayBrowserClient {
|
|||||||
this.opts.onHello?.(hello);
|
this.opts.onHello?.(hello);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
|
gatewayBrowserDebugLog("connect-failed", {
|
||||||
|
message: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
if (canFallbackToShared && deviceIdentity) {
|
if (canFallbackToShared && deviceIdentity) {
|
||||||
clearDeviceAuthToken({ deviceId: deviceIdentity.deviceId, role, scope: authScopeKey });
|
clearDeviceAuthToken({ deviceId: deviceIdentity.deviceId, role, scope: authScopeKey });
|
||||||
}
|
}
|
||||||
@@ -587,6 +630,7 @@ export class GatewayBrowserClient {
|
|||||||
if (frame.type === "event") {
|
if (frame.type === "event") {
|
||||||
const evt = parsed as GatewayEventFrame;
|
const evt = parsed as GatewayEventFrame;
|
||||||
if (evt.event === "connect.challenge") {
|
if (evt.event === "connect.challenge") {
|
||||||
|
gatewayBrowserDebugLog("connect-challenge");
|
||||||
const payload = evt.payload as { nonce?: unknown } | undefined;
|
const payload = evt.payload as { nonce?: unknown } | undefined;
|
||||||
const nonce = payload && typeof payload.nonce === "string" ? payload.nonce : null;
|
const nonce = payload && typeof payload.nonce === "string" ? payload.nonce : null;
|
||||||
if (nonce) {
|
if (nonce) {
|
||||||
@@ -650,6 +694,7 @@ export class GatewayBrowserClient {
|
|||||||
this.connectNonce = null;
|
this.connectNonce = null;
|
||||||
this.connectSent = false;
|
this.connectSent = false;
|
||||||
if (this.connectTimer !== null) window.clearTimeout(this.connectTimer);
|
if (this.connectTimer !== null) window.clearTimeout(this.connectTimer);
|
||||||
|
gatewayBrowserDebugLog("queue-connect", { delayMs: 750 });
|
||||||
this.connectTimer = window.setTimeout(() => {
|
this.connectTimer = window.setTimeout(() => {
|
||||||
void this.sendConnect();
|
void this.sendConnect();
|
||||||
}, 750);
|
}, 750);
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { CustomRuntimeProvider } from "@/lib/runtime/custom/provider";
|
||||||
|
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||||
|
import { DemoRuntimeProvider } from "@/lib/runtime/demo/provider";
|
||||||
|
import { HermesRuntimeProvider } from "@/lib/runtime/hermes/provider";
|
||||||
|
import { OpenClawRuntimeProvider } from "@/lib/runtime/openclaw/provider";
|
||||||
|
import type { RuntimeProvider } from "@/lib/runtime/types";
|
||||||
|
|
||||||
|
export const createRuntimeProvider = (
|
||||||
|
providerId: RuntimeProvider["id"],
|
||||||
|
client: GatewayClient,
|
||||||
|
runtimeUrl: string
|
||||||
|
): RuntimeProvider => {
|
||||||
|
switch (providerId) {
|
||||||
|
case "custom":
|
||||||
|
return new CustomRuntimeProvider(client, runtimeUrl);
|
||||||
|
case "demo":
|
||||||
|
return new DemoRuntimeProvider(client);
|
||||||
|
case "hermes":
|
||||||
|
return new HermesRuntimeProvider(client);
|
||||||
|
case "openclaw":
|
||||||
|
default:
|
||||||
|
return new OpenClawRuntimeProvider(client);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
export const normalizeCustomBaseUrl = (value: string): string => {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return "";
|
||||||
|
try {
|
||||||
|
const parsed = new URL(trimmed);
|
||||||
|
if (parsed.protocol === "ws:") {
|
||||||
|
parsed.protocol = "http:";
|
||||||
|
} else if (parsed.protocol === "wss:") {
|
||||||
|
parsed.protocol = "https:";
|
||||||
|
}
|
||||||
|
return parsed.toString().replace(/\/$/, "");
|
||||||
|
} catch {
|
||||||
|
return trimmed.replace(/\/$/, "");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
type CustomRuntimeProxyInput = {
|
||||||
|
runtimeUrl: string;
|
||||||
|
pathname: string;
|
||||||
|
method?: "GET" | "POST";
|
||||||
|
body?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function requestCustomRuntime<T = unknown>({
|
||||||
|
runtimeUrl,
|
||||||
|
pathname,
|
||||||
|
method = "GET",
|
||||||
|
body,
|
||||||
|
}: CustomRuntimeProxyInput): Promise<T> {
|
||||||
|
const normalizedRuntimeUrl = normalizeCustomBaseUrl(runtimeUrl);
|
||||||
|
if (!normalizedRuntimeUrl) {
|
||||||
|
throw new Error("Custom runtime URL is not configured.");
|
||||||
|
}
|
||||||
|
const response = await fetch("/api/runtime/custom", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
cache: "no-store",
|
||||||
|
body: JSON.stringify({
|
||||||
|
runtimeUrl: normalizedRuntimeUrl,
|
||||||
|
pathname,
|
||||||
|
method,
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
throw new Error(
|
||||||
|
text.trim() || `Custom runtime request failed (${response.status}) for ${pathname}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (await response.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCustomRuntimeJson<T = unknown>(
|
||||||
|
runtimeUrl: string,
|
||||||
|
pathname: string
|
||||||
|
): Promise<T> {
|
||||||
|
return requestCustomRuntime<T>({ runtimeUrl, pathname, method: "GET" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function probeCustomRuntime(runtimeUrl: string): Promise<void> {
|
||||||
|
await fetchCustomRuntimeJson(runtimeUrl, "/health");
|
||||||
|
}
|
||||||
@@ -0,0 +1,614 @@
|
|||||||
|
import type {
|
||||||
|
EventFrame,
|
||||||
|
GatewayConnectOptions,
|
||||||
|
GatewayGapInfo,
|
||||||
|
GatewayStatus,
|
||||||
|
} from "@/lib/gateway/GatewayClient";
|
||||||
|
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||||
|
import {
|
||||||
|
buildAgentMainSessionKey,
|
||||||
|
parseAgentIdFromSessionKey,
|
||||||
|
} from "@/lib/gateway/GatewayClient";
|
||||||
|
import {
|
||||||
|
fetchCustomRuntimeJson,
|
||||||
|
normalizeCustomBaseUrl,
|
||||||
|
requestCustomRuntime,
|
||||||
|
} from "@/lib/runtime/custom/http";
|
||||||
|
import type { RuntimeCapability, RuntimeEvent, RuntimeProvider } from "@/lib/runtime/types";
|
||||||
|
|
||||||
|
const CUSTOM_RUNTIME_CAPABILITIES: ReadonlySet<RuntimeCapability> = new Set([
|
||||||
|
"agents",
|
||||||
|
"sessions",
|
||||||
|
"chat",
|
||||||
|
"models",
|
||||||
|
"agent-roles",
|
||||||
|
]);
|
||||||
|
|
||||||
|
type CustomRuntimeStateResponse = {
|
||||||
|
profileName?: string | null;
|
||||||
|
registry_profile?: string | null;
|
||||||
|
active?: Record<string, unknown> | null;
|
||||||
|
profile?: string | null;
|
||||||
|
identity?: {
|
||||||
|
name?: string | null;
|
||||||
|
role?: string | null;
|
||||||
|
lane?: string | null;
|
||||||
|
model_id?: string | null;
|
||||||
|
} | null;
|
||||||
|
runtime?: {
|
||||||
|
name?: string | null;
|
||||||
|
version?: string | null;
|
||||||
|
vendor?: string | null;
|
||||||
|
status?: string | null;
|
||||||
|
active_model?: string | null;
|
||||||
|
governance?: string | null;
|
||||||
|
} | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CustomRuntimeRegistryResponse = {
|
||||||
|
models?: Record<string, unknown> | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CustomRuntimeHealthResponse = {
|
||||||
|
ok?: boolean;
|
||||||
|
status?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SyntheticAgent = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
role: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SessionMessage = {
|
||||||
|
role: "user" | "assistant";
|
||||||
|
text: string;
|
||||||
|
timestamp: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SessionRecord = {
|
||||||
|
sessionKey: string;
|
||||||
|
agentId: string;
|
||||||
|
role: string | null;
|
||||||
|
model: string | null;
|
||||||
|
updatedAt: number | null;
|
||||||
|
messages: SessionMessage[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ActiveRunRecord = {
|
||||||
|
runId: string;
|
||||||
|
sessionKey: string;
|
||||||
|
controller: AbortController;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||||
|
|
||||||
|
const titleCase = (value: string): string =>
|
||||||
|
value
|
||||||
|
.split(/[-_\s]+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((part) => part.slice(0, 1).toUpperCase() + part.slice(1))
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
const resolveRouteProfile = (state: CustomRuntimeStateResponse | null): string | null => {
|
||||||
|
if (!state) return null;
|
||||||
|
if (typeof state.profileName === "string" && state.profileName.trim()) return state.profileName.trim();
|
||||||
|
if (typeof state.registry_profile === "string" && state.registry_profile.trim()) {
|
||||||
|
return state.registry_profile.trim();
|
||||||
|
}
|
||||||
|
if (typeof state.profile === "string" && state.profile.trim()) return state.profile.trim();
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractContentText = (content: unknown): string => {
|
||||||
|
if (typeof content === "string") return content.trim();
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
return content
|
||||||
|
.map((item) => {
|
||||||
|
if (typeof item === "string") return item;
|
||||||
|
if (isRecord(item) && typeof item.text === "string") return item.text;
|
||||||
|
return "";
|
||||||
|
})
|
||||||
|
.join("")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveAssistantTextFromResponse = (payload: unknown): string | null => {
|
||||||
|
if (!isRecord(payload)) return null;
|
||||||
|
const choices = Array.isArray(payload.choices) ? payload.choices : [];
|
||||||
|
const first = choices[0];
|
||||||
|
if (!isRecord(first)) return null;
|
||||||
|
const message = isRecord(first.message) ? first.message : null;
|
||||||
|
const direct = extractContentText(message?.content);
|
||||||
|
if (direct) return direct;
|
||||||
|
const text = extractContentText(first.text);
|
||||||
|
return text || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeModelChoices = (registry: CustomRuntimeRegistryResponse | null): string[] => {
|
||||||
|
if (!registry || !isRecord(registry.models)) return [];
|
||||||
|
return Object.keys(registry.models).map((value) => value.trim()).filter(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveOptionalString = (value: unknown): string | null =>
|
||||||
|
typeof value === "string" && value.trim() ? value.trim() : null;
|
||||||
|
|
||||||
|
const resolveDefaultModelId = (
|
||||||
|
state: CustomRuntimeStateResponse | null,
|
||||||
|
modelChoices: string[]
|
||||||
|
): string | null => {
|
||||||
|
return (
|
||||||
|
resolveOptionalString(state?.identity?.model_id) ??
|
||||||
|
resolveOptionalString(state?.runtime?.active_model) ??
|
||||||
|
modelChoices[0] ??
|
||||||
|
null
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildIdentityAgent = (
|
||||||
|
state: CustomRuntimeStateResponse | null,
|
||||||
|
runtimeName: string
|
||||||
|
): SyntheticAgent | null => {
|
||||||
|
const name = resolveOptionalString(state?.identity?.name);
|
||||||
|
const role = resolveOptionalString(state?.identity?.role) ?? "assistant";
|
||||||
|
const lane = resolveOptionalString(state?.identity?.lane);
|
||||||
|
if (!name && !lane && !role) return null;
|
||||||
|
return {
|
||||||
|
id: lane ?? role ?? "main",
|
||||||
|
name: name ?? titleCase(lane ?? runtimeName),
|
||||||
|
role,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildChatFailureMessage = (
|
||||||
|
statusCode: number,
|
||||||
|
responseText: string,
|
||||||
|
health: CustomRuntimeHealthResponse | null
|
||||||
|
): string => {
|
||||||
|
const trimmed = responseText.trim();
|
||||||
|
if (trimmed) return trimmed;
|
||||||
|
const healthStatus = resolveOptionalString(health?.status);
|
||||||
|
if (healthStatus) {
|
||||||
|
return `Custom runtime chat failed (${statusCode}). Runtime health is ${healthStatus}.`;
|
||||||
|
}
|
||||||
|
return `Custom runtime chat failed (${statusCode}).`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildSyntheticAgents = (
|
||||||
|
state: CustomRuntimeStateResponse | null,
|
||||||
|
runtimeName: string
|
||||||
|
): SyntheticAgent[] => {
|
||||||
|
const active = isRecord(state?.active) ? state.active : null;
|
||||||
|
if (active) {
|
||||||
|
const agents: SyntheticAgent[] = [];
|
||||||
|
for (const [roleKey, value] of Object.entries(active)) {
|
||||||
|
const role = roleKey.trim();
|
||||||
|
if (!role) continue;
|
||||||
|
const hasModels =
|
||||||
|
(typeof value === "string" && value.trim()) ||
|
||||||
|
(Array.isArray(value) && value.some((entry) => typeof entry === "string" && entry.trim()));
|
||||||
|
if (!hasModels) continue;
|
||||||
|
agents.push({
|
||||||
|
id: role,
|
||||||
|
name: titleCase(role),
|
||||||
|
role,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (agents.length > 0) {
|
||||||
|
return agents;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const identityAgent = buildIdentityAgent(state, runtimeName);
|
||||||
|
if (identityAgent) {
|
||||||
|
return [identityAgent];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "main",
|
||||||
|
name: runtimeName,
|
||||||
|
role: "assistant",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
export class CustomRuntimeProvider implements RuntimeProvider {
|
||||||
|
readonly id = "custom" as const;
|
||||||
|
readonly label = "Custom";
|
||||||
|
readonly capabilities = CUSTOM_RUNTIME_CAPABILITIES;
|
||||||
|
readonly metadata;
|
||||||
|
private readonly baseUrl: string;
|
||||||
|
private readonly sessions = new Map<string, SessionRecord>();
|
||||||
|
private readonly activeRunsByRunId = new Map<string, ActiveRunRecord>();
|
||||||
|
private readonly activeRunIdBySessionKey = new Map<string, string>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly client: GatewayClient,
|
||||||
|
runtimeUrl: string
|
||||||
|
) {
|
||||||
|
this.baseUrl = normalizeCustomBaseUrl(runtimeUrl);
|
||||||
|
this.metadata = {
|
||||||
|
id: this.id,
|
||||||
|
label: this.label,
|
||||||
|
runtimeName: "Custom Runtime",
|
||||||
|
routeProfile: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
connect(options: GatewayConnectOptions): Promise<void> {
|
||||||
|
return this.client.connect(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect(): void {
|
||||||
|
this.client.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
async call<T = unknown>(method: string, params: unknown): Promise<T> {
|
||||||
|
switch (method) {
|
||||||
|
case "agents.list":
|
||||||
|
return (await this.callAgentsList()) as T;
|
||||||
|
case "sessions.list":
|
||||||
|
return (await this.callSessionsList(params)) as T;
|
||||||
|
case "status":
|
||||||
|
return (await this.callStatus()) as T;
|
||||||
|
case "models.list":
|
||||||
|
return (await this.callModelsList()) as T;
|
||||||
|
case "sessions.preview":
|
||||||
|
return (await this.callSessionsPreview(params)) as T;
|
||||||
|
case "chat.history":
|
||||||
|
return (await this.callChatHistory(params)) as T;
|
||||||
|
case "chat.send":
|
||||||
|
return (await this.callChatSend(params)) as T;
|
||||||
|
case "chat.abort":
|
||||||
|
return (await this.callChatAbort(params)) as T;
|
||||||
|
case "sessions.reset":
|
||||||
|
return (await this.callSessionsReset(params)) as T;
|
||||||
|
case "agent.wait":
|
||||||
|
return (await this.callAgentWait(params)) as T;
|
||||||
|
case "exec.approvals.get":
|
||||||
|
return ({ file: { agents: {} } } as T);
|
||||||
|
case "config.get":
|
||||||
|
case "config.patch":
|
||||||
|
case "config.set":
|
||||||
|
throw new Error(`Custom runtime does not support ${method}.`);
|
||||||
|
default:
|
||||||
|
throw new Error(`Custom runtime does not implement ${method}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onStatus(handler: (status: GatewayStatus) => void): () => void {
|
||||||
|
return this.client.onStatus(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
onGap(handler: (info: GatewayGapInfo) => void): () => void {
|
||||||
|
return this.client.onGap(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
onEvent(handler: (event: EventFrame) => void): () => void {
|
||||||
|
return this.client.onEvent(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
onRuntimeEvent(_handler: (event: RuntimeEvent) => void): () => void {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchHealth(): Promise<CustomRuntimeHealthResponse> {
|
||||||
|
return this.fetchJson<CustomRuntimeHealthResponse>("/health");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchState(): Promise<CustomRuntimeStateResponse> {
|
||||||
|
return this.fetchJson<CustomRuntimeStateResponse>("/state");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchRegistry(): Promise<CustomRuntimeRegistryResponse> {
|
||||||
|
return this.fetchJson<CustomRuntimeRegistryResponse>("/registry");
|
||||||
|
}
|
||||||
|
|
||||||
|
async describeRuntime() {
|
||||||
|
const [health, state, registry] = await Promise.all([
|
||||||
|
this.fetchHealth().catch(() => null),
|
||||||
|
this.fetchState().catch(() => null),
|
||||||
|
this.fetchRegistry().catch(() => null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const routeProfile = resolveRouteProfile(state);
|
||||||
|
const runtimeName =
|
||||||
|
typeof state?.runtime?.name === "string" && state.runtime.name.trim()
|
||||||
|
? state.runtime.name.trim()
|
||||||
|
: this.metadata.runtimeName;
|
||||||
|
const runtimeVersion =
|
||||||
|
typeof state?.runtime?.version === "string" && state.runtime.version.trim()
|
||||||
|
? state.runtime.version.trim()
|
||||||
|
: null;
|
||||||
|
const vendor =
|
||||||
|
typeof state?.runtime?.vendor === "string" && state.runtime.vendor.trim()
|
||||||
|
? state.runtime.vendor.trim()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
metadata: {
|
||||||
|
...this.metadata,
|
||||||
|
runtimeName,
|
||||||
|
runtimeVersion,
|
||||||
|
vendor,
|
||||||
|
routeProfile,
|
||||||
|
},
|
||||||
|
health,
|
||||||
|
state,
|
||||||
|
registry,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async callAgentsList() {
|
||||||
|
const descriptor = await this.describeRuntime();
|
||||||
|
const runtimeName = descriptor.metadata.runtimeName ?? this.metadata.runtimeName ?? "Custom Runtime";
|
||||||
|
const agents = buildSyntheticAgents(descriptor.state, runtimeName);
|
||||||
|
return {
|
||||||
|
defaultId: agents[0]?.id ?? "main",
|
||||||
|
mainKey: "main",
|
||||||
|
scope: "custom",
|
||||||
|
agents: agents.map((agent) => ({
|
||||||
|
id: agent.id,
|
||||||
|
name: agent.name,
|
||||||
|
role: agent.role,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async callSessionsList(rawParams: unknown) {
|
||||||
|
const params = isRecord(rawParams) ? rawParams : {};
|
||||||
|
const agentId = typeof params.agentId === "string" ? params.agentId.trim() : "";
|
||||||
|
const descriptor = await this.describeRuntime();
|
||||||
|
const modelChoices = normalizeModelChoices(descriptor.registry);
|
||||||
|
const sessions = agentId
|
||||||
|
? [this.ensureSession(agentId, agentId, resolveDefaultModelId(descriptor.state, modelChoices))]
|
||||||
|
: [...this.sessions.values()];
|
||||||
|
return {
|
||||||
|
sessions: sessions.map((session) => ({
|
||||||
|
key: session.sessionKey,
|
||||||
|
updatedAt: session.updatedAt,
|
||||||
|
displayName: session.agentId,
|
||||||
|
origin: {
|
||||||
|
label: descriptor.metadata.runtimeName ?? "Custom Runtime",
|
||||||
|
provider: "custom",
|
||||||
|
},
|
||||||
|
modelProvider: "custom",
|
||||||
|
model: session.model,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async callStatus() {
|
||||||
|
return {
|
||||||
|
sessions: {
|
||||||
|
recent: [...this.sessions.values()].map((session) => ({
|
||||||
|
key: session.sessionKey,
|
||||||
|
updatedAt: session.updatedAt,
|
||||||
|
})),
|
||||||
|
byAgent: [...this.sessions.values()].map((session) => ({
|
||||||
|
agentId: session.agentId,
|
||||||
|
recent: [
|
||||||
|
{
|
||||||
|
key: session.sessionKey,
|
||||||
|
updatedAt: session.updatedAt,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async callModelsList() {
|
||||||
|
const descriptor = await this.describeRuntime();
|
||||||
|
const modelIds = normalizeModelChoices(descriptor.registry);
|
||||||
|
return {
|
||||||
|
models: modelIds.map((id) => ({
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
provider: "custom",
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async callSessionsPreview(rawParams: unknown) {
|
||||||
|
const params = isRecord(rawParams) ? rawParams : {};
|
||||||
|
const keys = Array.isArray(params.keys)
|
||||||
|
? params.keys.filter((value): value is string => typeof value === "string")
|
||||||
|
: [];
|
||||||
|
return {
|
||||||
|
ts: Date.now(),
|
||||||
|
previews: keys.map((key) => {
|
||||||
|
const session = this.sessions.get(key) ?? null;
|
||||||
|
const items = session
|
||||||
|
? session.messages.slice(-8).map((message) => ({
|
||||||
|
role: message.role,
|
||||||
|
text: message.text,
|
||||||
|
timestamp: message.timestamp,
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
status: items.length > 0 ? "ok" : "empty",
|
||||||
|
items,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async callChatHistory(rawParams: unknown) {
|
||||||
|
const params = isRecord(rawParams) ? rawParams : {};
|
||||||
|
const sessionKey = typeof params.sessionKey === "string" ? params.sessionKey.trim() : "";
|
||||||
|
if (!sessionKey) {
|
||||||
|
throw new Error("Custom runtime requires sessionKey for chat.history.");
|
||||||
|
}
|
||||||
|
const session = this.sessions.get(sessionKey) ?? null;
|
||||||
|
return {
|
||||||
|
sessionKey,
|
||||||
|
messages: (session?.messages ?? []).map((message) => ({
|
||||||
|
role: message.role,
|
||||||
|
content: message.text,
|
||||||
|
timestamp: message.timestamp,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async callChatSend(rawParams: unknown) {
|
||||||
|
const params = isRecord(rawParams) ? rawParams : {};
|
||||||
|
const sessionKey = typeof params.sessionKey === "string" ? params.sessionKey.trim() : "";
|
||||||
|
const message = typeof params.message === "string" ? params.message.trim() : "";
|
||||||
|
const runId = typeof params.idempotencyKey === "string" ? params.idempotencyKey.trim() : "";
|
||||||
|
if (!sessionKey || !message) {
|
||||||
|
throw new Error("Custom runtime requires sessionKey and message for chat.send.");
|
||||||
|
}
|
||||||
|
const agentId = parseAgentIdFromSessionKey(sessionKey) ?? "main";
|
||||||
|
const descriptor = await this.describeRuntime();
|
||||||
|
const modelChoices = normalizeModelChoices(descriptor.registry);
|
||||||
|
const session = this.ensureSession(
|
||||||
|
sessionKey,
|
||||||
|
agentId,
|
||||||
|
resolveDefaultModelId(descriptor.state, modelChoices)
|
||||||
|
);
|
||||||
|
const resolvedRole =
|
||||||
|
session.role ??
|
||||||
|
resolveOptionalString(descriptor.state?.identity?.role) ??
|
||||||
|
undefined;
|
||||||
|
const resolvedLane =
|
||||||
|
resolveOptionalString(descriptor.state?.identity?.lane) ??
|
||||||
|
session.role ??
|
||||||
|
undefined;
|
||||||
|
const controller = new AbortController();
|
||||||
|
if (runId) {
|
||||||
|
const activeRun: ActiveRunRecord = { runId, sessionKey, controller };
|
||||||
|
this.activeRunsByRunId.set(runId, activeRun);
|
||||||
|
this.activeRunIdBySessionKey.set(sessionKey, runId);
|
||||||
|
}
|
||||||
|
const userTimestamp = Date.now();
|
||||||
|
session.messages.push({
|
||||||
|
role: "user",
|
||||||
|
text: message,
|
||||||
|
timestamp: userTimestamp,
|
||||||
|
});
|
||||||
|
session.updatedAt = userTimestamp;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = (await requestCustomRuntime({
|
||||||
|
runtimeUrl: this.baseUrl,
|
||||||
|
pathname: "/v1/chat/completions",
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
model: session.model ?? undefined,
|
||||||
|
stream: false,
|
||||||
|
role: resolvedRole,
|
||||||
|
lane: resolvedLane,
|
||||||
|
conversation_id: sessionKey,
|
||||||
|
session_id: sessionKey,
|
||||||
|
messages: session.messages.map((entry) => ({
|
||||||
|
role: entry.role,
|
||||||
|
content: entry.text,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
})) as unknown;
|
||||||
|
const assistantText = resolveAssistantTextFromResponse(payload);
|
||||||
|
if (!assistantText) {
|
||||||
|
throw new Error("Custom runtime returned an empty assistant response.");
|
||||||
|
}
|
||||||
|
const assistantTimestamp = Date.now();
|
||||||
|
session.messages.push({
|
||||||
|
role: "assistant",
|
||||||
|
text: assistantText,
|
||||||
|
timestamp: assistantTimestamp,
|
||||||
|
});
|
||||||
|
session.updatedAt = assistantTimestamp;
|
||||||
|
return {
|
||||||
|
status: "completed",
|
||||||
|
runId: runId || null,
|
||||||
|
text: assistantText,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const health = await this.fetchHealth().catch(() => null);
|
||||||
|
throw new Error(
|
||||||
|
buildChatFailureMessage(
|
||||||
|
502,
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
health
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (runId) {
|
||||||
|
this.activeRunsByRunId.delete(runId);
|
||||||
|
const activeSessionRunId = this.activeRunIdBySessionKey.get(sessionKey);
|
||||||
|
if (activeSessionRunId === runId) {
|
||||||
|
this.activeRunIdBySessionKey.delete(sessionKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async callChatAbort(rawParams: unknown) {
|
||||||
|
const params = isRecord(rawParams) ? rawParams : {};
|
||||||
|
const runId = typeof params.runId === "string" ? params.runId.trim() : "";
|
||||||
|
const sessionKey = typeof params.sessionKey === "string" ? params.sessionKey.trim() : "";
|
||||||
|
const targetRunId = runId || (sessionKey ? this.activeRunIdBySessionKey.get(sessionKey) ?? "" : "");
|
||||||
|
if (!targetRunId) {
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
const activeRun = this.activeRunsByRunId.get(targetRunId) ?? null;
|
||||||
|
activeRun?.controller.abort();
|
||||||
|
this.activeRunsByRunId.delete(targetRunId);
|
||||||
|
if (activeRun?.sessionKey) {
|
||||||
|
const activeSessionRunId = this.activeRunIdBySessionKey.get(activeRun.sessionKey);
|
||||||
|
if (activeSessionRunId === targetRunId) {
|
||||||
|
this.activeRunIdBySessionKey.delete(activeRun.sessionKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async callSessionsReset(rawParams: unknown) {
|
||||||
|
const params = isRecord(rawParams) ? rawParams : {};
|
||||||
|
const key = typeof params.key === "string" ? params.key.trim() : "";
|
||||||
|
if (!key) {
|
||||||
|
throw new Error("Custom runtime requires key for sessions.reset.");
|
||||||
|
}
|
||||||
|
this.sessions.delete(key);
|
||||||
|
const activeRunId = this.activeRunIdBySessionKey.get(key);
|
||||||
|
if (activeRunId) {
|
||||||
|
this.activeRunsByRunId.get(activeRunId)?.controller.abort();
|
||||||
|
this.activeRunsByRunId.delete(activeRunId);
|
||||||
|
this.activeRunIdBySessionKey.delete(key);
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async callAgentWait(rawParams: unknown) {
|
||||||
|
const params = isRecord(rawParams) ? rawParams : {};
|
||||||
|
const runId = typeof params.runId === "string" ? params.runId.trim() : "";
|
||||||
|
return {
|
||||||
|
status: runId && this.activeRunsByRunId.has(runId) ? "running" : "done",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureSession(sessionKey: string, agentId: string, model: string | null): SessionRecord {
|
||||||
|
const existing = this.sessions.get(sessionKey);
|
||||||
|
if (existing) return existing;
|
||||||
|
const session: SessionRecord = {
|
||||||
|
sessionKey,
|
||||||
|
agentId,
|
||||||
|
role: agentId || null,
|
||||||
|
model,
|
||||||
|
updatedAt: null,
|
||||||
|
messages: [],
|
||||||
|
};
|
||||||
|
this.sessions.set(sessionKey, session);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchJson<T = unknown>(pathname: string): Promise<T> {
|
||||||
|
return fetchCustomRuntimeJson<T>(this.baseUrl, pathname);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import type {
|
||||||
|
EventFrame,
|
||||||
|
GatewayConnectOptions,
|
||||||
|
GatewayGapInfo,
|
||||||
|
GatewayStatus,
|
||||||
|
} from "@/lib/gateway/GatewayClient";
|
||||||
|
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||||
|
import { normalizeGatewayEvent } from "@/lib/runtime/openclaw/normalizeGatewayEvent";
|
||||||
|
import type { RuntimeCapability, RuntimeEvent, RuntimeProvider } from "@/lib/runtime/types";
|
||||||
|
|
||||||
|
const DEMO_RUNTIME_CAPABILITIES: ReadonlySet<RuntimeCapability> = new Set([
|
||||||
|
"agents",
|
||||||
|
"sessions",
|
||||||
|
"chat",
|
||||||
|
"streaming",
|
||||||
|
"approvals",
|
||||||
|
"config",
|
||||||
|
"models",
|
||||||
|
"files",
|
||||||
|
"agent-roles",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export class DemoRuntimeProvider implements RuntimeProvider {
|
||||||
|
readonly id = "demo" as const;
|
||||||
|
readonly label = "Demo";
|
||||||
|
readonly metadata = {
|
||||||
|
id: this.id,
|
||||||
|
label: this.label,
|
||||||
|
runtimeName: "Demo",
|
||||||
|
} as const;
|
||||||
|
readonly capabilities = DEMO_RUNTIME_CAPABILITIES;
|
||||||
|
|
||||||
|
constructor(readonly client: GatewayClient) {}
|
||||||
|
|
||||||
|
connect(options: GatewayConnectOptions): Promise<void> {
|
||||||
|
return this.client.connect(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect(): void {
|
||||||
|
this.client.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
call<T = unknown>(method: string, params: unknown): Promise<T> {
|
||||||
|
return this.client.call<T>(method, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
onStatus(handler: (status: GatewayStatus) => void): () => void {
|
||||||
|
return this.client.onStatus(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
onGap(handler: (info: GatewayGapInfo) => void): () => void {
|
||||||
|
return this.client.onGap(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
onEvent(handler: (event: EventFrame) => void): () => void {
|
||||||
|
return this.client.onEvent(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
onRuntimeEvent(handler: (event: RuntimeEvent) => void): () => void {
|
||||||
|
return this.client.onEvent((event) => {
|
||||||
|
handler(normalizeGatewayEvent(event));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import type {
|
||||||
|
EventFrame,
|
||||||
|
GatewayConnectOptions,
|
||||||
|
GatewayGapInfo,
|
||||||
|
GatewayStatus,
|
||||||
|
} from "@/lib/gateway/GatewayClient";
|
||||||
|
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||||
|
import { normalizeGatewayEvent } from "@/lib/runtime/openclaw/normalizeGatewayEvent";
|
||||||
|
import type { RuntimeCapability, RuntimeEvent, RuntimeProvider } from "@/lib/runtime/types";
|
||||||
|
|
||||||
|
const HERMES_RUNTIME_CAPABILITIES: ReadonlySet<RuntimeCapability> = new Set([
|
||||||
|
"agents",
|
||||||
|
"sessions",
|
||||||
|
"chat",
|
||||||
|
"streaming",
|
||||||
|
"approvals",
|
||||||
|
"config",
|
||||||
|
"models",
|
||||||
|
"skills",
|
||||||
|
"cron",
|
||||||
|
"files",
|
||||||
|
"agent-roles",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export class HermesRuntimeProvider implements RuntimeProvider {
|
||||||
|
readonly id = "hermes" as const;
|
||||||
|
readonly label = "Hermes";
|
||||||
|
readonly metadata = {
|
||||||
|
id: this.id,
|
||||||
|
label: this.label,
|
||||||
|
runtimeName: "Hermes",
|
||||||
|
} as const;
|
||||||
|
readonly capabilities = HERMES_RUNTIME_CAPABILITIES;
|
||||||
|
|
||||||
|
constructor(readonly client: GatewayClient) {}
|
||||||
|
|
||||||
|
connect(options: GatewayConnectOptions): Promise<void> {
|
||||||
|
return this.client.connect(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect(): void {
|
||||||
|
this.client.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
call<T = unknown>(method: string, params: unknown): Promise<T> {
|
||||||
|
return this.client.call<T>(method, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
onStatus(handler: (status: GatewayStatus) => void): () => void {
|
||||||
|
return this.client.onStatus(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
onGap(handler: (info: GatewayGapInfo) => void): () => void {
|
||||||
|
return this.client.onGap(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
onEvent(handler: (event: EventFrame) => void): () => void {
|
||||||
|
return this.client.onEvent(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
onRuntimeEvent(handler: (event: RuntimeEvent) => void): () => void {
|
||||||
|
return this.client.onEvent((event) => {
|
||||||
|
handler(normalizeGatewayEvent(event));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import type { EventFrame } from "@/lib/gateway/GatewayClient";
|
||||||
|
import type { RuntimeEvent } from "@/lib/runtime/types";
|
||||||
|
|
||||||
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||||
|
|
||||||
|
const coerceTimestamp = (value: unknown): number | null => {
|
||||||
|
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const parsed = Date.parse(value);
|
||||||
|
if (Number.isFinite(parsed) && parsed > 0) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveTimestamp = (payload: Record<string, unknown> | null): number => {
|
||||||
|
if (!payload) return Date.now();
|
||||||
|
return (
|
||||||
|
coerceTimestamp(payload.timestamp) ??
|
||||||
|
coerceTimestamp(payload.createdAt) ??
|
||||||
|
coerceTimestamp(payload.updatedAt) ??
|
||||||
|
coerceTimestamp(payload.at) ??
|
||||||
|
Date.now()
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveChatText = (payload: Record<string, unknown> | null): string | null => {
|
||||||
|
if (!payload) return null;
|
||||||
|
const message = isRecord(payload.message) ? payload.message : null;
|
||||||
|
const directText = typeof payload.text === "string" ? payload.text.trim() : "";
|
||||||
|
if (directText) return directText;
|
||||||
|
const content = typeof message?.content === "string" ? message.content.trim() : "";
|
||||||
|
return content || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeGatewayEvent = (frame: EventFrame): RuntimeEvent => {
|
||||||
|
const payload = isRecord(frame.payload) ? frame.payload : null;
|
||||||
|
const at = resolveTimestamp(payload);
|
||||||
|
|
||||||
|
if (frame.event === "presence" || frame.event === "heartbeat") {
|
||||||
|
return {
|
||||||
|
type: "summary-refresh",
|
||||||
|
at,
|
||||||
|
frame,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frame.event === "chat") {
|
||||||
|
const state = typeof payload?.state === "string" ? payload.state.trim() : "";
|
||||||
|
const runId = typeof payload?.runId === "string" ? payload.runId.trim() || null : null;
|
||||||
|
const sessionKey =
|
||||||
|
typeof payload?.sessionKey === "string" ? payload.sessionKey.trim() || null : null;
|
||||||
|
const text = resolveChatText(payload);
|
||||||
|
if (state === "delta") {
|
||||||
|
return {
|
||||||
|
type: "chat.delta",
|
||||||
|
at,
|
||||||
|
frame,
|
||||||
|
runId,
|
||||||
|
sessionKey,
|
||||||
|
text,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (state === "final") {
|
||||||
|
return {
|
||||||
|
type: "chat.final",
|
||||||
|
at,
|
||||||
|
frame,
|
||||||
|
runId,
|
||||||
|
sessionKey,
|
||||||
|
text,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (state === "error") {
|
||||||
|
return {
|
||||||
|
type: "chat.error",
|
||||||
|
at,
|
||||||
|
frame,
|
||||||
|
runId,
|
||||||
|
sessionKey,
|
||||||
|
text,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (state === "aborted") {
|
||||||
|
return {
|
||||||
|
type: "chat.aborted",
|
||||||
|
at,
|
||||||
|
frame,
|
||||||
|
runId,
|
||||||
|
sessionKey,
|
||||||
|
text,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frame.event === "agent") {
|
||||||
|
const stream = typeof payload?.stream === "string" ? payload.stream.trim() : "";
|
||||||
|
const data = isRecord(payload?.data) ? payload.data : null;
|
||||||
|
const phase = typeof data?.phase === "string" ? data.phase.trim() : "";
|
||||||
|
if (stream === "lifecycle" && (phase === "start" || phase === "end" || phase === "error")) {
|
||||||
|
return {
|
||||||
|
type: "run.lifecycle",
|
||||||
|
at,
|
||||||
|
frame,
|
||||||
|
runId: typeof payload?.runId === "string" ? payload.runId.trim() || null : null,
|
||||||
|
sessionKey:
|
||||||
|
typeof payload?.sessionKey === "string" ? payload.sessionKey.trim() || null : null,
|
||||||
|
phase,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "unknown",
|
||||||
|
at,
|
||||||
|
frame,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type {
|
||||||
|
EventFrame,
|
||||||
|
GatewayConnectOptions,
|
||||||
|
GatewayGapInfo,
|
||||||
|
GatewayStatus,
|
||||||
|
} from "@/lib/gateway/GatewayClient";
|
||||||
|
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||||
|
import { normalizeGatewayEvent } from "@/lib/runtime/openclaw/normalizeGatewayEvent";
|
||||||
|
import type { RuntimeCapability, RuntimeEvent, RuntimeProvider } from "@/lib/runtime/types";
|
||||||
|
|
||||||
|
const OPENCLAW_RUNTIME_CAPABILITIES: ReadonlySet<RuntimeCapability> = new Set([
|
||||||
|
"agents",
|
||||||
|
"sessions",
|
||||||
|
"chat",
|
||||||
|
"streaming",
|
||||||
|
"runtime-agent-events",
|
||||||
|
"approvals",
|
||||||
|
"config",
|
||||||
|
"models",
|
||||||
|
"skills",
|
||||||
|
"cron",
|
||||||
|
"files",
|
||||||
|
"agent-roles",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export class OpenClawRuntimeProvider implements RuntimeProvider {
|
||||||
|
readonly id = "openclaw" as const;
|
||||||
|
readonly label = "OpenClaw";
|
||||||
|
readonly metadata = {
|
||||||
|
id: this.id,
|
||||||
|
label: this.label,
|
||||||
|
runtimeName: "OpenClaw",
|
||||||
|
} as const;
|
||||||
|
readonly capabilities = OPENCLAW_RUNTIME_CAPABILITIES;
|
||||||
|
|
||||||
|
constructor(readonly client: GatewayClient) {}
|
||||||
|
|
||||||
|
connect(options: GatewayConnectOptions): Promise<void> {
|
||||||
|
return this.client.connect(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect(): void {
|
||||||
|
this.client.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
call<T = unknown>(method: string, params: unknown): Promise<T> {
|
||||||
|
return this.client.call<T>(method, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
onStatus(handler: (status: GatewayStatus) => void): () => void {
|
||||||
|
return this.client.onStatus(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
onGap(handler: (info: GatewayGapInfo) => void): () => void {
|
||||||
|
return this.client.onGap(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
onEvent(handler: (event: EventFrame) => void): () => void {
|
||||||
|
return this.client.onEvent(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
onRuntimeEvent(handler: (event: RuntimeEvent) => void): () => void {
|
||||||
|
return this.client.onEvent((event) => {
|
||||||
|
handler(normalizeGatewayEvent(event));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import type {
|
||||||
|
EventFrame,
|
||||||
|
GatewayClient,
|
||||||
|
GatewayConnectOptions,
|
||||||
|
GatewayGapInfo,
|
||||||
|
GatewayStatus,
|
||||||
|
} from "@/lib/gateway/GatewayClient";
|
||||||
|
|
||||||
|
export type RuntimeCapability =
|
||||||
|
| "agents"
|
||||||
|
| "sessions"
|
||||||
|
| "chat"
|
||||||
|
| "streaming"
|
||||||
|
| "runtime-agent-events"
|
||||||
|
| "approvals"
|
||||||
|
| "config"
|
||||||
|
| "models"
|
||||||
|
| "skills"
|
||||||
|
| "cron"
|
||||||
|
| "files"
|
||||||
|
| "agent-roles";
|
||||||
|
|
||||||
|
export type RuntimeProviderId = "openclaw" | "hermes" | "demo" | "custom";
|
||||||
|
|
||||||
|
export type RuntimeProviderMetadata = {
|
||||||
|
id: RuntimeProviderId;
|
||||||
|
label: string;
|
||||||
|
runtimeName?: string | null;
|
||||||
|
vendor?: string | null;
|
||||||
|
runtimeVersion?: string | null;
|
||||||
|
routeProfile?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RuntimeSummaryEvent = {
|
||||||
|
type: "summary-refresh";
|
||||||
|
at: number;
|
||||||
|
frame: EventFrame;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RuntimeChatEvent =
|
||||||
|
| {
|
||||||
|
type: "chat.delta";
|
||||||
|
at: number;
|
||||||
|
frame: EventFrame;
|
||||||
|
runId: string | null;
|
||||||
|
sessionKey: string | null;
|
||||||
|
text: string | null;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "chat.final" | "chat.error" | "chat.aborted";
|
||||||
|
at: number;
|
||||||
|
frame: EventFrame;
|
||||||
|
runId: string | null;
|
||||||
|
sessionKey: string | null;
|
||||||
|
text: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RuntimeLifecycleEvent = {
|
||||||
|
type: "run.lifecycle";
|
||||||
|
at: number;
|
||||||
|
frame: EventFrame;
|
||||||
|
runId: string | null;
|
||||||
|
sessionKey: string | null;
|
||||||
|
phase: "start" | "end" | "error";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RuntimeUnknownEvent = {
|
||||||
|
type: "unknown";
|
||||||
|
at: number;
|
||||||
|
frame: EventFrame;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RuntimeEvent =
|
||||||
|
| RuntimeSummaryEvent
|
||||||
|
| RuntimeChatEvent
|
||||||
|
| RuntimeLifecycleEvent
|
||||||
|
| RuntimeUnknownEvent;
|
||||||
|
|
||||||
|
export type RuntimeStatus = GatewayStatus;
|
||||||
|
|
||||||
|
export interface RuntimeProvider {
|
||||||
|
readonly id: RuntimeProviderId;
|
||||||
|
readonly label: string;
|
||||||
|
readonly metadata: RuntimeProviderMetadata;
|
||||||
|
readonly capabilities: ReadonlySet<RuntimeCapability>;
|
||||||
|
readonly client: GatewayClient;
|
||||||
|
connect(options: GatewayConnectOptions): Promise<void>;
|
||||||
|
disconnect(): void;
|
||||||
|
call<T = unknown>(method: string, params: unknown): Promise<T>;
|
||||||
|
onStatus(handler: (status: RuntimeStatus) => void): () => void;
|
||||||
|
onGap(handler: (info: GatewayGapInfo) => void): () => void;
|
||||||
|
onEvent(handler: (event: EventFrame) => void): () => void;
|
||||||
|
onRuntimeEvent(handler: (event: RuntimeEvent) => void): () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hasRuntimeCapability = (
|
||||||
|
capabilities: ReadonlySet<RuntimeCapability>,
|
||||||
|
capability: RuntimeCapability
|
||||||
|
): boolean => capabilities.has(capability);
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
import { type GatewayConnectionState, useGatewayConnection } from "@/lib/gateway/GatewayClient";
|
||||||
|
import { createRuntimeProvider } from "@/lib/runtime/createRuntimeProvider";
|
||||||
|
import {
|
||||||
|
hasRuntimeCapability,
|
||||||
|
type RuntimeCapability,
|
||||||
|
type RuntimeProvider,
|
||||||
|
} from "@/lib/runtime/types";
|
||||||
|
import type { StudioSettingsCoordinator } from "@/lib/studio/coordinator";
|
||||||
|
|
||||||
|
export type RuntimeConnectionState = GatewayConnectionState & {
|
||||||
|
provider: RuntimeProvider;
|
||||||
|
providerId: RuntimeProvider["id"];
|
||||||
|
providerLabel: string;
|
||||||
|
providerMetadata: RuntimeProvider["metadata"];
|
||||||
|
capabilities: ReadonlySet<RuntimeCapability>;
|
||||||
|
supportsCapability: (capability: RuntimeCapability) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useRuntimeConnection = (
|
||||||
|
settingsCoordinator: StudioSettingsCoordinator
|
||||||
|
): RuntimeConnectionState => {
|
||||||
|
const gateway = useGatewayConnection(settingsCoordinator);
|
||||||
|
const provider = useMemo(
|
||||||
|
() => createRuntimeProvider(gateway.activeAdapterType, gateway.client, gateway.gatewayUrl),
|
||||||
|
[gateway.activeAdapterType, gateway.client, gateway.gatewayUrl]
|
||||||
|
);
|
||||||
|
const capabilities = provider.capabilities;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...gateway,
|
||||||
|
provider,
|
||||||
|
providerId: provider.id,
|
||||||
|
providerLabel: provider.label,
|
||||||
|
providerMetadata: provider.metadata,
|
||||||
|
capabilities,
|
||||||
|
supportsCapability: (capability) => hasRuntimeCapability(capabilities, capability),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -23,7 +23,11 @@ export const resolveStudioSettingsPath = () =>
|
|||||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
Boolean(value && typeof value === "object");
|
Boolean(value && typeof value === "object");
|
||||||
|
|
||||||
const readOpenclawGatewayDefaults = (): { url: string; token: string } | null => {
|
const readOpenclawGatewayDefaults = (): {
|
||||||
|
url: string;
|
||||||
|
token: string;
|
||||||
|
adapterType: "openclaw";
|
||||||
|
} | null => {
|
||||||
try {
|
try {
|
||||||
const configPath = path.join(resolveStateDir(), OPENCLAW_CONFIG_FILENAME);
|
const configPath = path.join(resolveStateDir(), OPENCLAW_CONFIG_FILENAME);
|
||||||
if (!fs.existsSync(configPath)) return null;
|
if (!fs.existsSync(configPath)) return null;
|
||||||
@@ -38,20 +42,24 @@ const readOpenclawGatewayDefaults = (): { url: string; token: string } | null =>
|
|||||||
if (!token) return null;
|
if (!token) return null;
|
||||||
const url = port ? `ws://localhost:${port}` : "";
|
const url = port ? `ws://localhost:${port}` : "";
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
return { url, token };
|
return { url, token, adapterType: "openclaw" };
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loadLocalGatewayDefaults = (): { url: string; token: string } | null => {
|
export const loadLocalGatewayDefaults = (): {
|
||||||
|
url: string;
|
||||||
|
token: string;
|
||||||
|
adapterType: "openclaw";
|
||||||
|
} | null => {
|
||||||
const fromFile = readOpenclawGatewayDefaults();
|
const fromFile = readOpenclawGatewayDefaults();
|
||||||
if (fromFile) return fromFile;
|
if (fromFile) return fromFile;
|
||||||
// Fall back to env vars so operators can configure the gateway URL at
|
// Fall back to env vars so operators can configure the gateway URL at
|
||||||
// runtime without openclaw.json and without a rebuild.
|
// runtime without openclaw.json and without a rebuild.
|
||||||
const envUrl = process.env.CLAW3D_GATEWAY_URL?.trim();
|
const envUrl = process.env.CLAW3D_GATEWAY_URL?.trim();
|
||||||
const envToken = process.env.CLAW3D_GATEWAY_TOKEN?.trim();
|
const envToken = process.env.CLAW3D_GATEWAY_TOKEN?.trim();
|
||||||
if (envUrl) return { url: envUrl, token: envToken ?? "" };
|
if (envUrl) return { url: envUrl, token: envToken ?? "", adapterType: "openclaw" };
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -71,7 +79,11 @@ export const loadStudioSettings = (): StudioSettings => {
|
|||||||
return {
|
return {
|
||||||
...settings,
|
...settings,
|
||||||
gateway: settings.gateway?.url?.trim()
|
gateway: settings.gateway?.url?.trim()
|
||||||
? { url: settings.gateway.url.trim(), token: gateway.token }
|
? {
|
||||||
|
url: settings.gateway.url.trim(),
|
||||||
|
token: gateway.token,
|
||||||
|
adapterType: settings.gateway.adapterType,
|
||||||
|
}
|
||||||
: gateway,
|
: gateway,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+191
-1
@@ -18,16 +18,60 @@ import {
|
|||||||
export type StudioGatewaySettings = {
|
export type StudioGatewaySettings = {
|
||||||
url: string;
|
url: string;
|
||||||
token: string;
|
token: string;
|
||||||
|
adapterType: StudioGatewayAdapterType;
|
||||||
|
profiles?: Partial<Record<StudioGatewayAdapterType, StudioGatewayProfile>>;
|
||||||
|
lastKnownGood?: StudioGatewayConnectionState;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StudioGatewayAdapterType = "openclaw" | "hermes" | "demo" | "custom";
|
||||||
|
|
||||||
|
export type StudioGatewayProfile = {
|
||||||
|
url: string;
|
||||||
|
token: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StudioGatewayConnectionState = {
|
||||||
|
url: string;
|
||||||
|
token: string;
|
||||||
|
adapterType: StudioGatewayAdapterType;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type StudioGatewaySettingsPublic = {
|
export type StudioGatewaySettingsPublic = {
|
||||||
url: string;
|
url: string;
|
||||||
tokenConfigured: boolean;
|
tokenConfigured: boolean;
|
||||||
|
adapterType: StudioGatewayAdapterType;
|
||||||
|
profiles?: Partial<Record<StudioGatewayAdapterType, StudioGatewayProfilePublic>>;
|
||||||
|
lastKnownGood?: StudioGatewayConnectionStatePublic;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StudioGatewayProfilePublic = {
|
||||||
|
url: string;
|
||||||
|
tokenConfigured: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StudioGatewayConnectionStatePublic = {
|
||||||
|
url: string;
|
||||||
|
tokenConfigured: boolean;
|
||||||
|
adapterType: StudioGatewayAdapterType;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type StudioGatewaySettingsPatch = {
|
export type StudioGatewaySettingsPatch = {
|
||||||
url?: string | null;
|
url?: string | null;
|
||||||
token?: string | null;
|
token?: string | null;
|
||||||
|
adapterType?: StudioGatewayAdapterType | null;
|
||||||
|
profiles?: Partial<Record<StudioGatewayAdapterType, StudioGatewayProfilePatch | null>> | null;
|
||||||
|
lastKnownGood?: StudioGatewayConnectionStatePatch | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StudioGatewayProfilePatch = {
|
||||||
|
url?: string | null;
|
||||||
|
token?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StudioGatewayConnectionStatePatch = {
|
||||||
|
url?: string | null;
|
||||||
|
token?: string | null;
|
||||||
|
adapterType?: StudioGatewayAdapterType | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FocusFilter = "all" | "running" | "approvals";
|
export type FocusFilter = "all" | "running" | "approvals";
|
||||||
@@ -208,7 +252,7 @@ const normalizeGatewayUrl = (value: unknown) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const normalizeGatewayKey = (value: unknown) => {
|
const normalizeGatewayKey = (value: unknown) => {
|
||||||
const key = coerceString(value);
|
const key = normalizeGatewayUrl(value);
|
||||||
return key ? key : null;
|
return key ? key : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -636,6 +680,23 @@ const normalizeFocusedPreference = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const normalizeGatewaySettings = (value: unknown): StudioGatewaySettings | null => {
|
const normalizeGatewaySettings = (value: unknown): StudioGatewaySettings | null => {
|
||||||
|
if (!isRecord(value)) return null;
|
||||||
|
const url = normalizeGatewayUrl(value.url);
|
||||||
|
if (!url) return null;
|
||||||
|
const token = coerceString(value.token);
|
||||||
|
const adapterType = normalizeGatewayAdapterType(value.adapterType);
|
||||||
|
const profiles = normalizeGatewayProfiles(value.profiles);
|
||||||
|
const lastKnownGood = normalizeGatewayConnectionState(value.lastKnownGood);
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
token,
|
||||||
|
adapterType,
|
||||||
|
...(profiles ? { profiles } : {}),
|
||||||
|
...(lastKnownGood ? { lastKnownGood } : {}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeGatewayProfile = (value: unknown): StudioGatewayProfile | null => {
|
||||||
if (!isRecord(value)) return null;
|
if (!isRecord(value)) return null;
|
||||||
const url = normalizeGatewayUrl(value.url);
|
const url = normalizeGatewayUrl(value.url);
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
@@ -643,6 +704,31 @@ const normalizeGatewaySettings = (value: unknown): StudioGatewaySettings | null
|
|||||||
return { url, token };
|
return { url, token };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeGatewayProfiles = (
|
||||||
|
value: unknown
|
||||||
|
): Partial<Record<StudioGatewayAdapterType, StudioGatewayProfile>> | undefined => {
|
||||||
|
if (!isRecord(value)) return undefined;
|
||||||
|
const profiles: Partial<Record<StudioGatewayAdapterType, StudioGatewayProfile>> = {};
|
||||||
|
for (const adapterType of ["openclaw", "hermes", "demo", "custom"] as const) {
|
||||||
|
const normalized = normalizeGatewayProfile(value[adapterType]);
|
||||||
|
if (normalized) {
|
||||||
|
profiles[adapterType] = normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Object.keys(profiles).length > 0 ? profiles : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeGatewayConnectionState = (
|
||||||
|
value: unknown
|
||||||
|
): StudioGatewayConnectionState | null => {
|
||||||
|
if (!isRecord(value)) return null;
|
||||||
|
const url = normalizeGatewayUrl(value.url);
|
||||||
|
if (!url) return null;
|
||||||
|
const token = coerceString(value.token);
|
||||||
|
const adapterType = normalizeGatewayAdapterType(value.adapterType);
|
||||||
|
return { url, token, adapterType };
|
||||||
|
};
|
||||||
|
|
||||||
const mergeGatewaySettings = (
|
const mergeGatewaySettings = (
|
||||||
current: StudioGatewaySettings | null,
|
current: StudioGatewaySettings | null,
|
||||||
patch: StudioGatewaySettingsPatch | null,
|
patch: StudioGatewaySettingsPatch | null,
|
||||||
@@ -653,12 +739,97 @@ const mergeGatewaySettings = (
|
|||||||
if (!nextUrl) return null;
|
if (!nextUrl) return null;
|
||||||
const nextToken =
|
const nextToken =
|
||||||
patch.token === undefined ? current?.token ?? "" : coerceString(patch.token);
|
patch.token === undefined ? current?.token ?? "" : coerceString(patch.token);
|
||||||
|
const nextAdapterType =
|
||||||
|
patch.adapterType === undefined
|
||||||
|
? current?.adapterType ?? "openclaw"
|
||||||
|
: normalizeGatewayAdapterType(patch.adapterType);
|
||||||
|
const nextProfiles = mergeGatewayProfiles(current?.profiles, patch.profiles);
|
||||||
|
const nextLastKnownGood = mergeGatewayConnectionState(
|
||||||
|
current?.lastKnownGood ?? null,
|
||||||
|
patch.lastKnownGood
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
url: nextUrl,
|
url: nextUrl,
|
||||||
token: nextToken,
|
token: nextToken,
|
||||||
|
adapterType: nextAdapterType,
|
||||||
|
...(nextProfiles ? { profiles: nextProfiles } : {}),
|
||||||
|
...(nextLastKnownGood ? { lastKnownGood: nextLastKnownGood } : {}),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mergeGatewayProfiles = (
|
||||||
|
current: Partial<Record<StudioGatewayAdapterType, StudioGatewayProfile>> | undefined,
|
||||||
|
patch:
|
||||||
|
| Partial<Record<StudioGatewayAdapterType, StudioGatewayProfilePatch | null>>
|
||||||
|
| null
|
||||||
|
| undefined,
|
||||||
|
): Partial<Record<StudioGatewayAdapterType, StudioGatewayProfile>> | undefined => {
|
||||||
|
if (patch === null) return undefined;
|
||||||
|
if (patch === undefined) return current;
|
||||||
|
const next: Partial<Record<StudioGatewayAdapterType, StudioGatewayProfile>> = {
|
||||||
|
...(current ?? {}),
|
||||||
|
};
|
||||||
|
for (const adapterType of ["openclaw", "hermes", "demo", "custom"] as const) {
|
||||||
|
const profilePatch = patch[adapterType];
|
||||||
|
if (profilePatch === undefined) continue;
|
||||||
|
if (profilePatch === null) {
|
||||||
|
delete next[adapterType];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const existing = current?.[adapterType] ?? null;
|
||||||
|
const nextUrl =
|
||||||
|
profilePatch.url === undefined
|
||||||
|
? existing?.url ?? ""
|
||||||
|
: normalizeGatewayUrl(profilePatch.url);
|
||||||
|
if (!nextUrl) {
|
||||||
|
delete next[adapterType];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const nextToken =
|
||||||
|
profilePatch.token === undefined ? existing?.token ?? "" : coerceString(profilePatch.token);
|
||||||
|
next[adapterType] = { url: nextUrl, token: nextToken };
|
||||||
|
}
|
||||||
|
return Object.keys(next).length > 0 ? next : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mergeGatewayConnectionState = (
|
||||||
|
current: StudioGatewayConnectionState | null,
|
||||||
|
patch: StudioGatewayConnectionStatePatch | null | undefined
|
||||||
|
): StudioGatewayConnectionState | null => {
|
||||||
|
if (patch === null) return null;
|
||||||
|
if (patch === undefined) return current;
|
||||||
|
const nextUrl =
|
||||||
|
patch.url === undefined ? current?.url ?? "" : normalizeGatewayUrl(patch.url);
|
||||||
|
if (!nextUrl) return null;
|
||||||
|
const nextToken =
|
||||||
|
patch.token === undefined ? current?.token ?? "" : coerceString(patch.token);
|
||||||
|
const nextAdapterType =
|
||||||
|
patch.adapterType === undefined
|
||||||
|
? current?.adapterType ?? "openclaw"
|
||||||
|
: normalizeGatewayAdapterType(patch.adapterType);
|
||||||
|
return {
|
||||||
|
url: nextUrl,
|
||||||
|
token: nextToken,
|
||||||
|
adapterType: nextAdapterType,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeGatewayAdapterType = (
|
||||||
|
value: unknown,
|
||||||
|
fallback: StudioGatewayAdapterType = "openclaw"
|
||||||
|
): StudioGatewayAdapterType => {
|
||||||
|
const adapterType = coerceString(value).toLowerCase();
|
||||||
|
if (
|
||||||
|
adapterType === "demo" ||
|
||||||
|
adapterType === "hermes" ||
|
||||||
|
adapterType === "openclaw" ||
|
||||||
|
adapterType === "custom"
|
||||||
|
) {
|
||||||
|
return adapterType;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
};
|
||||||
|
|
||||||
const normalizeFocused = (value: unknown): Record<string, StudioFocusedPreference> => {
|
const normalizeFocused = (value: unknown): Record<string, StudioFocusedPreference> => {
|
||||||
if (!isRecord(value)) return {};
|
if (!isRecord(value)) return {};
|
||||||
const focused: Record<string, StudioFocusedPreference> = {};
|
const focused: Record<string, StudioFocusedPreference> = {};
|
||||||
@@ -866,6 +1037,25 @@ export const sanitizeStudioGatewaySettings = (
|
|||||||
return {
|
return {
|
||||||
url: value.url,
|
url: value.url,
|
||||||
tokenConfigured: value.token.length > 0,
|
tokenConfigured: value.token.length > 0,
|
||||||
|
adapterType: value.adapterType,
|
||||||
|
profiles: value.profiles
|
||||||
|
? Object.fromEntries(
|
||||||
|
Object.entries(value.profiles).map(([adapterType, profile]) => [
|
||||||
|
adapterType,
|
||||||
|
{
|
||||||
|
url: profile.url,
|
||||||
|
tokenConfigured: profile.token.length > 0,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
: undefined,
|
||||||
|
lastKnownGood: value.lastKnownGood
|
||||||
|
? {
|
||||||
|
url: value.lastKnownGood.url,
|
||||||
|
tokenConfigured: value.lastKnownGood.token.length > 0,
|
||||||
|
adapterType: value.lastKnownGood.adapterType,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import os from "node:os";
|
// import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import type { TaskBoardCard, TaskBoardSource, TaskBoardStatus } from "@/features/office/tasks/types";
|
import type { TaskBoardCard, TaskBoardSource, TaskBoardStatus } from "@/features/office/tasks/types";
|
||||||
|
|||||||
@@ -15,10 +15,13 @@ describe("ConnectionPanel close control", () => {
|
|||||||
createElement(ConnectionPanel, {
|
createElement(ConnectionPanel, {
|
||||||
gatewayUrl: "ws://127.0.0.1:18789",
|
gatewayUrl: "ws://127.0.0.1:18789",
|
||||||
token: "token",
|
token: "token",
|
||||||
|
selectedAdapterType: "openclaw",
|
||||||
|
activeAdapterType: "openclaw",
|
||||||
status: "disconnected",
|
status: "disconnected",
|
||||||
error: null,
|
error: null,
|
||||||
onGatewayUrlChange: vi.fn(),
|
onGatewayUrlChange: vi.fn(),
|
||||||
onTokenChange: vi.fn(),
|
onTokenChange: vi.fn(),
|
||||||
|
onAdapterTypeChange: vi.fn(),
|
||||||
onConnect: vi.fn(),
|
onConnect: vi.fn(),
|
||||||
onDisconnect: vi.fn(),
|
onDisconnect: vi.fn(),
|
||||||
onClose,
|
onClose,
|
||||||
@@ -34,10 +37,13 @@ describe("ConnectionPanel close control", () => {
|
|||||||
createElement(ConnectionPanel, {
|
createElement(ConnectionPanel, {
|
||||||
gatewayUrl: "ws://127.0.0.1:18789",
|
gatewayUrl: "ws://127.0.0.1:18789",
|
||||||
token: "token",
|
token: "token",
|
||||||
|
selectedAdapterType: "openclaw",
|
||||||
|
activeAdapterType: "openclaw",
|
||||||
status: "disconnected",
|
status: "disconnected",
|
||||||
error: null,
|
error: null,
|
||||||
onGatewayUrlChange: vi.fn(),
|
onGatewayUrlChange: vi.fn(),
|
||||||
onTokenChange: vi.fn(),
|
onTokenChange: vi.fn(),
|
||||||
|
onAdapterTypeChange: vi.fn(),
|
||||||
onConnect: vi.fn(),
|
onConnect: vi.fn(),
|
||||||
onDisconnect: vi.fn(),
|
onDisconnect: vi.fn(),
|
||||||
})
|
})
|
||||||
@@ -51,10 +57,13 @@ describe("ConnectionPanel close control", () => {
|
|||||||
createElement(ConnectionPanel, {
|
createElement(ConnectionPanel, {
|
||||||
gatewayUrl: "ws://127.0.0.1:18789",
|
gatewayUrl: "ws://127.0.0.1:18789",
|
||||||
token: "token",
|
token: "token",
|
||||||
|
selectedAdapterType: "openclaw",
|
||||||
|
activeAdapterType: "openclaw",
|
||||||
status: "disconnected",
|
status: "disconnected",
|
||||||
error: null,
|
error: null,
|
||||||
onGatewayUrlChange: vi.fn(),
|
onGatewayUrlChange: vi.fn(),
|
||||||
onTokenChange: vi.fn(),
|
onTokenChange: vi.fn(),
|
||||||
|
onAdapterTypeChange: vi.fn(),
|
||||||
onConnect: vi.fn(),
|
onConnect: vi.fn(),
|
||||||
onDisconnect: vi.fn(),
|
onDisconnect: vi.fn(),
|
||||||
})
|
})
|
||||||
@@ -68,10 +77,13 @@ describe("ConnectionPanel close control", () => {
|
|||||||
createElement(ConnectionPanel, {
|
createElement(ConnectionPanel, {
|
||||||
gatewayUrl: "ws://127.0.0.1:18789",
|
gatewayUrl: "ws://127.0.0.1:18789",
|
||||||
token: "token",
|
token: "token",
|
||||||
|
selectedAdapterType: "openclaw",
|
||||||
|
activeAdapterType: "openclaw",
|
||||||
status: "connected",
|
status: "connected",
|
||||||
error: null,
|
error: null,
|
||||||
onGatewayUrlChange: vi.fn(),
|
onGatewayUrlChange: vi.fn(),
|
||||||
onTokenChange: vi.fn(),
|
onTokenChange: vi.fn(),
|
||||||
|
onAdapterTypeChange: vi.fn(),
|
||||||
onConnect: vi.fn(),
|
onConnect: vi.fn(),
|
||||||
onDisconnect: vi.fn(),
|
onDisconnect: vi.fn(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const { handleMethod } = await import("../../server/demo-gateway-adapter.js");
|
||||||
|
|
||||||
|
describe("demo-gateway-adapter", () => {
|
||||||
|
it("rejects_unsupported_cron_mutations", async () => {
|
||||||
|
await expect(handleMethod("cron.add", {}, "1", () => {})).resolves.toMatchObject({
|
||||||
|
type: "res",
|
||||||
|
id: "1",
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: "unsupported_method",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(handleMethod("cron.run", {}, "2", () => {})).resolves.toMatchObject({
|
||||||
|
type: "res",
|
||||||
|
id: "2",
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: "unsupported_method",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(handleMethod("cron.remove", {}, "3", () => {})).resolves.toMatchObject({
|
||||||
|
type: "res",
|
||||||
|
id: "3",
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: "unsupported_method",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { resolveSettingsSidebarEntries } from "@/features/agents/operations/settingsSidebarTabs";
|
||||||
|
|
||||||
|
describe("resolveSettingsSidebarEntries", () => {
|
||||||
|
it("hides_automations_when_runtime_lacks_cron_capability", () => {
|
||||||
|
const entries = resolveSettingsSidebarEntries(false);
|
||||||
|
|
||||||
|
expect(entries.find((entry) => entry.id === "automations")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows_automations_when_runtime_supports_cron", () => {
|
||||||
|
const entries = resolveSettingsSidebarEntries(true);
|
||||||
|
|
||||||
|
expect(entries.find((entry) => entry.id === "automations")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -55,10 +55,12 @@ describe("studio settings route", () => {
|
|||||||
expect(body.localGatewayDefaults).toEqual({
|
expect(body.localGatewayDefaults).toEqual({
|
||||||
url: "ws://localhost:18791",
|
url: "ws://localhost:18791",
|
||||||
tokenConfigured: true,
|
tokenConfigured: true,
|
||||||
|
adapterType: "openclaw",
|
||||||
});
|
});
|
||||||
expect(body.settings?.gateway).toEqual({
|
expect(body.settings?.gateway).toEqual({
|
||||||
url: "ws://localhost:18791",
|
url: "ws://localhost:18791",
|
||||||
tokenConfigured: true,
|
tokenConfigured: true,
|
||||||
|
adapterType: "openclaw",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,7 +83,7 @@ describe("studio settings route", () => {
|
|||||||
process.env.OPENCLAW_STATE_DIR = tempDir;
|
process.env.OPENCLAW_STATE_DIR = tempDir;
|
||||||
|
|
||||||
const patch = {
|
const patch = {
|
||||||
gateway: { url: "ws://example.test:1234", token: "t" },
|
gateway: { url: "ws://example.test:1234", token: "t", adapterType: "hermes" },
|
||||||
office: {
|
office: {
|
||||||
"ws://example.test:1234": {
|
"ws://example.test:1234": {
|
||||||
title: "Orbit Control",
|
title: "Orbit Control",
|
||||||
@@ -106,6 +108,7 @@ describe("studio settings route", () => {
|
|||||||
expect(body.settings?.gateway).toEqual({
|
expect(body.settings?.gateway).toEqual({
|
||||||
url: "ws://example.test:1234",
|
url: "ws://example.test:1234",
|
||||||
tokenConfigured: true,
|
tokenConfigured: true,
|
||||||
|
adapterType: "hermes",
|
||||||
});
|
});
|
||||||
expect(body.settings?.office?.["ws://example.test:1234"]).toEqual(
|
expect(body.settings?.office?.["ws://example.test:1234"]).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -117,10 +120,14 @@ describe("studio settings route", () => {
|
|||||||
expect(fs.existsSync(settingsPath)).toBe(true);
|
expect(fs.existsSync(settingsPath)).toBe(true);
|
||||||
const raw = fs.readFileSync(settingsPath, "utf8");
|
const raw = fs.readFileSync(settingsPath, "utf8");
|
||||||
const parsed = JSON.parse(raw) as {
|
const parsed = JSON.parse(raw) as {
|
||||||
gateway?: { url?: string; token?: string } | null;
|
gateway?: { url?: string; token?: string; adapterType?: string } | null;
|
||||||
office?: Record<string, { title?: string }>;
|
office?: Record<string, { title?: string }>;
|
||||||
};
|
};
|
||||||
expect(parsed.gateway).toEqual({ url: "ws://example.test:1234", token: "t" });
|
expect(parsed.gateway).toEqual({
|
||||||
|
url: "ws://example.test:1234",
|
||||||
|
token: "t",
|
||||||
|
adapterType: "hermes",
|
||||||
|
});
|
||||||
expect(parsed.office?.["ws://example.test:1234"]).toEqual(
|
expect(parsed.office?.["ws://example.test:1234"]).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
title: "Orbit Control",
|
title: "Orbit Control",
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ const renderController = (overrides?: Partial<Parameters<typeof useAgentSettings
|
|||||||
const params: Parameters<typeof useAgentSettingsMutationController>[0] = {
|
const params: Parameters<typeof useAgentSettingsMutationController>[0] = {
|
||||||
client: client as never,
|
client: client as never,
|
||||||
status: "connected",
|
status: "connected",
|
||||||
|
runtimeSupportsCron: true,
|
||||||
isLocalGateway: false,
|
isLocalGateway: false,
|
||||||
agents: [{ agentId: "agent-1", name: "Agent One", sessionKey: "session-1" }] as never,
|
agents: [{ agentId: "agent-1", name: "Agent One", sessionKey: "session-1" }] as never,
|
||||||
hasCreateBlock: false,
|
hasCreateBlock: false,
|
||||||
@@ -453,6 +454,21 @@ describe("useAgentSettingsMutationController", () => {
|
|||||||
expect(mockedPerformCronCreateFlow).toHaveBeenCalledTimes(1);
|
expect(mockedPerformCronCreateFlow).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("cron_mutations_fail_fast_when_runtime_lacks_cron_capability", async () => {
|
||||||
|
const ctx = renderController({ runtimeSupportsCron: false });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await ctx.getValue().handleCreateCronJob("agent-1", createCronDraft());
|
||||||
|
await ctx.getValue().handleRunCronJob("agent-1", "job-1");
|
||||||
|
await ctx.getValue().handleDeleteCronJob("agent-1", "job-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockedPerformCronCreateFlow).not.toHaveBeenCalled();
|
||||||
|
expect(mockedRunCronJobNow).not.toHaveBeenCalled();
|
||||||
|
expect(mockedRemoveCronJob).not.toHaveBeenCalled();
|
||||||
|
expect(ctx.getValue().settingsCronError).toBe("This runtime does not support automations.");
|
||||||
|
});
|
||||||
|
|
||||||
it("loads_skills_when_settings_skills_tab_is_active", async () => {
|
it("loads_skills_when_settings_skills_tab_is_active", async () => {
|
||||||
const report = {
|
const report = {
|
||||||
workspaceDir: "/tmp/workspace",
|
workspaceDir: "/tmp/workspace",
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ const setupAndImportHook = async (gatewayUrl: string | null) => {
|
|||||||
|
|
||||||
start() {
|
start() {
|
||||||
this.connected = true;
|
this.connected = true;
|
||||||
this.opts.onHello?.({ type: "hello-ok", protocol: 1 });
|
this.opts.onHello?.({ type: "hello-ok", protocol: 1, adapterType: "openclaw" });
|
||||||
}
|
}
|
||||||
|
|
||||||
stop() {
|
stop() {
|
||||||
@@ -73,8 +73,18 @@ const setupAndImportHook = async (gatewayUrl: string | null) => {
|
|||||||
}) => {
|
}) => {
|
||||||
gatewayUrl: string;
|
gatewayUrl: string;
|
||||||
token: string;
|
token: string;
|
||||||
localGatewayDefaults: { url: string; token: string } | null;
|
selectedAdapterType: "openclaw" | "hermes" | "demo" | "custom";
|
||||||
|
detectedAdapterType: "openclaw" | "hermes" | "demo" | "custom" | null;
|
||||||
|
activeAdapterType: "openclaw" | "hermes" | "demo" | "custom";
|
||||||
|
localGatewayDefaults: {
|
||||||
|
url: string;
|
||||||
|
token: string;
|
||||||
|
adapterType: "openclaw" | "hermes" | "demo" | "custom";
|
||||||
|
} | null;
|
||||||
|
shouldPromptForConnect: boolean;
|
||||||
useLocalGatewayDefaults: () => void;
|
useLocalGatewayDefaults: () => void;
|
||||||
|
setSelectedAdapterType: (value: "openclaw" | "hermes" | "demo" | "custom") => void;
|
||||||
|
connect: () => Promise<void>;
|
||||||
},
|
},
|
||||||
captured,
|
captured,
|
||||||
};
|
};
|
||||||
@@ -136,6 +146,30 @@ describe("useGatewayConnection", () => {
|
|||||||
const { useGatewayConnection, captured } = await setupAndImportHook(null);
|
const { useGatewayConnection, captured } = await setupAndImportHook(null);
|
||||||
const coordinator = {
|
const coordinator = {
|
||||||
loadSettings: async () => null,
|
loadSettings: async () => null,
|
||||||
|
loadSettingsEnvelope: async () => ({
|
||||||
|
settings: {
|
||||||
|
version: 1,
|
||||||
|
gateway: {
|
||||||
|
url: "wss://remote.example",
|
||||||
|
token: "",
|
||||||
|
adapterType: "hermes",
|
||||||
|
lastKnownGood: {
|
||||||
|
url: "wss://remote.example",
|
||||||
|
token: "",
|
||||||
|
adapterType: "hermes",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
focused: {},
|
||||||
|
avatars: {},
|
||||||
|
analytics: {},
|
||||||
|
voiceReplies: {},
|
||||||
|
office: {},
|
||||||
|
deskAssignments: {},
|
||||||
|
standup: {},
|
||||||
|
taskBoard: {},
|
||||||
|
},
|
||||||
|
localGatewayDefaults: null,
|
||||||
|
}),
|
||||||
schedulePatch: () => {},
|
schedulePatch: () => {},
|
||||||
flushPending: async () => {},
|
flushPending: async () => {},
|
||||||
};
|
};
|
||||||
@@ -151,7 +185,76 @@ describe("useGatewayConnection", () => {
|
|||||||
expect(captured.url).toBe("ws://localhost:3000/api/gateway/ws");
|
expect(captured.url).toBe("ws://localhost:3000/api/gateway/ws");
|
||||||
});
|
});
|
||||||
expect(captured.token).toBe("");
|
expect(captured.token).toBe("");
|
||||||
expect(captured.authScopeKey).toBe("ws://localhost:18789");
|
expect(captured.authScopeKey).toBe("wss://remote.example");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does_not_auto_connect_without_a_last_known_good_state", async () => {
|
||||||
|
const { useGatewayConnection, captured } = await setupAndImportHook(null);
|
||||||
|
const coordinator = {
|
||||||
|
loadSettingsEnvelope: async () => ({
|
||||||
|
settings: {
|
||||||
|
version: 1,
|
||||||
|
gateway: {
|
||||||
|
url: "ws://localhost:18789",
|
||||||
|
token: "",
|
||||||
|
adapterType: "hermes",
|
||||||
|
},
|
||||||
|
focused: {},
|
||||||
|
avatars: {},
|
||||||
|
analytics: {},
|
||||||
|
voiceReplies: {},
|
||||||
|
office: {},
|
||||||
|
deskAssignments: {},
|
||||||
|
standup: {},
|
||||||
|
taskBoard: {},
|
||||||
|
},
|
||||||
|
localGatewayDefaults: null,
|
||||||
|
}),
|
||||||
|
loadSettings: async () => null,
|
||||||
|
schedulePatch: () => {},
|
||||||
|
flushPending: async () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const Probe = () => {
|
||||||
|
const state = useGatewayConnection(coordinator);
|
||||||
|
return createElement(
|
||||||
|
"div",
|
||||||
|
null,
|
||||||
|
createElement("div", { "data-testid": "gatewayUrl" }, state.gatewayUrl),
|
||||||
|
createElement(
|
||||||
|
"div",
|
||||||
|
{ "data-testid": "shouldPromptForConnect" },
|
||||||
|
state.shouldPromptForConnect ? "yes" : "no"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
render(createElement(Probe));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("gatewayUrl")).toHaveTextContent("ws://localhost:18789");
|
||||||
|
});
|
||||||
|
expect(screen.getByTestId("shouldPromptForConnect")).toHaveTextContent("yes");
|
||||||
|
expect(captured.url).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses_a_small_initial_auto_connect_delay_for_hermes_and_demo_only", async () => {
|
||||||
|
const mod = await import("@/lib/gateway/GatewayClient");
|
||||||
|
expect(mod.resolveInitialGatewayAutoConnectDelayMs("openclaw")).toBe(0);
|
||||||
|
expect(mod.resolveInitialGatewayAutoConnectDelayMs("custom")).toBe(0);
|
||||||
|
expect(mod.resolveInitialGatewayAutoConnectDelayMs("hermes")).toBe(900);
|
||||||
|
expect(mod.resolveInitialGatewayAutoConnectDelayMs("demo")).toBe(900);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries_only_the_first_connect_for_hermes_and_demo", async () => {
|
||||||
|
const mod = await import("@/lib/gateway/GatewayClient");
|
||||||
|
expect(mod.resolveInitialGatewayConnectAttemptCount("openclaw", false)).toBe(1);
|
||||||
|
expect(mod.resolveInitialGatewayConnectAttemptCount("custom", false)).toBe(1);
|
||||||
|
expect(mod.resolveInitialGatewayConnectAttemptCount("hermes", false)).toBe(2);
|
||||||
|
expect(mod.resolveInitialGatewayConnectAttemptCount("demo", false)).toBe(2);
|
||||||
|
expect(mod.resolveInitialGatewayConnectAttemptCount("hermes", true)).toBe(2);
|
||||||
|
expect(mod.resolveInitialGatewayConnectAttemptCount("demo", true)).toBe(2);
|
||||||
|
expect(mod.resolveInitialGatewayConnectAttemptCount("openclaw", true)).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("auto_applies_runtime_local_defaults_when_no_saved_gateway_and_build_time_empty", async () => {
|
it("auto_applies_runtime_local_defaults_when_no_saved_gateway_and_build_time_empty", async () => {
|
||||||
@@ -269,4 +372,366 @@ describe("useGatewayConnection", () => {
|
|||||||
});
|
});
|
||||||
expect(screen.getByTestId("token")).toHaveTextContent("local-token");
|
expect(screen.getByTestId("token")).toHaveTextContent("local-token");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("loads_and_persists_selected_adapter_type", async () => {
|
||||||
|
const { useGatewayConnection } = await setupAndImportHook(null);
|
||||||
|
const patches: unknown[] = [];
|
||||||
|
const coordinator = {
|
||||||
|
loadSettingsEnvelope: async () => ({
|
||||||
|
settings: {
|
||||||
|
version: 1,
|
||||||
|
gateway: {
|
||||||
|
url: "ws://localhost:18789",
|
||||||
|
token: "",
|
||||||
|
adapterType: "hermes",
|
||||||
|
},
|
||||||
|
focused: {},
|
||||||
|
avatars: {},
|
||||||
|
analytics: {},
|
||||||
|
voiceReplies: {},
|
||||||
|
office: {},
|
||||||
|
deskAssignments: {},
|
||||||
|
standup: {},
|
||||||
|
taskBoard: {},
|
||||||
|
},
|
||||||
|
localGatewayDefaults: null,
|
||||||
|
}),
|
||||||
|
loadSettings: async () => null,
|
||||||
|
schedulePatch: (patch: unknown) => {
|
||||||
|
patches.push(patch);
|
||||||
|
},
|
||||||
|
flushPending: async () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const Probe = () => {
|
||||||
|
const state = useGatewayConnection(coordinator);
|
||||||
|
return createElement(
|
||||||
|
"div",
|
||||||
|
null,
|
||||||
|
createElement("div", { "data-testid": "selectedAdapterType" }, state.selectedAdapterType),
|
||||||
|
createElement("div", { "data-testid": "activeAdapterType" }, state.activeAdapterType)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
render(createElement(Probe));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("selectedAdapterType")).toHaveTextContent("hermes");
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("activeAdapterType")).toHaveTextContent("hermes");
|
||||||
|
});
|
||||||
|
expect(patches).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers_the_saved_selected_adapter_over_a_different_last_known_good_backend", async () => {
|
||||||
|
const { useGatewayConnection, captured } = await setupAndImportHook(null);
|
||||||
|
const coordinator = {
|
||||||
|
loadSettingsEnvelope: async () => ({
|
||||||
|
settings: {
|
||||||
|
version: 1,
|
||||||
|
gateway: {
|
||||||
|
url: "ws://localhost:18789",
|
||||||
|
token: "",
|
||||||
|
adapterType: "hermes",
|
||||||
|
lastKnownGood: {
|
||||||
|
url: "ws://localhost:9999",
|
||||||
|
token: "openclaw-token",
|
||||||
|
adapterType: "openclaw",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
focused: {},
|
||||||
|
avatars: {},
|
||||||
|
analytics: {},
|
||||||
|
voiceReplies: {},
|
||||||
|
office: {},
|
||||||
|
deskAssignments: {},
|
||||||
|
standup: {},
|
||||||
|
taskBoard: {},
|
||||||
|
},
|
||||||
|
localGatewayDefaults: null,
|
||||||
|
}),
|
||||||
|
loadSettings: async () => null,
|
||||||
|
schedulePatch: () => {},
|
||||||
|
flushPending: async () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const Probe = () => {
|
||||||
|
const state = useGatewayConnection(coordinator);
|
||||||
|
return createElement(
|
||||||
|
"div",
|
||||||
|
null,
|
||||||
|
createElement("div", { "data-testid": "gatewayUrl" }, state.gatewayUrl),
|
||||||
|
createElement("div", { "data-testid": "selectedAdapterType" }, state.selectedAdapterType),
|
||||||
|
createElement(
|
||||||
|
"div",
|
||||||
|
{ "data-testid": "shouldPromptForConnect" },
|
||||||
|
state.shouldPromptForConnect ? "yes" : "no"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
render(createElement(Probe));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("gatewayUrl")).toHaveTextContent("ws://localhost:18789");
|
||||||
|
});
|
||||||
|
expect(screen.getByTestId("selectedAdapterType")).toHaveTextContent("hermes");
|
||||||
|
expect(screen.getByTestId("shouldPromptForConnect")).toHaveTextContent("yes");
|
||||||
|
expect(captured.url).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads_custom_adapter_type_without_requiring_a_token", async () => {
|
||||||
|
const { useGatewayConnection } = await setupAndImportHook(null);
|
||||||
|
const coordinator = {
|
||||||
|
loadSettingsEnvelope: async () => ({
|
||||||
|
settings: {
|
||||||
|
version: 1,
|
||||||
|
gateway: {
|
||||||
|
url: "http://127.0.0.1:7770",
|
||||||
|
token: "",
|
||||||
|
adapterType: "custom",
|
||||||
|
},
|
||||||
|
focused: {},
|
||||||
|
avatars: {},
|
||||||
|
analytics: {},
|
||||||
|
voiceReplies: {},
|
||||||
|
office: {},
|
||||||
|
deskAssignments: {},
|
||||||
|
standup: {},
|
||||||
|
taskBoard: {},
|
||||||
|
},
|
||||||
|
localGatewayDefaults: null,
|
||||||
|
}),
|
||||||
|
loadSettings: async () => null,
|
||||||
|
schedulePatch: () => {},
|
||||||
|
flushPending: async () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const Probe = () => {
|
||||||
|
const state = useGatewayConnection(coordinator);
|
||||||
|
return createElement(
|
||||||
|
"div",
|
||||||
|
null,
|
||||||
|
createElement("div", { "data-testid": "gatewayUrl" }, state.gatewayUrl),
|
||||||
|
createElement("div", { "data-testid": "selectedAdapterType" }, state.selectedAdapterType),
|
||||||
|
createElement("div", { "data-testid": "activeAdapterType" }, state.activeAdapterType),
|
||||||
|
createElement(
|
||||||
|
"div",
|
||||||
|
{ "data-testid": "shouldPromptForConnect" },
|
||||||
|
state.shouldPromptForConnect ? "yes" : "no"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
render(createElement(Probe));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("gatewayUrl")).toHaveTextContent("http://127.0.0.1:7770");
|
||||||
|
});
|
||||||
|
expect(screen.getByTestId("selectedAdapterType")).toHaveTextContent("custom");
|
||||||
|
expect(screen.getByTestId("activeAdapterType")).toHaveTextContent("custom");
|
||||||
|
expect(screen.getByTestId("shouldPromptForConnect")).toHaveTextContent("yes");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still_prompts_to_reconnect_for_custom_with_last_known_good_state", async () => {
|
||||||
|
const { useGatewayConnection } = await setupAndImportHook(null);
|
||||||
|
const coordinator = {
|
||||||
|
loadSettingsEnvelope: async () => ({
|
||||||
|
settings: {
|
||||||
|
version: 1,
|
||||||
|
gateway: {
|
||||||
|
url: "http://127.0.0.1:7770",
|
||||||
|
token: "",
|
||||||
|
adapterType: "custom",
|
||||||
|
lastKnownGood: {
|
||||||
|
url: "http://127.0.0.1:7770",
|
||||||
|
token: "",
|
||||||
|
adapterType: "custom",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
focused: {},
|
||||||
|
avatars: {},
|
||||||
|
analytics: {},
|
||||||
|
voiceReplies: {},
|
||||||
|
office: {},
|
||||||
|
deskAssignments: {},
|
||||||
|
standup: {},
|
||||||
|
taskBoard: {},
|
||||||
|
},
|
||||||
|
localGatewayDefaults: null,
|
||||||
|
}),
|
||||||
|
loadSettings: async () => null,
|
||||||
|
schedulePatch: () => {},
|
||||||
|
flushPending: async () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const Probe = () => {
|
||||||
|
const state = useGatewayConnection(coordinator);
|
||||||
|
return createElement(
|
||||||
|
"div",
|
||||||
|
null,
|
||||||
|
createElement("div", { "data-testid": "selectedAdapterType" }, state.selectedAdapterType),
|
||||||
|
createElement(
|
||||||
|
"div",
|
||||||
|
{ "data-testid": "shouldPromptForConnect" },
|
||||||
|
state.shouldPromptForConnect ? "yes" : "no"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
render(createElement(Probe));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("selectedAdapterType")).toHaveTextContent("custom");
|
||||||
|
});
|
||||||
|
expect(screen.getByTestId("shouldPromptForConnect")).toHaveTextContent("yes");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists_the_detected_backend_identity_in_last_known_good", async () => {
|
||||||
|
const { useGatewayConnection } = await setupAndImportHook(null);
|
||||||
|
const patches: unknown[] = [];
|
||||||
|
const coordinator = {
|
||||||
|
loadSettingsEnvelope: async () => ({
|
||||||
|
settings: {
|
||||||
|
version: 1,
|
||||||
|
gateway: {
|
||||||
|
url: "wss://remote.example",
|
||||||
|
token: "",
|
||||||
|
adapterType: "hermes",
|
||||||
|
lastKnownGood: {
|
||||||
|
url: "wss://remote.example",
|
||||||
|
token: "",
|
||||||
|
adapterType: "hermes",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
focused: {},
|
||||||
|
avatars: {},
|
||||||
|
analytics: {},
|
||||||
|
voiceReplies: {},
|
||||||
|
office: {},
|
||||||
|
deskAssignments: {},
|
||||||
|
standup: {},
|
||||||
|
taskBoard: {},
|
||||||
|
},
|
||||||
|
localGatewayDefaults: null,
|
||||||
|
}),
|
||||||
|
loadSettings: async () => null,
|
||||||
|
schedulePatch: (patch: unknown) => {
|
||||||
|
patches.push(patch);
|
||||||
|
},
|
||||||
|
flushPending: async () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const Probe = () => {
|
||||||
|
const state = useGatewayConnection(coordinator);
|
||||||
|
return createElement(
|
||||||
|
"div",
|
||||||
|
null,
|
||||||
|
createElement("div", { "data-testid": "selectedAdapterType" }, state.selectedAdapterType),
|
||||||
|
createElement(
|
||||||
|
"button",
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
"data-testid": "connect",
|
||||||
|
onClick: () => {
|
||||||
|
void state.connect();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"connect"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
render(createElement(Probe));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("selectedAdapterType")).toHaveTextContent("hermes");
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByTestId("connect"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
patches.some(
|
||||||
|
(patch) =>
|
||||||
|
typeof patch === "object" &&
|
||||||
|
patch !== null &&
|
||||||
|
"gateway" in patch &&
|
||||||
|
typeof (patch as { gateway?: { lastKnownGood?: { adapterType?: string } } }).gateway
|
||||||
|
?.lastKnownGood?.adapterType === "string"
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(patches).toContainEqual({
|
||||||
|
gateway: {
|
||||||
|
lastKnownGood: {
|
||||||
|
url: "wss://remote.example",
|
||||||
|
token: "",
|
||||||
|
adapterType: "openclaw",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores_backend_specific_profiles_when_switching_adapter_type", async () => {
|
||||||
|
const { useGatewayConnection } = await setupAndImportHook(null);
|
||||||
|
const coordinator = {
|
||||||
|
loadSettingsEnvelope: async () => ({
|
||||||
|
settings: {
|
||||||
|
version: 1,
|
||||||
|
gateway: {
|
||||||
|
url: "ws://localhost:18789",
|
||||||
|
token: "",
|
||||||
|
adapterType: "hermes",
|
||||||
|
profiles: {
|
||||||
|
hermes: { url: "ws://localhost:18789", token: "" },
|
||||||
|
custom: { url: "http://127.0.0.1:7770", token: "" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
focused: {},
|
||||||
|
avatars: {},
|
||||||
|
analytics: {},
|
||||||
|
voiceReplies: {},
|
||||||
|
office: {},
|
||||||
|
deskAssignments: {},
|
||||||
|
standup: {},
|
||||||
|
taskBoard: {},
|
||||||
|
},
|
||||||
|
localGatewayDefaults: null,
|
||||||
|
}),
|
||||||
|
loadSettings: async () => null,
|
||||||
|
schedulePatch: () => {},
|
||||||
|
flushPending: async () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const Probe = () => {
|
||||||
|
const state = useGatewayConnection(coordinator);
|
||||||
|
return createElement(
|
||||||
|
"div",
|
||||||
|
null,
|
||||||
|
createElement("div", { "data-testid": "gatewayUrl" }, state.gatewayUrl),
|
||||||
|
createElement(
|
||||||
|
"button",
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
"data-testid": "switch-custom",
|
||||||
|
onClick: () => state.setSelectedAdapterType("custom"),
|
||||||
|
},
|
||||||
|
"custom"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
render(createElement(Probe));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("gatewayUrl")).toHaveTextContent("ws://localhost:18789");
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("switch-custom"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("gatewayUrl")).toHaveTextContent("http://127.0.0.1:7770");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { createElement } from "react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
|
||||||
|
describe("useRuntimeConnection", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.resetModules();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects the hermes provider from the active adapter type", async () => {
|
||||||
|
vi.doMock("@/lib/gateway/GatewayClient", () => ({
|
||||||
|
useGatewayConnection: () => ({
|
||||||
|
client: {},
|
||||||
|
status: "connected",
|
||||||
|
gatewayUrl: "ws://localhost:18789",
|
||||||
|
token: "",
|
||||||
|
selectedAdapterType: "hermes",
|
||||||
|
detectedAdapterType: "hermes",
|
||||||
|
activeAdapterType: "hermes",
|
||||||
|
localGatewayDefaults: null,
|
||||||
|
error: null,
|
||||||
|
connectPromptReady: true,
|
||||||
|
shouldPromptForConnect: false,
|
||||||
|
connect: async () => {},
|
||||||
|
disconnect: () => {},
|
||||||
|
useLocalGatewayDefaults: () => {},
|
||||||
|
setGatewayUrl: () => {},
|
||||||
|
setToken: () => {},
|
||||||
|
setSelectedAdapterType: () => {},
|
||||||
|
clearError: () => {},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { useRuntimeConnection } = await import("@/lib/runtime/useRuntimeConnection");
|
||||||
|
|
||||||
|
const Probe = () => {
|
||||||
|
const state = useRuntimeConnection({} as never);
|
||||||
|
return createElement(
|
||||||
|
"div",
|
||||||
|
null,
|
||||||
|
createElement("div", { "data-testid": "providerId" }, state.providerId),
|
||||||
|
createElement("div", { "data-testid": "providerLabel" }, state.providerLabel)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
render(createElement(Probe));
|
||||||
|
|
||||||
|
expect(screen.getByTestId("providerId")).toHaveTextContent("hermes");
|
||||||
|
expect(screen.getByTestId("providerLabel")).toHaveTextContent("Hermes");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects the custom provider from the active adapter type", async () => {
|
||||||
|
vi.doMock("@/lib/gateway/GatewayClient", () => ({
|
||||||
|
useGatewayConnection: () => ({
|
||||||
|
client: {},
|
||||||
|
status: "connected",
|
||||||
|
gatewayUrl: "http://127.0.0.1:7770",
|
||||||
|
token: "",
|
||||||
|
selectedAdapterType: "custom",
|
||||||
|
detectedAdapterType: "custom",
|
||||||
|
activeAdapterType: "custom",
|
||||||
|
localGatewayDefaults: null,
|
||||||
|
error: null,
|
||||||
|
connectPromptReady: true,
|
||||||
|
shouldPromptForConnect: false,
|
||||||
|
connect: async () => {},
|
||||||
|
disconnect: () => {},
|
||||||
|
useLocalGatewayDefaults: () => {},
|
||||||
|
setGatewayUrl: () => {},
|
||||||
|
setToken: () => {},
|
||||||
|
setSelectedAdapterType: () => {},
|
||||||
|
clearError: () => {},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { useRuntimeConnection } = await import("@/lib/runtime/useRuntimeConnection");
|
||||||
|
|
||||||
|
const Probe = () => {
|
||||||
|
const state = useRuntimeConnection({} as never);
|
||||||
|
return createElement(
|
||||||
|
"div",
|
||||||
|
null,
|
||||||
|
createElement("div", { "data-testid": "providerId" }, state.providerId),
|
||||||
|
createElement("div", { "data-testid": "providerLabel" }, state.providerLabel)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
render(createElement(Probe));
|
||||||
|
|
||||||
|
expect(screen.getByTestId("providerId")).toHaveTextContent("custom");
|
||||||
|
expect(screen.getByTestId("providerLabel")).toHaveTextContent("Custom");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user