Compare commits
10 Commits
5ea96b2650
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c2cbdeec44 | |||
| ac30f71db0 | |||
| e24ed41532 | |||
| 941612ab2d | |||
| 533bcd9b3f | |||
| 6b5895dcfe | |||
| 65c2b9cf85 | |||
| a5b0895dd8 | |||
| 3572499f5d | |||
| 4fa4f13558 |
@@ -0,0 +1,17 @@
|
|||||||
|
# Browser/client gateway URL
|
||||||
|
NEXT_PUBLIC_GATEWAY_URL=ws://localhost:18789
|
||||||
|
|
||||||
|
# Debug UI
|
||||||
|
DEBUG=true
|
||||||
|
|
||||||
|
# App server
|
||||||
|
# PORT=3000
|
||||||
|
# HOST=127.0.0.1
|
||||||
|
|
||||||
|
# Optional: required only for public/remote deployments
|
||||||
|
# STUDIO_ACCESS_TOKEN=
|
||||||
|
|
||||||
|
# Optional: voice features
|
||||||
|
# ELEVENLABS_API_KEY=
|
||||||
|
# ELEVENLABS_VOICE_ID=21m00Tcm4TlvDq8ikWAM
|
||||||
|
# ELEVENLABS_MODEL_ID=eleven_flash_v2_5
|
||||||
+84
@@ -0,0 +1,84 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.crt
|
||||||
|
*.cer
|
||||||
|
*.p12
|
||||||
|
*.pfx
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
.netrc
|
||||||
|
id_rsa
|
||||||
|
id_rsa.pub
|
||||||
|
id_ed25519
|
||||||
|
id_ed25519.pub
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# playwright
|
||||||
|
playwright-report
|
||||||
|
test-results
|
||||||
|
.playwright-home
|
||||||
|
/.playwright-cli
|
||||||
|
/output/playwright
|
||||||
|
|
||||||
|
# agent state
|
||||||
|
/.agent/*
|
||||||
|
!/.agent/PLANS.md
|
||||||
|
!/.agent/done/
|
||||||
|
!/.agent/done/**
|
||||||
|
/.agent/done/**/*.md
|
||||||
|
/.agent/local
|
||||||
|
/.agent/execplan-pending.md
|
||||||
|
/.agent/*.local.md
|
||||||
|
/.agent/future-plans
|
||||||
|
/.openclaw
|
||||||
|
/.clawdbot
|
||||||
|
/.moltbot
|
||||||
|
/agent-canvas
|
||||||
|
/worktrees
|
||||||
|
/.claude
|
||||||
|
|
||||||
|
# local issue tracker
|
||||||
|
/.beads
|
||||||
|
|
||||||
|
# bv (beads viewer) local config and caches
|
||||||
|
.bv/
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Agent Instructions
|
||||||
|
|
||||||
|
Keep repository instructions generic and safe for open source.
|
||||||
|
|
||||||
|
This repo is a frontend for OpenClaw. Keep any OpenClaw runtime checkout separate from this repository.
|
||||||
|
|
||||||
|
Do not modify the OpenClaw source code. When the user asks for changes, they are asking for changes to this app. Your solutions should be applied to this app but to understand the full context of implementing your solution, you will need to search through OpenClaw's source code.
|
||||||
|
|
||||||
|
If you use local private overlay instructions, keep them outside the repository and do not commit them here.
|
||||||
|
|
||||||
|
Do not commit personal, environment-specific, or secret instructions to this repository.
|
||||||
+35
-3
@@ -1,10 +1,42 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
All notable changes to Claw3D will be documented in this file.
|
## [0.1.2] - 2026-03-20
|
||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows Semantic Versioning where practical.
|
### Added
|
||||||
|
|
||||||
## [0.1.0] - 2026-03-17
|
- An in-app avatar creator for agents with live 3D preview, appearance presets, and accessory controls for customizing office avatars.
|
||||||
|
- A unified agent editor modal in the office that lets you edit avatars alongside agent brain files such as `IDENTITY.md`, `SOUL.md`, `AGENTS.md`, `USER.md`, `TOOLS.md`, `MEMORY.md`, and `HEARTBEAT.md`.
|
||||||
|
- Structured avatar profile persistence and normalization so studio settings can store full avatar appearance data per gateway and agent instead of only avatar seeds.
|
||||||
|
- A `DEBUG` environment toggle for controlling the OpenClaw event console in the office UI.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Reworked office avatar rendering so 3D agents reflect saved appearance profiles, including hair, clothing, hats, glasses, headsets, backpacks, and other visual variations.
|
||||||
|
- Replaced avatar shuffle entry points in the chat and office surfaces with avatar customization flows that open the editor directly.
|
||||||
|
- Updated the office HUD with a compact agent roster, overflow handling, and direct shortcuts into per-agent editing from the 3D office view.
|
||||||
|
- Expanded the brain editor so `IDENTITY.md` fields are edited in structured form and agent renames can be applied to the live gateway agent after saving.
|
||||||
|
- Defaulted the OpenClaw event console to a collapsed state and made it optional from environment configuration.
|
||||||
|
- Updated hydration and store state to carry full avatar profiles through agent loading, persistence, and rendering.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed WebSocket gateway authentication during the upgrade handshake by wiring access control through the `ws` `verifyClient` flow.
|
||||||
|
- Fixed the gym release directive TypeScript error by adding explicit `"release"` support to office gym directives and aligning release-hold logic.
|
||||||
|
- Corrected studio settings merging and normalization for avatar data so saved office appearances survive reloads and patch updates.
|
||||||
|
- Kept skill gym hold state active for release directives during office animation trigger reconciliation.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- Added unit coverage for avatar profile persistence, studio settings normalization, and fleet hydration with structured avatar data.
|
||||||
|
- Expanded end-to-end coverage for avatar settings fixtures, office header and sidebar flows, voice reply settings persistence, disconnected office settings surfaces, and office route expectations.
|
||||||
|
|
||||||
|
## [0.1.1] - 2026-03-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Uploaded entire repo
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-03-16
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,315 @@
|
|||||||
|
# Code Documentation
|
||||||
|
|
||||||
|
This file is the practical code map for Claw3D contributors.
|
||||||
|
|
||||||
|
Use it alongside `README.md` for setup and `ARCHITECTURE.md` for system boundaries. This document is intentionally more hands-on: where code lives, which files matter first, and how to extend the main systems without fighting the current structure.
|
||||||
|
|
||||||
|
## Repo Mental Model
|
||||||
|
|
||||||
|
Claw3D is the UI and local Studio/proxy layer around an existing OpenClaw Gateway.
|
||||||
|
|
||||||
|
- OpenClaw owns agent execution, sessions, tools, config, and runtime events.
|
||||||
|
- Claw3D owns visualization, local Studio settings, UI workflows, office rendering, and the same-origin WebSocket/API bridge.
|
||||||
|
- When a feature needs authoritative runtime state, prefer Gateway data over local UI state.
|
||||||
|
- When a feature is only a local preference, it usually belongs in Studio settings.
|
||||||
|
- Before publishing new bundled assets or vendored code, also update `THIRD_PARTY_ASSETS.md` or `THIRD_PARTY_CODE.md`.
|
||||||
|
|
||||||
|
## Top-Level Code Map
|
||||||
|
|
||||||
|
### `src/app`
|
||||||
|
|
||||||
|
Next.js App Router entry points and API routes.
|
||||||
|
|
||||||
|
- Route pages such as `src/app/office/page.tsx` and `src/app/office/builder/page.tsx` are composition roots.
|
||||||
|
- `src/app/api/*` contains server-side boundaries for Studio settings, gateway-backed helpers, office flows, and path suggestions.
|
||||||
|
- Keep heavy feature logic out of route files when possible. Route files should mostly compose feature modules and server boundaries.
|
||||||
|
|
||||||
|
### `src/features`
|
||||||
|
|
||||||
|
Vertical slices for UI and feature-specific state.
|
||||||
|
|
||||||
|
- `src/features/agents`: fleet UI, chat, approvals, runtime event workflows, hydration, history sync, and settings-related operations.
|
||||||
|
- `src/features/office`: office screens, panels, builder surfaces, standup/GitHub/voice flows, and office-facing hooks.
|
||||||
|
- `src/features/retro-office`: the immersive React Three Fiber office runtime, including 3D objects, navigation, persistence, scene systems, and actor behavior.
|
||||||
|
|
||||||
|
When you are changing a user-facing workflow, start in `src/features` before reaching for `src/lib`.
|
||||||
|
|
||||||
|
### `src/lib`
|
||||||
|
|
||||||
|
Shared domain logic, adapters, and pure helpers.
|
||||||
|
|
||||||
|
- `src/lib/gateway`: the browser gateway client and session-key helpers.
|
||||||
|
- `src/lib/office`: office intent parsing, animation trigger derivation, desk monitor helpers, janitor reset logic, builder schema, and related office domain code.
|
||||||
|
- `src/lib/studio`: local Studio settings persistence and the client coordinator.
|
||||||
|
- Other areas such as `text`, `cron`, `skills`, `ssh`, and `avatars` hold reusable cross-feature logic.
|
||||||
|
|
||||||
|
If a module is reused by more than one feature or represents a stable domain contract, it probably belongs in `src/lib`.
|
||||||
|
|
||||||
|
### `server`
|
||||||
|
|
||||||
|
Custom Studio server and WebSocket proxy.
|
||||||
|
|
||||||
|
- `server/index.js` boots the app.
|
||||||
|
- `server/gateway-proxy.js` bridges browser WebSocket traffic to the upstream OpenClaw Gateway.
|
||||||
|
- `server/studio-settings.js` loads the local Studio gateway settings on the server side.
|
||||||
|
|
||||||
|
This layer exists so gateway credentials stay server-side and browser traffic can always target the same-origin Studio server.
|
||||||
|
|
||||||
|
### `tests`
|
||||||
|
|
||||||
|
Automated coverage.
|
||||||
|
|
||||||
|
- `tests/unit`: the main source of regression coverage.
|
||||||
|
- Playwright covers end-to-end behavior from the app boundary.
|
||||||
|
|
||||||
|
For architecture-sensitive changes, read the nearest unit tests before editing the implementation.
|
||||||
|
|
||||||
|
### `scripts`
|
||||||
|
|
||||||
|
Repository utilities and generated-asset workflows.
|
||||||
|
|
||||||
|
- `scripts/sync-openclaw-gateway-client.ts` updates the vendored gateway client helpers.
|
||||||
|
- `scripts/studio-setup.js` prepares common local Studio prerequisites.
|
||||||
|
|
||||||
|
## Read These First
|
||||||
|
|
||||||
|
If you are new to the codebase, this order gives the fastest payoff:
|
||||||
|
|
||||||
|
1. `README.md`.
|
||||||
|
2. `ARCHITECTURE.md`.
|
||||||
|
3. `src/app/office/page.tsx`.
|
||||||
|
4. `src/features/office/screens/OfficeScreen.tsx`.
|
||||||
|
5. `src/features/agents/state/gatewayRuntimeEventHandler.ts`.
|
||||||
|
6. `src/features/agents/state/runtimeEventCoordinatorWorkflow.ts`.
|
||||||
|
7. `src/lib/office/eventTriggers.ts`.
|
||||||
|
8. `src/lib/office/deskDirectives.ts`.
|
||||||
|
9. `src/features/retro-office/RetroOffice3D.tsx`.
|
||||||
|
10. `src/features/retro-office/core/navigation.ts`.
|
||||||
|
|
||||||
|
## Main Runtime Flow
|
||||||
|
|
||||||
|
At a high level:
|
||||||
|
|
||||||
|
1. The browser connects to Studio at `/api/gateway/ws`.
|
||||||
|
2. Studio proxies that connection to the upstream OpenClaw Gateway.
|
||||||
|
3. `GatewayClient` receives runtime events.
|
||||||
|
4. `src/app/office/page.tsx` installs the main runtime subscription.
|
||||||
|
5. `gatewayRuntimeEventHandler.ts` classifies and routes runtime events.
|
||||||
|
6. Runtime workflow modules plan state updates and effect commands.
|
||||||
|
7. History sync pulls canonical `chat.history` when live streams are incomplete or transport-specific.
|
||||||
|
8. Agent UI and office UI both consume the resulting agent/session state.
|
||||||
|
|
||||||
|
Important runtime files:
|
||||||
|
|
||||||
|
- `src/lib/gateway/GatewayClient.ts`: transport contract and session-key helpers.
|
||||||
|
- `src/features/agents/state/gatewayRuntimeEventHandler.ts`: runtime event orchestrator.
|
||||||
|
- `src/features/agents/state/runtimeChatEventWorkflow.ts`: chat stream planning.
|
||||||
|
- `src/features/agents/state/runtimeAgentEventWorkflow.ts`: agent/lifecycle stream planning.
|
||||||
|
- `src/features/agents/state/runtimeTerminalWorkflow.ts`: terminal and closed-run handling.
|
||||||
|
- `src/features/agents/state/runtimeEventCoordinatorWorkflow.ts`: reducer/effect bridge for runtime commands.
|
||||||
|
- `src/features/agents/operations/historySyncOperation.ts`: canonical history reconciliation.
|
||||||
|
- `src/features/agents/state/transcript.ts`: transcript entry model and history merge logic.
|
||||||
|
|
||||||
|
## Office Architecture
|
||||||
|
|
||||||
|
There are two office-related stacks in this repository:
|
||||||
|
|
||||||
|
- The immersive live office at `/office`, powered by React Three Fiber and `src/features/retro-office`.
|
||||||
|
- The builder/editor stack at `/office/builder`, powered by Phaser and `src/features/office`.
|
||||||
|
|
||||||
|
These systems are related, but they are not the same runtime.
|
||||||
|
|
||||||
|
### Immersive Office Stack
|
||||||
|
|
||||||
|
Key files:
|
||||||
|
|
||||||
|
- `src/features/office/screens/OfficeScreen.tsx`: office composition root. It connects gateway state, debug/export tools, standup state, desk assignment persistence, and the 3D scene.
|
||||||
|
- `src/lib/office/eventTriggers.ts`: derives office animation/interaction holds from runtime events and agent transcript state.
|
||||||
|
- `src/lib/office/deskDirectives.ts`: parses user text into a unified office intent snapshot.
|
||||||
|
- `src/features/retro-office/RetroOffice3D.tsx`: renders the 3D world and consumes the derived animation state.
|
||||||
|
- `src/features/retro-office/core/navigation.ts`: builds nav grids, resolves destinations, and exports specialized route helpers.
|
||||||
|
- `src/features/retro-office/core/furnitureDefaults.ts`: default room/object layout plus migration-style ensure helpers.
|
||||||
|
|
||||||
|
### Builder Stack
|
||||||
|
|
||||||
|
Key files:
|
||||||
|
|
||||||
|
- `src/features/office/components/OfficeBuilderPanel.tsx`.
|
||||||
|
- `src/features/office/components/OfficePhaserCanvas.tsx`.
|
||||||
|
- `src/features/office/phaser/OfficeBuilderScene.ts`.
|
||||||
|
- `src/features/office/phaser/OfficeViewerScene.ts`.
|
||||||
|
- `src/lib/office/schema.ts`.
|
||||||
|
|
||||||
|
The builder uses the `OfficeMap` schema. The immersive retro office still has its own furniture/defaults/persistence pipeline. When touching office code, confirm which stack you are actually changing.
|
||||||
|
|
||||||
|
## Office Intent Layer
|
||||||
|
|
||||||
|
`src/lib/office/deskDirectives.ts` is the single entry point for natural-language office directives.
|
||||||
|
|
||||||
|
This is one of the most important conventions in the repo:
|
||||||
|
|
||||||
|
- New room or behavior triggers should be parsed here first.
|
||||||
|
- Runtime events from Telegram, WhatsApp, UI chat, or other transport-specific sessions should not require separate directive parsers.
|
||||||
|
- Consumers should prefer `resolveOfficeIntentSnapshot()` over adding one-off regex checks elsewhere.
|
||||||
|
|
||||||
|
Current intent categories include:
|
||||||
|
|
||||||
|
- Desk holds and releases.
|
||||||
|
- GitHub or server-room review holds.
|
||||||
|
- Gym commands and skill-building gym intents.
|
||||||
|
- QA lab holds and releases.
|
||||||
|
- Standup meeting requests.
|
||||||
|
|
||||||
|
If you add another room or action, first ask: can it be expressed as another field in `OfficeIntentSnapshot`?
|
||||||
|
|
||||||
|
## How Office Motion Works
|
||||||
|
|
||||||
|
Office motion is derived, not pushed directly into the scene.
|
||||||
|
|
||||||
|
1. Runtime events arrive from the gateway.
|
||||||
|
2. `reduceOfficeAnimationTriggerEvent()` records immediate latches such as working, streaming, thinking, and fresh user directives.
|
||||||
|
3. `reconcileOfficeAnimationTriggerState()` re-derives durable holds from current agent state and transcript history.
|
||||||
|
4. `buildOfficeAnimationState()` collapses the trigger state into the smaller shape consumed by the scene.
|
||||||
|
5. `RetroOffice3D` turns that state into concrete destinations, paths, overlays, and temporary actors.
|
||||||
|
|
||||||
|
This separation is important because it keeps transport-specific runtime details out of the 3D scene.
|
||||||
|
|
||||||
|
## How To Add A New 3D Object
|
||||||
|
|
||||||
|
For a new static or interactive object:
|
||||||
|
|
||||||
|
1. Add geometry and footprint rules in `src/features/retro-office/core/geometry.ts` if the object needs sizing, bounds, snapping, or rotation support.
|
||||||
|
2. Add default placement in `src/features/retro-office/core/furnitureDefaults.ts` if the object should exist in the default office.
|
||||||
|
3. Add rendering support in one of:
|
||||||
|
- `src/features/retro-office/objects/furniture.tsx`.
|
||||||
|
- `src/features/retro-office/objects/primitives.tsx`.
|
||||||
|
- `src/features/retro-office/objects/machines.tsx`.
|
||||||
|
- Another focused object file if the object family deserves its own module.
|
||||||
|
4. Wire the item type into the `RetroOffice3D.tsx` render switch if needed.
|
||||||
|
5. If the object affects navigation, add its type to the blocking/target logic in `src/features/retro-office/core/navigation.ts`.
|
||||||
|
|
||||||
|
Good examples:
|
||||||
|
|
||||||
|
- Server-room objects in `src/features/retro-office/objects/machines.tsx`.
|
||||||
|
- Environment primitives in `src/features/retro-office/objects/primitives.tsx`.
|
||||||
|
|
||||||
|
## How To Add A New Room Or Activity
|
||||||
|
|
||||||
|
For a new room that agents can intentionally visit:
|
||||||
|
|
||||||
|
1. Add room objects and defaults in `src/features/retro-office/core/furnitureDefaults.ts`.
|
||||||
|
2. Add navigation targets in `src/features/retro-office/core/navigation.ts`.
|
||||||
|
3. If the room needs staged entry behavior, add a dedicated helper under `src/features/retro-office/core/navigation/`.
|
||||||
|
4. Extend `OfficeIntentSnapshot` in `src/lib/office/deskDirectives.ts`.
|
||||||
|
5. Update `src/lib/office/eventTriggers.ts` so the new intent becomes a derived hold or request.
|
||||||
|
6. Update `RetroOffice3D.tsx` so `useAgentTick()` maps that hold to a real target and interaction state.
|
||||||
|
7. Add or update unit tests around the new intent and trigger behavior.
|
||||||
|
|
||||||
|
Current examples to follow:
|
||||||
|
|
||||||
|
- `navigation/gymRoute.ts`.
|
||||||
|
- `navigation/serverRoomRoute.ts`.
|
||||||
|
- `navigation/qaLabRoute.ts`.
|
||||||
|
- `tests/unit/deskDirectives.test.ts`.
|
||||||
|
- `tests/unit/officeEventTriggers.test.ts`.
|
||||||
|
|
||||||
|
## How Desk Assignment Works
|
||||||
|
|
||||||
|
Desk ownership is explicit now.
|
||||||
|
|
||||||
|
- Desk assignments are stored in Studio settings, not inferred sequentially.
|
||||||
|
- `OfficeScreen.tsx` loads and persists `deskAssignmentByDeskUid`.
|
||||||
|
- `RetroOffice3D.tsx` resolves assigned desk indexes from those persisted mappings.
|
||||||
|
- Unassigned agents are intentionally safe and should not wander to random desks.
|
||||||
|
|
||||||
|
If you change desk semantics, make sure the Studio settings contract and the retro-office consumer stay aligned.
|
||||||
|
|
||||||
|
## API Route Inventory
|
||||||
|
|
||||||
|
Current `src/app/api` routes:
|
||||||
|
|
||||||
|
- `studio/route.ts`: load and patch local Studio settings.
|
||||||
|
- `path-suggestions/route.ts`: local filesystem path suggestions.
|
||||||
|
- `office/route.ts`: office layout/builder persistence.
|
||||||
|
- `office/publish/route.ts`: publish office maps.
|
||||||
|
- `office/github/route.ts`: GitHub-related office flow helpers.
|
||||||
|
- `office/browser-preview/route.ts`: browser preview helpers for office experiences.
|
||||||
|
- `office/presence/route.ts`: office presence/state helpers.
|
||||||
|
- `office/voice/transcribe/route.ts`: voice transcription.
|
||||||
|
- `office/voice/reply/route.ts`: voice reply generation.
|
||||||
|
- `office/standup/config/route.ts`: standup config persistence.
|
||||||
|
- `office/standup/meeting/route.ts`: standup meeting state helpers.
|
||||||
|
- `office/standup/run/route.ts`: standup run execution.
|
||||||
|
- `gateway/media/route.ts`: gateway-backed media access.
|
||||||
|
- `gateway/agent-state/route.ts`: gateway-backed agent state operations.
|
||||||
|
- `gateway/skills/remove/route.ts`: gateway-backed skill removal flow.
|
||||||
|
|
||||||
|
When adding a new API route, keep it narrow and put shared business logic in `src/lib` or a feature operation module instead of the route handler itself.
|
||||||
|
|
||||||
|
## Scripts Worth Knowing
|
||||||
|
|
||||||
|
- `npm run dev`: starts the Studio dev server.
|
||||||
|
- `npm run build`: production build.
|
||||||
|
- `npm run start`: production server.
|
||||||
|
- `npm run lint`: ESLint.
|
||||||
|
- `npm run typecheck`: TypeScript without emit.
|
||||||
|
- `npm run test`: Vitest.
|
||||||
|
- `npm run e2e`: Playwright.
|
||||||
|
- `npm run studio:setup`: local Studio prerequisites.
|
||||||
|
- `npm run sync:gateway-client`: sync the vendored gateway browser client.
|
||||||
|
- `npm run smoke:dev-server`: basic dev-server smoke check.
|
||||||
|
|
||||||
|
## Testing Map
|
||||||
|
|
||||||
|
Start with the tests closest to the subsystem you are touching.
|
||||||
|
|
||||||
|
Useful examples:
|
||||||
|
|
||||||
|
- `tests/unit/deskDirectives.test.ts`: office intent parsing.
|
||||||
|
- `tests/unit/officeEventTriggers.test.ts`: office trigger derivation.
|
||||||
|
- `tests/unit/janitorActors.test.ts` and `tests/unit/janitorReset.test.ts`: janitor and reset cues.
|
||||||
|
- `tests/unit/gatewayRuntimeEventHandler.chat.test.ts`.
|
||||||
|
- `tests/unit/gatewayRuntimeEventHandler.agent.test.ts`.
|
||||||
|
- `tests/unit/runtimeEventCoordinatorWorkflow.test.ts`.
|
||||||
|
- `tests/unit/runtimeTerminalWorkflow.test.ts`.
|
||||||
|
- `tests/unit/historySyncOperation.test.ts`.
|
||||||
|
- `tests/unit/transcript.test.ts`.
|
||||||
|
- `tests/unit/studioDeskAssignments.test.ts`.
|
||||||
|
|
||||||
|
If you introduce a new intent, route, or runtime reduction rule, add unit coverage in the same area before relying on manual testing.
|
||||||
|
|
||||||
|
## Folder Structure Conventions
|
||||||
|
|
||||||
|
A few patterns are used repeatedly in the repo:
|
||||||
|
|
||||||
|
- Route files in `src/app/*` compose feature modules but should not become the main home for business logic.
|
||||||
|
- `src/features/<area>/operations` usually contains orchestration logic with side effects.
|
||||||
|
- `src/features/<area>/state` usually contains reducers, workflow planners, and state models.
|
||||||
|
- `src/lib/<domain>` usually contains pure helpers, adapters, contracts, and persistence helpers shared across features.
|
||||||
|
- In `src/features/retro-office/core/navigation/`, each route helper gets its own file when the path logic becomes room-specific.
|
||||||
|
|
||||||
|
## Contributor Footguns
|
||||||
|
|
||||||
|
- The immersive retro office and the Phaser builder are separate systems. Verify which one you need before editing.
|
||||||
|
- The Gateway is the source of truth for runtime state. Avoid inventing local parallel state for sessions, runs, or transcripts.
|
||||||
|
- Studio settings are local and per-workspace/gateway. Use them for UI preferences, desk assignments, and connection details only.
|
||||||
|
- Transport-specific session keys such as Telegram sessions still need to map back to the correct agent. Reuse session-key helpers instead of writing ad-hoc parsing.
|
||||||
|
- The immersive retro office now uses procedural furniture geometry instead of bundled third-party model assets.
|
||||||
|
- This repo is not the OpenClaw runtime. Do not modify upstream OpenClaw source code from here.
|
||||||
|
|
||||||
|
## When You Need Upstream OpenClaw Context
|
||||||
|
|
||||||
|
Sometimes Claw3D behavior depends on the upstream event contract or session behavior. In those cases:
|
||||||
|
|
||||||
|
1. Inspect the relevant client or gateway contract in `src/lib/gateway`.
|
||||||
|
2. If the answer is not in this repo, inspect your separate local OpenClaw checkout.
|
||||||
|
3. Apply changes in Claw3D unless the user explicitly asked for upstream OpenClaw work.
|
||||||
|
|
||||||
|
## Documentation Philosophy
|
||||||
|
|
||||||
|
Keep these docs useful by preferring:
|
||||||
|
|
||||||
|
- File paths over vague descriptions.
|
||||||
|
- Extension points over exhaustive inventories of every component.
|
||||||
|
- Stable contracts over temporary implementation details.
|
||||||
|
- Focused inline comments in hard-to-read architecture hotspots instead of comment-heavy code everywhere.
|
||||||
@@ -6,6 +6,8 @@ A 3D workspace for AI agents.
|
|||||||
|
|
||||||
Claw3D turns AI automation into a visual workplace where agents collaborate, review code, run tests, train skills, and execute tasks inside a shared 3D environment.
|
Claw3D turns AI automation into a visual workplace where agents collaborate, review code, run tests, train skills, and execute tasks inside a shared 3D environment.
|
||||||
|
|
||||||
|
Built and maintained by LukeTheDev. Follow on X: [@iamlukethedev](https://x.com/iamlukethedev).
|
||||||
|
|
||||||
Think of it as:
|
Think of it as:
|
||||||
|
|
||||||
An office for your AI team.
|
An office for your AI team.
|
||||||
@@ -33,7 +35,6 @@ Claw3D is the visualization and interaction layer.
|
|||||||
|
|
||||||
In practical terms, this app gives you:
|
In practical terms, this app gives you:
|
||||||
|
|
||||||
- a focused `/agents` workspace for fleet management, chat, approvals, settings, and runtime monitoring
|
|
||||||
- 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 agent state in OpenClaw while Studio stores local UI preferences
|
||||||
@@ -77,11 +78,12 @@ Prerequisite:
|
|||||||
- Claw3D does not install, build, or run OpenClaw for you.
|
- Claw3D does not install, build, or run OpenClaw 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, make sure your OpenClaw gateway is already running and that you know the gateway URL and token you want Studio to use.
|
||||||
- This repository is the UI and Studio/proxy layer only.
|
- This repository is the UI and Studio/proxy layer only.
|
||||||
|
- If you need a full cross-machine setup guide (OpenClaw + Tailscale + Claw3D), follow [`TUTORIAL.md`](TUTORIAL.md).
|
||||||
|
|
||||||
Run from source:
|
Run from source:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/libinysolutions/openclaw-control-center.git claw3d
|
git clone <your-public-repo-url> claw3d
|
||||||
cd claw3d
|
cd claw3d
|
||||||
npm install
|
npm install
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
@@ -173,31 +175,23 @@ See [`.env.example`](.env.example) for the full local development template.
|
|||||||
- `npm run e2e` runs Playwright tests.
|
- `npm run e2e` runs Playwright tests.
|
||||||
- `npm run studio:setup` prepares common local Studio prerequisites.
|
- `npm run studio:setup` prepares common local Studio prerequisites.
|
||||||
- `npm run smoke:dev-server` runs a basic dev-server smoke check.
|
- `npm run smoke:dev-server` runs a basic dev-server smoke check.
|
||||||
- `npm run office:assets` rebuilds office atlas assets.
|
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- [`VISION.md`](VISION.md): project direction and long-term guardrails.
|
- [`VISION.md`](VISION.md): project direction and long-term guardrails.
|
||||||
- [`ARCHITECTURE.md`](ARCHITECTURE.md): system boundaries, data flow, and major trade-offs.
|
- [`ARCHITECTURE.md`](ARCHITECTURE.md): system boundaries, data flow, and major trade-offs.
|
||||||
|
- [`TUTORIAL.md`](TUTORIAL.md): detailed step-by-step setup for OpenClaw + Tailscale + Claw3D.
|
||||||
- [`CODE_DOCUMENTATION.md`](CODE_DOCUMENTATION.md): practical code map, extension points, and contributor onboarding order.
|
- [`CODE_DOCUMENTATION.md`](CODE_DOCUMENTATION.md): practical code map, extension points, and contributor onboarding order.
|
||||||
- [`KNOWN_ISSUES.md`](KNOWN_ISSUES.md): current limitations and publication caveats.
|
|
||||||
- [`THIRD_PARTY_ASSETS.md`](THIRD_PARTY_ASSETS.md): bundled asset provenance and open questions.
|
|
||||||
- [`THIRD_PARTY_CODE.md`](THIRD_PARTY_CODE.md): vendored code and dependency disclosure notes.
|
|
||||||
- [`CONTRIBUTING.md`](CONTRIBUTING.md): local workflow, testing, and PR expectations.
|
- [`CONTRIBUTING.md`](CONTRIBUTING.md): local workflow, testing, and PR expectations.
|
||||||
- [`SUPPORT.md`](SUPPORT.md): where to ask for help and how to route reports.
|
- [`SUPPORT.md`](SUPPORT.md): where to ask for help and how to route reports.
|
||||||
- [`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/ui-guide.md`](docs/ui-guide.md): current IA and user-facing Studio behavior.
|
|
||||||
- [`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/color-system.md`](docs/color-system.md): semantic color usage guidelines.
|
|
||||||
|
|
||||||
## Current Limitations
|
## Current Limitations
|
||||||
|
|
||||||
- The immersive retro office (`/office`) and the Phaser builder (`/office/builder`) are related but still separate stacks.
|
- The immersive retro office (`/office`) and the Phaser builder (`/office/builder`) are related but still separate stacks.
|
||||||
- Some bundled assets and one GitHub-sourced dependency are still documented as redistributability follow-ups rather than fully cleared artifacts.
|
|
||||||
- The Studio access gate currently supports a legacy one-time query bootstrap flow for setting its cookie. Avoid using it on shared machines and clear browser history if you do.
|
|
||||||
- The app keeps gateway secrets out of browser persistent storage, but the current connection flow still loads the upstream URL/token into browser memory at runtime.
|
- The app keeps gateway secrets out of browser persistent storage, but the current connection flow still loads the upstream URL/token into browser memory at runtime.
|
||||||
- The repo still has known lint, test, build, and smoke-check blockers documented in `KNOWN_ISSUES.md`.
|
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
@@ -205,11 +199,17 @@ 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.
|
||||||
- `401 Studio access token required` usually means `STUDIO_ACCESS_TOKEN` is enabled; the current bootstrap flow uses a one-time query parameter to set an HttpOnly cookie. Treat that as a temporary local-admin flow and avoid sharing the resulting URL.
|
- `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`.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Keep pull requests focused, run `npm run lint`, `npm run typecheck`, and `npm run test` before opening a PR, and update docs when behavior or architecture changes.
|
Keep pull requests focused, run `npm run lint`, `npm run typecheck`, and `npm run test` before opening a PR, and update docs when behavior or architecture changes.
|
||||||
|
|
||||||
|
## AI Editing Guardrails
|
||||||
|
|
||||||
|
If you use Cursor or another AI-assisted workflow, review the committed project guardrails in [`.cursor/rules/claw3d-project-guardrails.mdc`](.cursor/rules/claw3d-project-guardrails.mdc).
|
||||||
|
|
||||||
|
That rule file captures the shared editing expectations for this repository, including the Claw3D-vs-OpenClaw boundary, code placement conventions, office-stack distinctions, and documentation/test update expectations.
|
||||||
|
|
||||||
Community expectations live in [`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md). Security reporting instructions live in [`SECURITY.md`](SECURITY.md).
|
Community expectations live in [`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md). Security reporting instructions live in [`SECURITY.md`](SECURITY.md).
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ We aim to acknowledge reports promptly, investigate them, and coordinate a fix a
|
|||||||
|
|
||||||
- Studio gateway settings are stored on disk in plaintext under the local OpenClaw state directory.
|
- Studio gateway settings are stored on disk in plaintext under the local OpenClaw state directory.
|
||||||
- The current UI loads the configured upstream gateway URL/token into browser memory at runtime, even though those values are not stored in browser persistent storage.
|
- The current UI loads the configured upstream gateway URL/token into browser memory at runtime, even though those values are not stored in browser persistent storage.
|
||||||
- The Studio access gate still supports a legacy query-parameter bootstrap flow for setting its access cookie when `STUDIO_ACCESS_TOKEN` is enabled.
|
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
|
|||||||
+276
@@ -0,0 +1,276 @@
|
|||||||
|
# Claw3D + OpenClaw + Tailscale Setup Tutorial
|
||||||
|
|
||||||
|
This guide is a step-by-step runbook for the most common production-like setup:
|
||||||
|
|
||||||
|
- **Machine A** runs **OpenClaw Gateway**.
|
||||||
|
- **Machine B** runs **Claw3D**.
|
||||||
|
- **Tailscale** connects both machines securely.
|
||||||
|
|
||||||
|
If you follow this exactly, people should avoid the most common confusion: **Claw3D does not install or run OpenClaw for you.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0) Architecture and Responsibilities
|
||||||
|
|
||||||
|
- **OpenClaw** is the runtime and Gateway.
|
||||||
|
- **Claw3D** is the UI and Studio proxy.
|
||||||
|
- Claw3D connects to an already running OpenClaw Gateway.
|
||||||
|
- In this tutorial, the Gateway lives on a different machine from Claw3D.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1) Prerequisites
|
||||||
|
|
||||||
|
### Machine A (Gateway host)
|
||||||
|
|
||||||
|
- macOS, Linux, or WSL2.
|
||||||
|
- Internet access.
|
||||||
|
- Ability to install OpenClaw and Tailscale.
|
||||||
|
|
||||||
|
### Machine B (Claw3D host)
|
||||||
|
|
||||||
|
- Node.js `20+` recommended for this repo.
|
||||||
|
- npm `10+` recommended.
|
||||||
|
- Internet access.
|
||||||
|
- Ability to install Tailscale.
|
||||||
|
|
||||||
|
### Accounts and permissions
|
||||||
|
|
||||||
|
- A Tailscale account for your tailnet.
|
||||||
|
- If your tailnet uses device approval, you need Owner/Admin/IT admin access in Tailscale admin.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) Install and Start OpenClaw on Machine A
|
||||||
|
|
||||||
|
OpenClaw official install docs are here: [Install](https://docs.openclaw.ai/install/index.md) and [Getting Started](https://docs.openclaw.ai/start/getting-started.md).
|
||||||
|
|
||||||
|
### 2.1 Install OpenClaw
|
||||||
|
|
||||||
|
On **Machine A**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://openclaw.ai/install.sh | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Run onboarding and install daemon
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openclaw onboard --install-daemon
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 Verify Gateway health
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openclaw gateway status
|
||||||
|
openclaw status
|
||||||
|
```
|
||||||
|
|
||||||
|
You want a healthy result such as runtime running and RPC probe ok.
|
||||||
|
|
||||||
|
### 2.4 Get your Gateway token
|
||||||
|
|
||||||
|
You will need this token in Claw3D:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openclaw config get gateway.auth.token
|
||||||
|
```
|
||||||
|
|
||||||
|
Store it securely.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3) Install and Authorize Tailscale on Both Machines
|
||||||
|
|
||||||
|
Tailscale docs: [Serve overview](https://tailscale.com/kb/1312/serve), [Serve CLI](https://tailscale.com/docs/reference/tailscale-cli/serve), and [Device approval](https://tailscale.com/kb/1099/device-approval).
|
||||||
|
|
||||||
|
### 3.1 Install Tailscale
|
||||||
|
|
||||||
|
Install Tailscale on **Machine A** and **Machine B** using official installers: [Tailscale downloads](https://tailscale.com/download).
|
||||||
|
|
||||||
|
### 3.2 Join both machines to the same tailnet
|
||||||
|
|
||||||
|
On each machine:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tailscale up
|
||||||
|
tailscale status
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirm both machines appear in the same tailnet.
|
||||||
|
|
||||||
|
### 3.3 If your tailnet requires approval, approve devices
|
||||||
|
|
||||||
|
In Tailscale admin:
|
||||||
|
|
||||||
|
1. Open [Machines](https://login.tailscale.com/admin/machines).
|
||||||
|
2. Find devices marked **Needs approval**.
|
||||||
|
3. Approve both Machine A and Machine B.
|
||||||
|
|
||||||
|
Without this, the machines cannot communicate over tailnet traffic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4) Expose OpenClaw Gateway Through Tailscale on Machine A
|
||||||
|
|
||||||
|
You have two valid ways. Pick one.
|
||||||
|
|
||||||
|
### Option A (simple and explicit): Tailscale Serve command
|
||||||
|
|
||||||
|
On **Machine A**, keep Gateway bound locally (`127.0.0.1:18789`) and publish through Serve:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tailscale serve --yes --bg --https=443 http://127.0.0.1:18789
|
||||||
|
tailscale serve status
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- Newer Tailscale CLI uses `--https=443`.
|
||||||
|
- If you are on older docs/commands, you may see syntax like `--https 443`. Use `tailscale serve --help` on your installed version.
|
||||||
|
|
||||||
|
### Option B (OpenClaw-managed Tailscale mode)
|
||||||
|
|
||||||
|
OpenClaw can manage Tailscale mode itself:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openclaw gateway --tailscale serve
|
||||||
|
```
|
||||||
|
|
||||||
|
OpenClaw Tailscale docs: [Gateway Tailscale](https://docs.openclaw.ai/gateway/tailscale.md).
|
||||||
|
|
||||||
|
### 4.1 Confirm the public tailnet URL
|
||||||
|
|
||||||
|
You need the `https://<gateway-host>.<tailnet>.ts.net` host.
|
||||||
|
|
||||||
|
This host is what Claw3D will use as `wss://<gateway-host>.<tailnet>.ts.net`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5) Install and Run Claw3D on Machine B
|
||||||
|
|
||||||
|
On **Machine B**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/iamlukethedev/Claw3D.git claw3d
|
||||||
|
cd claw3d
|
||||||
|
npm install
|
||||||
|
cp .env.example .env
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open:
|
||||||
|
|
||||||
|
- `http://localhost:3000`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6) Connect Claw3D to OpenClaw
|
||||||
|
|
||||||
|
In Claw3D connection UI:
|
||||||
|
|
||||||
|
1. Set **Gateway URL** to:
|
||||||
|
- `wss://<gateway-host>.<tailnet>.ts.net`
|
||||||
|
2. Paste the token from Machine A (`openclaw config get gateway.auth.token`).
|
||||||
|
3. Click **Connect**.
|
||||||
|
|
||||||
|
Important:
|
||||||
|
|
||||||
|
- Use `wss://` for Tailscale HTTPS endpoints.
|
||||||
|
- Use `ws://localhost:18789` only when Gateway is local to the same machine as Claw3D or when using an SSH tunnel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7) Required Device-Pairing Approval Step
|
||||||
|
|
||||||
|
This is the step people often miss.
|
||||||
|
|
||||||
|
After Claw3D is running and tries to connect for the first time, approve pending device pairing on **Machine A**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openclaw devices list
|
||||||
|
openclaw devices approve --latest
|
||||||
|
```
|
||||||
|
|
||||||
|
OpenClaw devices docs: [openclaw devices](https://docs.openclaw.ai/cli/devices.md).
|
||||||
|
|
||||||
|
If multiple requests are pending, approve by id instead:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openclaw devices approve <requestId>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8) Verification Checklist
|
||||||
|
|
||||||
|
Run this checklist in order:
|
||||||
|
|
||||||
|
1. `openclaw gateway status` on Machine A shows healthy runtime.
|
||||||
|
2. `tailscale status` on both machines shows connected devices in same tailnet.
|
||||||
|
3. `tailscale serve status` on Machine A shows active Serve config for port `443` to `127.0.0.1:18789`.
|
||||||
|
4. Claw3D connect UI uses `wss://...ts.net` plus valid token.
|
||||||
|
5. `openclaw devices approve --latest` has been run after first connect attempt.
|
||||||
|
6. Claw3D UI shows gateway connected and loads agents.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9) Troubleshooting
|
||||||
|
|
||||||
|
### `EPROTO` or `wrong version number`
|
||||||
|
|
||||||
|
- Usually means protocol mismatch.
|
||||||
|
- Fix: if your endpoint is HTTPS/Tailscale Serve, use `wss://...`.
|
||||||
|
- Do not use `wss://` against a plain `ws://` endpoint.
|
||||||
|
|
||||||
|
### `401` or auth errors from Claw3D
|
||||||
|
|
||||||
|
- Re-copy token from Machine A:
|
||||||
|
- `openclaw config get gateway.auth.token`.
|
||||||
|
- Confirm Gateway auth mode and token are current.
|
||||||
|
|
||||||
|
### Claw3D still cannot connect after token is correct
|
||||||
|
|
||||||
|
- Approve pending device:
|
||||||
|
- `openclaw devices approve --latest`.
|
||||||
|
- Check pending requests:
|
||||||
|
- `openclaw devices list`.
|
||||||
|
|
||||||
|
### Tailscale URL works nowhere
|
||||||
|
|
||||||
|
- Confirm both devices are approved in Tailscale admin if device approval is enabled.
|
||||||
|
- Re-run:
|
||||||
|
- `tailscale status`.
|
||||||
|
- `tailscale serve status`.
|
||||||
|
- Recreate serve config if needed:
|
||||||
|
- `tailscale serve reset`.
|
||||||
|
- `tailscale serve --yes --bg --https=443 http://127.0.0.1:18789`.
|
||||||
|
|
||||||
|
### Gateway itself is unhealthy
|
||||||
|
|
||||||
|
- Run:
|
||||||
|
- `openclaw doctor`.
|
||||||
|
- `openclaw gateway restart`.
|
||||||
|
- `openclaw gateway status`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10) Security Notes
|
||||||
|
|
||||||
|
- Keep Gateway bound to loopback unless you have a deliberate reason not to.
|
||||||
|
- Do not commit tokens into git or `.env` files intended for sharing.
|
||||||
|
- Prefer Tailscale Serve over exposing raw Gateway ports publicly.
|
||||||
|
- Treat OpenClaw device pairing approval as a security gate, not a one-time annoyance.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- OpenClaw install: [docs.openclaw.ai/install/index.md](https://docs.openclaw.ai/install/index.md).
|
||||||
|
- OpenClaw getting started: [docs.openclaw.ai/start/getting-started.md](https://docs.openclaw.ai/start/getting-started.md).
|
||||||
|
- OpenClaw gateway runbook: [docs.openclaw.ai/gateway/index.md](https://docs.openclaw.ai/gateway/index.md).
|
||||||
|
- OpenClaw devices CLI: [docs.openclaw.ai/cli/devices.md](https://docs.openclaw.ai/cli/devices.md).
|
||||||
|
- OpenClaw tailscale gateway mode: [docs.openclaw.ai/gateway/tailscale.md](https://docs.openclaw.ai/gateway/tailscale.md).
|
||||||
|
- Tailscale Serve: [tailscale.com/kb/1312/serve](https://tailscale.com/kb/1312/serve).
|
||||||
|
- Tailscale serve CLI: [tailscale.com/docs/reference/tailscale-cli/serve](https://tailscale.com/docs/reference/tailscale-cli/serve).
|
||||||
|
- Tailscale device approval: [tailscale.com/kb/1099/device-approval](https://tailscale.com/kb/1099/device-approval).
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/app/globals.css",
|
||||||
|
"baseColor": "zinc",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
# Permissions, Sandboxing, and Workspaces (Studio -> Gateway -> PI)
|
||||||
|
|
||||||
|
This document exists to onboard coding agents quickly when debugging:
|
||||||
|
- Why an agent can or cannot read/write files
|
||||||
|
- Why command execution requires approvals (or not)
|
||||||
|
- Why a sandboxed run behaves differently from a non-sandboxed run
|
||||||
|
- How “create agent” choices in **Claw3D** flow into the **OpenClaw Gateway** (often running on an EC2 host) where enforcement actually happens
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- Studio one-step agent creation and post-create authority updates, including exact gateway calls.
|
||||||
|
- The upstream OpenClaw implementation that persists and enforces those settings at runtime.
|
||||||
|
|
||||||
|
Non-scope:
|
||||||
|
- Full PI internal reasoning/toolchain. Studio does not implement PI logic; it configures and displays the Gateway session.
|
||||||
|
- Any private EC2 runbook or SSH/hostnames. Keep this doc repo-safe.
|
||||||
|
|
||||||
|
## Mental Model (First Principles)
|
||||||
|
|
||||||
|
Studio is a UI + proxy. It does two things related to “permissions”:
|
||||||
|
1. Writes **configuration** into the Gateway (per-agent overrides in `openclaw.json`).
|
||||||
|
2. Writes **policy** into the Gateway (per-agent exec approvals in `exec-approvals.json`).
|
||||||
|
|
||||||
|
The Gateway (OpenClaw) is the enforcement point:
|
||||||
|
- It decides whether a session is sandboxed.
|
||||||
|
- It decides which workspace is mounted into the sandbox.
|
||||||
|
- It constructs the PI toolset (read/write/edit/apply_patch/exec/etc) based on config + sandbox context.
|
||||||
|
- It asks for exec approvals when policy requires it and broadcasts approval events.
|
||||||
|
|
||||||
|
## Glossary
|
||||||
|
|
||||||
|
- **Gateway**: OpenClaw Gateway WebSocket server (upstream project).
|
||||||
|
- **Studio**: this repo. Next.js UI plus a Node WS proxy.
|
||||||
|
- **Agent**: an OpenClaw agent entry stored in gateway config (`agents.list[]`).
|
||||||
|
- **Session key**: OpenClaw session identifier. Studio uses `agent:<agentId>:<mainKey>` for the agent’s “main” session.
|
||||||
|
- **Agent workspace**: a directory on the Gateway host filesystem configured per-agent (where bootstrap files and edits live).
|
||||||
|
- **Sandbox workspace**: a separate directory used when a session is sandboxed and `workspaceAccess` is not `rw`.
|
||||||
|
- **Sandbox mode** (`sandbox.mode`): when to sandbox (`off`, `non-main`, `all`).
|
||||||
|
- **Workspace access** (`sandbox.workspaceAccess`): how the sandbox relates to the agent workspace (`none`, `ro`, `rw`).
|
||||||
|
- **Tool policy** (`tools.profile`, `tools.alsoAllow`, `tools.deny`): allow/deny gating for PI tools (OpenClaw resolves effective policy).
|
||||||
|
- **Exec approvals policy**: per-agent `{ security, ask, allowlist }` stored in exec approvals file; drives “Allow once / Always allow / Deny” UX.
|
||||||
|
|
||||||
|
## Studio: Where “Permissions” Are Chosen
|
||||||
|
|
||||||
|
Agent creation is intentionally lightweight:
|
||||||
|
- `src/features/agents/components/AgentCreateModal.tsx` captures `name` and optional avatar shuffle seed.
|
||||||
|
- `src/features/agents/operations/mutationLifecycleWorkflow.ts` applies queue/guard behavior and calls create.
|
||||||
|
- `src/lib/gateway/agentConfig.ts` (`createGatewayAgent`) performs `config.get` + `agents.create`.
|
||||||
|
|
||||||
|
After creation, Studio applies a permissive default capability envelope:
|
||||||
|
- Commands: `Auto`
|
||||||
|
- Web access: `On`
|
||||||
|
- File tools: `On`
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
- `src/app/page.tsx` (`handleCreateAgentSubmit`) applies `CREATE_AGENT_DEFAULT_PERMISSIONS`.
|
||||||
|
- `src/features/agents/operations/agentPermissionsOperation.ts` (`updateAgentPermissionsViaStudio`) persists those defaults.
|
||||||
|
|
||||||
|
Further capability changes happen from the `Capabilities` tab:
|
||||||
|
- `src/features/agents/operations/agentPermissionsOperation.ts` (`updateAgentPermissionsViaStudio`)
|
||||||
|
- updates per-agent exec approvals (`exec.approvals.get` + `exec.approvals.set`)
|
||||||
|
- updates tool-group overrides for runtime, web, and file access (`config.get` + `config.patch` via `updateGatewayAgentOverrides`)
|
||||||
|
- updates session exec behavior (`sessions.patch` via `syncGatewaySessionSettings`)
|
||||||
|
|
||||||
|
### Runtime Tool Groups Used By Capability Updates
|
||||||
|
|
||||||
|
Studio capability updates rely on OpenClaw tool-group expansion (`openclaw/src/agents/tool-policy.ts`), especially:
|
||||||
|
- `group:runtime` -> runtime execution tools (`exec`, `process`)
|
||||||
|
|
||||||
|
Internal mapping detail:
|
||||||
|
- Command mode `off|ask|auto` maps to role logic (`conservative|collaborative|autonomous`) for policy generation.
|
||||||
|
- UI exposes direct capability controls, not role labels.
|
||||||
|
|
||||||
|
## Studio -> Gateway: “Create Agent” End-to-End
|
||||||
|
|
||||||
|
Primary entry points:
|
||||||
|
- `src/features/agents/operations/mutationLifecycleWorkflow.ts`
|
||||||
|
- `src/lib/gateway/agentConfig.ts` (`createGatewayAgent`)
|
||||||
|
|
||||||
|
Sequence:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant UI as Studio UI
|
||||||
|
participant L as Create lifecycle
|
||||||
|
participant GC as Studio GatewayClient
|
||||||
|
participant G as OpenClaw Gateway
|
||||||
|
|
||||||
|
UI->>L: submit({ name, avatarSeed? })
|
||||||
|
L->>GC: createGatewayAgent(name)
|
||||||
|
GC->>G: config.get
|
||||||
|
G-->>GC: { path: ".../openclaw.json", ... }
|
||||||
|
GC->>G: agents.create({ name, workspace: "<stateDir>/workspace-<slug>" })
|
||||||
|
G-->>GC: { agentId, workspace }
|
||||||
|
L-->>UI: completion(agentId)
|
||||||
|
```
|
||||||
|
|
||||||
|
### How Studio Chooses the Default Workspace Path
|
||||||
|
|
||||||
|
Studio computes a default workspace path from the gateway’s config path:
|
||||||
|
- `src/lib/gateway/agentConfig.ts` (`createGatewayAgent`)
|
||||||
|
|
||||||
|
Logic:
|
||||||
|
1. Call `config.get` and read `snapshot.path` (the gateway host config path).
|
||||||
|
2. Compute `stateDir = dirname(configPath)`.
|
||||||
|
3. Compute `workspace = join(stateDir, "workspace-" + slugify(name))`.
|
||||||
|
4. Call `agents.create({ name, workspace })`.
|
||||||
|
|
||||||
|
Important: for a remote gateway (EC2), that `workspace` path refers to the gateway host filesystem, not your laptop.
|
||||||
|
|
||||||
|
## Studio: Sandbox Env Allowlist Sync (Current Scope)
|
||||||
|
|
||||||
|
Create flow does not perform setup writes during initial create anymore. If Studio needs to ensure sandbox env allowlist entries, that behavior should be attached to explicit settings/config operations rather than create-time side effects.
|
||||||
|
|
||||||
|
## OpenClaw (Upstream): What `agents.create` Actually Does
|
||||||
|
|
||||||
|
Gateway method:
|
||||||
|
- `openclaw/src/gateway/server-methods/agents.ts` (`"agents.create"`)
|
||||||
|
|
||||||
|
Key behaviors:
|
||||||
|
- Normalizes `agentId` from the provided `name` (and reserves `"default"`).
|
||||||
|
- Uses the provided `workspace` and resolves it to an absolute path.
|
||||||
|
- Writes a config entry for the agent (including the workspace dir and agent dir).
|
||||||
|
- Ensures the workspace directory exists and that bootstrap files exist (unless `agents.defaults.skipBootstrap` is set).
|
||||||
|
- Ensures the session transcripts directory exists for the agent.
|
||||||
|
- Writes the config file only after those directories exist (to avoid persisting a broken agent entry).
|
||||||
|
- Appends `- Name: ...` (and optional emoji/avatar) to `IDENTITY.md` in the workspace.
|
||||||
|
|
||||||
|
So: the “workspace” is not a UI-only concept; it is a real directory created on the Gateway host.
|
||||||
|
|
||||||
|
## OpenClaw (Upstream): Sandbox Semantics
|
||||||
|
|
||||||
|
Sandbox configuration resolution:
|
||||||
|
- `openclaw/src/agents/sandbox/config.ts` (`resolveSandboxConfigForAgent`)
|
||||||
|
|
||||||
|
Sandbox context creation (where workspace selection happens):
|
||||||
|
- `openclaw/src/agents/sandbox/context.ts` (`resolveSandboxContext`)
|
||||||
|
|
||||||
|
Docker mount behavior:
|
||||||
|
- `openclaw/src/agents/sandbox/docker.ts` (`createSandboxContainer`)
|
||||||
|
|
||||||
|
### Sandbox Mode (`sandbox.mode`)
|
||||||
|
|
||||||
|
Modes (as implemented upstream):
|
||||||
|
- `off`: sessions are not sandboxed.
|
||||||
|
- `all`: every session is sandboxed.
|
||||||
|
- `non-main`: sandbox all sessions except the agent’s main session key.
|
||||||
|
|
||||||
|
The “main session key” comparison is done against the configured main key, with alias-canonicalization:
|
||||||
|
- Upstream canonicalizes the session key before comparing so that main-session aliases are treated as “main” (see `canonicalizeMainSessionAlias` in upstream sandbox runtime-status).
|
||||||
|
- If `session.scope` is `global`, the main session key is `global` and `non-main` effectively means “sandbox everything except the global session”.
|
||||||
|
|
||||||
|
Upstream implementation reference:
|
||||||
|
- `openclaw/src/agents/sandbox/runtime-status.ts` (`resolveSandboxRuntimeStatus`)
|
||||||
|
|
||||||
|
### Sandbox Scope (`sandbox.scope`)
|
||||||
|
|
||||||
|
Sandbox scope controls how sandboxes are shared and therefore what persists between runs:
|
||||||
|
- `session`: per-session sandbox workspace/container (highest isolation, most churn)
|
||||||
|
- `agent`: per-agent sandbox workspace/container keyed by agent id (shared across that agent’s sessions)
|
||||||
|
- `shared`: one sandbox workspace/container shared across everything (lowest isolation)
|
||||||
|
|
||||||
|
Upstream implementation reference:
|
||||||
|
- `openclaw/src/agents/sandbox/types.ts` (`SandboxScope`)
|
||||||
|
- `openclaw/src/agents/sandbox/shared.ts` (`resolveSandboxScopeKey`)
|
||||||
|
|
||||||
|
### Workspace Access (`sandbox.workspaceAccess`)
|
||||||
|
|
||||||
|
Upstream behavior (important):
|
||||||
|
- `rw`:
|
||||||
|
- The sandbox uses the **agent workspace** as the sandbox root.
|
||||||
|
- PI filesystem tools (`read`/`write`/`edit`/`apply_patch`) operate on the agent workspace.
|
||||||
|
- `ro`:
|
||||||
|
- The sandbox uses a **sandbox workspace** as the sandbox root (writable sandbox dir).
|
||||||
|
- The real agent workspace is mounted at `/agent` read-only for command-line inspection.
|
||||||
|
- PI filesystem tools are additionally restricted: upstream disables write/edit/apply_patch in this mode (see below).
|
||||||
|
- `none`:
|
||||||
|
- The sandbox uses a **sandbox workspace** as the sandbox root.
|
||||||
|
- The agent workspace is not mounted into the container.
|
||||||
|
|
||||||
|
Sandbox workspace root default:
|
||||||
|
- `openclaw/src/agents/sandbox/constants.ts` uses `<STATE_DIR>/sandboxes` (where `STATE_DIR` defaults to `~/.openclaw` unless overridden by `OPENCLAW_STATE_DIR`).
|
||||||
|
|
||||||
|
Sandbox workspace seeding:
|
||||||
|
- When using a sandbox workspace root, upstream seeds missing bootstrap files from the agent workspace and ensures bootstrap exists:
|
||||||
|
- `openclaw/src/agents/sandbox/workspace.ts` (`ensureSandboxWorkspace`)
|
||||||
|
- The sandbox workspace also syncs skills from the agent workspace (best-effort) in `resolveSandboxContext`.
|
||||||
|
|
||||||
|
### Hard Enforcement: Filesystem Tool Root Guard
|
||||||
|
|
||||||
|
In upstream OpenClaw, sandboxed filesystem tools are rooted and guarded:
|
||||||
|
- `openclaw/src/agents/pi-tools.read.ts` (`assertSandboxPath` usage)
|
||||||
|
|
||||||
|
Result:
|
||||||
|
- `read`/`write`/`edit` tools cannot access paths outside the sandbox root, even if the container has other mounts (like `/agent`).
|
||||||
|
|
||||||
|
This is intentional: the “filesystem tools” and “exec tool” have different access characteristics inside a sandbox.
|
||||||
|
|
||||||
|
## Sandbox Tool Policy (Separate From Per-Agent Tool Overrides)
|
||||||
|
|
||||||
|
OpenClaw has an additional sandbox-only tool allow/deny policy:
|
||||||
|
- `tools.sandbox.tools.allow|deny` (global)
|
||||||
|
- `agents.list[].tools.sandbox.tools.allow|deny` (per-agent override)
|
||||||
|
|
||||||
|
Upstream resolution:
|
||||||
|
- `openclaw/src/agents/sandbox/tool-policy.ts` (`resolveSandboxToolPolicyForAgent`)
|
||||||
|
|
||||||
|
Important nuance:
|
||||||
|
- If `tools.sandbox.tools.allow` is present and non-empty, it becomes an allowlist.
|
||||||
|
- If it is set to an empty array, upstream will still auto-add `image` to the allowlist (unless explicitly denied), which often turns “empty” into effectively “image-only”.
|
||||||
|
- If you want “allow everything” semantics in sandbox policy, prefer `["*"]` over `[]` to avoid the image auto-add corner case.
|
||||||
|
|
||||||
|
This is why Studio treats some configs as “broken” and repairs them (see below).
|
||||||
|
|
||||||
|
### Policy Layering (Why “Allowed” Can Still Be Blocked)
|
||||||
|
|
||||||
|
In a sandboxed session, a tool must pass multiple gates:
|
||||||
|
- The normal tool policy gates (`tools.profile`, `tools.allow|alsoAllow`, `tools.deny`, plus any provider/group/subagent policies upstream applies).
|
||||||
|
- The sandbox tool policy gate (`tools.sandbox.tools.allow|deny` resolved for that agent).
|
||||||
|
|
||||||
|
So even if Studio enables `group:runtime` for an agent, the tool can still be blocked in sandboxed sessions if sandbox tool policy denies it.
|
||||||
|
|
||||||
|
## OpenClaw (Upstream): Tool Availability and `workspaceAccess=ro`
|
||||||
|
|
||||||
|
PI tool construction:
|
||||||
|
- `openclaw/src/agents/pi-tools.ts` (`createOpenClawCodingTools`)
|
||||||
|
|
||||||
|
Key enforcement:
|
||||||
|
- When sandboxed, upstream removes the normal host `write`/`edit` tools.
|
||||||
|
- It only adds sandboxed `write`/`edit` tools if `workspaceAccess !== "ro"`.
|
||||||
|
- It disables `apply_patch` in sandbox when `workspaceAccess === "ro"`.
|
||||||
|
|
||||||
|
This is why “`workspaceAccess=ro`” means more than “mount it read-only”:
|
||||||
|
- It is also a tool-policy gate that prevents direct file writes/edits through PI tools.
|
||||||
|
|
||||||
|
### Studio Note: Authority Is No Longer Compiled During Create
|
||||||
|
|
||||||
|
Studio create flow no longer compiles authority/sandbox settings during initial create.
|
||||||
|
|
||||||
|
When capabilities are changed post-create, Studio uses:
|
||||||
|
- `src/features/agents/operations/agentPermissionsOperation.ts` (`updateAgentPermissionsViaStudio`)
|
||||||
|
|
||||||
|
That operation updates:
|
||||||
|
- exec approvals policy (`exec.approvals.set`)
|
||||||
|
- per-agent tool overrides (`config.patch` via `updateGatewayAgentOverrides`)
|
||||||
|
- session exec host/security/ask (`sessions.patch`)
|
||||||
|
|
||||||
|
Upstream enforcement is unchanged: `workspaceAccess="ro"` still disables PI `write`/`edit`/`apply_patch` in sandboxed sessions.
|
||||||
|
|
||||||
|
## Session-Level Exec Settings (Where `exec` Runs)
|
||||||
|
|
||||||
|
Separately from per-agent config and exec approvals, OpenClaw supports per-session exec settings:
|
||||||
|
- `execHost`: `sandbox | gateway | node`
|
||||||
|
- `execSecurity`: `deny | allowlist | full`
|
||||||
|
- `execAsk`: `off | on-miss | always`
|
||||||
|
|
||||||
|
These are stored in the gateway session store and mutated with `sessions.patch`:
|
||||||
|
- Upstream method: `openclaw/src/gateway/server-methods/sessions.ts` (`"sessions.patch"`)
|
||||||
|
- Patch application: `openclaw/src/gateway/sessions-patch.ts`
|
||||||
|
- Session entry shape includes `execHost|execSecurity|execAsk`: `openclaw/src/config/sessions/types.ts`
|
||||||
|
|
||||||
|
Studio uses these fields to keep “what the UI expects” aligned with gateway runtime:
|
||||||
|
- Hydration derives the expected values using the exec approvals policy plus sandbox mode:
|
||||||
|
- `src/features/agents/operations/agentFleetHydrationDerivation.ts`
|
||||||
|
- Special case: if `sandbox.mode === "all"` and there are exec overrides, Studio forces `execHost = "sandbox"` to avoid accidentally running on the host.
|
||||||
|
- On first send (or when out of sync), Studio patches the session:
|
||||||
|
- `src/features/agents/operations/chatSendOperation.ts` calls `syncGatewaySessionSettings(...)`
|
||||||
|
- Transport: `src/lib/gateway/GatewayClient.ts` (`sessions.patch`)
|
||||||
|
|
||||||
|
Net effect:
|
||||||
|
- Exec approvals policy controls whether the user will be prompted to approve.
|
||||||
|
- Session exec settings control where execution happens (sandbox vs host) and the default `security/ask` values for runs.
|
||||||
|
|
||||||
|
## OpenClaw (Upstream): Exec Approvals (Policy + Events)
|
||||||
|
|
||||||
|
Exec approvals file (defaults upstream):
|
||||||
|
- `openclaw/src/infra/exec-approvals.ts`
|
||||||
|
- default file path: `~/.openclaw/exec-approvals.json`
|
||||||
|
- default socket path: `~/.openclaw/exec-approvals.sock`
|
||||||
|
|
||||||
|
Gateway methods (persist policy):
|
||||||
|
- `openclaw/src/gateway/server-methods/exec-approvals.ts`
|
||||||
|
- `exec.approvals.get` returns `{ path, exists, hash, file }` (socket token is redacted in responses)
|
||||||
|
- `exec.approvals.set` requires a matching `baseHash` when the file already exists (prevents lost updates)
|
||||||
|
|
||||||
|
Approval request/resolve + broadcast events:
|
||||||
|
- `openclaw/src/gateway/server-methods/exec-approval.ts`
|
||||||
|
- broadcasts `exec.approval.requested`
|
||||||
|
- broadcasts `exec.approval.resolved`
|
||||||
|
|
||||||
|
Exec tool approval decision logic:
|
||||||
|
- `openclaw/src/agents/bash-tools.exec.ts` (calls `requiresExecApproval`, `evaluateShellAllowlist`, etc.)
|
||||||
|
|
||||||
|
Studio wiring for policy persistence:
|
||||||
|
- Studio writes per-agent policy with `exec.approvals.set`:
|
||||||
|
- `src/lib/gateway/execApprovals.ts` (`upsertGatewayAgentExecApprovals`)
|
||||||
|
|
||||||
|
Studio wiring for UX:
|
||||||
|
- Studio listens to `exec.approval.requested` and `exec.approval.resolved` and renders in-chat approval cards.
|
||||||
|
- When the user clicks approve/deny, Studio calls `exec.approval.resolve`.
|
||||||
|
|
||||||
|
## Debug Checklist (When Something Feels “Wrong”)
|
||||||
|
|
||||||
|
1. Determine if the session is sandboxed and what workspace it is using.
|
||||||
|
- Upstream CLI helper: `openclaw sandbox explain --agent <agentId>` (see upstream `src/commands/sandbox-explain.ts`)
|
||||||
|
2. Confirm what Studio wrote:
|
||||||
|
- Agent overrides: `config.get` and inspect `agents.list[]` entry for the agent.
|
||||||
|
- Exec approvals: `exec.approvals.get` and inspect `file.agents[agentId]`.
|
||||||
|
3. If file edits are not happening:
|
||||||
|
- Check `sandbox.workspaceAccess` (if `ro`, upstream disables write/edit/apply_patch tools in sandbox).
|
||||||
|
- Check tool policy (`tools.profile`, `tools.alsoAllow`, `tools.deny`) for explicit denies on `write`/`edit`/`apply_patch`.
|
||||||
|
4. If approvals are not showing up:
|
||||||
|
- Check exec approvals `security` + `ask`.
|
||||||
|
- Check allowlist patterns (a match may suppress prompts when `ask=on-miss`).
|
||||||
|
5. If the agent can see different files than expected:
|
||||||
|
- `workspaceAccess=rw` means “tools operate on the agent workspace”.
|
||||||
|
- `workspaceAccess=ro|none` means “tools operate on a sandbox workspace”.
|
||||||
|
- `/agent` mount exists only for `workspaceAccess=ro` and is accessible via sandbox exec, not via filesystem tools.
|
||||||
|
|
||||||
|
## Studio Post-Create “Permissions” Flows (Not Just Creation)
|
||||||
|
|
||||||
|
Studio can also change permissions after an agent exists.
|
||||||
|
|
||||||
|
### Capabilities Permissions Updates
|
||||||
|
|
||||||
|
Studio’s permissions flow applies coordinated changes from one save action:
|
||||||
|
- Exec approvals policy (per-agent, persisted in exec approvals file)
|
||||||
|
- Tool allow/deny for runtime/web/fs groups (`group:runtime`, `group:web`, `group:fs`) in agent config
|
||||||
|
- Session exec settings (`execHost|execSecurity|execAsk`) via `sessions.patch`
|
||||||
|
|
||||||
|
Code:
|
||||||
|
- `src/features/agents/operations/agentPermissionsOperation.ts` (`updateAgentPermissionsViaStudio`)
|
||||||
|
|
||||||
|
UI model:
|
||||||
|
- Direct controls: `Command mode` (`Off`/`Ask`/`Auto`), `Web access` (`Off`/`On`), `File tools` (`Off`/`On`)
|
||||||
|
- Create modal remains permission-light (name/avatar only) and create flow immediately applies permissive defaults (`Auto`, web on, file tools on).
|
||||||
|
|
||||||
|
Why it matters:
|
||||||
|
- You can have exec approvals configured but still be unable to run commands if `group:runtime` is denied.
|
||||||
|
- You can have permissive approvals but still be safe if `execHost` is forced to `sandbox` when sandboxing is enabled.
|
||||||
|
|
||||||
|
### One-Shot Sandbox Tool Policy Repair
|
||||||
|
|
||||||
|
On connect, Studio scans the gateway config for agents that are sandboxed (`sandbox.mode === "all"`) and have an explicitly empty sandbox allowlist (`tools.sandbox.tools.allow = []`), and repairs those entries by setting:
|
||||||
|
- `agents.list[].tools.sandbox.tools.allow = ["*"]`
|
||||||
|
|
||||||
|
Code:
|
||||||
|
- Detection + repair enqueue: `src/app/page.tsx` (`repair-sandbox-tool-allowlist`)
|
||||||
|
- Gateway write: `src/lib/gateway/agentConfig.ts` (`updateGatewayAgentOverrides`)
|
||||||
|
|
||||||
|
This exists to prevent sandboxed sessions from effectively losing access to almost all sandbox tools due to an empty allowlist interacting with upstream sandbox tool-policy behavior.
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
# PI + Chat Streaming (Studio Side)
|
||||||
|
|
||||||
|
This document exists to onboard coding agents quickly when debugging chat issues in Claw3D.
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- Describes how Studio connects to the OpenClaw Gateway, how runtime streaming arrives over WebSockets, and how the UI renders it.
|
||||||
|
- Treats **PI** as “the coding agent running behind the Gateway” (an OpenClaw agent). Studio does not implement PI logic; it displays and controls the Gateway session.
|
||||||
|
|
||||||
|
Non-scope:
|
||||||
|
- PI internals and model/tool execution details. Those live in the OpenClaw repository and the Gateway implementation.
|
||||||
|
|
||||||
|
## Key Files (Start Here)
|
||||||
|
|
||||||
|
- Studio server entry + upgrade wiring: `server/index.js`
|
||||||
|
- Browser WS bridge to upstream gateway: `server/gateway-proxy.js`
|
||||||
|
- Browser WS URL (always same-origin `/api/gateway/ws`): `src/lib/gateway/proxy-url.ts`
|
||||||
|
- Browser gateway protocol client (vendored): `src/lib/gateway/openclaw/GatewayBrowserClient.ts`
|
||||||
|
- Studio gateway wrapper + connect policy: `src/lib/gateway/GatewayClient.ts`
|
||||||
|
- Runtime stream classification and merge helpers: `src/features/agents/state/runtimeEventBridge.ts`
|
||||||
|
- Runtime event executor (streaming -> state -> transcript lines): `src/features/agents/state/gatewayRuntimeEventHandler.ts`
|
||||||
|
- Chat rendering: `src/features/agents/components/AgentChatPanel.tsx`, `src/features/agents/components/chatItems.ts`
|
||||||
|
- Message parsing (text/thinking/tool markers): `src/lib/text/message-extract.ts`
|
||||||
|
- History sync + transcript merge: `src/features/agents/operations/historySyncOperation.ts`, `src/features/agents/state/transcript.ts`
|
||||||
|
|
||||||
|
## Relationship To OpenClaw (What’s Vendored Here)
|
||||||
|
|
||||||
|
Studio vendors the browser Gateway client used to speak the Gateway protocol:
|
||||||
|
- Vendored client: `src/lib/gateway/openclaw/GatewayBrowserClient.ts`
|
||||||
|
- Sync script: `scripts/sync-openclaw-gateway-client.ts`
|
||||||
|
- Sync source: provide an explicit local source path to the sync script via CLI arg or env var.
|
||||||
|
|
||||||
|
Important:
|
||||||
|
- Studio does not currently auto-sync `GatewayBrowserClient.ts` from a fixed maintainer-local checkout path.
|
||||||
|
- If protocol mismatch is suspected, first verify the sync source file and the upstream Gateway runtime/protocol files are aligned.
|
||||||
|
|
||||||
|
If a protocol mismatch is suspected (missing event fields, renamed streams, different error codes), start by checking whether Studio’s vendored client is in sync with the Gateway version you’re running.
|
||||||
|
|
||||||
|
## Upstream Source Of Truth (OpenClaw)
|
||||||
|
|
||||||
|
For chat streaming behavior, these upstream files are authoritative:
|
||||||
|
- `src/gateway/protocol/schema/logs-chat.ts` in your OpenClaw checkout (`chat.send`, `chat.history`, and chat event schema)
|
||||||
|
- `src/gateway/server-methods/chat.ts` in your OpenClaw checkout (`chat.send` ack + idempotency, `chat.history` payload shaping/sanitization)
|
||||||
|
- `src/gateway/server-chat.ts` in your OpenClaw checkout (`agent` event fanout and synthetic `chat` delta/final bridging)
|
||||||
|
- `src/agents/pi-embedded-subscribe.ts` and related handlers in your OpenClaw checkout (`assistant`/`tool`/`lifecycle` stream emission)
|
||||||
|
|
||||||
|
When updating this doc, verify behavior against those files, not assumptions.
|
||||||
|
|
||||||
|
## Terminology
|
||||||
|
|
||||||
|
- Studio: this repo, a Next.js UI with a custom Node server.
|
||||||
|
- Gateway (upstream): the OpenClaw Gateway WebSocket server (default `ws://localhost:18789`).
|
||||||
|
- WS bridge / proxy: Studio’s server-side WebSocket that bridges the browser to the upstream Gateway.
|
||||||
|
- Frame: JSON message over WebSocket (request/response/event).
|
||||||
|
- Run: a single streamed execution identified by `runId`.
|
||||||
|
- Session: identified by `sessionKey` (Studio uses `agent:<agentId>:<mainKey>` for main sessions).
|
||||||
|
|
||||||
|
## High-Level Network Path
|
||||||
|
|
||||||
|
There are two separate WebSocket hops, plus a protocol-level `connect` request:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant B as Browser (Studio UI)
|
||||||
|
participant S as Studio server (WS proxy)
|
||||||
|
participant G as OpenClaw Gateway (upstream)
|
||||||
|
|
||||||
|
B->>S: WS connect /api/gateway/ws
|
||||||
|
B->>S: req(connect) (Gateway protocol frame)
|
||||||
|
S->>G: WS connect upstream (url from settings.json)
|
||||||
|
S->>G: req(connect) (injects token if missing)
|
||||||
|
G-->>S: res(connect)
|
||||||
|
S-->>B: res(connect)
|
||||||
|
G-->>S: event(chat/agent/presence/heartbeat)
|
||||||
|
S-->>B: event(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
Files:
|
||||||
|
- WS proxy entrypoint: `server/index.js`
|
||||||
|
- WS proxy implementation: `server/gateway-proxy.js`
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- The browser never opens a WebSocket directly to the upstream Gateway URL. The browser always speaks to the Studio same-origin bridge at `/api/gateway/ws` (computed by `src/lib/gateway/proxy-url.ts`).
|
||||||
|
- The “upstream gateway URL” shown in Studio settings is used by the Studio server (the proxy) to open the upstream connection.
|
||||||
|
|
||||||
|
## End-To-End Flow (PI Run -> UI)
|
||||||
|
|
||||||
|
This is the “happy path” you want in your head when debugging:
|
||||||
|
|
||||||
|
1. User types in the chat composer and hits Send (`src/features/agents/components/AgentChatPanel.tsx`).
|
||||||
|
2. Studio calls `chat.send` with `sessionKey` and `idempotencyKey = runId` (`src/features/agents/operations/chatSendOperation.ts`).
|
||||||
|
3. Gateway runs the agent (PI) for that session.
|
||||||
|
4. While the run is executing, the Gateway may stream:
|
||||||
|
- `event: "agent"` frames for live partial output (`stream: "assistant"`), live thinking (`reason*`/`think*` streams), tool calls/results (`stream: "tool"`), and lifecycle (`stream: "lifecycle"`).
|
||||||
|
- `event: "chat"` frames for the chat message stream (`state: "delta" | "final" | ...`).
|
||||||
|
- Both streams can describe the same run progression from different layers (`agent` stream events and `chat` message events), so Studio must merge idempotently.
|
||||||
|
5. Studio merges those events into:
|
||||||
|
- live fields (`streamText`, `thinkingTrace`) via batched `queueLivePatch` (fast UI updates without committing to the transcript yet)
|
||||||
|
- committed transcript lines (`outputLines`) via `appendOutput` (final messages, tool lines, meta/timestamp, thinking trace)
|
||||||
|
6. The chat panel renders:
|
||||||
|
- historical transcript from `outputLines`
|
||||||
|
- an extra “live assistant” card at the bottom built from `streamText` + `thinkingTrace` while `status === "running"`.
|
||||||
|
|
||||||
|
The key wiring is in:
|
||||||
|
- Event subscription + dispatch: `src/app/page.tsx`
|
||||||
|
- Runtime event handler: `src/features/agents/state/gatewayRuntimeEventHandler.ts`
|
||||||
|
- Store reducer: `src/features/agents/state/store.tsx`
|
||||||
|
|
||||||
|
## Studio Settings (Where Gateway URL/Token Come From)
|
||||||
|
|
||||||
|
Studio persists Gateway connection settings on the Studio host (not in browser persistent storage). The UI still loads them into browser memory at runtime:
|
||||||
|
- `~/.openclaw/claw3d/settings.json` (see `README.md` for the canonical location)
|
||||||
|
|
||||||
|
The WS proxy loads these settings server-side and opens the upstream connection.
|
||||||
|
|
||||||
|
Files:
|
||||||
|
- Settings file access (WS proxy): `server/studio-settings.js`
|
||||||
|
- Settings API route (browser -> server): `src/app/api/studio/route.ts`
|
||||||
|
- Client-side load/patch coordinator: `src/lib/studio/coordinator.ts`
|
||||||
|
- Settings storage + fallback behavior used by `/api/studio`: `src/lib/studio/settings-store.ts`
|
||||||
|
|
||||||
|
Connection note:
|
||||||
|
- In the browser, `useGatewayConnection()` stores the upstream URL/token in memory (loaded from `/api/studio`) but connects the WebSocket to Studio via `resolveStudioProxyGatewayUrl()`; the upstream URL is passed as `authScopeKey` (not as the WebSocket URL). See `src/lib/gateway/GatewayClient.ts`.
|
||||||
|
|
||||||
|
Token resolution note:
|
||||||
|
- The Studio server resolves an upstream token from `claw3d/settings.json`, and if it is missing it may fall back to the local OpenClaw config in `openclaw.json` (token + port). This behavior exists in both the WS proxy path (`server/studio-settings.js`) and the `/api/studio` storage layer (`src/lib/studio/settings-store.ts`) and they should remain consistent.
|
||||||
|
- During `connect`, the WS proxy forwards browser-provided auth (`params.auth.token` or `params.device.signature`) as-is. It injects the host-resolved token only when browser auth is absent. `studio.gateway_token_missing` is returned only when neither browser auth nor host token is available.
|
||||||
|
|
||||||
|
## WebSocket Frame Shapes
|
||||||
|
|
||||||
|
Studio expects Gateway frames shaped like:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "req", "id": "uuid", "method": "connect", "params": { } }
|
||||||
|
{ "type": "res", "id": "uuid", "ok": true, "payload": { } }
|
||||||
|
{ "type": "res", "id": "uuid", "ok": false, "error": { "code": "…", "message": "…" } }
|
||||||
|
{ "type": "event", "event": "chat", "payload": { } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Types live in:
|
||||||
|
- `src/lib/gateway/GatewayClient.ts`
|
||||||
|
|
||||||
|
### Connect handshake
|
||||||
|
|
||||||
|
The first *protocol frame* from the browser must be `req(connect)`. The WS proxy:
|
||||||
|
- Rejects non-`connect` frames until connected.
|
||||||
|
- Opens an upstream WS to the configured Gateway URL.
|
||||||
|
- Injects `auth.token` into the connect params if the connect frame does not already contain a token, and if it does not include a device signature.
|
||||||
|
- Returns `studio.gateway_token_missing` only when no browser auth is present and no host token can be resolved.
|
||||||
|
- Sets an `Origin` header for the upstream WebSocket derived from the upstream URL (and normalizes loopback hostnames to `localhost`).
|
||||||
|
|
||||||
|
Code:
|
||||||
|
- Connect enforcement + token injection: `server/gateway-proxy.js`
|
||||||
|
|
||||||
|
### Connect failures
|
||||||
|
|
||||||
|
On failure to load settings or open upstream, the proxy sends an error `res` for the connect request (when possible) and then closes the WS.
|
||||||
|
|
||||||
|
Important detail (how errors become actionable in the UI):
|
||||||
|
- The browser-side Gateway client (`src/lib/gateway/openclaw/GatewayBrowserClient.ts`) closes the WebSocket with close code `4008` and a reason like `connect failed: <CODE> <MESSAGE>` after it receives a failed `res(connect)`. `GatewayClient.connect()` parses that close into `GatewayResponseError(code, message)` for UI retry policy and user-facing errors.
|
||||||
|
- Separately, the proxy may also close with `1011` / `connect failed`; the “connect failed: …” close reason that the UI parses is produced by the browser client, not the proxy.
|
||||||
|
- WebSocket close reasons are truncated to 123 UTF-8 bytes in the browser client to avoid protocol errors on long messages.
|
||||||
|
|
||||||
|
Error codes used by the proxy include:
|
||||||
|
- `studio.gateway_url_missing`
|
||||||
|
- `studio.gateway_token_missing`
|
||||||
|
- `studio.gateway_url_invalid`
|
||||||
|
- `studio.settings_load_failed`
|
||||||
|
- `studio.upstream_error`
|
||||||
|
- `studio.upstream_closed`
|
||||||
|
|
||||||
|
## Reconnects And Retries
|
||||||
|
|
||||||
|
There are two layers of retry behavior:
|
||||||
|
|
||||||
|
- Transport reconnect (after a successful hello): the vendored browser client reconnects the browser->Studio WebSocket with backoff when it closes, and continues emitting events after reconnect. See `src/lib/gateway/openclaw/GatewayBrowserClient.ts`.
|
||||||
|
- Initial connect failure retry: when the initial `connect` handshake fails (for example bad token), `GatewayClient.connect()` tears down the vendored client and returns a rejected promise; `useGatewayConnection()` may schedule a limited re-attempt unless the error code is known non-retryable. See `resolveGatewayAutoRetryDelayMs` in `src/lib/gateway/GatewayClient.ts`.
|
||||||
|
|
||||||
|
## Studio Access Gate
|
||||||
|
|
||||||
|
When Studio is bound to a public host, `STUDIO_ACCESS_TOKEN` is required. For loopback-only binds, it remains optional. When enabled, Studio enforces a simple access gate:
|
||||||
|
- HTTP: blocks `/api/*` routes unless the correct `studio_access` cookie is present.
|
||||||
|
- WebSocket: blocks `/api/gateway/ws` upgrades unless the cookie is present.
|
||||||
|
|
||||||
|
Files:
|
||||||
|
- Gate implementation: `server/access-gate.js`
|
||||||
|
- Gate integration for WS upgrades: `server/index.js`
|
||||||
|
|
||||||
|
## Streaming: What the Gateway Sends and How Studio Uses It
|
||||||
|
|
||||||
|
Studio classifies gateway events by `event` name:
|
||||||
|
- `presence`, `heartbeat`: summary refresh triggers
|
||||||
|
- `chat`: runtime chat messages (delta/final)
|
||||||
|
- `agent`: runtime per-stream deltas (assistant/thinking/tool/lifecycle)
|
||||||
|
|
||||||
|
Code:
|
||||||
|
- Classification: `src/features/agents/state/runtimeEventBridge.ts`
|
||||||
|
- Execution: `src/features/agents/state/gatewayRuntimeEventHandler.ts`
|
||||||
|
|
||||||
|
## Live Fields vs Committed Transcript (Why Streaming Can “Look Weird”)
|
||||||
|
|
||||||
|
Studio intentionally separates:
|
||||||
|
- Live streaming UI: `AgentState.streamText` and `AgentState.thinkingTrace` are updated via `queueLivePatch`, which batches patches and coalesces multiple deltas before they hit React state (`src/app/page.tsx`).
|
||||||
|
- Committed transcript: `AgentState.outputLines` is appended via `appendOutput`. These are the lines that become the durable on-screen transcript and are later merged with `chat.history` results (`src/features/agents/state/store.tsx`).
|
||||||
|
|
||||||
|
This split is why you can see:
|
||||||
|
- “live” assistant output update rapidly at the bottom card during a run
|
||||||
|
- then a finalized assistant message (plus tool lines / thinking trace / meta timestamp) appear in the transcript on `final`
|
||||||
|
|
||||||
|
### `event: "chat"` payload
|
||||||
|
|
||||||
|
Studio treats `chat` events as the canonical “message” stream for transcript completion. Expected fields:
|
||||||
|
- `runId`
|
||||||
|
- `sessionKey`
|
||||||
|
- `state`: `delta | final | aborted | error`
|
||||||
|
- `message` (shape varies; Studio extracts text/thinking/tool metadata defensively)
|
||||||
|
|
||||||
|
Key behaviors (Studio-side):
|
||||||
|
- Ignores user/system roles for transcript append (but uses them for status/summary).
|
||||||
|
- User messages shown in the transcript are primarily from local optimistic send and from `chat.history` sync (not from runtime `chat` user-role events).
|
||||||
|
- On `final`, appends:
|
||||||
|
- a `[[meta]]{...}` line (timestamp and thinking duration when available)
|
||||||
|
- a `[[trace]]` thinking block when extracted
|
||||||
|
- tool call/result markdown lines when present
|
||||||
|
- the assistant text (if any)
|
||||||
|
- If a `final` assistant message arrives without an extractable thinking trace, Studio may request `chat.history` as recovery.
|
||||||
|
- `chat.send` is idempotency-keyed upstream and returns a started ack before async completion; this is why history reconciliation can race with runtime events and must be idempotent.
|
||||||
|
|
||||||
|
### `event: "agent"` payload
|
||||||
|
|
||||||
|
Studio uses `agent` events for live streaming and richer tool/lifecycle updates. Expected fields:
|
||||||
|
- `runId`
|
||||||
|
- `stream`: `assistant | tool | lifecycle | <reasoning stream>`
|
||||||
|
- `data`: record with `text`/`delta` and stream-specific keys
|
||||||
|
|
||||||
|
Stream handling (high-level):
|
||||||
|
- `assistant`: merges `data.delta` into a live `streamText` for the UI.
|
||||||
|
- reasoning stream (anything that is not `assistant`, `tool`, `lifecycle` and matches hints like `reason`/`think`/`analysis`/`trace`): merged into `thinkingTrace`.
|
||||||
|
- `tool`: formats tool call and tool result lines using `[[tool]]` and `[[tool-result]]`.
|
||||||
|
- `lifecycle`: start/end/error transitions; if a run reaches `end` without chat final events, Studio may flush the last streamed assistant text as a fallback final transcript entry.
|
||||||
|
|
||||||
|
Code:
|
||||||
|
- Runtime agent stream merge + append: `src/features/agents/state/gatewayRuntimeEventHandler.ts`
|
||||||
|
|
||||||
|
## How Chat UI Renders Streaming
|
||||||
|
|
||||||
|
Studio keeps an `outputLines: string[]` transcript per agent, plus live fields like `streamText` and `thinkingTrace`.
|
||||||
|
|
||||||
|
Rendering pipeline:
|
||||||
|
- `outputLines` contains:
|
||||||
|
- user messages as `> ...`
|
||||||
|
- assistant messages as raw markdown text
|
||||||
|
- tool call/results with prefixes `[[tool]]` and `[[tool-result]]`
|
||||||
|
- optional meta lines `[[meta]]{...}` for timestamps and thinking durations
|
||||||
|
- optional thinking trace lines `[[trace]] ...`
|
||||||
|
- The panel derives structured chat items from `outputLines` and (optionally) live streaming state.
|
||||||
|
- UI toggles that change rendering:
|
||||||
|
- `showThinkingTraces`: hides/shows `[[trace]]` thinking entries.
|
||||||
|
- `toolCallingEnabled`: when off, tool lines are hidden and some exec tool results may be shown as assistant text.
|
||||||
|
|
||||||
|
### Rendering contract
|
||||||
|
|
||||||
|
- Assistant markdown renders as assistant markdown. Studio does not wrap normal assistant markdown in a synthetic `Output` container.
|
||||||
|
- Tool cards render only from explicit marker lines: `[[tool]]` and `[[tool-result]]`.
|
||||||
|
- List-marker visibility comes from chat markdown styles in `src/app/styles/markdown.css`; stream parsing does not invent list bullets.
|
||||||
|
|
||||||
|
Files:
|
||||||
|
- Chat panel UI: `src/features/agents/components/AgentChatPanel.tsx`
|
||||||
|
- Transcript parsing into items: `src/features/agents/components/chatItems.ts`
|
||||||
|
- Message extraction helpers (text/thinking/tool parsing): `src/lib/text/message-extract.ts`
|
||||||
|
- Media line rewrite (images/audio/video rendered in markdown): `src/lib/text/media-markdown.ts`
|
||||||
|
|
||||||
|
## Sending Messages (Browser -> PI via Gateway)
|
||||||
|
|
||||||
|
Send path (high level):
|
||||||
|
- UI submits a message through `sendChatMessageViaStudio()` which:
|
||||||
|
- Sets agent state to running and clears live streams.
|
||||||
|
- Optionally resets local transcript state for `/new` or `/reset` (local UI behavior).
|
||||||
|
- Optimistically appends the user line (`> ...`) to the transcript.
|
||||||
|
- Ensures session settings are synced once via `sessions.patch` (model/thinking/exec settings) before first send.
|
||||||
|
- Calls `chat.send` with `idempotencyKey = runId` and `deliver: false`.
|
||||||
|
|
||||||
|
Stop path:
|
||||||
|
- UI calls `chat.abort` to stop an active run.
|
||||||
|
|
||||||
|
Files:
|
||||||
|
- Send operation: `src/features/agents/operations/chatSendOperation.ts`
|
||||||
|
- Session settings sync transport: `src/lib/gateway/GatewayClient.ts`
|
||||||
|
- Stop call site: `src/app/page.tsx`
|
||||||
|
|
||||||
|
## Post-Connect Side Effects (Local Gateway Only)
|
||||||
|
|
||||||
|
After a successful connection, Studio may mutate gateway config when the upstream gateway URL is local:
|
||||||
|
- It reads `config.get` and may write `config.set` to ensure `gateway.reload.mode` is `"hot"` for local Studio usage.
|
||||||
|
|
||||||
|
File:
|
||||||
|
- Reload mode enforcement: `src/lib/gateway/gatewayReloadMode.ts`
|
||||||
|
|
||||||
|
## Sequence Gaps (Dropped Events)
|
||||||
|
|
||||||
|
Gateway event frames may include `seq`. The vendored browser client tracks `seq` and reports gaps (`expected`, `received`) via `onGap`.
|
||||||
|
|
||||||
|
Studio behavior on gap:
|
||||||
|
- Logs a warning.
|
||||||
|
- Forces a summary snapshot refresh and reconciles running agents.
|
||||||
|
|
||||||
|
Files:
|
||||||
|
- Gap detection: `src/lib/gateway/openclaw/GatewayBrowserClient.ts`
|
||||||
|
- Gap handling: `src/app/page.tsx`
|
||||||
|
|
||||||
|
## History Sync (Recovery, Load More)
|
||||||
|
|
||||||
|
Studio can fetch history via `chat.history` and merge it into the transcript.
|
||||||
|
|
||||||
|
Key points:
|
||||||
|
- Studio intentionally treats gateway history as canonical for timestamps/final ordering.
|
||||||
|
- History merge is designed to avoid duplicates and reconcile local optimistic sends.
|
||||||
|
- History parsing intentionally skips some system-ish content (heartbeat prompts, restart sentinel messages, and UI metadata prefixes). See `buildHistoryLines()` in `src/features/agents/state/runtimeEventBridge.ts`.
|
||||||
|
- Transcript v2 can be toggled with `NEXT_PUBLIC_STUDIO_TRANSCRIPT_V2`.
|
||||||
|
- Transcript debug logs can be enabled with `NEXT_PUBLIC_STUDIO_TRANSCRIPT_DEBUG`.
|
||||||
|
|
||||||
|
Files:
|
||||||
|
- History operation: `src/features/agents/operations/historySyncOperation.ts`
|
||||||
|
- Transcript merge/sort primitives: `src/features/agents/state/transcript.ts`
|
||||||
|
|
||||||
|
## Exec Approvals In Chat (Related To “PI Runs”)
|
||||||
|
|
||||||
|
Some runs require exec approval. These are surfaced as in-chat cards and are handled separately from the `chat`/`agent` runtime stream.
|
||||||
|
|
||||||
|
Files:
|
||||||
|
- Event to pending-card state: `src/features/agents/approvals/execApprovalEvents.ts`
|
||||||
|
- Resolve operation: `src/features/agents/approvals/execApprovalResolveOperation.ts`
|
||||||
|
- Wiring (subscribe + render): `src/app/page.tsx`, `src/features/agents/components/AgentChatPanel.tsx`
|
||||||
|
|
||||||
|
## Media Rendering (Images From Agent Output)
|
||||||
|
|
||||||
|
If an agent outputs lines like:
|
||||||
|
- `MEDIA: /home/ubuntu/.openclaw/.../image.png`
|
||||||
|
|
||||||
|
Studio may render them inline:
|
||||||
|
1. UI rewrites eligible `MEDIA:` lines into markdown images (``) but avoids rewriting inside fenced code blocks.
|
||||||
|
2. The browser requests `/api/gateway/media`.
|
||||||
|
3. The API route reads the image either locally (only under `~/.openclaw`) or over SSH for remote gateways, and returns the bytes with the correct `Content-Type`.
|
||||||
|
|
||||||
|
Files:
|
||||||
|
- Rewrite helper: `src/lib/text/media-markdown.ts`
|
||||||
|
- Media API route: `src/app/api/gateway/media/route.ts`
|
||||||
|
- SSH helper + env vars (`OPENCLAW_GATEWAY_SSH_TARGET`, `OPENCLAW_GATEWAY_SSH_USER`): `src/lib/ssh/gateway-host.ts`
|
||||||
|
|
||||||
|
## Debugging Checklist (When Chat “Feels Buggy”)
|
||||||
|
|
||||||
|
Start with the hop where symptoms appear.
|
||||||
|
|
||||||
|
WS bridge / connectivity:
|
||||||
|
- Studio server logs (proxy): `server/gateway-proxy.js`
|
||||||
|
- Common failures: wrong `ws://` vs `wss://`, missing token, gateway closed, upstream TLS mismatch
|
||||||
|
|
||||||
|
Streaming correctness (missing/duplicated output):
|
||||||
|
- Event classification + runtime stream merge: `src/features/agents/state/gatewayRuntimeEventHandler.ts`
|
||||||
|
- Text/thinking/tool extraction quirks: `src/lib/text/message-extract.ts`
|
||||||
|
- UI item derivation and collapsing rules: `src/features/agents/components/chatItems.ts`
|
||||||
|
- Dedupe of tool lines per run + closed-run ignore window: `src/features/agents/state/gatewayRuntimeEventHandler.ts`
|
||||||
|
|
||||||
|
History and ordering issues:
|
||||||
|
- `chat.history` merge logic and dedupe: `src/features/agents/operations/historySyncOperation.ts`
|
||||||
|
- Transcript entry ordering/fingerprints: `src/features/agents/state/transcript.ts`
|
||||||
|
|
||||||
|
Media not rendering:
|
||||||
|
- `MEDIA:` rewrite behavior and code-fence skipping: `src/lib/text/media-markdown.ts`
|
||||||
|
- Image fetch route behavior (local vs SSH, allowlisted extensions, size limits): `src/app/api/gateway/media/route.ts`
|
||||||
|
|
||||||
|
If you need Gateway-side observability:
|
||||||
|
- Capture the exact `connect` settings used by Studio (URL + token are stored server-side in the Studio settings file).
|
||||||
|
- Inspect Gateway logs on the Gateway host using your environment’s service/log tooling.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
import prettier from "eslint-config-prettier/flat";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
{
|
||||||
|
files: ["server/**/*.js", "scripts/**/*.js"],
|
||||||
|
rules: {
|
||||||
|
"@typescript-eslint/no-require-imports": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
|
||||||
|
// Vendored third-party code (kept as-is; linting it adds noise).
|
||||||
|
"src/lib/avatars/vendor/**",
|
||||||
|
]),
|
||||||
|
prettier,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
async function main() {
|
||||||
|
const THREE = await import("three");
|
||||||
|
const target = new THREE.Vector3(0, 0, 1);
|
||||||
|
const v = new THREE.Vector3(-0.42, 1.32, -1.47).normalize();
|
||||||
|
|
||||||
|
// We want to find a rotation of the earth group (with order XYZ or YXZ).
|
||||||
|
// that brings v to target.
|
||||||
|
// But the earth might have arbitrary rotations.
|
||||||
|
// we want to rotate earth such that earth.localToWorld(v) == target.
|
||||||
|
// so earth.quaternion * v = target.
|
||||||
|
// earth.quaternion = quaternion that rotates v to target.
|
||||||
|
const q = new THREE.Quaternion().setFromUnitVectors(v, target);
|
||||||
|
const e = new THREE.Euler().setFromQuaternion(q, "XYZ");
|
||||||
|
console.log("To point exactly at +Z:");
|
||||||
|
console.log("x:", e.x, "y:", e.y, "z:", e.z);
|
||||||
|
|
||||||
|
// Since the camera is looking slightly down/up, maybe we need to pitch it.
|
||||||
|
const cameraDir = new THREE.Vector3(0, 0.8 - 0.5, -0.5 - 2.05).normalize().negate(); // Vector from surface to camera.
|
||||||
|
console.log("Camera dir:", cameraDir);
|
||||||
|
const q2 = new THREE.Quaternion().setFromUnitVectors(v, cameraDir);
|
||||||
|
const e2 = new THREE.Euler().setFromQuaternion(q2, "XYZ");
|
||||||
|
console.log("To point at camera:");
|
||||||
|
console.log("x:", e2.x, "y:", e2.y, "z:", e2.z);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main();
|
||||||
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
async function main() {
|
||||||
|
const THREE = await import("three");
|
||||||
|
|
||||||
|
// Earth rotation order is default XYZ.
|
||||||
|
// The beacon's local position.
|
||||||
|
const beaconLocal = new THREE.Vector3(-0.42, 1.32, -1.47).normalize();
|
||||||
|
|
||||||
|
// The camera's position during dive.
|
||||||
|
const cameraPos = new THREE.Vector3(0, 0.5, 2.05).normalize();
|
||||||
|
|
||||||
|
// We want to rotate beaconLocal to cameraPos.
|
||||||
|
// The required quaternion.
|
||||||
|
const q = new THREE.Quaternion().setFromUnitVectors(beaconLocal, cameraPos);
|
||||||
|
|
||||||
|
// Convert to Euler so we can damp x and y.
|
||||||
|
const e = new THREE.Euler().setFromQuaternion(q, "XYZ");
|
||||||
|
|
||||||
|
console.log("Target Euler:");
|
||||||
|
console.log("x:", e.x, "y:", e.y, "z:", e.z);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main();
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
@@ -0,0 +1,28 @@
|
|||||||
|
async function main() {
|
||||||
|
const THREE = await import("three");
|
||||||
|
|
||||||
|
function latLonToVector3(lat, lon, radius) {
|
||||||
|
const phi = (90 - lat) * (Math.PI / 180);
|
||||||
|
const theta = (lon + 90) * (Math.PI / 180);
|
||||||
|
const x = -radius * Math.sin(phi) * Math.cos(theta);
|
||||||
|
const y = radius * Math.cos(phi);
|
||||||
|
const z = radius * Math.sin(phi) * Math.sin(theta);
|
||||||
|
return new THREE.Vector3(x, y, z);
|
||||||
|
}
|
||||||
|
|
||||||
|
const continents = {
|
||||||
|
NA: latLonToVector3(45, -100, 2),
|
||||||
|
NY: latLonToVector3(40.7, -74, 2),
|
||||||
|
SA: latLonToVector3(-15, -60, 2),
|
||||||
|
EU: latLonToVector3(50, 15, 2),
|
||||||
|
AF: latLonToVector3(0, 20, 2),
|
||||||
|
AS: latLonToVector3(40, 90, 2),
|
||||||
|
AU: latLonToVector3(-25, 135, 2),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [name, v] of Object.entries(continents)) {
|
||||||
|
console.log(`${name}: [${v.x.toFixed(2)}, ${v.y.toFixed(2)}, ${v.z.toFixed(2)}],`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main();
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
Generated
+11180
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"name": "claw3d",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "node server/index.js --dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "node server/index.js",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"cleanup:ux-artifacts": "node scripts/cleanup-ux-artifacts.mjs",
|
||||||
|
"sync:gateway-client": "node scripts/sync-openclaw-gateway-client.ts",
|
||||||
|
"studio:setup": "node scripts/studio-setup.js",
|
||||||
|
"smoke:dev-server": "node scripts/smoke-dev-server.mjs",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest",
|
||||||
|
"e2e": "playwright test"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@noble/ed25519": "^3.0.0",
|
||||||
|
"@react-three/drei": "^10.7.7",
|
||||||
|
"@react-three/fiber": "^9.5.0",
|
||||||
|
"@vercel/otel": "^2.1.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.563.0",
|
||||||
|
"next": "16.1.6",
|
||||||
|
"phaser": "^3.90.0",
|
||||||
|
"react": "19.2.3",
|
||||||
|
"react-dom": "19.2.3",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"react-mentions-ts": "^5.4.7",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"three": "^0.183.2",
|
||||||
|
"ws": "^8.18.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.58.0",
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"@types/three": "^0.183.1",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.1.6",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"jsdom": "^27.4.0",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"typescript": "^5",
|
||||||
|
"vitest": "^4.0.18"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from "@playwright/test";
|
||||||
|
import path from "node:path";
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./tests/e2e",
|
||||||
|
use: {
|
||||||
|
baseURL: "http://127.0.0.1:3000",
|
||||||
|
},
|
||||||
|
webServer: {
|
||||||
|
command: "npm run dev",
|
||||||
|
port: 3000,
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
OPENCLAW_STATE_DIR: path.resolve("./tests/fixtures/openclaw-empty-state"),
|
||||||
|
NEXT_PUBLIC_GATEWAY_URL: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 215 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 207 KiB |
@@ -0,0 +1,88 @@
|
|||||||
|
import { constants as fsConstants, promises as fs } from "node:fs";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const repoRoot = path.resolve(scriptDir, "..");
|
||||||
|
const uxAuditDir = path.join(repoRoot, "output", "playwright", "ux-audit");
|
||||||
|
const transientFiles = [
|
||||||
|
path.join(repoRoot, ".agent", "ux-audit.md"),
|
||||||
|
path.join(repoRoot, ".agent", "execplan-pending.md"),
|
||||||
|
];
|
||||||
|
|
||||||
|
async function ensureDir(dir) {
|
||||||
|
await fs.mkdir(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearDirContents(dir) {
|
||||||
|
await ensureDir(dir);
|
||||||
|
const entries = await fs.readdir(dir);
|
||||||
|
await Promise.all(
|
||||||
|
entries.map((entry) =>
|
||||||
|
fs.rm(path.join(dir, entry), { recursive: true, force: true }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeIfPresent(filePath) {
|
||||||
|
try {
|
||||||
|
await fs.unlink(filePath);
|
||||||
|
} catch (error) {
|
||||||
|
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(command, args) {
|
||||||
|
return spawnSync(command, args, { encoding: "utf8" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopPlaywrightSessions() {
|
||||||
|
const codeHome = process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
|
||||||
|
const pwcli = path.join(codeHome, "skills", "playwright", "scripts", "playwright_cli.sh");
|
||||||
|
try {
|
||||||
|
await fs.access(pwcli, fsConstants.X_OK);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = run(pwcli, ["session-stop-all"]);
|
||||||
|
if (result.status === 0) return;
|
||||||
|
if (result.error) {
|
||||||
|
throw result.error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function killPattern(pattern) {
|
||||||
|
const result = run("pkill", ["-f", pattern]);
|
||||||
|
if (result.status === 0 || result.status === 1) return;
|
||||||
|
if (result.error && result.error.code === "ENOENT") return;
|
||||||
|
if (result.error) throw result.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupPlaywrightProcesses() {
|
||||||
|
killPattern("ms-playwright/daemon");
|
||||||
|
killPattern("playwright/cli.js run-mcp-server");
|
||||||
|
killPattern("chrome-headless-shell");
|
||||||
|
killPattern("Google Chrome --headless");
|
||||||
|
killPattern("Chromium --headless");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await stopPlaywrightSessions();
|
||||||
|
cleanupPlaywrightProcesses();
|
||||||
|
await clearDirContents(uxAuditDir);
|
||||||
|
for (const transientFile of transientFiles) {
|
||||||
|
await removeIfPresent(transientFile);
|
||||||
|
}
|
||||||
|
console.log("cleanup:ux-artifacts complete");
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error("cleanup:ux-artifacts failed");
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import net from "node:net";
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
const getFreePort = async () => {
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
const port = 20000 + Math.floor(Math.random() * 20000);
|
||||||
|
const ok = await new Promise((resolve) => {
|
||||||
|
const server = net.createServer();
|
||||||
|
server.once("error", () => resolve(false));
|
||||||
|
server.listen(port, "127.0.0.1", () => {
|
||||||
|
server.close(() => resolve(true));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (ok) return port;
|
||||||
|
}
|
||||||
|
throw new Error("Failed to find a free port for smoke test.");
|
||||||
|
};
|
||||||
|
|
||||||
|
const main = async () => {
|
||||||
|
const port = await getFreePort();
|
||||||
|
const url = `http://127.0.0.1:${port}/`;
|
||||||
|
|
||||||
|
const child = spawn(process.execPath, ["server/index.js", "--dev"], {
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
HOST: "127.0.0.1",
|
||||||
|
PORT: String(port),
|
||||||
|
},
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const lines = [];
|
||||||
|
const pushLines = (chunk) => {
|
||||||
|
const text = String(chunk ?? "");
|
||||||
|
for (const line of text.split(/\r?\n/)) {
|
||||||
|
if (!line) continue;
|
||||||
|
lines.push(line);
|
||||||
|
if (lines.length > 80) lines.shift();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
child.stdout.on("data", pushLines);
|
||||||
|
child.stderr.on("data", pushLines);
|
||||||
|
|
||||||
|
const deadline = Date.now() + 60_000;
|
||||||
|
let lastErr = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (child.exitCode !== null) {
|
||||||
|
throw new Error(`Dev server exited early with code ${child.exitCode}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { redirect: "manual" });
|
||||||
|
if (res.status >= 200 && res.status < 500) {
|
||||||
|
process.stdout.write(`OK ${res.status} ${url}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastErr = new Error(`Unexpected status ${res.status} for ${url}`);
|
||||||
|
} catch (err) {
|
||||||
|
lastErr = err;
|
||||||
|
}
|
||||||
|
|
||||||
|
await sleep(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Timed out waiting for dev server to respond at ${url}. Last error: ${lastErr?.message || "unknown"}`
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
child.kill("SIGTERM");
|
||||||
|
await Promise.race([new Promise((r) => child.once("exit", r)), sleep(2000)]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
process.stderr.write(String(err?.stack || err) + "\n");
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { execFileSync } = require("node:child_process");
|
||||||
|
const readline = require("node:readline/promises");
|
||||||
|
|
||||||
|
const { resolveStudioSettingsPath } = require("../server/studio-settings");
|
||||||
|
|
||||||
|
const DEFAULT_GATEWAY_URL = "ws://127.0.0.1:18789";
|
||||||
|
|
||||||
|
const parseArgs = (argv) => {
|
||||||
|
return {
|
||||||
|
force: argv.includes("--force"),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const tryReadGatewayTokenFromOpenclawCli = () => {
|
||||||
|
try {
|
||||||
|
const raw = execFileSync("openclaw", ["config", "get", "gateway.auth.token"], {
|
||||||
|
encoding: "utf8",
|
||||||
|
stdio: ["ignore", "pipe", "ignore"],
|
||||||
|
});
|
||||||
|
const token = String(raw ?? "").trim();
|
||||||
|
return token || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
|
||||||
|
const settingsPath = resolveStudioSettingsPath(process.env);
|
||||||
|
const settingsDir = path.dirname(settingsPath);
|
||||||
|
|
||||||
|
if (fs.existsSync(settingsPath) && !args.force) {
|
||||||
|
console.error(
|
||||||
|
`Studio settings already exist at ${settingsPath}. Re-run with --force to overwrite.`
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const urlAnswer = await rl.question(
|
||||||
|
`Upstream Gateway URL [${DEFAULT_GATEWAY_URL}]: `
|
||||||
|
);
|
||||||
|
const gatewayUrl = (urlAnswer || DEFAULT_GATEWAY_URL).trim();
|
||||||
|
if (!gatewayUrl) {
|
||||||
|
throw new Error("Gateway URL is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenDefault = tryReadGatewayTokenFromOpenclawCli();
|
||||||
|
const tokenPrompt = tokenDefault
|
||||||
|
? "Upstream Gateway Token [detected from openclaw]: "
|
||||||
|
: "Upstream Gateway Token: ";
|
||||||
|
const tokenAnswer = await rl.question(tokenPrompt);
|
||||||
|
const token = (tokenAnswer || tokenDefault || "").trim();
|
||||||
|
if (!token) {
|
||||||
|
throw new Error(
|
||||||
|
"Gateway token is required. Provide it, or install/openclaw so it can be auto-detected."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(settingsDir, { recursive: true });
|
||||||
|
const next = {
|
||||||
|
version: 1,
|
||||||
|
gateway: {
|
||||||
|
url: gatewayUrl,
|
||||||
|
token,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
fs.writeFileSync(settingsPath, JSON.stringify(next, null, 2), "utf8");
|
||||||
|
|
||||||
|
console.info(`Wrote Studio settings to ${settingsPath}.`);
|
||||||
|
} finally {
|
||||||
|
rl.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error(msg);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const repoRoot = process.cwd();
|
||||||
|
const requestedSourcePath =
|
||||||
|
process.argv[2]?.trim() ||
|
||||||
|
process.env.OPENCLAW_GATEWAY_CLIENT_SOURCE?.trim() ||
|
||||||
|
process.env.OPENCLAW_UI_PATH?.trim() ||
|
||||||
|
"";
|
||||||
|
const sourcePath = requestedSourcePath
|
||||||
|
? path.resolve(requestedSourcePath)
|
||||||
|
: "";
|
||||||
|
const destPath = path.join(
|
||||||
|
repoRoot,
|
||||||
|
"src",
|
||||||
|
"lib",
|
||||||
|
"gateway",
|
||||||
|
"openclaw",
|
||||||
|
"GatewayBrowserClient.ts"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!sourcePath) {
|
||||||
|
console.error(
|
||||||
|
"Missing upstream gateway client source path. Provide it as `npm run sync:gateway-client -- /path/to/gateway.ts` or set OPENCLAW_GATEWAY_CLIENT_SOURCE."
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(sourcePath)) {
|
||||||
|
console.error(`Missing upstream gateway client at ${sourcePath}.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let contents = fs.readFileSync(sourcePath, "utf8");
|
||||||
|
contents = contents
|
||||||
|
.replace(
|
||||||
|
/from "\.\.\/\.\.\/\.\.\/src\/gateway\/protocol\/client-info\.js";/g,
|
||||||
|
'from "./client-info";'
|
||||||
|
)
|
||||||
|
.replace(
|
||||||
|
/from "\.\.\/\.\.\/\.\.\/src\/gateway\/device-auth\.js";/g,
|
||||||
|
'from "./device-auth-payload";'
|
||||||
|
);
|
||||||
|
|
||||||
|
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
||||||
|
fs.writeFileSync(destPath, contents, "utf8");
|
||||||
|
console.log(`Synced gateway client to ${destPath}.`);
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
const parseCookies = (header) => {
|
||||||
|
const raw = typeof header === "string" ? header : "";
|
||||||
|
if (!raw.trim()) return {};
|
||||||
|
const out = {};
|
||||||
|
for (const part of raw.split(";")) {
|
||||||
|
const idx = part.indexOf("=");
|
||||||
|
if (idx === -1) continue;
|
||||||
|
const key = part.slice(0, idx).trim();
|
||||||
|
const value = part.slice(idx + 1).trim();
|
||||||
|
if (!key) continue;
|
||||||
|
out[key] = value;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createAccessGate(options) {
|
||||||
|
const token = String(options?.token ?? "").trim();
|
||||||
|
const cookieName = String(options?.cookieName ?? "studio_access").trim() || "studio_access";
|
||||||
|
|
||||||
|
const enabled = Boolean(token);
|
||||||
|
|
||||||
|
const isAuthorized = (req) => {
|
||||||
|
if (!enabled) return true;
|
||||||
|
const cookieHeader = req.headers?.cookie;
|
||||||
|
const cookies = parseCookies(cookieHeader);
|
||||||
|
return cookies[cookieName] === token;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHttp = (req, res) => {
|
||||||
|
if (!enabled) return false;
|
||||||
|
if (!isAuthorized(req)) {
|
||||||
|
if (String(req.url || "/").startsWith("/api/")) {
|
||||||
|
res.statusCode = 401;
|
||||||
|
res.setHeader("Content-Type", "application/json");
|
||||||
|
res.end(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Studio access token required. Send the configured Studio access cookie and retry.",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
res.statusCode = 401;
|
||||||
|
res.setHeader("Content-Type", "text/plain");
|
||||||
|
res.end("Studio access token required. Set the studio_access cookie to access this page.");
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const allowUpgrade = (req) => {
|
||||||
|
if (!enabled) return true;
|
||||||
|
return isAuthorized(req);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { enabled, handleHttp, allowUpgrade };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createAccessGate };
|
||||||
|
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
const { WebSocket, WebSocketServer } = require("ws");
|
||||||
|
|
||||||
|
const buildErrorResponse = (id, code, message) => {
|
||||||
|
return {
|
||||||
|
type: "res",
|
||||||
|
id,
|
||||||
|
ok: false,
|
||||||
|
error: { code, message },
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const isObject = (value) => Boolean(value && typeof value === "object");
|
||||||
|
|
||||||
|
const safeJsonParse = (raw) => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolvePathname = (url) => {
|
||||||
|
const raw = typeof url === "string" ? url : "";
|
||||||
|
const idx = raw.indexOf("?");
|
||||||
|
return (idx === -1 ? raw : raw.slice(0, idx)) || "/";
|
||||||
|
};
|
||||||
|
|
||||||
|
const injectAuthToken = (params, token) => {
|
||||||
|
const next = isObject(params) ? { ...params } : {};
|
||||||
|
const auth = isObject(next.auth) ? { ...next.auth } : {};
|
||||||
|
auth.token = token;
|
||||||
|
next.auth = auth;
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveOriginForUpstream = (upstreamUrl) => {
|
||||||
|
const url = new URL(upstreamUrl);
|
||||||
|
const proto = url.protocol === "wss:" ? "https:" : "http:";
|
||||||
|
const hostname =
|
||||||
|
url.hostname === "127.0.0.1" || url.hostname === "::1" || url.hostname === "0.0.0.0"
|
||||||
|
? "localhost"
|
||||||
|
: url.hostname;
|
||||||
|
const host = url.port ? `${hostname}:${url.port}` : hostname;
|
||||||
|
return `${proto}//${host}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasNonEmptyToken = (params) => {
|
||||||
|
const raw = params && isObject(params) && isObject(params.auth) ? params.auth.token : "";
|
||||||
|
return typeof raw === "string" && raw.trim().length > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasNonEmptyPassword = (params) => {
|
||||||
|
const raw = params && isObject(params) && isObject(params.auth) ? params.auth.password : "";
|
||||||
|
return typeof raw === "string" && raw.trim().length > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasNonEmptyDeviceToken = (params) => {
|
||||||
|
const raw = params && isObject(params) && isObject(params.auth) ? params.auth.deviceToken : "";
|
||||||
|
return typeof raw === "string" && raw.trim().length > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasCompleteDeviceAuth = (params) => {
|
||||||
|
const device = params && isObject(params) && isObject(params.device) ? params.device : null;
|
||||||
|
if (!device) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const id = typeof device.id === "string" ? device.id.trim() : "";
|
||||||
|
const publicKey = typeof device.publicKey === "string" ? device.publicKey.trim() : "";
|
||||||
|
const signature = typeof device.signature === "string" ? device.signature.trim() : "";
|
||||||
|
const nonce = typeof device.nonce === "string" ? device.nonce.trim() : "";
|
||||||
|
const signedAt = device.signedAt;
|
||||||
|
return (
|
||||||
|
id.length > 0 &&
|
||||||
|
publicKey.length > 0 &&
|
||||||
|
signature.length > 0 &&
|
||||||
|
nonce.length > 0 &&
|
||||||
|
Number.isFinite(signedAt) &&
|
||||||
|
signedAt >= 0
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function createGatewayProxy(options) {
|
||||||
|
const {
|
||||||
|
loadUpstreamSettings,
|
||||||
|
allowWs = (req) => resolvePathname(req.url) === "/api/gateway/ws",
|
||||||
|
log = () => {},
|
||||||
|
logError = (msg, err) => console.error(msg, err),
|
||||||
|
} = options || {};
|
||||||
|
|
||||||
|
const { verifyClient } = options || {};
|
||||||
|
|
||||||
|
if (typeof loadUpstreamSettings !== "function") {
|
||||||
|
throw new Error("createGatewayProxy requires loadUpstreamSettings().");
|
||||||
|
}
|
||||||
|
|
||||||
|
const wss = new WebSocketServer({ noServer: true, verifyClient });
|
||||||
|
|
||||||
|
wss.on("connection", (browserWs) => {
|
||||||
|
let upstreamWs = null;
|
||||||
|
let upstreamReady = false;
|
||||||
|
let upstreamUrl = "";
|
||||||
|
let upstreamToken = "";
|
||||||
|
let connectRequestId = null;
|
||||||
|
let connectResponseSent = false;
|
||||||
|
let pendingConnectFrame = null;
|
||||||
|
let pendingUpstreamSetupError = null;
|
||||||
|
let closed = false;
|
||||||
|
|
||||||
|
const closeBoth = (code, reason) => {
|
||||||
|
if (closed) return;
|
||||||
|
closed = true;
|
||||||
|
try {
|
||||||
|
browserWs.close(code, reason);
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
upstreamWs?.close(code, reason);
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendToBrowser = (frame) => {
|
||||||
|
if (browserWs.readyState !== WebSocket.OPEN) return;
|
||||||
|
browserWs.send(JSON.stringify(frame));
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendConnectError = (code, message) => {
|
||||||
|
if (connectRequestId && !connectResponseSent) {
|
||||||
|
connectResponseSent = true;
|
||||||
|
sendToBrowser(buildErrorResponse(connectRequestId, code, message));
|
||||||
|
}
|
||||||
|
closeBoth(1011, "connect failed");
|
||||||
|
};
|
||||||
|
|
||||||
|
const forwardConnectFrame = (frame) => {
|
||||||
|
const browserHasAuth =
|
||||||
|
hasNonEmptyToken(frame.params) ||
|
||||||
|
hasNonEmptyPassword(frame.params) ||
|
||||||
|
hasNonEmptyDeviceToken(frame.params) ||
|
||||||
|
hasCompleteDeviceAuth(frame.params);
|
||||||
|
|
||||||
|
if (!upstreamToken && !browserHasAuth) {
|
||||||
|
sendConnectError(
|
||||||
|
"studio.gateway_token_missing",
|
||||||
|
"Upstream gateway token is not configured on the Studio host."
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectFrame = browserHasAuth
|
||||||
|
? frame
|
||||||
|
: {
|
||||||
|
...frame,
|
||||||
|
params: injectAuthToken(frame.params, upstreamToken),
|
||||||
|
};
|
||||||
|
upstreamWs.send(JSON.stringify(connectFrame));
|
||||||
|
};
|
||||||
|
|
||||||
|
const maybeForwardPendingConnect = () => {
|
||||||
|
if (!pendingConnectFrame || !upstreamReady || upstreamWs?.readyState !== WebSocket.OPEN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const frame = pendingConnectFrame;
|
||||||
|
pendingConnectFrame = null;
|
||||||
|
forwardConnectFrame(frame);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startUpstream = async () => {
|
||||||
|
try {
|
||||||
|
const settings = await loadUpstreamSettings();
|
||||||
|
upstreamUrl = typeof settings?.url === "string" ? settings.url.trim() : "";
|
||||||
|
upstreamToken = typeof settings?.token === "string" ? settings.token.trim() : "";
|
||||||
|
} catch (err) {
|
||||||
|
logError("Failed to load upstream gateway settings.", err);
|
||||||
|
pendingUpstreamSetupError = {
|
||||||
|
code: "studio.settings_load_failed",
|
||||||
|
message: "Failed to load Studio gateway settings.",
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!upstreamUrl) {
|
||||||
|
pendingUpstreamSetupError = {
|
||||||
|
code: "studio.gateway_url_missing",
|
||||||
|
message: "Upstream gateway URL is not configured on the Studio host.",
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let upstreamOrigin = "";
|
||||||
|
try {
|
||||||
|
upstreamOrigin = resolveOriginForUpstream(upstreamUrl);
|
||||||
|
} catch {
|
||||||
|
pendingUpstreamSetupError = {
|
||||||
|
code: "studio.gateway_url_invalid",
|
||||||
|
message: "Upstream gateway URL is invalid on the Studio host.",
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
upstreamWs = new WebSocket(upstreamUrl, { origin: upstreamOrigin });
|
||||||
|
|
||||||
|
upstreamWs.on("open", () => {
|
||||||
|
upstreamReady = true;
|
||||||
|
maybeForwardPendingConnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
upstreamWs.on("message", (upRaw) => {
|
||||||
|
const upParsed = safeJsonParse(String(upRaw ?? ""));
|
||||||
|
if (upParsed && isObject(upParsed) && upParsed.type === "res") {
|
||||||
|
const resId = typeof upParsed.id === "string" ? upParsed.id : "";
|
||||||
|
if (resId && connectRequestId && resId === connectRequestId) {
|
||||||
|
connectResponseSent = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (browserWs.readyState === WebSocket.OPEN) {
|
||||||
|
browserWs.send(String(upRaw ?? ""));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
upstreamWs.on("close", (ev) => {
|
||||||
|
const reason = typeof ev?.reason === "string" ? ev.reason : "";
|
||||||
|
if (!connectResponseSent && connectRequestId) {
|
||||||
|
sendToBrowser(
|
||||||
|
buildErrorResponse(
|
||||||
|
connectRequestId,
|
||||||
|
"studio.upstream_closed",
|
||||||
|
`Upstream gateway closed (${ev.code}): ${reason}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
closeBoth(1012, "upstream closed");
|
||||||
|
});
|
||||||
|
|
||||||
|
upstreamWs.on("error", (err) => {
|
||||||
|
logError("Upstream gateway WebSocket error.", err);
|
||||||
|
sendConnectError(
|
||||||
|
"studio.upstream_error",
|
||||||
|
"Failed to connect to upstream gateway WebSocket."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
log("proxy connected");
|
||||||
|
};
|
||||||
|
|
||||||
|
void startUpstream();
|
||||||
|
|
||||||
|
browserWs.on("message", async (raw) => {
|
||||||
|
const parsed = safeJsonParse(String(raw ?? ""));
|
||||||
|
if (!parsed || !isObject(parsed)) {
|
||||||
|
closeBoth(1003, "invalid json");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!connectRequestId) {
|
||||||
|
if (parsed.type !== "req" || parsed.method !== "connect") {
|
||||||
|
closeBoth(1008, "connect required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = typeof parsed.id === "string" ? parsed.id : "";
|
||||||
|
if (!id) {
|
||||||
|
closeBoth(1008, "connect id required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
connectRequestId = id;
|
||||||
|
if (pendingUpstreamSetupError) {
|
||||||
|
sendConnectError(pendingUpstreamSetupError.code, pendingUpstreamSetupError.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pendingConnectFrame = parsed;
|
||||||
|
maybeForwardPendingConnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!upstreamReady || upstreamWs.readyState !== WebSocket.OPEN) {
|
||||||
|
closeBoth(1013, "upstream not ready");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.type === "req" && parsed.method === "connect" && !connectResponseSent) {
|
||||||
|
pendingConnectFrame = null;
|
||||||
|
forwardConnectFrame(parsed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
upstreamWs.send(JSON.stringify(parsed));
|
||||||
|
});
|
||||||
|
|
||||||
|
browserWs.on("close", () => {
|
||||||
|
closeBoth(1000, "client closed");
|
||||||
|
});
|
||||||
|
|
||||||
|
browserWs.on("error", (err) => {
|
||||||
|
logError("Browser WebSocket error.", err);
|
||||||
|
closeBoth(1011, "client error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleUpgrade = (req, socket, head) => {
|
||||||
|
if (!allowWs(req)) {
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||||
|
wss.emit("connection", ws, req);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return { wss, handleUpgrade };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createGatewayProxy };
|
||||||
+130
@@ -0,0 +1,130 @@
|
|||||||
|
const http = require("node:http");
|
||||||
|
const next = require("next");
|
||||||
|
|
||||||
|
const { createAccessGate } = require("./access-gate");
|
||||||
|
const { createGatewayProxy } = require("./gateway-proxy");
|
||||||
|
const { assertPublicHostAllowed, resolveHosts } = require("./network-policy");
|
||||||
|
const { loadUpstreamGatewaySettings } = require("./studio-settings");
|
||||||
|
|
||||||
|
const resolvePort = () => {
|
||||||
|
const raw = process.env.PORT?.trim() || "3000";
|
||||||
|
const port = Number(raw);
|
||||||
|
if (!Number.isFinite(port) || port <= 0) return 3000;
|
||||||
|
return port;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolvePathname = (url) => {
|
||||||
|
const raw = typeof url === "string" ? url : "";
|
||||||
|
const idx = raw.indexOf("?");
|
||||||
|
return (idx === -1 ? raw : raw.slice(0, idx)) || "/";
|
||||||
|
};
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const dev = process.argv.includes("--dev");
|
||||||
|
const hostnames = Array.from(new Set(resolveHosts(process.env)));
|
||||||
|
const hostname = hostnames[0] ?? "127.0.0.1";
|
||||||
|
const port = resolvePort();
|
||||||
|
for (const host of hostnames) {
|
||||||
|
assertPublicHostAllowed({
|
||||||
|
host,
|
||||||
|
studioAccessToken: process.env.STUDIO_ACCESS_TOKEN,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = next({
|
||||||
|
dev,
|
||||||
|
hostname,
|
||||||
|
port,
|
||||||
|
...(dev ? { webpack: true } : null),
|
||||||
|
});
|
||||||
|
const handle = app.getRequestHandler();
|
||||||
|
|
||||||
|
const accessGate = createAccessGate({
|
||||||
|
token: process.env.STUDIO_ACCESS_TOKEN,
|
||||||
|
});
|
||||||
|
|
||||||
|
const proxy = createGatewayProxy({
|
||||||
|
loadUpstreamSettings: async () => {
|
||||||
|
const settings = loadUpstreamGatewaySettings(process.env);
|
||||||
|
return { url: settings.url, token: settings.token };
|
||||||
|
},
|
||||||
|
allowWs: (req) => {
|
||||||
|
if (resolvePathname(req.url) !== "/api/gateway/ws") return false;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
verifyClient: (info) => accessGate.allowUpgrade(info.req),
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.prepare();
|
||||||
|
const handleUpgrade = app.getUpgradeHandler();
|
||||||
|
const handleServerUpgrade = (req, socket, head) => {
|
||||||
|
if (resolvePathname(req.url) === "/api/gateway/ws") {
|
||||||
|
proxy.handleUpgrade(req, socket, head);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleUpgrade(req, socket, head);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createServer = () =>
|
||||||
|
http.createServer((req, res) => {
|
||||||
|
if (accessGate.handleHttp(req, res)) return;
|
||||||
|
handle(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
const servers = hostnames.map(() => createServer());
|
||||||
|
|
||||||
|
const attachUpgradeHandlers = (server) => {
|
||||||
|
server.on("upgrade", handleServerUpgrade);
|
||||||
|
server.on("newListener", (eventName, listener) => {
|
||||||
|
if (eventName !== "upgrade") return;
|
||||||
|
if (listener === handleServerUpgrade) return;
|
||||||
|
process.nextTick(() => {
|
||||||
|
server.removeListener("upgrade", listener);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const server of servers) {
|
||||||
|
attachUpgradeHandlers(server);
|
||||||
|
}
|
||||||
|
|
||||||
|
const listenOnHost = (server, host) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const onError = (err) => {
|
||||||
|
server.off("error", onError);
|
||||||
|
reject(err);
|
||||||
|
};
|
||||||
|
server.once("error", onError);
|
||||||
|
server.listen(port, host, () => {
|
||||||
|
server.off("error", onError);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const closeServer = (server) =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
if (!server.listening) return resolve();
|
||||||
|
server.close(() => resolve());
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all(servers.map((server, index) => listenOnHost(server, hostnames[index])));
|
||||||
|
} catch (err) {
|
||||||
|
await Promise.all(servers.map((server) => closeServer(server)));
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostForBrowser = hostnames.some((value) => value === "127.0.0.1" || value === "::1")
|
||||||
|
? "localhost"
|
||||||
|
: hostname === "0.0.0.0" || hostname === "::"
|
||||||
|
? "localhost"
|
||||||
|
: hostname;
|
||||||
|
|
||||||
|
const browserUrl = `http://${hostForBrowser}:${port}`;
|
||||||
|
console.info(`Open in browser: ${browserUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
const net = require("node:net");
|
||||||
|
|
||||||
|
const normalizeHost = (host) => {
|
||||||
|
let raw = String(host ?? "").trim().toLowerCase();
|
||||||
|
if (!raw) return "";
|
||||||
|
|
||||||
|
if (raw.startsWith("[")) {
|
||||||
|
const end = raw.indexOf("]");
|
||||||
|
if (end !== -1) {
|
||||||
|
return raw.slice(1, end).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const colonCount = (raw.match(/:/g) || []).length;
|
||||||
|
if (colonCount === 1) {
|
||||||
|
const idx = raw.lastIndexOf(":");
|
||||||
|
const maybePort = raw.slice(idx + 1);
|
||||||
|
if (/^\d+$/.test(maybePort)) {
|
||||||
|
raw = raw.slice(0, idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveHosts = (env = process.env) => {
|
||||||
|
const host = String(env.HOST ?? "").trim();
|
||||||
|
if (host) return [host];
|
||||||
|
return ["127.0.0.1", "::1"];
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveHost = (env = process.env) => {
|
||||||
|
const hosts = resolveHosts(env);
|
||||||
|
return hosts[0] ?? "127.0.0.1";
|
||||||
|
};
|
||||||
|
|
||||||
|
const isIpv4Loopback = (value) => value.startsWith("127.");
|
||||||
|
|
||||||
|
const isIpv6Loopback = (value) => {
|
||||||
|
if (value === "::1" || value === "0:0:0:0:0:0:0:1") return true;
|
||||||
|
if (!value.startsWith("::ffff:")) return false;
|
||||||
|
const mapped = value.slice("::ffff:".length);
|
||||||
|
return net.isIP(mapped) === 4 && isIpv4Loopback(mapped);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPublicHost = (host) => {
|
||||||
|
const normalized = normalizeHost(host);
|
||||||
|
if (!normalized) return false;
|
||||||
|
|
||||||
|
if (normalized === "localhost") return false;
|
||||||
|
if (normalized === "0.0.0.0" || normalized === "::") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ipVersion = net.isIP(normalized);
|
||||||
|
if (ipVersion === 4) {
|
||||||
|
return !isIpv4Loopback(normalized);
|
||||||
|
}
|
||||||
|
if (ipVersion === 6) {
|
||||||
|
return !isIpv6Loopback(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const assertPublicHostAllowed = ({ host, studioAccessToken }) => {
|
||||||
|
if (!isPublicHost(host)) return;
|
||||||
|
|
||||||
|
const token = String(studioAccessToken ?? "").trim();
|
||||||
|
if (token) return;
|
||||||
|
|
||||||
|
const normalized = normalizeHost(host) || String(host ?? "").trim() || "(unknown)";
|
||||||
|
throw new Error(
|
||||||
|
`Refusing to bind Studio to public host "${normalized}" without STUDIO_ACCESS_TOKEN. ` +
|
||||||
|
"Set STUDIO_ACCESS_TOKEN or bind HOST to 127.0.0.1/::1/localhost."
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
resolveHosts,
|
||||||
|
resolveHost,
|
||||||
|
isPublicHost,
|
||||||
|
assertPublicHostAllowed,
|
||||||
|
};
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const LEGACY_STATE_DIRNAMES = [".clawdbot", ".moltbot"];
|
||||||
|
const NEW_STATE_DIRNAME = ".openclaw";
|
||||||
|
|
||||||
|
const resolveUserPath = (input) => {
|
||||||
|
const trimmed = String(input ?? "").trim();
|
||||||
|
if (!trimmed) return trimmed;
|
||||||
|
if (trimmed.startsWith("~")) {
|
||||||
|
const expanded = trimmed.replace(/^~(?=$|[\\/])/, os.homedir());
|
||||||
|
return path.resolve(expanded);
|
||||||
|
}
|
||||||
|
return path.resolve(trimmed);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveDefaultHomeDir = () => {
|
||||||
|
const home = os.homedir();
|
||||||
|
if (home) {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(home)) return home;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return os.tmpdir();
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveStateDir = (env = process.env) => {
|
||||||
|
const override =
|
||||||
|
env.OPENCLAW_STATE_DIR?.trim() ||
|
||||||
|
env.MOLTBOT_STATE_DIR?.trim() ||
|
||||||
|
env.CLAWDBOT_STATE_DIR?.trim();
|
||||||
|
if (override) return resolveUserPath(override);
|
||||||
|
|
||||||
|
const home = resolveDefaultHomeDir();
|
||||||
|
const newDir = path.join(home, NEW_STATE_DIRNAME);
|
||||||
|
const legacyDirs = LEGACY_STATE_DIRNAMES.map((dir) => path.join(home, dir));
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(newDir)) return newDir;
|
||||||
|
} catch {}
|
||||||
|
for (const dir of legacyDirs) {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(dir)) return dir;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return newDir;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveStudioSettingsPath = (env = process.env) => {
|
||||||
|
return path.join(resolveStateDir(env), "claw3d", "settings.json");
|
||||||
|
};
|
||||||
|
|
||||||
|
const readJsonFile = (filePath) => {
|
||||||
|
if (!fs.existsSync(filePath)) return null;
|
||||||
|
const raw = fs.readFileSync(filePath, "utf8");
|
||||||
|
return JSON.parse(raw);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_GATEWAY_URL = "ws://localhost:18789";
|
||||||
|
const OPENCLAW_CONFIG_FILENAME = "openclaw.json";
|
||||||
|
const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "0.0.0.0"]);
|
||||||
|
|
||||||
|
const isRecord = (value) => Boolean(value && typeof value === "object");
|
||||||
|
|
||||||
|
const isLocalGatewayUrl = (value) => {
|
||||||
|
const trimmed = typeof value === "string" ? value.trim() : "";
|
||||||
|
if (!trimmed) return false;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(trimmed);
|
||||||
|
return LOOPBACK_HOSTNAMES.has(parsed.hostname.toLowerCase());
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const readOpenclawGatewayDefaults = (env = process.env) => {
|
||||||
|
try {
|
||||||
|
const stateDir = resolveStateDir(env);
|
||||||
|
const configPath = path.join(stateDir, OPENCLAW_CONFIG_FILENAME);
|
||||||
|
const parsed = readJsonFile(configPath);
|
||||||
|
if (!isRecord(parsed)) return null;
|
||||||
|
const gateway = isRecord(parsed.gateway) ? parsed.gateway : null;
|
||||||
|
if (!gateway) return null;
|
||||||
|
const auth = isRecord(gateway.auth) ? gateway.auth : null;
|
||||||
|
const token = typeof auth?.token === "string" ? auth.token.trim() : "";
|
||||||
|
const port =
|
||||||
|
typeof gateway.port === "number" && Number.isFinite(gateway.port) ? gateway.port : null;
|
||||||
|
if (!token) return null;
|
||||||
|
const url = port ? `ws://localhost:${port}` : "";
|
||||||
|
if (!url) return null;
|
||||||
|
return { url, token };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadUpstreamGatewaySettings = (env = process.env) => {
|
||||||
|
const settingsPath = resolveStudioSettingsPath(env);
|
||||||
|
const parsed = readJsonFile(settingsPath);
|
||||||
|
const gateway = parsed && typeof parsed === "object" ? parsed.gateway : null;
|
||||||
|
const url = typeof gateway?.url === "string" ? gateway.url.trim() : "";
|
||||||
|
const token = typeof gateway?.token === "string" ? gateway.token.trim() : "";
|
||||||
|
if (!token && (!url || isLocalGatewayUrl(url))) {
|
||||||
|
const defaults = readOpenclawGatewayDefaults(env);
|
||||||
|
if (defaults) {
|
||||||
|
return {
|
||||||
|
url: url || defaults.url,
|
||||||
|
token: defaults.token,
|
||||||
|
settingsPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
url: url || DEFAULT_GATEWAY_URL,
|
||||||
|
token,
|
||||||
|
settingsPath,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
resolveStateDir,
|
||||||
|
resolveStudioSettingsPath,
|
||||||
|
loadUpstreamGatewaySettings,
|
||||||
|
};
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
# Skills in OpenClaw + Claw3D
|
||||||
|
|
||||||
|
This document explains skills from first principles, how they work in the OpenClaw runtime, and how Claw3D currently exposes them in UX.
|
||||||
|
|
||||||
|
It is intended as design context for rethinking the Skills UX.
|
||||||
|
|
||||||
|
## 1) Why skills exist (first principles)
|
||||||
|
|
||||||
|
Skills are the mechanism OpenClaw uses to give agents reusable operational know-how without hardcoding that know-how into core runtime logic.
|
||||||
|
|
||||||
|
At a product level, a skill is:
|
||||||
|
- A unit of capability guidance (`SKILL.md`) that teaches an agent how to perform a job.
|
||||||
|
- A gated unit of readiness (only available when required binaries/env/config/OS are satisfied).
|
||||||
|
- A portable package format compatible with AgentSkills (`agentskills.io`) so skill content can be authored and shared outside a single product.
|
||||||
|
|
||||||
|
Without skills, every workflow instruction would need to live in prompts, app code, or ad hoc user messages. Skills create a middle layer: structured capability packs that are discoverable, filterable, and enforceable.
|
||||||
|
|
||||||
|
## 2) AgentSkills.io context
|
||||||
|
|
||||||
|
OpenClaw intentionally uses AgentSkills-compatible `SKILL.md` structure and semantics.
|
||||||
|
|
||||||
|
Why this matters:
|
||||||
|
- Interoperability: skills can move between ecosystems that understand AgentSkills.
|
||||||
|
- Community/network effects: external skill ecosystems (for OpenClaw specifically, ClawHub) can be leveraged instead of reinventing proprietary formats.
|
||||||
|
- UX consistency: users can reason about “a skill folder with `SKILL.md` + metadata gates” instead of app-specific abstractions.
|
||||||
|
|
||||||
|
OpenClaw adds product-specific metadata under `metadata.openclaw` (install specs, gating fields, primary env key, etc.) while keeping the base skill shape compatible.
|
||||||
|
|
||||||
|
## 3) Skill object model
|
||||||
|
|
||||||
|
A skill is loaded from a directory containing `SKILL.md` with frontmatter.
|
||||||
|
|
||||||
|
Minimum frontmatter:
|
||||||
|
- `name`
|
||||||
|
- `description`
|
||||||
|
|
||||||
|
Important optional fields used by OpenClaw:
|
||||||
|
- `metadata.openclaw.always`
|
||||||
|
- `metadata.openclaw.skillKey`
|
||||||
|
- `metadata.openclaw.primaryEnv`
|
||||||
|
- `metadata.openclaw.os`
|
||||||
|
- `metadata.openclaw.requires.{bins, anyBins, env, config}`
|
||||||
|
- `metadata.openclaw.install[]`
|
||||||
|
- `user-invocable`
|
||||||
|
- `disable-model-invocation`
|
||||||
|
- `command-dispatch`, `command-tool`, `command-arg-mode`
|
||||||
|
|
||||||
|
In runtime, this becomes a normalized `SkillEntry`:
|
||||||
|
- Raw skill (`name`, `description`, `source`, file paths)
|
||||||
|
- Parsed frontmatter
|
||||||
|
- Resolved OpenClaw metadata
|
||||||
|
- Invocation policy flags
|
||||||
|
|
||||||
|
## 4) Where skills come from (discovery + precedence)
|
||||||
|
|
||||||
|
OpenClaw merges multiple sources into one effective skill set.
|
||||||
|
|
||||||
|
Current merge precedence in code (lowest -> highest):
|
||||||
|
1. `skills.load.extraDirs` and plugin-contributed skill dirs (`source: openclaw-extra`)
|
||||||
|
2. Bundled skills (`openclaw-bundled`)
|
||||||
|
3. Managed/global local skills (`~/.openclaw/skills`, `openclaw-managed`)
|
||||||
|
4. Personal agents skills (`~/.agents/skills`, `agents-skills-personal`)
|
||||||
|
5. Project agents skills (`<workspace>/.agents/skills`, `agents-skills-project`)
|
||||||
|
6. Workspace skills (`<workspace>/skills`, `openclaw-workspace`)
|
||||||
|
|
||||||
|
Name conflicts are resolved by “last writer wins” according to this order.
|
||||||
|
|
||||||
|
## 5) Eligibility and gating model
|
||||||
|
|
||||||
|
Eligibility is not just “is this skill installed.” It is computed every load/snapshot using:
|
||||||
|
- Per-skill disable (`skills.entries.<skillKey>.enabled === false`)
|
||||||
|
- Bundled allowlist (`skills.allowBundled`) for bundled skills only
|
||||||
|
- Runtime requirements:
|
||||||
|
- `requires.bins` (all required)
|
||||||
|
- `requires.anyBins` (at least one)
|
||||||
|
- `requires.env`
|
||||||
|
- `requires.config`
|
||||||
|
- `os`
|
||||||
|
- Remote node eligibility (macOS node bin probing can satisfy certain requirements)
|
||||||
|
- `always: true` short-circuiting requirement failures
|
||||||
|
|
||||||
|
Status output carries:
|
||||||
|
- `eligible` / `blocked`
|
||||||
|
- structured `missing` reasons
|
||||||
|
- `configChecks` with `{ path, satisfied }` (not secret values)
|
||||||
|
- install options derived from metadata
|
||||||
|
|
||||||
|
## 6) Agent-level filtering semantics
|
||||||
|
|
||||||
|
OpenClaw has a separate per-agent skill filter via `agents.list[].skills`:
|
||||||
|
- Missing `skills` key: all discovered skills are allowed
|
||||||
|
- `skills: []`: no skills allowed
|
||||||
|
- `skills: ["a", "b"]`: allowlist mode
|
||||||
|
|
||||||
|
This filter is normalized and passed into snapshot generation as `skillFilter`.
|
||||||
|
|
||||||
|
In practice this is the key UX distinction:
|
||||||
|
- Discovery/readiness is global + workspace-derived.
|
||||||
|
- “Can this specific agent use it?” is per-agent allowlist.
|
||||||
|
|
||||||
|
## 7) Snapshot + prompt lifecycle
|
||||||
|
|
||||||
|
Skills are snapshotted into session state (`skillsSnapshot`) to avoid re-scanning every turn.
|
||||||
|
|
||||||
|
Snapshot contains:
|
||||||
|
- prebuilt prompt block
|
||||||
|
- lightweight skill metadata (`name`, `primaryEnv`, required env names)
|
||||||
|
- normalized `skillFilter`
|
||||||
|
- resolved skills list
|
||||||
|
- version
|
||||||
|
|
||||||
|
Lifecycle:
|
||||||
|
1. First turn/new session builds snapshot.
|
||||||
|
2. File watcher / remote-node events bump snapshot version.
|
||||||
|
3. Later turns refresh snapshot only if version is newer.
|
||||||
|
4. Prompt injection uses snapshot prompt when present.
|
||||||
|
|
||||||
|
Watcher scope includes:
|
||||||
|
- workspace `skills/`
|
||||||
|
- workspace `.agents/skills`
|
||||||
|
- `~/.openclaw/skills`
|
||||||
|
- `~/.agents/skills`
|
||||||
|
- configured extra dirs
|
||||||
|
- plugin skill dirs
|
||||||
|
|
||||||
|
Watcher monitors `SKILL.md` patterns (not entire trees) and debounces changes.
|
||||||
|
|
||||||
|
## 8) Runtime execution behavior
|
||||||
|
|
||||||
|
During an agent run:
|
||||||
|
1. Skill env overrides are applied (`skills.entries.*.env` + `apiKey` mapping to `primaryEnv`).
|
||||||
|
2. Overrides are sanitized/guarded (dangerous host env keys blocked).
|
||||||
|
3. Skills prompt is injected.
|
||||||
|
4. Environment is restored after run.
|
||||||
|
|
||||||
|
Invocation behavior:
|
||||||
|
- `disable-model-invocation: true` keeps skill out of model prompt.
|
||||||
|
- `user-invocable: true` exposes slash commands.
|
||||||
|
- Optional direct tool dispatch can bypass model routing.
|
||||||
|
|
||||||
|
Sandbox nuance:
|
||||||
|
- For non-`rw` sandbox workspaces, OpenClaw syncs skills into sandbox workspace (best-effort) so skill files remain accessible.
|
||||||
|
|
||||||
|
## 9) Gateway API surface for skills
|
||||||
|
|
||||||
|
Core RPC methods:
|
||||||
|
- `skills.status` -> returns `SkillStatusReport` for an agent workspace.
|
||||||
|
- `skills.install` -> installs dependencies for a skill install option.
|
||||||
|
- `skills.update` -> updates `skills.entries.<skillKey>` config (`enabled`, `apiKey`, `env`).
|
||||||
|
- `skills.bins` -> aggregates required bins across agent workspaces.
|
||||||
|
|
||||||
|
Important scope behavior:
|
||||||
|
- `skills.install` is executed against the default agent workspace (not arbitrary selected agent workspace).
|
||||||
|
- `skills.update` writes gateway config (`openclaw.json`) and is gateway-wide state mutation.
|
||||||
|
|
||||||
|
Security detail:
|
||||||
|
- `skills.status` exposes config check satisfaction, not raw secret config values.
|
||||||
|
|
||||||
|
## 10) Claw3D UX (current behavior)
|
||||||
|
|
||||||
|
### 10.1 Route and navigation model
|
||||||
|
|
||||||
|
Studio settings currently live on root route with a query-driven settings mode:
|
||||||
|
- Canonical settings state is `/?settingsAgentId=<agentId>`.
|
||||||
|
- `/agents/[agentId]/settings` currently redirects to that query route.
|
||||||
|
|
||||||
|
Left nav tabs in settings mode:
|
||||||
|
- Behavior
|
||||||
|
- Capabilities
|
||||||
|
- Skills
|
||||||
|
- Automations
|
||||||
|
- Advanced
|
||||||
|
|
||||||
|
### 10.2 Skills tab data and interactions
|
||||||
|
|
||||||
|
When either `Skills` or `System setup` tab is active and connected, Studio:
|
||||||
|
1. Calls `skills.status`.
|
||||||
|
2. Reads current per-agent allowlist from gateway config (`agents.list[].skills`).
|
||||||
|
3. Renders two distinct settings surfaces:
|
||||||
|
|
||||||
|
`Skills` tab (agent-scoped):
|
||||||
|
- Shows one list focused on “what this agent can use”.
|
||||||
|
- Per-skill allow toggle (`Skill <name>` switch) for agent access only.
|
||||||
|
- Simplified status chips (`Ready`, `Setup required`, `Not supported`).
|
||||||
|
- Search + status filters for scanning.
|
||||||
|
- Non-ready rows provide `Open System Setup` instead of inline setup actions.
|
||||||
|
|
||||||
|
`System setup` tab (gateway-scoped):
|
||||||
|
- Explicitly states that setup actions affect all agents.
|
||||||
|
- Shows setup queue and full readiness details.
|
||||||
|
- Per-skill `Configure` modal with setup/lifecycle actions:
|
||||||
|
- install dependencies (`skills.install`)
|
||||||
|
- save API key (`skills.update` with `apiKey`)
|
||||||
|
- global enable/disable (`skills.update` with `enabled`)
|
||||||
|
- remove removable skill directories via Studio remove route
|
||||||
|
- Supports transition handoff from agent row to preselected skill setup context.
|
||||||
|
|
||||||
|
### 10.3 Mutation wiring from Studio
|
||||||
|
|
||||||
|
Per-agent access mutations:
|
||||||
|
- `updateGatewayAgentSkillsAllowlist` in Studio writes `config.set` with retry-on-stale-hash behavior.
|
||||||
|
- Agent toggles continue to rely on allowlist semantics (`undefined` means all, explicit array means selected-only).
|
||||||
|
|
||||||
|
System setup mutations:
|
||||||
|
- Install -> `skills.install`
|
||||||
|
- API key save -> `skills.update`
|
||||||
|
- Remove files -> Studio route `/api/gateway/skills/remove` (local fs or SSH helper)
|
||||||
|
|
||||||
|
Removal has strict guards:
|
||||||
|
- Only specific sources removable (`openclaw-managed`, `openclaw-workspace`).
|
||||||
|
- Must stay inside allowed root.
|
||||||
|
- Cannot remove skills root directory.
|
||||||
|
- Must look like a real skill dir (`SKILL.md` exists).
|
||||||
|
|
||||||
|
### 10.4 Scope warning shown in Studio
|
||||||
|
|
||||||
|
Studio computes the default agent id and passes install-scope context into the system setup surface.
|
||||||
|
|
||||||
|
Current scope copy behavior:
|
||||||
|
- `Skills` tab copy states controls apply to the current agent.
|
||||||
|
- `System setup` tab copy states actions apply to all agents.
|
||||||
|
- Install target caveat (default-agent workspace behavior) is shown in system setup context and setup modal context, where install actions actually occur.
|
||||||
|
|
||||||
|
This keeps scope and install-target warnings accurate while minimizing noise in the agent access flow.
|
||||||
|
|
||||||
|
## 11) What recent `.agent/done` plans show
|
||||||
|
|
||||||
|
Sorted by most recent creation time in `claw3d/.agent/done`, the latest items are mostly bugfix exec plans (streaming, proxy auth, stale config, cron rollback, etc.).
|
||||||
|
|
||||||
|
The most recent plan with explicit skills direction is:
|
||||||
|
- `ui-execplan-stuff.md` (2026-02-20 create time), which intentionally scoped skills as coming-soon during that IA pass.
|
||||||
|
|
||||||
|
Additional files with incidental skill mentions:
|
||||||
|
- `simplify-agent-creation-starter-kits.md`
|
||||||
|
- `ux-zero-agent-layout-consolidation.md`
|
||||||
|
|
||||||
|
Interpretation:
|
||||||
|
- The current Studio code now has a real Skills tab and mutation flow, but the older IA/doc language still contains “coming soon” assumptions in places.
|
||||||
|
- For redesign, trust current code behavior over older plan phrasing.
|
||||||
|
|
||||||
|
## 12) UX redesign constraints that are not optional
|
||||||
|
|
||||||
|
Any redesign should preserve these distinctions:
|
||||||
|
|
||||||
|
1. Three separate scopes:
|
||||||
|
- Agent allowlist scope (`agents.list[].skills`)
|
||||||
|
- Gateway setup scope (`skills.entries.*`, installs)
|
||||||
|
- Source/discovery scope (workspace/managed/bundled/extra/plugin)
|
||||||
|
|
||||||
|
2. Eligibility vs enablement:
|
||||||
|
- A skill can be enabled by allowlist but still blocked by missing requirements.
|
||||||
|
- A skill can be eligible but disabled by agent allowlist.
|
||||||
|
|
||||||
|
3. Session-snapshot behavior:
|
||||||
|
- Skills changes may not appear mid-turn; they apply on next turn/snapshot refresh.
|
||||||
|
|
||||||
|
4. Install target caveat:
|
||||||
|
- Install currently targets default agent workspace context in gateway path.
|
||||||
|
|
||||||
|
5. Security posture:
|
||||||
|
- Secret values should never be exposed in status surfaces.
|
||||||
|
- Removal must stay bounded to allowed roots and verified skill dirs.
|
||||||
|
|
||||||
|
## 13) Practical mental model for reviewing a Skills screenshot
|
||||||
|
|
||||||
|
If you hand a screenshot to another LLM for UX feedback, ask it to evaluate on three axes:
|
||||||
|
|
||||||
|
1. **Scope clarity**
|
||||||
|
- Can a user tell what is per-agent vs gateway-wide?
|
||||||
|
|
||||||
|
2. **Readiness clarity**
|
||||||
|
- Can a user tell blocked vs eligible and why?
|
||||||
|
|
||||||
|
3. **Action safety**
|
||||||
|
- Are destructive/setup actions clearly separated from allowlist toggles?
|
||||||
|
|
||||||
|
If a design fails any of those axes, users will misconfigure skills even if controls are technically correct.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default function InvalidRoutePage() {
|
||||||
|
redirect("/office");
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default async function AgentSettingsPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ agentId?: string }> | { agentId?: string };
|
||||||
|
}) {
|
||||||
|
await params;
|
||||||
|
redirect("/office");
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default function AgentsPage() {
|
||||||
|
redirect("/office");
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { restoreAgentStateLocally, trashAgentStateLocally } from "@/lib/agent-state/local";
|
||||||
|
import { isLocalGatewayUrl } from "@/lib/gateway/local-gateway";
|
||||||
|
import {
|
||||||
|
resolveConfiguredSshTarget,
|
||||||
|
resolveGatewaySshTargetFromGatewayUrl,
|
||||||
|
} from "@/lib/ssh/gateway-host";
|
||||||
|
import {
|
||||||
|
restoreAgentStateOverSsh,
|
||||||
|
trashAgentStateOverSsh,
|
||||||
|
} from "@/lib/ssh/agent-state";
|
||||||
|
import { loadStudioSettings } from "@/lib/studio/settings-store";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
type TrashAgentStateRequest = {
|
||||||
|
agentId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RestoreAgentStateRequest = {
|
||||||
|
agentId: string;
|
||||||
|
trashDir: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSafeAgentId = (value: string) => /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/.test(value);
|
||||||
|
|
||||||
|
const resolveAgentStateSshTarget = (): string | null => {
|
||||||
|
const configured = resolveConfiguredSshTarget(process.env);
|
||||||
|
if (configured) return configured;
|
||||||
|
const settings = loadStudioSettings();
|
||||||
|
const gatewayUrl = settings.gateway?.url ?? "";
|
||||||
|
if (isLocalGatewayUrl(gatewayUrl)) return null;
|
||||||
|
return resolveGatewaySshTargetFromGatewayUrl(gatewayUrl, process.env);
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = (await request.json()) as unknown;
|
||||||
|
if (!body || typeof body !== "object") {
|
||||||
|
return NextResponse.json({ error: "Invalid request payload." }, { status: 400 });
|
||||||
|
}
|
||||||
|
const { agentId } = body as Partial<TrashAgentStateRequest>;
|
||||||
|
const trimmed = typeof agentId === "string" ? agentId.trim() : "";
|
||||||
|
if (!trimmed) {
|
||||||
|
return NextResponse.json({ error: "agentId is required." }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!isSafeAgentId(trimmed)) {
|
||||||
|
return NextResponse.json({ error: `Invalid agentId: ${trimmed}` }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sshTarget = resolveAgentStateSshTarget();
|
||||||
|
const result = sshTarget
|
||||||
|
? trashAgentStateOverSsh({ sshTarget, agentId: trimmed })
|
||||||
|
: trashAgentStateLocally({ agentId: trimmed });
|
||||||
|
return NextResponse.json({ result });
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
err instanceof Error ? err.message : "Failed to trash agent workspace/state.";
|
||||||
|
console.error(message);
|
||||||
|
const status =
|
||||||
|
message.includes("Invalid request payload") ||
|
||||||
|
message.includes("agentId is required") ||
|
||||||
|
message.includes("trashDir is required") ||
|
||||||
|
message.includes("Invalid agentId") ||
|
||||||
|
message.includes("Gateway URL is missing") ||
|
||||||
|
message.includes("Invalid gateway URL") ||
|
||||||
|
message.includes("require OPENCLAW_GATEWAY_SSH_TARGET")
|
||||||
|
? 400
|
||||||
|
: 500;
|
||||||
|
return NextResponse.json({ error: message }, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = (await request.json()) as unknown;
|
||||||
|
if (!body || typeof body !== "object") {
|
||||||
|
return NextResponse.json({ error: "Invalid request payload." }, { status: 400 });
|
||||||
|
}
|
||||||
|
const { agentId, trashDir } = body as Partial<RestoreAgentStateRequest>;
|
||||||
|
const trimmedAgent = typeof agentId === "string" ? agentId.trim() : "";
|
||||||
|
const trimmedTrash = typeof trashDir === "string" ? trashDir.trim() : "";
|
||||||
|
if (!trimmedAgent) {
|
||||||
|
return NextResponse.json({ error: "agentId is required." }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!trimmedTrash) {
|
||||||
|
return NextResponse.json({ error: "trashDir is required." }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!isSafeAgentId(trimmedAgent)) {
|
||||||
|
return NextResponse.json({ error: `Invalid agentId: ${trimmedAgent}` }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sshTarget = resolveAgentStateSshTarget();
|
||||||
|
const result = sshTarget
|
||||||
|
? restoreAgentStateOverSsh({
|
||||||
|
sshTarget,
|
||||||
|
agentId: trimmedAgent,
|
||||||
|
trashDir: trimmedTrash,
|
||||||
|
})
|
||||||
|
: restoreAgentStateLocally({
|
||||||
|
agentId: trimmedAgent,
|
||||||
|
trashDir: trimmedTrash,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ result });
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Failed to restore agent state.";
|
||||||
|
console.error(message);
|
||||||
|
const status =
|
||||||
|
message.includes("Invalid request payload") ||
|
||||||
|
message.includes("agentId is required") ||
|
||||||
|
message.includes("trashDir is required") ||
|
||||||
|
message.includes("Invalid agentId") ||
|
||||||
|
message.includes("Gateway URL is missing") ||
|
||||||
|
message.includes("Invalid gateway URL") ||
|
||||||
|
message.includes("require OPENCLAW_GATEWAY_SSH_TARGET")
|
||||||
|
? 400
|
||||||
|
: 500;
|
||||||
|
return NextResponse.json({ error: message }, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { isLocalGatewayUrl } from "@/lib/gateway/local-gateway";
|
||||||
|
import {
|
||||||
|
resolveConfiguredSshTarget,
|
||||||
|
resolveGatewaySshTargetFromGatewayUrl,
|
||||||
|
runSshJson,
|
||||||
|
} from "@/lib/ssh/gateway-host";
|
||||||
|
import { loadStudioSettings } from "@/lib/studio/settings-store";
|
||||||
|
import * as fs from "node:fs/promises";
|
||||||
|
import * as os from "node:os";
|
||||||
|
import * as path from "node:path";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const MAX_MEDIA_BYTES = 25 * 1024 * 1024;
|
||||||
|
|
||||||
|
const MIME_BY_EXT: Record<string, string> = {
|
||||||
|
".png": "image/png",
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".gif": "image/gif",
|
||||||
|
".webp": "image/webp",
|
||||||
|
};
|
||||||
|
|
||||||
|
const expandTildeLocal = (value: string): string => {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (trimmed === "~") return os.homedir();
|
||||||
|
if (trimmed.startsWith("~/")) return path.join(os.homedir(), trimmed.slice(2));
|
||||||
|
return trimmed;
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateRawMediaPath = (raw: string): { trimmed: string; mime: string } => {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) throw new Error("path is required");
|
||||||
|
if (trimmed.length > 4096) throw new Error("path too long");
|
||||||
|
if (/[^\S\r\n]*[\0\r\n]/.test(trimmed)) throw new Error("path contains invalid characters");
|
||||||
|
|
||||||
|
const ext = path.extname(trimmed).toLowerCase();
|
||||||
|
const mime = MIME_BY_EXT[ext];
|
||||||
|
if (!mime) throw new Error(`Unsupported media extension: ${ext || "(none)"}`);
|
||||||
|
|
||||||
|
return { trimmed, mime };
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveAndValidateLocalMediaPath = (raw: string): { resolved: string; mime: string } => {
|
||||||
|
const { trimmed, mime } = validateRawMediaPath(raw);
|
||||||
|
|
||||||
|
const expanded = expandTildeLocal(trimmed);
|
||||||
|
if (!path.isAbsolute(expanded)) {
|
||||||
|
throw new Error("path must be absolute or start with ~/");
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = path.resolve(expanded);
|
||||||
|
|
||||||
|
const allowedRoot = path.join(os.homedir(), ".openclaw");
|
||||||
|
const allowedPrefix = `${allowedRoot}${path.sep}`;
|
||||||
|
if (!(resolved === allowedRoot || resolved.startsWith(allowedPrefix))) {
|
||||||
|
throw new Error(`Refusing to read media outside ${allowedRoot}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { resolved, mime };
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateRemoteMediaPath = (raw: string): { remotePath: string; mime: string } => {
|
||||||
|
const { trimmed, mime } = validateRawMediaPath(raw);
|
||||||
|
|
||||||
|
if (!(trimmed.startsWith("/") || trimmed === "~" || trimmed.startsWith("~/"))) {
|
||||||
|
throw new Error("path must be absolute or start with ~/");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remote side enforces ~/.openclaw; this guard lets Studio on macOS request
|
||||||
|
// /home/ubuntu/.openclaw/... without tripping local homedir checks.
|
||||||
|
const normalized = trimmed.replaceAll("\\\\", "/");
|
||||||
|
const inOpenclaw =
|
||||||
|
normalized === "~/.openclaw" ||
|
||||||
|
normalized.startsWith("~/.openclaw/") ||
|
||||||
|
normalized.includes("/.openclaw/");
|
||||||
|
if (!inOpenclaw) {
|
||||||
|
throw new Error("Refusing to read remote media outside ~/.openclaw");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { remotePath: trimmed, mime };
|
||||||
|
};
|
||||||
|
|
||||||
|
const readLocalMedia = async (resolvedPath: string): Promise<{ bytes: Buffer; size: number }> => {
|
||||||
|
const stat = await fs.stat(resolvedPath);
|
||||||
|
if (!stat.isFile()) {
|
||||||
|
throw new Error("path is not a file");
|
||||||
|
}
|
||||||
|
if (stat.size > MAX_MEDIA_BYTES) {
|
||||||
|
throw new Error(`media file too large (${stat.size} bytes)`);
|
||||||
|
}
|
||||||
|
const buf = await fs.readFile(resolvedPath);
|
||||||
|
return { bytes: buf, size: stat.size };
|
||||||
|
};
|
||||||
|
|
||||||
|
const REMOTE_READ_SCRIPT = `
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
python3 - "$1" <<'PY'
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
|
||||||
|
raw = sys.argv[1].strip()
|
||||||
|
if not raw:
|
||||||
|
print(json.dumps({"error": "path is required"}))
|
||||||
|
raise SystemExit(2)
|
||||||
|
|
||||||
|
p = pathlib.Path(os.path.expanduser(raw))
|
||||||
|
try:
|
||||||
|
resolved = p.resolve(strict=True)
|
||||||
|
except FileNotFoundError:
|
||||||
|
print(json.dumps({"error": f"file not found: {raw}"}))
|
||||||
|
raise SystemExit(3)
|
||||||
|
|
||||||
|
home = pathlib.Path.home().resolve()
|
||||||
|
allowed = (home / ".openclaw").resolve()
|
||||||
|
if resolved != allowed and allowed not in resolved.parents:
|
||||||
|
print(json.dumps({"error": f"Refusing to read media outside {allowed}"}))
|
||||||
|
raise SystemExit(4)
|
||||||
|
|
||||||
|
ext = resolved.suffix.lower()
|
||||||
|
mime = {
|
||||||
|
".png": "image/png",
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".gif": "image/gif",
|
||||||
|
".webp": "image/webp",
|
||||||
|
}.get(ext) or (mimetypes.guess_type(str(resolved))[0] or "")
|
||||||
|
|
||||||
|
if not mime.startswith("image/"):
|
||||||
|
print(json.dumps({"error": f"Unsupported media extension: {ext or '(none)'}"}))
|
||||||
|
raise SystemExit(5)
|
||||||
|
|
||||||
|
size = resolved.stat().st_size
|
||||||
|
max_bytes = ${MAX_MEDIA_BYTES}
|
||||||
|
if size > max_bytes:
|
||||||
|
print(json.dumps({"error": f"media file too large ({size} bytes)"}))
|
||||||
|
raise SystemExit(6)
|
||||||
|
|
||||||
|
data = base64.b64encode(resolved.read_bytes()).decode("ascii")
|
||||||
|
print(json.dumps({"ok": True, "mime": mime, "size": size, "data": data}))
|
||||||
|
PY
|
||||||
|
`;
|
||||||
|
|
||||||
|
const resolveSshTarget = (): string | null => {
|
||||||
|
const settings = loadStudioSettings();
|
||||||
|
const gatewayUrl = settings.gateway?.url ?? "";
|
||||||
|
if (isLocalGatewayUrl(gatewayUrl)) return null;
|
||||||
|
const configured = resolveConfiguredSshTarget(process.env);
|
||||||
|
if (configured) return configured;
|
||||||
|
return resolveGatewaySshTargetFromGatewayUrl(gatewayUrl, process.env);
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const rawPath = (searchParams.get("path") ?? "").trim();
|
||||||
|
|
||||||
|
const sshTarget = resolveSshTarget();
|
||||||
|
|
||||||
|
if (!sshTarget) {
|
||||||
|
const { resolved, mime } = resolveAndValidateLocalMediaPath(rawPath);
|
||||||
|
const { bytes, size } = await readLocalMedia(resolved);
|
||||||
|
const body = new Blob([Uint8Array.from(bytes)], { type: mime });
|
||||||
|
return new Response(body, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": mime,
|
||||||
|
"Content-Length": String(size),
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { remotePath, mime } = validateRemoteMediaPath(rawPath);
|
||||||
|
|
||||||
|
const payload = runSshJson({
|
||||||
|
sshTarget,
|
||||||
|
argv: ["bash", "-s", "--", remotePath],
|
||||||
|
label: "gateway media read",
|
||||||
|
input: REMOTE_READ_SCRIPT,
|
||||||
|
fallbackMessage: `Failed to fetch media over ssh (${sshTarget})`,
|
||||||
|
maxBuffer: Math.ceil(MAX_MEDIA_BYTES * 1.6),
|
||||||
|
}) as {
|
||||||
|
ok?: boolean;
|
||||||
|
data?: string;
|
||||||
|
mime?: string;
|
||||||
|
size?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const b64 = payload.data ?? "";
|
||||||
|
if (!b64) {
|
||||||
|
throw new Error("Remote media fetch returned empty data");
|
||||||
|
}
|
||||||
|
|
||||||
|
const buf = Buffer.from(b64, "base64");
|
||||||
|
const responseMime = payload.mime || mime;
|
||||||
|
const body = new Blob([Uint8Array.from(buf)], { type: responseMime });
|
||||||
|
|
||||||
|
return new Response(body, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": responseMime,
|
||||||
|
"Content-Length": String(buf.length),
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Failed to fetch media";
|
||||||
|
return NextResponse.json({ error: message }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { isLocalGatewayUrl } from "@/lib/gateway/local-gateway";
|
||||||
|
import { removeSkillLocally } from "@/lib/skills/remove-local";
|
||||||
|
import type { RemovableSkillSource, SkillRemoveRequest } from "@/lib/skills/types";
|
||||||
|
import {
|
||||||
|
resolveConfiguredSshTarget,
|
||||||
|
resolveGatewaySshTargetFromGatewayUrl,
|
||||||
|
} from "@/lib/ssh/gateway-host";
|
||||||
|
import { removeSkillOverSsh } from "@/lib/ssh/skills-remove";
|
||||||
|
import { loadStudioSettings } from "@/lib/studio/settings-store";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const REMOVABLE_SOURCES = new Set<RemovableSkillSource>([
|
||||||
|
"openclaw-managed",
|
||||||
|
"openclaw-workspace",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const normalizeRequired = (value: unknown, field: string): string => {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
throw new Error(`${field} is required.`);
|
||||||
|
}
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
throw new Error(`${field} is required.`);
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveSkillRemovalSshTarget = (): string | null => {
|
||||||
|
const configured = resolveConfiguredSshTarget(process.env);
|
||||||
|
if (configured) return configured;
|
||||||
|
const settings = loadStudioSettings();
|
||||||
|
const gatewayUrl = settings.gateway?.url ?? "";
|
||||||
|
if (isLocalGatewayUrl(gatewayUrl)) return null;
|
||||||
|
return resolveGatewaySshTargetFromGatewayUrl(gatewayUrl, process.env);
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeRemoveRequest = (body: unknown): SkillRemoveRequest => {
|
||||||
|
if (!body || typeof body !== "object") {
|
||||||
|
throw new Error("Invalid request payload.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = body as Partial<Record<keyof SkillRemoveRequest, unknown>>;
|
||||||
|
const sourceRaw = normalizeRequired(record.source, "source");
|
||||||
|
if (!REMOVABLE_SOURCES.has(sourceRaw as RemovableSkillSource)) {
|
||||||
|
throw new Error(`Unsupported skill source for removal: ${sourceRaw}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
skillKey: normalizeRequired(record.skillKey, "skillKey"),
|
||||||
|
source: sourceRaw as RemovableSkillSource,
|
||||||
|
baseDir: normalizeRequired(record.baseDir, "baseDir"),
|
||||||
|
workspaceDir: normalizeRequired(record.workspaceDir, "workspaceDir"),
|
||||||
|
managedSkillsDir: normalizeRequired(record.managedSkillsDir, "managedSkillsDir"),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = (await request.json()) as unknown;
|
||||||
|
const removeRequest = normalizeRemoveRequest(body);
|
||||||
|
|
||||||
|
const sshTarget = resolveSkillRemovalSshTarget();
|
||||||
|
const result = sshTarget
|
||||||
|
? removeSkillOverSsh({ sshTarget, request: removeRequest })
|
||||||
|
: removeSkillLocally(removeRequest);
|
||||||
|
|
||||||
|
return NextResponse.json({ result });
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Failed to remove skill.";
|
||||||
|
const status =
|
||||||
|
message.includes("required") ||
|
||||||
|
message.includes("Invalid request payload") ||
|
||||||
|
message.includes("Unsupported skill source") ||
|
||||||
|
message.includes("Refusing to remove") ||
|
||||||
|
message.includes("not a directory") ||
|
||||||
|
message.includes("Gateway URL is missing") ||
|
||||||
|
message.includes("Invalid gateway URL") ||
|
||||||
|
message.includes("require OPENCLAW_GATEWAY_SSH_TARGET")
|
||||||
|
? 400
|
||||||
|
: 500;
|
||||||
|
if (status >= 500) {
|
||||||
|
console.error(message);
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: message }, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import {
|
||||||
|
normalizeBrowserPreviewUrl,
|
||||||
|
resolveBrowserControlBaseUrl,
|
||||||
|
} from "@/lib/office/browserPreview";
|
||||||
|
import { validateBrowserPreviewTarget } from "@/lib/security/urlSafety";
|
||||||
|
import { loadStudioSettings } from "@/lib/studio/settings-store";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
type BrowserTab = {
|
||||||
|
targetId: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BrowserTabsResponse = {
|
||||||
|
running?: boolean;
|
||||||
|
tabs?: BrowserTab[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type BrowserOpenResponse = {
|
||||||
|
targetId?: string;
|
||||||
|
url?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BrowserScreenshotResponse = {
|
||||||
|
ok?: boolean;
|
||||||
|
path?: string;
|
||||||
|
targetId?: string;
|
||||||
|
url?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_CAPTURE_WAIT_MS = 1_500;
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
const buildBrowserHeaders = (token: string | null): HeadersInit => {
|
||||||
|
if (!token) return { "Content-Type": "application/json" };
|
||||||
|
return {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseBrowserError = async (response: Response): Promise<string> => {
|
||||||
|
try {
|
||||||
|
const payload = (await response.json()) as { error?: string; message?: string };
|
||||||
|
return payload.error?.trim() || payload.message?.trim() || response.statusText || "Browser request failed";
|
||||||
|
} catch {
|
||||||
|
return response.statusText || "Browser request failed";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const browserRequest = async <T>(
|
||||||
|
baseUrl: string,
|
||||||
|
pathname: string,
|
||||||
|
init: RequestInit,
|
||||||
|
token: string | null,
|
||||||
|
): Promise<T> => {
|
||||||
|
const response = await fetch(`${baseUrl}${pathname}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
...buildBrowserHeaders(token),
|
||||||
|
...(init.headers ?? {}),
|
||||||
|
},
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await parseBrowserError(response));
|
||||||
|
}
|
||||||
|
return (await response.json()) as T;
|
||||||
|
};
|
||||||
|
|
||||||
|
const samePreviewTarget = (left: string, right: string): boolean => {
|
||||||
|
return normalizeBrowserPreviewUrl(left) === normalizeBrowserPreviewUrl(right);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensurePreviewTab = async (
|
||||||
|
baseUrl: string,
|
||||||
|
token: string | null,
|
||||||
|
browserUrl: string,
|
||||||
|
): Promise<{ targetId: string; resolvedUrl: string }> => {
|
||||||
|
const tabsPayload = await browserRequest<BrowserTabsResponse>(baseUrl, "/tabs", { method: "GET" }, token);
|
||||||
|
|
||||||
|
if (tabsPayload.running === false) {
|
||||||
|
await browserRequest(baseUrl, "/start", { method: "POST" }, token);
|
||||||
|
await sleep(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs = Array.isArray(tabsPayload.tabs) ? tabsPayload.tabs : [];
|
||||||
|
const exactMatch = tabs.find((tab) => samePreviewTarget(tab.url, browserUrl));
|
||||||
|
if (exactMatch?.targetId) {
|
||||||
|
await browserRequest(baseUrl, "/tabs/focus", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ targetId: exactMatch.targetId }),
|
||||||
|
}, token);
|
||||||
|
return { targetId: exactMatch.targetId, resolvedUrl: exactMatch.url || browserUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
const reusableTab = tabs[0];
|
||||||
|
if (reusableTab?.targetId) {
|
||||||
|
await browserRequest(baseUrl, "/tabs/focus", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ targetId: reusableTab.targetId }),
|
||||||
|
}, token);
|
||||||
|
await browserRequest(baseUrl, "/navigate", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ targetId: reusableTab.targetId, url: browserUrl }),
|
||||||
|
}, token);
|
||||||
|
return { targetId: reusableTab.targetId, resolvedUrl: browserUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
const opened = await browserRequest<BrowserOpenResponse>(baseUrl, "/tabs/open", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ url: browserUrl }),
|
||||||
|
}, token);
|
||||||
|
if (!opened.targetId) {
|
||||||
|
throw new Error("Browser preview did not return a target tab.");
|
||||||
|
}
|
||||||
|
return { targetId: opened.targetId, resolvedUrl: opened.url || browserUrl };
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const rawUrl = (searchParams.get("url") ?? "").trim();
|
||||||
|
if (!rawUrl) {
|
||||||
|
return NextResponse.json({ error: "url is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let browserUrl: string;
|
||||||
|
try {
|
||||||
|
browserUrl = validateBrowserPreviewTarget(rawUrl).toString();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "url must be an absolute public http(s) URL" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const waitMsRaw = Number(searchParams.get("waitMs") ?? "");
|
||||||
|
const waitMs =
|
||||||
|
Number.isFinite(waitMsRaw) && waitMsRaw >= 0
|
||||||
|
? Math.min(Math.round(waitMsRaw), 8_000)
|
||||||
|
: DEFAULT_CAPTURE_WAIT_MS;
|
||||||
|
|
||||||
|
const settings = loadStudioSettings();
|
||||||
|
const gatewayUrl = settings.gateway?.url?.trim() ?? "";
|
||||||
|
const controlBaseUrl = resolveBrowserControlBaseUrl(gatewayUrl);
|
||||||
|
if (!controlBaseUrl) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Browser screenshot preview only works when Studio is connected to a local gateway." },
|
||||||
|
{ status: 501 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = settings.gateway?.token?.trim() || null;
|
||||||
|
const { targetId, resolvedUrl } = await ensurePreviewTab(controlBaseUrl, token, browserUrl);
|
||||||
|
|
||||||
|
if (waitMs > 0) {
|
||||||
|
await sleep(waitMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
const screenshot = await browserRequest<BrowserScreenshotResponse>(controlBaseUrl, "/screenshot", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ targetId, type: "png" }),
|
||||||
|
}, token);
|
||||||
|
|
||||||
|
if (!screenshot.path?.trim()) {
|
||||||
|
throw new Error("Browser screenshot did not return a media path.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaUrl = `/api/gateway/media?path=${encodeURIComponent(screenshot.path)}`;
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
ok: true,
|
||||||
|
browserUrl: screenshot.url || resolvedUrl,
|
||||||
|
imagePath: screenshot.path,
|
||||||
|
mediaUrl,
|
||||||
|
targetId: screenshot.targetId || targetId,
|
||||||
|
capturedAt: Date.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Failed to build browser preview";
|
||||||
|
return NextResponse.json({ error: message }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { buildMockPhoneCallScenario } from "@/lib/office/call/mock";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
type PhoneCallRequestBody = {
|
||||||
|
callee?: string;
|
||||||
|
message?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_CALLEE_CHARS = 120;
|
||||||
|
const MAX_MESSAGE_CHARS = 1_000;
|
||||||
|
|
||||||
|
const normalizeText = (value: string | null | undefined): string =>
|
||||||
|
(value ?? "").replace(/\s+/g, " ").trim();
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = (await request.json()) as PhoneCallRequestBody;
|
||||||
|
const callee = normalizeText(body.callee);
|
||||||
|
const message = normalizeText(body.message);
|
||||||
|
|
||||||
|
if (!callee) {
|
||||||
|
return NextResponse.json({ error: "callee is required." }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (callee.length > MAX_CALLEE_CHARS) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `callee exceeds ${MAX_CALLEE_CHARS} characters.` },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (message.length > MAX_MESSAGE_CHARS) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `message exceeds ${MAX_MESSAGE_CHARS} characters.` },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Create Claw3D voice and text skill.
|
||||||
|
const scenario = buildMockPhoneCallScenario({
|
||||||
|
callee,
|
||||||
|
message: message || null,
|
||||||
|
voiceAvailable: Boolean(process.env.ELEVENLABS_API_KEY?.trim()),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ scenario },
|
||||||
|
{ headers: { "Cache-Control": "no-store" } },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Failed to prepare the mock phone call.";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import {
|
||||||
|
loadGitHubDashboard,
|
||||||
|
loadGitHubPullRequestDetail,
|
||||||
|
submitGitHubInlineComment,
|
||||||
|
submitGitHubPullRequestReview,
|
||||||
|
type GitHubInlineCommentSide,
|
||||||
|
type GitHubReviewAction,
|
||||||
|
} from "@/lib/office/github";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
type ReviewRequestBody = {
|
||||||
|
repo?: string;
|
||||||
|
number?: number;
|
||||||
|
action?: GitHubReviewAction;
|
||||||
|
body?: string | null;
|
||||||
|
path?: string;
|
||||||
|
line?: number;
|
||||||
|
side?: GitHubInlineCommentSide;
|
||||||
|
commitId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parsePullRequestNumber = (value: string | null): number | null => {
|
||||||
|
if (!value) return null;
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) return null;
|
||||||
|
return Math.round(parsed);
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const repo = (searchParams.get("repo") ?? "").trim();
|
||||||
|
const number = parsePullRequestNumber(searchParams.get("number"));
|
||||||
|
if (repo && number) {
|
||||||
|
return NextResponse.json(loadGitHubPullRequestDetail({ repo, number }), {
|
||||||
|
headers: { "Cache-Control": "no-store" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(loadGitHubDashboard(), {
|
||||||
|
headers: { "Cache-Control": "no-store" },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Failed to load GitHub server room data.";
|
||||||
|
return NextResponse.json({ error: message }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = (await request.json()) as ReviewRequestBody;
|
||||||
|
const repo = typeof body.repo === "string" ? body.repo.trim() : "";
|
||||||
|
const number =
|
||||||
|
typeof body.number === "number" && Number.isFinite(body.number)
|
||||||
|
? Math.round(body.number)
|
||||||
|
: null;
|
||||||
|
const action = typeof body.action === "string" ? body.action : null;
|
||||||
|
const path = typeof body.path === "string" ? body.path.trim() : "";
|
||||||
|
const line =
|
||||||
|
typeof body.line === "number" && Number.isFinite(body.line)
|
||||||
|
? Math.round(body.line)
|
||||||
|
: null;
|
||||||
|
const side = body.side === "LEFT" || body.side === "RIGHT" ? body.side : null;
|
||||||
|
const commentBody = typeof body.body === "string" ? body.body : null;
|
||||||
|
|
||||||
|
if (action) {
|
||||||
|
if (!repo || !number) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "repo, number, and action are required." },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!["APPROVE", "COMMENT", "REQUEST_CHANGES"].includes(action)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Unsupported review action." },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = submitGitHubPullRequestReview({
|
||||||
|
repo,
|
||||||
|
number,
|
||||||
|
action,
|
||||||
|
body: commentBody,
|
||||||
|
});
|
||||||
|
return NextResponse.json(result, {
|
||||||
|
headers: { "Cache-Control": "no-store" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!repo || !number || !path || !line || !side || !commentBody?.trim()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "repo, number, path, line, side, and body are required." },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = submitGitHubInlineComment({
|
||||||
|
repo,
|
||||||
|
number,
|
||||||
|
path,
|
||||||
|
line,
|
||||||
|
side,
|
||||||
|
body: commentBody,
|
||||||
|
commitId: typeof body.commitId === "string" ? body.commitId : null,
|
||||||
|
});
|
||||||
|
return NextResponse.json(result, {
|
||||||
|
headers: { "Cache-Control": "no-store" },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Failed to submit GitHub review.";
|
||||||
|
return NextResponse.json({ error: message }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { loadOfficePresenceSnapshot } from "@/lib/office/presence";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const workspaceId = url.searchParams.get("workspaceId")?.trim() || "default";
|
||||||
|
const snapshot = loadOfficePresenceSnapshot(workspaceId);
|
||||||
|
return NextResponse.json(snapshot);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to load office presence.";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { listOfficeVersions, publishOfficeVersion } from "@/lib/office/store";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const asString = (value: unknown) => (typeof value === "string" ? value.trim() : "");
|
||||||
|
|
||||||
|
export async function PUT(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = (await request.json()) as Record<string, unknown>;
|
||||||
|
const workspaceId = asString(body.workspaceId) || "default";
|
||||||
|
const officeId = asString(body.officeId);
|
||||||
|
const officeVersionId = asString(body.officeVersionId);
|
||||||
|
const publishedBy = asString(body.publishedBy) || "studio";
|
||||||
|
if (!workspaceId || !officeId) {
|
||||||
|
return NextResponse.json({ error: "workspaceId and officeId are required." }, { status: 400 });
|
||||||
|
}
|
||||||
|
let selectedVersionId = officeVersionId;
|
||||||
|
if (!selectedVersionId) {
|
||||||
|
const versions = listOfficeVersions(workspaceId, officeId);
|
||||||
|
selectedVersionId = versions[0]?.id ?? "";
|
||||||
|
}
|
||||||
|
if (!selectedVersionId) {
|
||||||
|
return NextResponse.json({ error: "No office version available to publish." }, { status: 400 });
|
||||||
|
}
|
||||||
|
const published = publishOfficeVersion({
|
||||||
|
workspaceId,
|
||||||
|
officeId,
|
||||||
|
officeVersionId: selectedVersionId,
|
||||||
|
publishedBy,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ published });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to publish office version.";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { createEmptyOfficeMap, normalizeOfficeMap, type OfficeMap } from "@/lib/office/schema";
|
||||||
|
import {
|
||||||
|
getPublishedOffice,
|
||||||
|
getPublishedOfficeMap,
|
||||||
|
listOfficesForWorkspace,
|
||||||
|
listOfficeVersions,
|
||||||
|
saveOfficeVersion,
|
||||||
|
upsertOffice,
|
||||||
|
} from "@/lib/office/store";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const asString = (value: unknown) => (typeof value === "string" ? value.trim() : "");
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const workspaceId = asString(url.searchParams.get("workspaceId")) || "default";
|
||||||
|
const officeId = asString(url.searchParams.get("officeId"));
|
||||||
|
const offices = listOfficesForWorkspace(workspaceId);
|
||||||
|
const published = getPublishedOffice(workspaceId);
|
||||||
|
const publishedMap = getPublishedOfficeMap(workspaceId);
|
||||||
|
const versions = officeId ? listOfficeVersions(workspaceId, officeId) : [];
|
||||||
|
return NextResponse.json({
|
||||||
|
workspaceId,
|
||||||
|
offices,
|
||||||
|
versions,
|
||||||
|
published,
|
||||||
|
publishedMap,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to load office data.";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = (await request.json()) as Record<string, unknown>;
|
||||||
|
const action = asString(body.action);
|
||||||
|
if (action === "upsertOffice") {
|
||||||
|
const workspaceId = asString(body.workspaceId) || "default";
|
||||||
|
const officeId = asString(body.officeId);
|
||||||
|
const name = asString(body.name);
|
||||||
|
if (!officeId || !name) {
|
||||||
|
return NextResponse.json({ error: "officeId and name are required." }, { status: 400 });
|
||||||
|
}
|
||||||
|
const office = upsertOffice({
|
||||||
|
workspaceId,
|
||||||
|
officeId,
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ office });
|
||||||
|
}
|
||||||
|
if (action === "saveVersion") {
|
||||||
|
const workspaceId = asString(body.workspaceId) || "default";
|
||||||
|
const officeId = asString(body.officeId);
|
||||||
|
const versionId = asString(body.versionId);
|
||||||
|
const createdBy = asString(body.createdBy) || "studio";
|
||||||
|
if (!officeId || !versionId) {
|
||||||
|
return NextResponse.json({ error: "officeId and versionId are required." }, { status: 400 });
|
||||||
|
}
|
||||||
|
const incomingMap = body.map as OfficeMap | undefined;
|
||||||
|
const fallback = createEmptyOfficeMap({
|
||||||
|
workspaceId,
|
||||||
|
officeVersionId: versionId,
|
||||||
|
width: 1600,
|
||||||
|
height: 900,
|
||||||
|
});
|
||||||
|
const map = normalizeOfficeMap(incomingMap, fallback);
|
||||||
|
const record = saveOfficeVersion({
|
||||||
|
workspaceId,
|
||||||
|
officeId,
|
||||||
|
versionId,
|
||||||
|
createdBy,
|
||||||
|
notes: asString(body.notes),
|
||||||
|
map,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ version: record });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: "Unsupported office action." }, { status: 400 });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to save office data.";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import {
|
||||||
|
applyStudioSettingsPatch,
|
||||||
|
loadStudioSettings,
|
||||||
|
} from "@/lib/studio/settings-store";
|
||||||
|
import {
|
||||||
|
resolveStandupPreference,
|
||||||
|
sanitizeStandupPreference,
|
||||||
|
type StudioStandupPreferencePatch,
|
||||||
|
} from "@/lib/studio/settings";
|
||||||
|
import { validateJiraBaseUrl } from "@/lib/security/urlSafety";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const readGatewayUrl = (request: Request) => {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
return (url.searchParams.get("gatewayUrl") ?? "").trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const gatewayUrl = readGatewayUrl(request);
|
||||||
|
if (!gatewayUrl) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "gatewayUrl is required." },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const settings = loadStudioSettings();
|
||||||
|
const config = resolveStandupPreference(settings, gatewayUrl);
|
||||||
|
return NextResponse.json({ gatewayUrl, config: sanitizeStandupPreference(config) });
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Failed to load standup config.";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = (await request.json()) as {
|
||||||
|
gatewayUrl?: string;
|
||||||
|
config?: StudioStandupPreferencePatch;
|
||||||
|
};
|
||||||
|
const gatewayUrl = typeof body.gatewayUrl === "string" ? body.gatewayUrl.trim() : "";
|
||||||
|
if (!gatewayUrl) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "gatewayUrl is required." },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!body.config || typeof body.config !== "object") {
|
||||||
|
return NextResponse.json({ error: "config is required." }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (body.config.jira?.baseUrl?.trim()) {
|
||||||
|
validateJiraBaseUrl(body.config.jira.baseUrl);
|
||||||
|
}
|
||||||
|
const settings = applyStudioSettingsPatch({
|
||||||
|
standup: {
|
||||||
|
[gatewayUrl]: body.config,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json({
|
||||||
|
gatewayUrl,
|
||||||
|
config: sanitizeStandupPreference(resolveStandupPreference(settings, gatewayUrl)),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Failed to save standup config.";
|
||||||
|
const status =
|
||||||
|
message.includes("gatewayUrl is required") ||
|
||||||
|
message.includes("config is required") ||
|
||||||
|
message.includes("Jira base URL")
|
||||||
|
? 400
|
||||||
|
: 500;
|
||||||
|
return NextResponse.json({ error: message }, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import {
|
||||||
|
advanceStandupMeeting,
|
||||||
|
startStandupSpeaker,
|
||||||
|
updateStandupArrivals,
|
||||||
|
} from "@/lib/office/standup/service";
|
||||||
|
import {
|
||||||
|
loadActiveStandupMeeting,
|
||||||
|
updateStandupMeeting,
|
||||||
|
} from "@/lib/office/standup/store";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ meeting: loadActiveStandupMeeting() },
|
||||||
|
{ headers: { "Cache-Control": "no-store" } }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Failed to load standup meeting.";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = (await request.json()) as {
|
||||||
|
action?: "arrivals" | "start" | "advance" | "complete";
|
||||||
|
arrivedAgentIds?: string[];
|
||||||
|
speakerAgentId?: string | null;
|
||||||
|
};
|
||||||
|
const action = typeof body.action === "string" ? body.action : "";
|
||||||
|
if (!action) {
|
||||||
|
return NextResponse.json({ error: "action is required." }, { status: 400 });
|
||||||
|
}
|
||||||
|
const store = updateStandupMeeting((meeting) => {
|
||||||
|
if (!meeting) return null;
|
||||||
|
if (action === "arrivals") {
|
||||||
|
return updateStandupArrivals(meeting, body.arrivedAgentIds ?? []);
|
||||||
|
}
|
||||||
|
if (action === "start") {
|
||||||
|
const speakerAgentId =
|
||||||
|
typeof body.speakerAgentId === "string" ? body.speakerAgentId.trim() : null;
|
||||||
|
return startStandupSpeaker(meeting, speakerAgentId);
|
||||||
|
}
|
||||||
|
if (action === "advance") {
|
||||||
|
return advanceStandupMeeting(meeting);
|
||||||
|
}
|
||||||
|
if (action === "complete") {
|
||||||
|
return startStandupSpeaker(meeting, null);
|
||||||
|
}
|
||||||
|
return meeting;
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ meeting: store.activeMeeting },
|
||||||
|
{ headers: { "Cache-Control": "no-store" } }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Failed to update standup meeting.";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { buildStandupMeeting } from "@/lib/office/standup/service";
|
||||||
|
import { saveStandupMeeting } from "@/lib/office/standup/store";
|
||||||
|
import type { StandupAgentSnapshot, StandupTriggerKind } from "@/lib/office/standup/types";
|
||||||
|
import {
|
||||||
|
applyStudioSettingsPatch,
|
||||||
|
loadStudioSettings,
|
||||||
|
} from "@/lib/studio/settings-store";
|
||||||
|
import { resolveStandupPreference } from "@/lib/studio/settings";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = (await request.json()) as {
|
||||||
|
gatewayUrl?: string;
|
||||||
|
agents?: StandupAgentSnapshot[];
|
||||||
|
trigger?: StandupTriggerKind;
|
||||||
|
scheduledFor?: string | null;
|
||||||
|
};
|
||||||
|
const gatewayUrl =
|
||||||
|
typeof body.gatewayUrl === "string" ? body.gatewayUrl.trim() : "";
|
||||||
|
if (!gatewayUrl) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "gatewayUrl is required." },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const settings = loadStudioSettings();
|
||||||
|
const config = resolveStandupPreference(settings, gatewayUrl);
|
||||||
|
const trigger = body.trigger === "scheduled" ? "scheduled" : "manual";
|
||||||
|
const meeting = await buildStandupMeeting({
|
||||||
|
config,
|
||||||
|
agents: Array.isArray(body.agents) ? body.agents : [],
|
||||||
|
trigger,
|
||||||
|
scheduledFor:
|
||||||
|
typeof body.scheduledFor === "string" ? body.scheduledFor : null,
|
||||||
|
});
|
||||||
|
saveStandupMeeting(meeting);
|
||||||
|
if (trigger === "scheduled") {
|
||||||
|
applyStudioSettingsPatch({
|
||||||
|
standup: {
|
||||||
|
[gatewayUrl]: {
|
||||||
|
schedule: {
|
||||||
|
lastAutoRunAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ meeting },
|
||||||
|
{ headers: { "Cache-Control": "no-store" } }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Failed to start standup meeting.";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { buildMockTextMessageScenario } from "@/lib/office/text/mock";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
type TextMessageRequestBody = {
|
||||||
|
recipient?: string;
|
||||||
|
message?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_RECIPIENT_CHARS = 120;
|
||||||
|
const MAX_MESSAGE_CHARS = 1_000;
|
||||||
|
|
||||||
|
const normalizeText = (value: string | null | undefined): string =>
|
||||||
|
(value ?? "").replace(/\s+/g, " ").trim();
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = (await request.json()) as TextMessageRequestBody;
|
||||||
|
const recipient = normalizeText(body.recipient);
|
||||||
|
const message = normalizeText(body.message);
|
||||||
|
|
||||||
|
if (!recipient) {
|
||||||
|
return NextResponse.json({ error: "recipient is required." }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (recipient.length > MAX_RECIPIENT_CHARS) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `recipient exceeds ${MAX_RECIPIENT_CHARS} characters.` },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (message.length > MAX_MESSAGE_CHARS) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `message exceeds ${MAX_MESSAGE_CHARS} characters.` },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Create Claw3D voice and text skill.
|
||||||
|
const scenario = buildMockTextMessageScenario({
|
||||||
|
recipient,
|
||||||
|
message: message || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ scenario },
|
||||||
|
{ headers: { "Cache-Control": "no-store" } },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Failed to prepare the mock text message.";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { synthesizeVoiceReply, type VoiceReplyProvider } from "@/lib/voiceReply/provider";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
type VoiceReplyRequestBody = {
|
||||||
|
text?: string;
|
||||||
|
provider?: VoiceReplyProvider;
|
||||||
|
voiceId?: string | null;
|
||||||
|
speed?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_REPLY_CHARS = 5_000;
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = (await request.json()) as VoiceReplyRequestBody;
|
||||||
|
const text = typeof body.text === "string" ? body.text.replace(/\s+/g, " ").trim() : "";
|
||||||
|
if (!text) {
|
||||||
|
return NextResponse.json({ error: "Voice reply text is required." }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (text.length > MAX_REPLY_CHARS) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Voice reply text exceeds ${MAX_REPLY_CHARS} characters.` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const response = await synthesizeVoiceReply({
|
||||||
|
text,
|
||||||
|
provider: body.provider,
|
||||||
|
voiceId: body.voiceId,
|
||||||
|
speed: body.speed,
|
||||||
|
});
|
||||||
|
return new Response(response.body, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
"Content-Type": response.headers.get("content-type") ?? "audio/mpeg",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Failed to synthesize the voice reply.";
|
||||||
|
const status = message.includes("Missing ELEVENLABS_API_KEY") ? 503 : 500;
|
||||||
|
return NextResponse.json({ error: message }, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { transcribeVoiceWithOpenClaw } from "@/lib/openclaw/voiceTranscription";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const MAX_VOICE_UPLOAD_BYTES = 20 * 1024 * 1024;
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const audio = formData.get("audio");
|
||||||
|
if (!(audio instanceof File)) {
|
||||||
|
return NextResponse.json({ error: "audio file is required." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const arrayBuffer = await audio.arrayBuffer();
|
||||||
|
const byteLength = arrayBuffer.byteLength;
|
||||||
|
if (byteLength <= 0) {
|
||||||
|
return NextResponse.json({ error: "Audio upload is empty." }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (byteLength > MAX_VOICE_UPLOAD_BYTES) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Audio upload exceeds the ${MAX_VOICE_UPLOAD_BYTES} byte limit.` },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await transcribeVoiceWithOpenClaw({
|
||||||
|
buffer: Buffer.from(arrayBuffer),
|
||||||
|
fileName: audio.name,
|
||||||
|
mimeType: audio.type,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
transcript: result.transcript,
|
||||||
|
provider: result.provider,
|
||||||
|
model: result.model,
|
||||||
|
decision: result.decision,
|
||||||
|
ignored: result.ignored,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to transcribe audio.";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { resolveUserPath } from "@/lib/clawdbot/paths";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
type PathAutocompleteEntry = {
|
||||||
|
name: string;
|
||||||
|
fullPath: string;
|
||||||
|
displayPath: string;
|
||||||
|
isDirectory: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PathAutocompleteResult = {
|
||||||
|
query: string;
|
||||||
|
directory: string;
|
||||||
|
entries: PathAutocompleteEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type PathAutocompleteOptions = {
|
||||||
|
query: string;
|
||||||
|
maxResults?: number;
|
||||||
|
homedir?: () => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeQuery = (query: string): string => {
|
||||||
|
const trimmed = query.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
throw new Error("Query is required.");
|
||||||
|
}
|
||||||
|
if (trimmed === "~") {
|
||||||
|
return "~/";
|
||||||
|
}
|
||||||
|
if (trimmed.startsWith("~")) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
const withoutLeading = trimmed.replace(/^[\\/]+/, "");
|
||||||
|
return `~/${withoutLeading}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isWithinHome = (target: string, home: string): boolean => {
|
||||||
|
const relative = path.relative(home, target);
|
||||||
|
if (!relative) return true;
|
||||||
|
return !relative.startsWith("..") && !path.isAbsolute(relative);
|
||||||
|
};
|
||||||
|
|
||||||
|
const listPathAutocompleteEntries = ({
|
||||||
|
query,
|
||||||
|
maxResults = 10,
|
||||||
|
homedir = os.homedir,
|
||||||
|
}: PathAutocompleteOptions): PathAutocompleteResult => {
|
||||||
|
const normalized = normalizeQuery(query);
|
||||||
|
const resolvedHome = path.resolve(homedir());
|
||||||
|
const resolvedQuery = resolveUserPath(normalized, homedir);
|
||||||
|
if (!isWithinHome(resolvedQuery, resolvedHome)) {
|
||||||
|
throw new Error("Path must stay within the home directory.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const endsWithSlash = normalized.endsWith("/") || normalized.endsWith(path.sep);
|
||||||
|
const directoryPath = endsWithSlash ? resolvedQuery : path.dirname(resolvedQuery);
|
||||||
|
const prefix = endsWithSlash ? "" : path.basename(resolvedQuery);
|
||||||
|
|
||||||
|
if (!isWithinHome(directoryPath, resolvedHome)) {
|
||||||
|
throw new Error("Path must stay within the home directory.");
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(directoryPath)) {
|
||||||
|
throw new Error(`Directory does not exist: ${directoryPath}`);
|
||||||
|
}
|
||||||
|
const stat = fs.statSync(directoryPath);
|
||||||
|
if (!stat.isDirectory()) {
|
||||||
|
throw new Error(`Path is not a directory: ${directoryPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = Number.isFinite(maxResults) && maxResults > 0 ? Math.floor(maxResults) : 10;
|
||||||
|
|
||||||
|
const entries = fs
|
||||||
|
.readdirSync(directoryPath, { withFileTypes: true })
|
||||||
|
.filter((entry) => !entry.name.startsWith("."))
|
||||||
|
.filter((entry) => entry.name.startsWith(prefix))
|
||||||
|
.map((entry) => {
|
||||||
|
const fullPath = path.join(directoryPath, entry.name);
|
||||||
|
const relative = path.relative(resolvedHome, fullPath);
|
||||||
|
const normalizedRelative = relative.split(path.sep).join("/");
|
||||||
|
const displayBase = `~/${normalizedRelative}`;
|
||||||
|
return {
|
||||||
|
name: entry.name,
|
||||||
|
fullPath,
|
||||||
|
displayPath: entry.isDirectory() ? `${displayBase}/` : displayBase,
|
||||||
|
isDirectory: entry.isDirectory(),
|
||||||
|
} satisfies PathAutocompleteEntry;
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.isDirectory !== b.isDirectory) {
|
||||||
|
return a.isDirectory ? -1 : 1;
|
||||||
|
}
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
})
|
||||||
|
.slice(0, limit);
|
||||||
|
|
||||||
|
return { query: normalized, directory: directoryPath, entries };
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const rawQuery = searchParams.get("q");
|
||||||
|
const query = rawQuery && rawQuery.trim() ? rawQuery.trim() : "~/";
|
||||||
|
const result = listPathAutocompleteEntries({ query, maxResults: 10 });
|
||||||
|
return NextResponse.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
err instanceof Error ? err.message : "Failed to list path suggestions.";
|
||||||
|
console.error(message);
|
||||||
|
const status = message.includes("does not exist") ? 404 : 400;
|
||||||
|
return NextResponse.json({ error: message }, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import {
|
||||||
|
sanitizeStudioGatewaySettings,
|
||||||
|
sanitizeStudioSettings,
|
||||||
|
type StudioSettingsPatch,
|
||||||
|
} from "@/lib/studio/settings";
|
||||||
|
import {
|
||||||
|
applyStudioSettingsPatch,
|
||||||
|
loadLocalGatewayDefaults,
|
||||||
|
loadStudioSettings,
|
||||||
|
} from "@/lib/studio/settings-store";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const isPatch = (value: unknown): value is StudioSettingsPatch =>
|
||||||
|
Boolean(value && typeof value === "object");
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const settings = loadStudioSettings();
|
||||||
|
const localGatewayDefaults = loadLocalGatewayDefaults();
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
settings: sanitizeStudioSettings(settings),
|
||||||
|
localGatewayDefaults: sanitizeStudioGatewaySettings(localGatewayDefaults),
|
||||||
|
},
|
||||||
|
{ headers: { "Cache-Control": "no-store" } }
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Failed to load studio settings.";
|
||||||
|
console.error(message);
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = (await request.json()) as unknown;
|
||||||
|
if (!isPatch(body)) {
|
||||||
|
return NextResponse.json({ error: "Invalid settings payload." }, { status: 400 });
|
||||||
|
}
|
||||||
|
const settings = applyStudioSettingsPatch(body);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ settings: sanitizeStudioSettings(settings) },
|
||||||
|
{ headers: { "Cache-Control": "no-store" } }
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Failed to save studio settings.";
|
||||||
|
console.error(message);
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
+1355
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Bebas_Neue, IBM_Plex_Mono, IBM_Plex_Sans } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Claw3D",
|
||||||
|
description: "Focused operator studio for the OpenClaw gateway.",
|
||||||
|
};
|
||||||
|
|
||||||
|
const display = Bebas_Neue({
|
||||||
|
variable: "--font-display",
|
||||||
|
weight: "400",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const sans = IBM_Plex_Sans({
|
||||||
|
variable: "--font-sans",
|
||||||
|
weight: ["400", "500", "600", "700"],
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const mono = IBM_Plex_Mono({
|
||||||
|
variable: "--font-mono",
|
||||||
|
weight: ["400", "500", "600"],
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="en" suppressHydrationWarning>
|
||||||
|
<head>
|
||||||
|
<script
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html:
|
||||||
|
"(function(){try{var t=localStorage.getItem('theme');var m=window.matchMedia('(prefers-color-scheme: dark)').matches;var d=t?t==='dark':m;document.documentElement.classList.toggle('dark',d);}catch(e){}})();",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body className={`${display.variable} ${sans.variable} ${mono.variable} antialiased`}>
|
||||||
|
<main className="h-screen w-screen overflow-hidden bg-background">{children}</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { OfficeBuilderPanel } from "@/features/office/components/OfficeBuilderPanel";
|
||||||
|
import { createStarterOfficeMap, normalizeOfficeMap } from "@/lib/office/schema";
|
||||||
|
import { getPublishedOfficeMap } from "@/lib/office/store";
|
||||||
|
|
||||||
|
const WORKSPACE_ID = "default";
|
||||||
|
const OFFICE_ID = "hq";
|
||||||
|
|
||||||
|
export default function OfficeBuilderPage() {
|
||||||
|
const fallback = createStarterOfficeMap({
|
||||||
|
workspaceId: WORKSPACE_ID,
|
||||||
|
officeVersionId: "builder-draft",
|
||||||
|
width: 1600,
|
||||||
|
height: 900,
|
||||||
|
});
|
||||||
|
const map = normalizeOfficeMap(getPublishedOfficeMap(WORKSPACE_ID), fallback);
|
||||||
|
return (
|
||||||
|
<main className="relative h-screen w-screen overflow-hidden bg-background p-3">
|
||||||
|
<OfficeBuilderPanel initialMap={map} workspaceId={WORKSPACE_ID} officeId={OFFICE_ID} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
|
import { AgentStoreProvider } from "@/features/agents/state/store";
|
||||||
|
import { OfficeScreen } from "@/features/office/screens/OfficeScreen";
|
||||||
|
|
||||||
|
const ENABLED_RE = /^(1|true|yes|on)$/i;
|
||||||
|
|
||||||
|
const readDebugFlag = (value: string | undefined): boolean => {
|
||||||
|
const normalized = (value ?? "").trim();
|
||||||
|
if (!normalized) return true;
|
||||||
|
return ENABLED_RE.test(normalized);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function OfficePage() {
|
||||||
|
const showOpenClawConsole = readDebugFlag(process.env.DEBUG);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AgentStoreProvider>
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<OfficeScreen showOpenClawConsole={showOpenClawConsole} />
|
||||||
|
</Suspense>
|
||||||
|
</AgentStoreProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
redirect("/office");
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
.agent-markdown {
|
||||||
|
display: block;
|
||||||
|
white-space: normal;
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-markdown p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-markdown p + p,
|
||||||
|
.agent-markdown ul,
|
||||||
|
.agent-markdown ol,
|
||||||
|
.agent-markdown pre,
|
||||||
|
.agent-markdown blockquote,
|
||||||
|
.agent-markdown table {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-markdown h1,
|
||||||
|
.agent-markdown h2,
|
||||||
|
.agent-markdown h3,
|
||||||
|
.agent-markdown h4,
|
||||||
|
.agent-markdown h5,
|
||||||
|
.agent-markdown h6 {
|
||||||
|
margin: 1.1rem 0 0.5rem 0;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-markdown :is(h1, h2, h3, h4, h5, h6):first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-markdown h1 + p,
|
||||||
|
.agent-markdown h2 + p,
|
||||||
|
.agent-markdown h3 + p,
|
||||||
|
.agent-markdown h4 + p,
|
||||||
|
.agent-markdown h5 + p,
|
||||||
|
.agent-markdown h6 + p,
|
||||||
|
.agent-markdown h1 + ul,
|
||||||
|
.agent-markdown h2 + ul,
|
||||||
|
.agent-markdown h3 + ul,
|
||||||
|
.agent-markdown h4 + ul,
|
||||||
|
.agent-markdown h5 + ul,
|
||||||
|
.agent-markdown h6 + ul,
|
||||||
|
.agent-markdown h1 + ol,
|
||||||
|
.agent-markdown h2 + ol,
|
||||||
|
.agent-markdown h3 + ol,
|
||||||
|
.agent-markdown h4 + ol,
|
||||||
|
.agent-markdown h5 + ol,
|
||||||
|
.agent-markdown h6 + ol {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-markdown ul,
|
||||||
|
.agent-markdown ol {
|
||||||
|
padding-left: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-markdown ul {
|
||||||
|
list-style-type: disc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-markdown ol {
|
||||||
|
list-style-type: decimal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-markdown ul ul {
|
||||||
|
list-style-type: circle;
|
||||||
|
padding-left: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-markdown ul ul ul {
|
||||||
|
list-style-type: square;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-markdown ol ol {
|
||||||
|
list-style-type: lower-alpha;
|
||||||
|
padding-left: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-markdown ol ol ol {
|
||||||
|
list-style-type: lower-roman;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-markdown ul ol {
|
||||||
|
list-style-type: decimal;
|
||||||
|
padding-left: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-markdown ol ul {
|
||||||
|
list-style-type: disc;
|
||||||
|
padding-left: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-markdown li {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-markdown li > p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-markdown code {
|
||||||
|
font-family: var(--font-mono), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||||
|
"Liberation Mono", "Courier New", monospace;
|
||||||
|
font-size: 0.82em;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
background: color-mix(in oklch, var(--surface-3) 92%, transparent);
|
||||||
|
padding: 0.14rem 0.28rem;
|
||||||
|
border-radius: 0.3125rem;
|
||||||
|
border: 1px solid color-mix(in oklch, var(--surface-selected-border) 88%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-markdown pre {
|
||||||
|
background: color-mix(in oklch, var(--surface-2) 94%, transparent);
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
padding: 0.75rem 0.85rem;
|
||||||
|
border: 1px solid color-mix(in oklch, var(--surface-selected-border) 82%, transparent);
|
||||||
|
overflow-x: auto;
|
||||||
|
line-height: 1.64;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-markdown pre code {
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0.85em;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-tool-markdown pre {
|
||||||
|
max-height: 70px;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-markdown blockquote {
|
||||||
|
border-left: 3px solid var(--surface-selected-border);
|
||||||
|
padding-left: 0.7rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-markdown table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-markdown th,
|
||||||
|
.agent-markdown td {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 0.45rem 0.65rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-markdown a {
|
||||||
|
color: color-mix(in oklch, var(--action-bg) 68%, var(--foreground));
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-thickness: 1px;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-markdown img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .agent-markdown code {
|
||||||
|
background: color-mix(in oklch, var(--surface-1) 80%, var(--surface-0));
|
||||||
|
border-color: color-mix(in oklch, var(--chat-assistant-border) 88%, var(--surface-selected-border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .agent-markdown pre {
|
||||||
|
background: color-mix(in oklch, var(--surface-0) 82%, var(--surface-1));
|
||||||
|
border-color: color-mix(in oklch, var(--chat-assistant-border) 86%, transparent);
|
||||||
|
box-shadow: inset 0 1px 0 var(--elev-overlay-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .ui-chat-assistant-card .agent-markdown :is(p + p, ul, ol, pre, blockquote, table) {
|
||||||
|
margin-top: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .ui-chat-assistant-card .agent-markdown :is(h1, h2, h3, h4, h5, h6) {
|
||||||
|
margin: 1.35rem 0 0.68rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .ui-chat-assistant-card .agent-markdown li + li {
|
||||||
|
margin-top: 0.42rem;
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Moon, Sun } from "lucide-react";
|
||||||
|
|
||||||
|
const THEME_STORAGE_KEY = "theme";
|
||||||
|
|
||||||
|
type ThemeMode = "light" | "dark";
|
||||||
|
|
||||||
|
const getPreferredTheme = (): ThemeMode => {
|
||||||
|
if (typeof window === "undefined") return "light";
|
||||||
|
const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
|
||||||
|
if (stored === "light" || stored === "dark") return stored;
|
||||||
|
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||||
|
return prefersDark ? "dark" : "light";
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyTheme = (mode: ThemeMode) => {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
document.documentElement.classList.toggle("dark", mode === "dark");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ThemeToggle = () => {
|
||||||
|
// Keep SSR + initial hydration stable ("light") to avoid markup mismatch.
|
||||||
|
const [theme, setTheme] = useState<ThemeMode>("light");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const preferred = getPreferredTheme();
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
setTheme(preferred);
|
||||||
|
applyTheme(preferred);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
setTheme((current) => {
|
||||||
|
const next: ThemeMode = current === "dark" ? "light" : "dark";
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.localStorage.setItem(THEME_STORAGE_KEY, next);
|
||||||
|
}
|
||||||
|
applyTheme(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDark = theme === "dark";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleTheme}
|
||||||
|
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
|
||||||
|
className="ui-btn-icon ui-btn-icon-xs"
|
||||||
|
>
|
||||||
|
{isDark ? <Sun className="h-3 w-3" /> : <Moon className="h-3 w-3" />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import type { PendingExecApproval } from "@/features/agents/approvals/types";
|
||||||
|
import {
|
||||||
|
applyApprovalIngressEffects,
|
||||||
|
deriveAwaitingUserInputPatches,
|
||||||
|
derivePendingApprovalPruneDelayMs,
|
||||||
|
prunePendingApprovalState,
|
||||||
|
resolveApprovalAutoResumeDispatch,
|
||||||
|
resolveApprovalAutoResumePreflight,
|
||||||
|
type ApprovalPendingState,
|
||||||
|
type AwaitingUserInputPatch,
|
||||||
|
} from "@/features/agents/approvals/execApprovalRuntimeCoordinator";
|
||||||
|
import { shouldPauseRunForPendingExecApproval } from "@/features/agents/approvals/execApprovalPausePolicy";
|
||||||
|
import {
|
||||||
|
resolveGatewayEventIngressDecision,
|
||||||
|
type CronTranscriptIntent,
|
||||||
|
} from "@/features/agents/state/gatewayEventIngressWorkflow";
|
||||||
|
import type { AgentState } from "@/features/agents/state/store";
|
||||||
|
import type { EventFrame } from "@/lib/gateway/GatewayClient";
|
||||||
|
|
||||||
|
export type ExecApprovalPendingSnapshot = ApprovalPendingState;
|
||||||
|
|
||||||
|
export type ExecApprovalIngressCommand =
|
||||||
|
| { kind: "replacePendingState"; pendingState: ApprovalPendingState }
|
||||||
|
| {
|
||||||
|
kind: "pauseRunForApproval";
|
||||||
|
approval: PendingExecApproval;
|
||||||
|
preferredAgentId: string | null;
|
||||||
|
}
|
||||||
|
| { kind: "markActivity"; agentId: string }
|
||||||
|
| { kind: "recordCronDedupeKey"; dedupeKey: string }
|
||||||
|
| { kind: "appendCronTranscript"; intent: CronTranscriptIntent };
|
||||||
|
|
||||||
|
export type PauseRunIntent =
|
||||||
|
| { kind: "skip"; reason: string }
|
||||||
|
| { kind: "pause"; agentId: string; sessionKey: string; runId: string };
|
||||||
|
|
||||||
|
export type AutoResumeIntent =
|
||||||
|
| { kind: "skip"; reason: string }
|
||||||
|
| { kind: "resume"; targetAgentId: string; pausedRunId: string; sessionKey: string };
|
||||||
|
|
||||||
|
const resolvePauseTargetAgent = (params: {
|
||||||
|
approval: PendingExecApproval;
|
||||||
|
preferredAgentId: string | null | undefined;
|
||||||
|
agents: AgentState[];
|
||||||
|
}): AgentState | null => {
|
||||||
|
const preferredAgentId = params.preferredAgentId?.trim() ?? "";
|
||||||
|
if (preferredAgentId) {
|
||||||
|
const match =
|
||||||
|
params.agents.find((agent) => agent.agentId === preferredAgentId) ?? null;
|
||||||
|
if (match) return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
const approvalSessionKey = params.approval.sessionKey?.trim() ?? "";
|
||||||
|
if (!approvalSessionKey) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
params.agents.find((agent) => agent.sessionKey.trim() === approvalSessionKey) ??
|
||||||
|
null
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const planPausedRunMapCleanup = (params: {
|
||||||
|
pausedRunIdByAgentId: ReadonlyMap<string, string>;
|
||||||
|
agents: AgentState[];
|
||||||
|
}): string[] => {
|
||||||
|
const staleAgentIds: string[] = [];
|
||||||
|
for (const [agentId, trackedRunId] of params.pausedRunIdByAgentId.entries()) {
|
||||||
|
const trackedAgent = params.agents.find((agent) => agent.agentId === agentId) ?? null;
|
||||||
|
const currentRunId = trackedAgent?.runId?.trim() ?? "";
|
||||||
|
if (!currentRunId || currentRunId !== trackedRunId) {
|
||||||
|
staleAgentIds.push(agentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return staleAgentIds;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const planPauseRunIntent = (params: {
|
||||||
|
approval: PendingExecApproval;
|
||||||
|
preferredAgentId?: string | null;
|
||||||
|
agents: AgentState[];
|
||||||
|
pausedRunIdByAgentId: ReadonlyMap<string, string>;
|
||||||
|
}): PauseRunIntent => {
|
||||||
|
const agent = resolvePauseTargetAgent({
|
||||||
|
approval: params.approval,
|
||||||
|
preferredAgentId: params.preferredAgentId,
|
||||||
|
agents: params.agents,
|
||||||
|
});
|
||||||
|
if (!agent) {
|
||||||
|
return { kind: "skip", reason: "missing-agent" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const runId = agent.runId?.trim() ?? "";
|
||||||
|
if (!runId) {
|
||||||
|
return { kind: "skip", reason: "missing-run-id" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const pausedRunId = params.pausedRunIdByAgentId.get(agent.agentId) ?? null;
|
||||||
|
const shouldPause = shouldPauseRunForPendingExecApproval({
|
||||||
|
agent,
|
||||||
|
approval: params.approval,
|
||||||
|
pausedRunId,
|
||||||
|
});
|
||||||
|
if (!shouldPause) {
|
||||||
|
return { kind: "skip", reason: "pause-policy-denied" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionKey = agent.sessionKey.trim();
|
||||||
|
if (!sessionKey) {
|
||||||
|
return { kind: "skip", reason: "missing-session-key" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: "pause",
|
||||||
|
agentId: agent.agentId,
|
||||||
|
sessionKey,
|
||||||
|
runId,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const planAutoResumeIntent = (params: {
|
||||||
|
approval: PendingExecApproval;
|
||||||
|
targetAgentId: string;
|
||||||
|
pendingState: ApprovalPendingState;
|
||||||
|
pausedRunIdByAgentId: ReadonlyMap<string, string>;
|
||||||
|
agents: AgentState[];
|
||||||
|
}): AutoResumeIntent => {
|
||||||
|
const preflight = resolveApprovalAutoResumePreflight({
|
||||||
|
approval: params.approval,
|
||||||
|
targetAgentId: params.targetAgentId,
|
||||||
|
pendingState: params.pendingState,
|
||||||
|
pausedRunIdByAgentId: params.pausedRunIdByAgentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (preflight.kind !== "resume") {
|
||||||
|
return { kind: "skip", reason: preflight.reason };
|
||||||
|
}
|
||||||
|
|
||||||
|
const dispatchIntent = resolveApprovalAutoResumeDispatch({
|
||||||
|
targetAgentId: preflight.targetAgentId,
|
||||||
|
pausedRunId: preflight.pausedRunId,
|
||||||
|
agents: params.agents,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dispatchIntent.kind !== "resume") {
|
||||||
|
return { kind: "skip", reason: dispatchIntent.reason };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: "resume",
|
||||||
|
targetAgentId: dispatchIntent.targetAgentId,
|
||||||
|
pausedRunId: dispatchIntent.pausedRunId,
|
||||||
|
sessionKey: dispatchIntent.sessionKey,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const planIngressCommands = (params: {
|
||||||
|
event: EventFrame;
|
||||||
|
agents: AgentState[];
|
||||||
|
pendingState: ApprovalPendingState;
|
||||||
|
pausedRunIdByAgentId: ReadonlyMap<string, string>;
|
||||||
|
seenCronDedupeKeys: ReadonlySet<string>;
|
||||||
|
nowMs: number;
|
||||||
|
}): ExecApprovalIngressCommand[] => {
|
||||||
|
const ingressDecision = resolveGatewayEventIngressDecision({
|
||||||
|
event: params.event,
|
||||||
|
agents: params.agents,
|
||||||
|
seenCronDedupeKeys: params.seenCronDedupeKeys,
|
||||||
|
nowMs: params.nowMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
const approvalIngress = applyApprovalIngressEffects({
|
||||||
|
pendingState: params.pendingState,
|
||||||
|
approvalEffects: ingressDecision.approvalEffects,
|
||||||
|
agents: params.agents,
|
||||||
|
pausedRunIdByAgentId: params.pausedRunIdByAgentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const commands: ExecApprovalIngressCommand[] = [];
|
||||||
|
if (
|
||||||
|
approvalIngress.pendingState.approvalsByAgentId !== params.pendingState.approvalsByAgentId ||
|
||||||
|
approvalIngress.pendingState.unscopedApprovals !== params.pendingState.unscopedApprovals
|
||||||
|
) {
|
||||||
|
commands.push({
|
||||||
|
kind: "replacePendingState",
|
||||||
|
pendingState: approvalIngress.pendingState,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const pauseRequest of approvalIngress.pauseRequests) {
|
||||||
|
commands.push({
|
||||||
|
kind: "pauseRunForApproval",
|
||||||
|
approval: pauseRequest.approval,
|
||||||
|
preferredAgentId: pauseRequest.preferredAgentId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const agentId of approvalIngress.markActivityAgentIds) {
|
||||||
|
commands.push({ kind: "markActivity", agentId });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ingressDecision.cronDedupeKeyToRecord) {
|
||||||
|
commands.push({
|
||||||
|
kind: "recordCronDedupeKey",
|
||||||
|
dedupeKey: ingressDecision.cronDedupeKeyToRecord,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ingressDecision.cronTranscriptIntent) {
|
||||||
|
commands.push({
|
||||||
|
kind: "appendCronTranscript",
|
||||||
|
intent: ingressDecision.cronTranscriptIntent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return commands;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const planPendingPruneDelay = (params: {
|
||||||
|
pendingState: ApprovalPendingState;
|
||||||
|
nowMs: number;
|
||||||
|
graceMs: number;
|
||||||
|
}): number | null => {
|
||||||
|
return derivePendingApprovalPruneDelayMs(params);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const planPrunedPendingState = (params: {
|
||||||
|
pendingState: ApprovalPendingState;
|
||||||
|
nowMs: number;
|
||||||
|
graceMs: number;
|
||||||
|
}): ApprovalPendingState => {
|
||||||
|
return prunePendingApprovalState(params).pendingState;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const planAwaitingUserInputPatches = (params: {
|
||||||
|
agents: AgentState[];
|
||||||
|
approvalsByAgentId: Record<string, PendingExecApproval[]>;
|
||||||
|
}): AwaitingUserInputPatch[] => {
|
||||||
|
return deriveAwaitingUserInputPatches(params);
|
||||||
|
};
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import type { AgentState } from "@/features/agents/state/store";
|
||||||
|
import type { EventFrame } from "@/lib/gateway/GatewayClient";
|
||||||
|
import type { ExecApprovalDecision } from "@/features/agents/approvals/types";
|
||||||
|
|
||||||
|
type RequestedPayload = {
|
||||||
|
id: string;
|
||||||
|
request: {
|
||||||
|
command: string;
|
||||||
|
cwd: string | null;
|
||||||
|
host: string | null;
|
||||||
|
security: string | null;
|
||||||
|
ask: string | null;
|
||||||
|
agentId: string | null;
|
||||||
|
resolvedPath: string | null;
|
||||||
|
sessionKey: string | null;
|
||||||
|
};
|
||||||
|
createdAtMs: number;
|
||||||
|
expiresAtMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResolvedPayload = {
|
||||||
|
id: string;
|
||||||
|
decision: ExecApprovalDecision;
|
||||||
|
resolvedBy: string | null;
|
||||||
|
ts: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const asRecord = (value: unknown): Record<string, unknown> | null =>
|
||||||
|
value && typeof value === "object" && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const asNonEmptyString = (value: unknown): string | null => {
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const asOptionalString = (value: unknown): string | null =>
|
||||||
|
typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||||
|
|
||||||
|
const asPositiveTimestamp = (value: unknown): number | null =>
|
||||||
|
typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null;
|
||||||
|
|
||||||
|
export const parseExecApprovalRequested = (event: EventFrame): RequestedPayload | null => {
|
||||||
|
if (event.type !== "event" || event.event !== "exec.approval.requested") return null;
|
||||||
|
const payload = asRecord(event.payload);
|
||||||
|
if (!payload) return null;
|
||||||
|
const id = asNonEmptyString(payload.id);
|
||||||
|
const request = asRecord(payload.request);
|
||||||
|
const createdAtMs = asPositiveTimestamp(payload.createdAtMs);
|
||||||
|
const expiresAtMs = asPositiveTimestamp(payload.expiresAtMs);
|
||||||
|
if (!id || !request || !createdAtMs || !expiresAtMs) return null;
|
||||||
|
const command = asNonEmptyString(request.command);
|
||||||
|
if (!command) return null;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
request: {
|
||||||
|
command,
|
||||||
|
cwd: asOptionalString(request.cwd),
|
||||||
|
host: asOptionalString(request.host),
|
||||||
|
security: asOptionalString(request.security),
|
||||||
|
ask: asOptionalString(request.ask),
|
||||||
|
agentId: asOptionalString(request.agentId),
|
||||||
|
resolvedPath: asOptionalString(request.resolvedPath),
|
||||||
|
sessionKey: asOptionalString(request.sessionKey),
|
||||||
|
},
|
||||||
|
createdAtMs,
|
||||||
|
expiresAtMs,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseExecApprovalResolved = (event: EventFrame): ResolvedPayload | null => {
|
||||||
|
if (event.type !== "event" || event.event !== "exec.approval.resolved") return null;
|
||||||
|
const payload = asRecord(event.payload);
|
||||||
|
if (!payload) return null;
|
||||||
|
const id = asNonEmptyString(payload.id);
|
||||||
|
const decisionRaw = asNonEmptyString(payload.decision);
|
||||||
|
const ts = asPositiveTimestamp(payload.ts);
|
||||||
|
if (!id || !decisionRaw || !ts) return null;
|
||||||
|
if (decisionRaw !== "allow-once" && decisionRaw !== "allow-always" && decisionRaw !== "deny") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
decision: decisionRaw,
|
||||||
|
resolvedBy: asOptionalString(payload.resolvedBy),
|
||||||
|
ts,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveExecApprovalAgentId = (params: {
|
||||||
|
requested: RequestedPayload;
|
||||||
|
agents: AgentState[];
|
||||||
|
}): string | null => {
|
||||||
|
const requestedAgentId = params.requested.request.agentId;
|
||||||
|
if (requestedAgentId) {
|
||||||
|
return requestedAgentId;
|
||||||
|
}
|
||||||
|
const requestedSessionKey = params.requested.request.sessionKey;
|
||||||
|
if (!requestedSessionKey) return null;
|
||||||
|
const matchedBySession = params.agents.find(
|
||||||
|
(agent) => agent.sessionKey.trim() === requestedSessionKey
|
||||||
|
);
|
||||||
|
return matchedBySession?.agentId ?? null;
|
||||||
|
};
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import type { ExecApprovalDecision, PendingExecApproval } from "@/features/agents/approvals/types";
|
||||||
|
import {
|
||||||
|
parseExecApprovalRequested,
|
||||||
|
parseExecApprovalResolved,
|
||||||
|
resolveExecApprovalAgentId,
|
||||||
|
} from "@/features/agents/approvals/execApprovalEvents";
|
||||||
|
import type { AgentState } from "@/features/agents/state/store";
|
||||||
|
import type { EventFrame } from "@/lib/gateway/GatewayClient";
|
||||||
|
import { GatewayResponseError } from "@/lib/gateway/errors";
|
||||||
|
|
||||||
|
export type ExecApprovalEventEffects = {
|
||||||
|
scopedUpserts: Array<{ agentId: string; approval: PendingExecApproval }>;
|
||||||
|
unscopedUpserts: PendingExecApproval[];
|
||||||
|
removals: string[];
|
||||||
|
markActivityAgentIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExecApprovalFollowUpIntent = {
|
||||||
|
shouldSend: boolean;
|
||||||
|
agentId: string | null;
|
||||||
|
sessionKey: string | null;
|
||||||
|
message: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_EVENT_EFFECTS: ExecApprovalEventEffects = {
|
||||||
|
scopedUpserts: [],
|
||||||
|
unscopedUpserts: [],
|
||||||
|
removals: [],
|
||||||
|
markActivityAgentIds: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const NO_FOLLOW_UP_INTENT: ExecApprovalFollowUpIntent = {
|
||||||
|
shouldSend: false,
|
||||||
|
agentId: null,
|
||||||
|
sessionKey: null,
|
||||||
|
message: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveExecApprovalEventEffects = (params: {
|
||||||
|
event: EventFrame;
|
||||||
|
agents: AgentState[];
|
||||||
|
}): ExecApprovalEventEffects | null => {
|
||||||
|
const requested = parseExecApprovalRequested(params.event);
|
||||||
|
if (requested) {
|
||||||
|
const resolvedAgentId = resolveExecApprovalAgentId({
|
||||||
|
requested,
|
||||||
|
agents: params.agents,
|
||||||
|
});
|
||||||
|
const approval: PendingExecApproval = {
|
||||||
|
id: requested.id,
|
||||||
|
agentId: resolvedAgentId,
|
||||||
|
sessionKey: requested.request.sessionKey,
|
||||||
|
command: requested.request.command,
|
||||||
|
cwd: requested.request.cwd,
|
||||||
|
host: requested.request.host,
|
||||||
|
security: requested.request.security,
|
||||||
|
ask: requested.request.ask,
|
||||||
|
resolvedPath: requested.request.resolvedPath,
|
||||||
|
createdAtMs: requested.createdAtMs,
|
||||||
|
expiresAtMs: requested.expiresAtMs,
|
||||||
|
resolving: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
if (!resolvedAgentId) {
|
||||||
|
return {
|
||||||
|
...EMPTY_EVENT_EFFECTS,
|
||||||
|
unscopedUpserts: [approval],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...EMPTY_EVENT_EFFECTS,
|
||||||
|
scopedUpserts: [{ agentId: resolvedAgentId, approval }],
|
||||||
|
markActivityAgentIds: [resolvedAgentId],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = parseExecApprovalResolved(params.event);
|
||||||
|
if (!resolved) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...EMPTY_EVENT_EFFECTS,
|
||||||
|
removals: [resolved.id],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveExecApprovalFollowUpIntent = (params: {
|
||||||
|
decision: ExecApprovalDecision;
|
||||||
|
approval: PendingExecApproval | null;
|
||||||
|
agents: AgentState[];
|
||||||
|
followUpMessage: string;
|
||||||
|
}): ExecApprovalFollowUpIntent => {
|
||||||
|
if (params.decision !== "allow-once" && params.decision !== "allow-always") {
|
||||||
|
return NO_FOLLOW_UP_INTENT;
|
||||||
|
}
|
||||||
|
if (!params.approval) {
|
||||||
|
return NO_FOLLOW_UP_INTENT;
|
||||||
|
}
|
||||||
|
const scopedAgentId = params.approval.agentId?.trim() ?? "";
|
||||||
|
const sessionAgentId =
|
||||||
|
params.approval.sessionKey?.trim()
|
||||||
|
? (params.agents.find(
|
||||||
|
(agent) => agent.sessionKey.trim() === params.approval?.sessionKey?.trim()
|
||||||
|
)?.agentId ?? "")
|
||||||
|
: "";
|
||||||
|
const targetAgentId = scopedAgentId || sessionAgentId;
|
||||||
|
if (!targetAgentId) {
|
||||||
|
return NO_FOLLOW_UP_INTENT;
|
||||||
|
}
|
||||||
|
const targetSessionKey =
|
||||||
|
params.approval.sessionKey?.trim() ||
|
||||||
|
params.agents.find((agent) => agent.agentId === targetAgentId)?.sessionKey?.trim() ||
|
||||||
|
"";
|
||||||
|
const followUpMessage = params.followUpMessage.trim();
|
||||||
|
if (!targetSessionKey || !followUpMessage) {
|
||||||
|
return NO_FOLLOW_UP_INTENT;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
shouldSend: true,
|
||||||
|
agentId: targetAgentId,
|
||||||
|
sessionKey: targetSessionKey,
|
||||||
|
message: followUpMessage,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const shouldTreatExecApprovalResolveErrorAsUnknownId = (error: unknown): boolean =>
|
||||||
|
error instanceof GatewayResponseError && /unknown approval id/i.test(error.message);
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { PendingExecApproval } from "@/features/agents/approvals/types";
|
||||||
|
import type { AgentState } from "@/features/agents/state/store";
|
||||||
|
|
||||||
|
const normalizeExecAsk = (
|
||||||
|
value: string | null | undefined
|
||||||
|
): "off" | "on-miss" | "always" | null => {
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
if (normalized === "off" || normalized === "on-miss" || normalized === "always") {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const shouldPauseRunForPendingExecApproval = (params: {
|
||||||
|
agent: AgentState | null;
|
||||||
|
approval: PendingExecApproval;
|
||||||
|
pausedRunId: string | null;
|
||||||
|
}): boolean => {
|
||||||
|
const agent = params.agent;
|
||||||
|
if (!agent) return false;
|
||||||
|
if (agent.status !== "running") return false;
|
||||||
|
|
||||||
|
const runId = agent.runId?.trim() ?? "";
|
||||||
|
if (!runId) return false;
|
||||||
|
if (params.pausedRunId === runId) return false;
|
||||||
|
|
||||||
|
const approvalAsk = normalizeExecAsk(params.approval.ask);
|
||||||
|
const agentAsk = normalizeExecAsk(agent.sessionExecAsk);
|
||||||
|
const effectiveAsk = approvalAsk ?? agentAsk;
|
||||||
|
return effectiveAsk === "always";
|
||||||
|
};
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import type { AgentState } from "@/features/agents/state/store";
|
||||||
|
import type { ExecApprovalDecision, PendingExecApproval } from "@/features/agents/approvals/types";
|
||||||
|
import {
|
||||||
|
removePendingApprovalEverywhere,
|
||||||
|
updatePendingApprovalById,
|
||||||
|
} from "@/features/agents/approvals/pendingStore";
|
||||||
|
import { shouldTreatExecApprovalResolveErrorAsUnknownId } from "@/features/agents/approvals/execApprovalLifecycleWorkflow";
|
||||||
|
|
||||||
|
type GatewayClientLike = {
|
||||||
|
call: (method: string, params: unknown) => Promise<unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SetState<T> = (next: T | ((current: T) => T)) => void;
|
||||||
|
|
||||||
|
export const resolveExecApprovalViaStudio = async (params: {
|
||||||
|
client: GatewayClientLike;
|
||||||
|
approvalId: string;
|
||||||
|
decision: ExecApprovalDecision;
|
||||||
|
getAgents: () => AgentState[];
|
||||||
|
getLatestAgent: (agentId: string) => AgentState | null;
|
||||||
|
getPendingState: () => {
|
||||||
|
approvalsByAgentId: Record<string, PendingExecApproval[]>;
|
||||||
|
unscopedApprovals: PendingExecApproval[];
|
||||||
|
};
|
||||||
|
setPendingExecApprovalsByAgentId: SetState<Record<string, PendingExecApproval[]>>;
|
||||||
|
setUnscopedPendingExecApprovals: SetState<PendingExecApproval[]>;
|
||||||
|
requestHistoryRefresh: (agentId: string) => Promise<void> | void;
|
||||||
|
onAllowResolved?: (params: {
|
||||||
|
approval: PendingExecApproval;
|
||||||
|
targetAgentId: string;
|
||||||
|
}) => Promise<void> | void;
|
||||||
|
onAllowed?: (params: { approval: PendingExecApproval; targetAgentId: string }) => Promise<void> | void;
|
||||||
|
isDisconnectLikeError: (error: unknown) => boolean;
|
||||||
|
shouldTreatUnknownId?: (error: unknown) => boolean;
|
||||||
|
logWarn?: (message: string, error: unknown) => void;
|
||||||
|
}): Promise<void> => {
|
||||||
|
const id = params.approvalId.trim();
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
const resolvePendingApproval = (
|
||||||
|
approvalId: string,
|
||||||
|
state: {
|
||||||
|
approvalsByAgentId: Record<string, PendingExecApproval[]>;
|
||||||
|
unscopedApprovals: PendingExecApproval[];
|
||||||
|
}
|
||||||
|
): PendingExecApproval | null => {
|
||||||
|
for (const approvals of Object.values(state.approvalsByAgentId)) {
|
||||||
|
const found = approvals.find((approval) => approval.id === approvalId);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
return state.unscopedApprovals.find((approval) => approval.id === approvalId) ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveApprovalTargetAgentId = (approval: PendingExecApproval | null): string | null => {
|
||||||
|
if (!approval) return null;
|
||||||
|
const scopedAgentId = approval.agentId?.trim() ?? "";
|
||||||
|
if (scopedAgentId) return scopedAgentId;
|
||||||
|
const scopedSessionKey = approval.sessionKey?.trim() ?? "";
|
||||||
|
if (!scopedSessionKey) return null;
|
||||||
|
const matched = params
|
||||||
|
.getAgents()
|
||||||
|
.find((agent) => agent.sessionKey.trim() === scopedSessionKey);
|
||||||
|
return matched?.agentId ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const snapshot = params.getPendingState();
|
||||||
|
const approval = resolvePendingApproval(id, snapshot);
|
||||||
|
|
||||||
|
const removeLocalApproval = (approvalId: string) => {
|
||||||
|
params.setPendingExecApprovalsByAgentId((current) => {
|
||||||
|
return removePendingApprovalEverywhere({
|
||||||
|
approvalsByAgentId: current,
|
||||||
|
unscopedApprovals: [],
|
||||||
|
approvalId,
|
||||||
|
}).approvalsByAgentId;
|
||||||
|
});
|
||||||
|
params.setUnscopedPendingExecApprovals((current) => {
|
||||||
|
return removePendingApprovalEverywhere({
|
||||||
|
approvalsByAgentId: {},
|
||||||
|
unscopedApprovals: current,
|
||||||
|
approvalId,
|
||||||
|
}).unscopedApprovals;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const setLocalApprovalState = (resolving: boolean, error: string | null) => {
|
||||||
|
params.setPendingExecApprovalsByAgentId((current) => {
|
||||||
|
let changed = false;
|
||||||
|
const next: Record<string, PendingExecApproval[]> = {};
|
||||||
|
for (const [agentId, approvals] of Object.entries(current)) {
|
||||||
|
const updated = updatePendingApprovalById(approvals, id, (approval) => ({
|
||||||
|
...approval,
|
||||||
|
resolving,
|
||||||
|
error,
|
||||||
|
}));
|
||||||
|
if (updated !== approvals) {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (updated.length > 0) {
|
||||||
|
next[agentId] = updated;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed ? next : current;
|
||||||
|
});
|
||||||
|
params.setUnscopedPendingExecApprovals((current) =>
|
||||||
|
updatePendingApprovalById(current, id, (approval) => ({
|
||||||
|
...approval,
|
||||||
|
resolving,
|
||||||
|
error,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
setLocalApprovalState(true, null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await params.client.call("exec.approval.resolve", { id, decision: params.decision });
|
||||||
|
removeLocalApproval(id);
|
||||||
|
|
||||||
|
if (params.decision !== "allow-once" && params.decision !== "allow-always") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!approval) return;
|
||||||
|
const targetAgentId = resolveApprovalTargetAgentId(approval);
|
||||||
|
if (!targetAgentId) return;
|
||||||
|
await params.onAllowResolved?.({ approval, targetAgentId });
|
||||||
|
|
||||||
|
const latest = params.getLatestAgent(targetAgentId);
|
||||||
|
const activeRunId = latest?.runId?.trim() ?? "";
|
||||||
|
if (activeRunId) {
|
||||||
|
try {
|
||||||
|
await params.client.call("agent.wait", { runId: activeRunId, timeoutMs: 15_000 });
|
||||||
|
} catch (waitError) {
|
||||||
|
if (!params.isDisconnectLikeError(waitError)) {
|
||||||
|
(params.logWarn ?? ((message, error) => console.warn(message, error)))(
|
||||||
|
"Failed to wait for run after exec approval resolve.",
|
||||||
|
waitError
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await params.requestHistoryRefresh(targetAgentId);
|
||||||
|
await params.onAllowed?.({ approval, targetAgentId });
|
||||||
|
} catch (err) {
|
||||||
|
const shouldTreatUnknownId = params.shouldTreatUnknownId ?? shouldTreatExecApprovalResolveErrorAsUnknownId;
|
||||||
|
if (shouldTreatUnknownId(err)) {
|
||||||
|
removeLocalApproval(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const message = err instanceof Error ? err.message : "Failed to resolve exec approval.";
|
||||||
|
setLocalApprovalState(false, message);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import type {
|
||||||
|
ExecApprovalDecision,
|
||||||
|
PendingExecApproval,
|
||||||
|
} from "@/features/agents/approvals/types";
|
||||||
|
import type {
|
||||||
|
ExecApprovalIngressCommand,
|
||||||
|
ExecApprovalPendingSnapshot,
|
||||||
|
} from "@/features/agents/approvals/execApprovalControlLoopWorkflow";
|
||||||
|
import { resolveExecApprovalViaStudio } from "@/features/agents/approvals/execApprovalResolveOperation";
|
||||||
|
import {
|
||||||
|
planApprovalIngressRunControl,
|
||||||
|
planAutoResumeRunControl,
|
||||||
|
planPauseRunControl,
|
||||||
|
} from "@/features/agents/approvals/execApprovalRunControlWorkflow";
|
||||||
|
import { sendChatMessageViaStudio } from "@/features/agents/operations/chatSendOperation";
|
||||||
|
import type { AgentState } from "@/features/agents/state/store";
|
||||||
|
import type { EventFrame } from "@/lib/gateway/GatewayClient";
|
||||||
|
import { EXEC_APPROVAL_AUTO_RESUME_MARKER } from "@/lib/text/message-extract";
|
||||||
|
|
||||||
|
type GatewayClientLike = {
|
||||||
|
call: (method: string, params: unknown) => Promise<unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RunControlDispatchAction =
|
||||||
|
| { type: "updateAgent"; agentId: string; patch: Partial<AgentState> }
|
||||||
|
| { type: "appendOutput"; agentId: string; line: string; transcript?: Record<string, unknown> }
|
||||||
|
| { type: "markActivity"; agentId: string; at?: number };
|
||||||
|
|
||||||
|
type RunControlDispatch = (action: RunControlDispatchAction) => void;
|
||||||
|
|
||||||
|
type SetState<T> = (next: T | ((current: T) => T)) => void;
|
||||||
|
|
||||||
|
const AUTO_RESUME_FOLLOW_UP_MESSAGE = `${EXEC_APPROVAL_AUTO_RESUME_MARKER}\nContinue where you left off and finish the task.`;
|
||||||
|
export const EXEC_APPROVAL_AUTO_RESUME_WAIT_TIMEOUT_MS = 3_000;
|
||||||
|
|
||||||
|
export async function runPauseRunForExecApprovalOperation(params: {
|
||||||
|
status: string;
|
||||||
|
client: GatewayClientLike;
|
||||||
|
approval: PendingExecApproval;
|
||||||
|
preferredAgentId?: string | null;
|
||||||
|
getAgents: () => AgentState[];
|
||||||
|
pausedRunIdByAgentId: Map<string, string>;
|
||||||
|
isDisconnectLikeError: (error: unknown) => boolean;
|
||||||
|
logWarn?: (message: string, error: unknown) => void;
|
||||||
|
}): Promise<void> {
|
||||||
|
if (params.status !== "connected") return;
|
||||||
|
|
||||||
|
const plan = planPauseRunControl({
|
||||||
|
approval: params.approval,
|
||||||
|
preferredAgentId: params.preferredAgentId ?? null,
|
||||||
|
agents: params.getAgents(),
|
||||||
|
pausedRunIdByAgentId: params.pausedRunIdByAgentId,
|
||||||
|
});
|
||||||
|
for (const agentId of plan.stalePausedAgentIds) {
|
||||||
|
params.pausedRunIdByAgentId.delete(agentId);
|
||||||
|
}
|
||||||
|
if (plan.pauseIntent.kind !== "pause") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
params.pausedRunIdByAgentId.set(plan.pauseIntent.agentId, plan.pauseIntent.runId);
|
||||||
|
try {
|
||||||
|
await params.client.call("chat.abort", {
|
||||||
|
sessionKey: plan.pauseIntent.sessionKey,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
params.pausedRunIdByAgentId.delete(plan.pauseIntent.agentId);
|
||||||
|
if (!params.isDisconnectLikeError(error)) {
|
||||||
|
(params.logWarn ?? ((message, err) => console.warn(message, err)))(
|
||||||
|
"Failed to pause run for pending exec approval.",
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runExecApprovalAutoResumeOperation(params: {
|
||||||
|
client: GatewayClientLike;
|
||||||
|
dispatch: RunControlDispatch;
|
||||||
|
approval: PendingExecApproval;
|
||||||
|
targetAgentId: string;
|
||||||
|
getAgents: () => AgentState[];
|
||||||
|
getPendingState: () => ExecApprovalPendingSnapshot;
|
||||||
|
pausedRunIdByAgentId: Map<string, string>;
|
||||||
|
isDisconnectLikeError: (error: unknown) => boolean;
|
||||||
|
logWarn?: (message: string, error: unknown) => void;
|
||||||
|
clearRunTracking?: (runId: string) => void;
|
||||||
|
sendChatMessage?: typeof sendChatMessageViaStudio;
|
||||||
|
now?: () => number;
|
||||||
|
}): Promise<void> {
|
||||||
|
const sendChatMessage = params.sendChatMessage ?? sendChatMessageViaStudio;
|
||||||
|
const pendingState = params.getPendingState();
|
||||||
|
const prePlan = planAutoResumeRunControl({
|
||||||
|
approval: params.approval,
|
||||||
|
targetAgentId: params.targetAgentId,
|
||||||
|
pendingState,
|
||||||
|
pausedRunIdByAgentId: params.pausedRunIdByAgentId,
|
||||||
|
agents: params.getAgents(),
|
||||||
|
});
|
||||||
|
if (prePlan.preWaitIntent.kind !== "resume") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preWaitIntent = prePlan.preWaitIntent;
|
||||||
|
params.pausedRunIdByAgentId.delete(preWaitIntent.targetAgentId);
|
||||||
|
params.dispatch({
|
||||||
|
type: "updateAgent",
|
||||||
|
agentId: preWaitIntent.targetAgentId,
|
||||||
|
patch: {
|
||||||
|
status: "running",
|
||||||
|
runId: preWaitIntent.pausedRunId,
|
||||||
|
lastActivityAt: (params.now ?? (() => Date.now()))(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await params.client.call("agent.wait", {
|
||||||
|
runId: preWaitIntent.pausedRunId,
|
||||||
|
timeoutMs: EXEC_APPROVAL_AUTO_RESUME_WAIT_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (!params.isDisconnectLikeError(error)) {
|
||||||
|
(params.logWarn ?? ((message, err) => console.warn(message, err)))(
|
||||||
|
"Failed waiting for paused run before auto-resume.",
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const postPlan = planAutoResumeRunControl({
|
||||||
|
approval: params.approval,
|
||||||
|
targetAgentId: preWaitIntent.targetAgentId,
|
||||||
|
pendingState,
|
||||||
|
pausedRunIdByAgentId: new Map([[preWaitIntent.targetAgentId, preWaitIntent.pausedRunId]]),
|
||||||
|
agents: params.getAgents(),
|
||||||
|
});
|
||||||
|
if (postPlan.postWaitIntent.kind !== "resume") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendChatMessage({
|
||||||
|
client: params.client,
|
||||||
|
dispatch: params.dispatch,
|
||||||
|
getAgent: (agentId) => params.getAgents().find((entry) => entry.agentId === agentId) ?? null,
|
||||||
|
agentId: postPlan.postWaitIntent.targetAgentId,
|
||||||
|
sessionKey: postPlan.postWaitIntent.sessionKey,
|
||||||
|
message: AUTO_RESUME_FOLLOW_UP_MESSAGE,
|
||||||
|
clearRunTracking: params.clearRunTracking,
|
||||||
|
echoUserMessage: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runResolveExecApprovalOperation(params: {
|
||||||
|
client: GatewayClientLike;
|
||||||
|
approvalId: string;
|
||||||
|
decision: ExecApprovalDecision;
|
||||||
|
getAgents: () => AgentState[];
|
||||||
|
getPendingState: () => ExecApprovalPendingSnapshot;
|
||||||
|
setPendingExecApprovalsByAgentId: SetState<Record<string, PendingExecApproval[]>>;
|
||||||
|
setUnscopedPendingExecApprovals: SetState<PendingExecApproval[]>;
|
||||||
|
requestHistoryRefresh: (agentId: string) => Promise<void> | void;
|
||||||
|
pausedRunIdByAgentId: Map<string, string>;
|
||||||
|
dispatch: RunControlDispatch;
|
||||||
|
isDisconnectLikeError: (error: unknown) => boolean;
|
||||||
|
logWarn?: (message: string, error: unknown) => void;
|
||||||
|
clearRunTracking?: (runId: string) => void;
|
||||||
|
resolveExecApproval?: typeof resolveExecApprovalViaStudio;
|
||||||
|
runAutoResume?: typeof runExecApprovalAutoResumeOperation;
|
||||||
|
}): Promise<void> {
|
||||||
|
const resolveExecApproval = params.resolveExecApproval ?? resolveExecApprovalViaStudio;
|
||||||
|
const runAutoResume = params.runAutoResume ?? runExecApprovalAutoResumeOperation;
|
||||||
|
|
||||||
|
await resolveExecApproval({
|
||||||
|
client: params.client,
|
||||||
|
approvalId: params.approvalId,
|
||||||
|
decision: params.decision,
|
||||||
|
getAgents: params.getAgents,
|
||||||
|
getLatestAgent: (agentId) =>
|
||||||
|
params.getAgents().find((entry) => entry.agentId === agentId) ?? null,
|
||||||
|
getPendingState: params.getPendingState,
|
||||||
|
setPendingExecApprovalsByAgentId: params.setPendingExecApprovalsByAgentId,
|
||||||
|
setUnscopedPendingExecApprovals: params.setUnscopedPendingExecApprovals,
|
||||||
|
requestHistoryRefresh: params.requestHistoryRefresh,
|
||||||
|
onAllowed: async ({ approval, targetAgentId }) => {
|
||||||
|
await runAutoResume({
|
||||||
|
client: params.client,
|
||||||
|
dispatch: params.dispatch,
|
||||||
|
approval,
|
||||||
|
targetAgentId,
|
||||||
|
getAgents: params.getAgents,
|
||||||
|
getPendingState: params.getPendingState,
|
||||||
|
pausedRunIdByAgentId: params.pausedRunIdByAgentId,
|
||||||
|
isDisconnectLikeError: params.isDisconnectLikeError,
|
||||||
|
logWarn: params.logWarn,
|
||||||
|
clearRunTracking: params.clearRunTracking,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
isDisconnectLikeError: params.isDisconnectLikeError,
|
||||||
|
logWarn: params.logWarn,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function executeExecApprovalIngressCommands(params: {
|
||||||
|
commands: ExecApprovalIngressCommand[];
|
||||||
|
replacePendingState: (nextPendingState: ExecApprovalPendingSnapshot) => void;
|
||||||
|
pauseRunForApproval: (
|
||||||
|
approval: PendingExecApproval,
|
||||||
|
preferredAgentId: string | null
|
||||||
|
) => Promise<void> | void;
|
||||||
|
dispatch: RunControlDispatch;
|
||||||
|
recordCronDedupeKey: (dedupeKey: string) => void;
|
||||||
|
}): void {
|
||||||
|
for (const command of params.commands) {
|
||||||
|
if (command.kind === "replacePendingState") {
|
||||||
|
params.replacePendingState(command.pendingState);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (command.kind === "pauseRunForApproval") {
|
||||||
|
void params.pauseRunForApproval(command.approval, command.preferredAgentId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (command.kind === "markActivity") {
|
||||||
|
params.dispatch({
|
||||||
|
type: "markActivity",
|
||||||
|
agentId: command.agentId,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (command.kind === "recordCronDedupeKey") {
|
||||||
|
params.recordCronDedupeKey(command.dedupeKey);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const intent = command.intent;
|
||||||
|
params.dispatch({
|
||||||
|
type: "appendOutput",
|
||||||
|
agentId: intent.agentId,
|
||||||
|
line: intent.line,
|
||||||
|
transcript: {
|
||||||
|
source: "runtime-agent",
|
||||||
|
role: "assistant",
|
||||||
|
kind: "assistant",
|
||||||
|
sessionKey: intent.sessionKey,
|
||||||
|
timestampMs: intent.timestampMs,
|
||||||
|
entryId: intent.dedupeKey,
|
||||||
|
confirmed: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
params.dispatch({
|
||||||
|
type: "markActivity",
|
||||||
|
agentId: intent.agentId,
|
||||||
|
at: intent.activityAtMs ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runGatewayEventIngressOperation(params: {
|
||||||
|
event: EventFrame;
|
||||||
|
getAgents: () => AgentState[];
|
||||||
|
getPendingState: () => ExecApprovalPendingSnapshot;
|
||||||
|
pausedRunIdByAgentId: ReadonlyMap<string, string>;
|
||||||
|
seenCronDedupeKeys: ReadonlySet<string>;
|
||||||
|
nowMs: number;
|
||||||
|
replacePendingState: (nextPendingState: ExecApprovalPendingSnapshot) => void;
|
||||||
|
pauseRunForApproval: (
|
||||||
|
approval: PendingExecApproval,
|
||||||
|
preferredAgentId: string | null
|
||||||
|
) => Promise<void> | void;
|
||||||
|
dispatch: RunControlDispatch;
|
||||||
|
recordCronDedupeKey: (dedupeKey: string) => void;
|
||||||
|
}): ExecApprovalIngressCommand[] {
|
||||||
|
const commands = planApprovalIngressRunControl({
|
||||||
|
event: params.event,
|
||||||
|
agents: params.getAgents(),
|
||||||
|
pendingState: params.getPendingState(),
|
||||||
|
pausedRunIdByAgentId: params.pausedRunIdByAgentId,
|
||||||
|
seenCronDedupeKeys: params.seenCronDedupeKeys,
|
||||||
|
nowMs: params.nowMs,
|
||||||
|
});
|
||||||
|
executeExecApprovalIngressCommands({
|
||||||
|
commands,
|
||||||
|
replacePendingState: params.replacePendingState,
|
||||||
|
pauseRunForApproval: params.pauseRunForApproval,
|
||||||
|
dispatch: params.dispatch,
|
||||||
|
recordCronDedupeKey: params.recordCronDedupeKey,
|
||||||
|
});
|
||||||
|
return commands;
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import type { PendingExecApproval } from "@/features/agents/approvals/types";
|
||||||
|
import {
|
||||||
|
planAutoResumeIntent,
|
||||||
|
planIngressCommands,
|
||||||
|
planPausedRunMapCleanup,
|
||||||
|
planPauseRunIntent,
|
||||||
|
type ExecApprovalIngressCommand,
|
||||||
|
type ExecApprovalPendingSnapshot,
|
||||||
|
} from "@/features/agents/approvals/execApprovalControlLoopWorkflow";
|
||||||
|
import type { AgentState } from "@/features/agents/state/store";
|
||||||
|
|
||||||
|
type GatewayEventFrame = Parameters<typeof planIngressCommands>[0]["event"];
|
||||||
|
|
||||||
|
export type PauseRunControlPlan = {
|
||||||
|
stalePausedAgentIds: string[];
|
||||||
|
pauseIntent: ReturnType<typeof planPauseRunIntent>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AutoResumeRunControlPlan = {
|
||||||
|
preWaitIntent: ReturnType<typeof planAutoResumeIntent>;
|
||||||
|
postWaitIntent: ReturnType<typeof planAutoResumeIntent>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function planPauseRunControl(params: {
|
||||||
|
approval: PendingExecApproval;
|
||||||
|
preferredAgentId: string | null;
|
||||||
|
agents: AgentState[];
|
||||||
|
pausedRunIdByAgentId: ReadonlyMap<string, string>;
|
||||||
|
}): PauseRunControlPlan {
|
||||||
|
return {
|
||||||
|
stalePausedAgentIds: planPausedRunMapCleanup({
|
||||||
|
pausedRunIdByAgentId: params.pausedRunIdByAgentId,
|
||||||
|
agents: params.agents,
|
||||||
|
}),
|
||||||
|
pauseIntent: planPauseRunIntent({
|
||||||
|
approval: params.approval,
|
||||||
|
preferredAgentId: params.preferredAgentId,
|
||||||
|
agents: params.agents,
|
||||||
|
pausedRunIdByAgentId: params.pausedRunIdByAgentId,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function planAutoResumeRunControl(params: {
|
||||||
|
approval: PendingExecApproval;
|
||||||
|
targetAgentId: string;
|
||||||
|
pendingState: ExecApprovalPendingSnapshot;
|
||||||
|
pausedRunIdByAgentId: ReadonlyMap<string, string>;
|
||||||
|
agents: AgentState[];
|
||||||
|
}): AutoResumeRunControlPlan {
|
||||||
|
const preWaitIntent = planAutoResumeIntent({
|
||||||
|
approval: params.approval,
|
||||||
|
targetAgentId: params.targetAgentId,
|
||||||
|
pendingState: params.pendingState,
|
||||||
|
pausedRunIdByAgentId: params.pausedRunIdByAgentId,
|
||||||
|
agents: params.agents,
|
||||||
|
});
|
||||||
|
if (preWaitIntent.kind !== "resume") {
|
||||||
|
return {
|
||||||
|
preWaitIntent,
|
||||||
|
postWaitIntent: preWaitIntent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
preWaitIntent,
|
||||||
|
postWaitIntent: planAutoResumeIntent({
|
||||||
|
approval: params.approval,
|
||||||
|
targetAgentId: preWaitIntent.targetAgentId,
|
||||||
|
pendingState: params.pendingState,
|
||||||
|
pausedRunIdByAgentId: new Map([
|
||||||
|
[preWaitIntent.targetAgentId, preWaitIntent.pausedRunId],
|
||||||
|
]),
|
||||||
|
agents: params.agents,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function planApprovalIngressRunControl(params: {
|
||||||
|
event: GatewayEventFrame;
|
||||||
|
agents: AgentState[];
|
||||||
|
pendingState: ExecApprovalPendingSnapshot;
|
||||||
|
pausedRunIdByAgentId: ReadonlyMap<string, string>;
|
||||||
|
seenCronDedupeKeys: ReadonlySet<string>;
|
||||||
|
nowMs: number;
|
||||||
|
}): ExecApprovalIngressCommand[] {
|
||||||
|
return planIngressCommands({
|
||||||
|
event: params.event,
|
||||||
|
agents: params.agents,
|
||||||
|
pendingState: params.pendingState,
|
||||||
|
pausedRunIdByAgentId: params.pausedRunIdByAgentId,
|
||||||
|
seenCronDedupeKeys: params.seenCronDedupeKeys,
|
||||||
|
nowMs: params.nowMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
import type { ExecApprovalEventEffects } from "@/features/agents/approvals/execApprovalLifecycleWorkflow";
|
||||||
|
import { shouldPauseRunForPendingExecApproval } from "@/features/agents/approvals/execApprovalPausePolicy";
|
||||||
|
import type { PendingExecApproval } from "@/features/agents/approvals/types";
|
||||||
|
import {
|
||||||
|
nextPendingApprovalPruneDelayMs,
|
||||||
|
pruneExpiredPendingApprovals,
|
||||||
|
pruneExpiredPendingApprovalsMap,
|
||||||
|
removePendingApprovalById,
|
||||||
|
removePendingApprovalByIdMap,
|
||||||
|
removePendingApprovalEverywhere,
|
||||||
|
upsertPendingApproval,
|
||||||
|
} from "@/features/agents/approvals/pendingStore";
|
||||||
|
import type { AgentState } from "@/features/agents/state/store";
|
||||||
|
|
||||||
|
export type ApprovalPendingState = {
|
||||||
|
approvalsByAgentId: Record<string, PendingExecApproval[]>;
|
||||||
|
unscopedApprovals: PendingExecApproval[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApprovalPauseRequest = {
|
||||||
|
approval: PendingExecApproval;
|
||||||
|
preferredAgentId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApprovalIngressResult = {
|
||||||
|
pendingState: ApprovalPendingState;
|
||||||
|
pauseRequests: ApprovalPauseRequest[];
|
||||||
|
markActivityAgentIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AwaitingUserInputPatch = {
|
||||||
|
agentId: string;
|
||||||
|
awaitingUserInput: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AutoResumePreflightIntent =
|
||||||
|
| { kind: "skip"; reason: "missing-paused-run" | "blocking-pending-approvals" }
|
||||||
|
| { kind: "resume"; targetAgentId: string; pausedRunId: string };
|
||||||
|
|
||||||
|
export type AutoResumeDispatchIntent =
|
||||||
|
| { kind: "skip"; reason: "missing-paused-run" | "missing-agent" | "run-replaced" | "missing-session-key" }
|
||||||
|
| { kind: "resume"; targetAgentId: string; pausedRunId: string; sessionKey: string };
|
||||||
|
|
||||||
|
const resolveAgentForPauseRequest = (params: {
|
||||||
|
approval: PendingExecApproval;
|
||||||
|
preferredAgentId: string | null;
|
||||||
|
agents: AgentState[];
|
||||||
|
}): AgentState | null => {
|
||||||
|
const preferredAgentId = params.preferredAgentId?.trim() ?? "";
|
||||||
|
if (preferredAgentId) {
|
||||||
|
const match = params.agents.find((agent) => agent.agentId === preferredAgentId) ?? null;
|
||||||
|
if (match) return match;
|
||||||
|
}
|
||||||
|
const approvalSessionKey = params.approval.sessionKey?.trim() ?? "";
|
||||||
|
if (!approvalSessionKey) return null;
|
||||||
|
return (
|
||||||
|
params.agents.find((agent) => agent.sessionKey.trim() === approvalSessionKey) ?? null
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const shouldQueuePauseRequest = (params: {
|
||||||
|
approval: PendingExecApproval;
|
||||||
|
preferredAgentId: string | null;
|
||||||
|
agents: AgentState[];
|
||||||
|
pausedRunIdByAgentId: ReadonlyMap<string, string>;
|
||||||
|
}): boolean => {
|
||||||
|
const agent = resolveAgentForPauseRequest(params);
|
||||||
|
if (!agent) return false;
|
||||||
|
const pausedRunId = params.pausedRunIdByAgentId.get(agent.agentId) ?? null;
|
||||||
|
return shouldPauseRunForPendingExecApproval({
|
||||||
|
agent,
|
||||||
|
approval: params.approval,
|
||||||
|
pausedRunId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const applyApprovalIngressEffects = (params: {
|
||||||
|
pendingState: ApprovalPendingState;
|
||||||
|
approvalEffects: ExecApprovalEventEffects | null;
|
||||||
|
agents: AgentState[];
|
||||||
|
pausedRunIdByAgentId: ReadonlyMap<string, string>;
|
||||||
|
}): ApprovalIngressResult => {
|
||||||
|
const effects = params.approvalEffects;
|
||||||
|
if (!effects) {
|
||||||
|
return {
|
||||||
|
pendingState: params.pendingState,
|
||||||
|
pauseRequests: [],
|
||||||
|
markActivityAgentIds: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let approvalsByAgentId = params.pendingState.approvalsByAgentId;
|
||||||
|
let unscopedApprovals = params.pendingState.unscopedApprovals;
|
||||||
|
const pauseRequests: ApprovalPauseRequest[] = [];
|
||||||
|
|
||||||
|
for (const approvalId of effects.removals) {
|
||||||
|
const removed = removePendingApprovalEverywhere({
|
||||||
|
approvalsByAgentId,
|
||||||
|
unscopedApprovals,
|
||||||
|
approvalId,
|
||||||
|
});
|
||||||
|
approvalsByAgentId = removed.approvalsByAgentId;
|
||||||
|
unscopedApprovals = removed.unscopedApprovals;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const scopedUpsert of effects.scopedUpserts) {
|
||||||
|
approvalsByAgentId = removePendingApprovalByIdMap(
|
||||||
|
approvalsByAgentId,
|
||||||
|
scopedUpsert.approval.id
|
||||||
|
);
|
||||||
|
const existing = approvalsByAgentId[scopedUpsert.agentId] ?? [];
|
||||||
|
const upserted = upsertPendingApproval(existing, scopedUpsert.approval);
|
||||||
|
if (upserted !== existing) {
|
||||||
|
approvalsByAgentId = {
|
||||||
|
...approvalsByAgentId,
|
||||||
|
[scopedUpsert.agentId]: upserted,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
unscopedApprovals = removePendingApprovalById(
|
||||||
|
unscopedApprovals,
|
||||||
|
scopedUpsert.approval.id
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
shouldQueuePauseRequest({
|
||||||
|
approval: scopedUpsert.approval,
|
||||||
|
preferredAgentId: scopedUpsert.agentId,
|
||||||
|
agents: params.agents,
|
||||||
|
pausedRunIdByAgentId: params.pausedRunIdByAgentId,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
pauseRequests.push({
|
||||||
|
approval: scopedUpsert.approval,
|
||||||
|
preferredAgentId: scopedUpsert.agentId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const unscopedUpsert of effects.unscopedUpserts) {
|
||||||
|
approvalsByAgentId = removePendingApprovalByIdMap(
|
||||||
|
approvalsByAgentId,
|
||||||
|
unscopedUpsert.id
|
||||||
|
);
|
||||||
|
const withoutExisting = removePendingApprovalById(
|
||||||
|
unscopedApprovals,
|
||||||
|
unscopedUpsert.id
|
||||||
|
);
|
||||||
|
unscopedApprovals = upsertPendingApproval(withoutExisting, unscopedUpsert);
|
||||||
|
if (
|
||||||
|
shouldQueuePauseRequest({
|
||||||
|
approval: unscopedUpsert,
|
||||||
|
preferredAgentId: null,
|
||||||
|
agents: params.agents,
|
||||||
|
pausedRunIdByAgentId: params.pausedRunIdByAgentId,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
pauseRequests.push({
|
||||||
|
approval: unscopedUpsert,
|
||||||
|
preferredAgentId: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
pendingState: {
|
||||||
|
approvalsByAgentId,
|
||||||
|
unscopedApprovals,
|
||||||
|
},
|
||||||
|
pauseRequests,
|
||||||
|
markActivityAgentIds: effects.markActivityAgentIds,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deriveAwaitingUserInputPatches = (params: {
|
||||||
|
agents: AgentState[];
|
||||||
|
approvalsByAgentId: Record<string, PendingExecApproval[]>;
|
||||||
|
}): AwaitingUserInputPatch[] => {
|
||||||
|
const pendingCountsByAgentId = new Map<string, number>();
|
||||||
|
for (const [agentId, approvals] of Object.entries(params.approvalsByAgentId)) {
|
||||||
|
if (approvals.length <= 0) continue;
|
||||||
|
pendingCountsByAgentId.set(agentId, approvals.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
const patches: AwaitingUserInputPatch[] = [];
|
||||||
|
for (const agent of params.agents) {
|
||||||
|
const awaitingUserInput = (pendingCountsByAgentId.get(agent.agentId) ?? 0) > 0;
|
||||||
|
if (agent.awaitingUserInput === awaitingUserInput) continue;
|
||||||
|
patches.push({
|
||||||
|
agentId: agent.agentId,
|
||||||
|
awaitingUserInput,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return patches;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const derivePendingApprovalPruneDelayMs = (params: {
|
||||||
|
pendingState: ApprovalPendingState;
|
||||||
|
nowMs: number;
|
||||||
|
graceMs: number;
|
||||||
|
}): number | null => {
|
||||||
|
return nextPendingApprovalPruneDelayMs({
|
||||||
|
approvalsByAgentId: params.pendingState.approvalsByAgentId,
|
||||||
|
unscopedApprovals: params.pendingState.unscopedApprovals,
|
||||||
|
nowMs: params.nowMs,
|
||||||
|
graceMs: params.graceMs,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const prunePendingApprovalState = (params: {
|
||||||
|
pendingState: ApprovalPendingState;
|
||||||
|
nowMs: number;
|
||||||
|
graceMs: number;
|
||||||
|
}): { pendingState: ApprovalPendingState } => {
|
||||||
|
return {
|
||||||
|
pendingState: {
|
||||||
|
approvalsByAgentId: pruneExpiredPendingApprovalsMap(
|
||||||
|
params.pendingState.approvalsByAgentId,
|
||||||
|
{
|
||||||
|
nowMs: params.nowMs,
|
||||||
|
graceMs: params.graceMs,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
unscopedApprovals: pruneExpiredPendingApprovals(
|
||||||
|
params.pendingState.unscopedApprovals,
|
||||||
|
{
|
||||||
|
nowMs: params.nowMs,
|
||||||
|
graceMs: params.graceMs,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveApprovalAutoResumePreflight = (params: {
|
||||||
|
approval: PendingExecApproval;
|
||||||
|
targetAgentId: string;
|
||||||
|
pendingState: ApprovalPendingState;
|
||||||
|
pausedRunIdByAgentId: ReadonlyMap<string, string>;
|
||||||
|
}): AutoResumePreflightIntent => {
|
||||||
|
const pausedRunId = params.pausedRunIdByAgentId.get(params.targetAgentId)?.trim() ?? "";
|
||||||
|
if (!pausedRunId) {
|
||||||
|
return { kind: "skip", reason: "missing-paused-run" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const scopedPending = (
|
||||||
|
params.pendingState.approvalsByAgentId[params.targetAgentId] ?? []
|
||||||
|
).some((pendingApproval) => pendingApproval.id !== params.approval.id);
|
||||||
|
|
||||||
|
const targetSessionKey = params.approval.sessionKey?.trim() ?? "";
|
||||||
|
const unscopedPending = params.pendingState.unscopedApprovals.some((pendingApproval) => {
|
||||||
|
if (pendingApproval.id === params.approval.id) return false;
|
||||||
|
const pendingAgentId = pendingApproval.agentId?.trim() ?? "";
|
||||||
|
if (pendingAgentId && pendingAgentId === params.targetAgentId) return true;
|
||||||
|
if (!targetSessionKey) return false;
|
||||||
|
return (pendingApproval.sessionKey?.trim() ?? "") === targetSessionKey;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (scopedPending || unscopedPending) {
|
||||||
|
return { kind: "skip", reason: "blocking-pending-approvals" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: "resume",
|
||||||
|
targetAgentId: params.targetAgentId,
|
||||||
|
pausedRunId,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveApprovalAutoResumeDispatch = (params: {
|
||||||
|
targetAgentId: string;
|
||||||
|
pausedRunId: string;
|
||||||
|
agents: AgentState[];
|
||||||
|
}): AutoResumeDispatchIntent => {
|
||||||
|
const pausedRunId = params.pausedRunId.trim();
|
||||||
|
if (!pausedRunId) {
|
||||||
|
return { kind: "skip", reason: "missing-paused-run" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const latest =
|
||||||
|
params.agents.find((agent) => agent.agentId === params.targetAgentId) ?? null;
|
||||||
|
if (!latest) {
|
||||||
|
return { kind: "skip", reason: "missing-agent" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestRunId = latest.runId?.trim() ?? "";
|
||||||
|
if (latest.status === "running" && latestRunId && latestRunId !== pausedRunId) {
|
||||||
|
return { kind: "skip", reason: "run-replaced" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionKey = latest.sessionKey.trim();
|
||||||
|
if (!sessionKey) {
|
||||||
|
return { kind: "skip", reason: "missing-session-key" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: "resume",
|
||||||
|
targetAgentId: params.targetAgentId,
|
||||||
|
pausedRunId,
|
||||||
|
sessionKey,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import type { PendingExecApproval } from "@/features/agents/approvals/types";
|
||||||
|
|
||||||
|
export const upsertPendingApproval = (
|
||||||
|
approvals: PendingExecApproval[],
|
||||||
|
nextApproval: PendingExecApproval
|
||||||
|
): PendingExecApproval[] => {
|
||||||
|
const index = approvals.findIndex((entry) => entry.id === nextApproval.id);
|
||||||
|
if (index < 0) {
|
||||||
|
return [nextApproval, ...approvals];
|
||||||
|
}
|
||||||
|
const next = [...approvals];
|
||||||
|
next[index] = nextApproval;
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mergePendingApprovalsForFocusedAgent = (params: {
|
||||||
|
scopedApprovals: PendingExecApproval[];
|
||||||
|
unscopedApprovals: PendingExecApproval[];
|
||||||
|
}): PendingExecApproval[] => {
|
||||||
|
if (params.scopedApprovals.length === 0) return params.unscopedApprovals;
|
||||||
|
if (params.unscopedApprovals.length === 0) return params.scopedApprovals;
|
||||||
|
const merged = [...params.unscopedApprovals];
|
||||||
|
const seen = new Map<string, number>();
|
||||||
|
for (let index = 0; index < merged.length; index += 1) {
|
||||||
|
seen.set(merged[index]!.id, index);
|
||||||
|
}
|
||||||
|
for (const approval of params.scopedApprovals) {
|
||||||
|
const existingIndex = seen.get(approval.id);
|
||||||
|
if (existingIndex === undefined) {
|
||||||
|
seen.set(approval.id, merged.length);
|
||||||
|
merged.push(approval);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
merged[existingIndex] = approval;
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updatePendingApprovalById = (
|
||||||
|
approvals: PendingExecApproval[],
|
||||||
|
approvalId: string,
|
||||||
|
updater: (approval: PendingExecApproval) => PendingExecApproval
|
||||||
|
): PendingExecApproval[] => {
|
||||||
|
let changed = false;
|
||||||
|
const next = approvals.map((approval) => {
|
||||||
|
if (approval.id !== approvalId) return approval;
|
||||||
|
changed = true;
|
||||||
|
return updater(approval);
|
||||||
|
});
|
||||||
|
return changed ? next : approvals;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removePendingApprovalById = (
|
||||||
|
approvals: PendingExecApproval[],
|
||||||
|
approvalId: string
|
||||||
|
): PendingExecApproval[] => approvals.filter((approval) => approval.id !== approvalId);
|
||||||
|
|
||||||
|
export const removePendingApprovalEverywhere = (params: {
|
||||||
|
approvalsByAgentId: Record<string, PendingExecApproval[]>;
|
||||||
|
unscopedApprovals: PendingExecApproval[];
|
||||||
|
approvalId: string;
|
||||||
|
}): {
|
||||||
|
approvalsByAgentId: Record<string, PendingExecApproval[]>;
|
||||||
|
unscopedApprovals: PendingExecApproval[];
|
||||||
|
} => {
|
||||||
|
const hasScoped = Object.values(params.approvalsByAgentId).some((approvals) =>
|
||||||
|
approvals.some((approval) => approval.id === params.approvalId)
|
||||||
|
);
|
||||||
|
const hasUnscoped = params.unscopedApprovals.some(
|
||||||
|
(approval) => approval.id === params.approvalId
|
||||||
|
);
|
||||||
|
if (!hasScoped && !hasUnscoped) {
|
||||||
|
return {
|
||||||
|
approvalsByAgentId: params.approvalsByAgentId,
|
||||||
|
unscopedApprovals: params.unscopedApprovals,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
approvalsByAgentId: hasScoped
|
||||||
|
? removePendingApprovalByIdMap(params.approvalsByAgentId, params.approvalId)
|
||||||
|
: params.approvalsByAgentId,
|
||||||
|
unscopedApprovals: hasUnscoped
|
||||||
|
? removePendingApprovalById(params.unscopedApprovals, params.approvalId)
|
||||||
|
: params.unscopedApprovals,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removePendingApprovalByIdMap = (
|
||||||
|
approvalsByAgentId: Record<string, PendingExecApproval[]>,
|
||||||
|
approvalId: string
|
||||||
|
): Record<string, PendingExecApproval[]> => {
|
||||||
|
let changed = false;
|
||||||
|
const next: Record<string, PendingExecApproval[]> = {};
|
||||||
|
for (const [agentId, approvals] of Object.entries(approvalsByAgentId)) {
|
||||||
|
const filtered = removePendingApprovalById(approvals, approvalId);
|
||||||
|
if (filtered.length !== approvals.length) {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (filtered.length > 0) {
|
||||||
|
next[agentId] = filtered;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed ? next : approvalsByAgentId;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pruneExpiredPendingApprovals = (
|
||||||
|
approvals: PendingExecApproval[],
|
||||||
|
params: { nowMs: number; graceMs: number }
|
||||||
|
): PendingExecApproval[] => {
|
||||||
|
const cutoff = params.nowMs - params.graceMs;
|
||||||
|
return approvals.filter((approval) => approval.expiresAtMs >= cutoff);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pruneExpiredPendingApprovalsMap = (
|
||||||
|
approvalsByAgentId: Record<string, PendingExecApproval[]>,
|
||||||
|
params: { nowMs: number; graceMs: number }
|
||||||
|
): Record<string, PendingExecApproval[]> => {
|
||||||
|
let changed = false;
|
||||||
|
const next: Record<string, PendingExecApproval[]> = {};
|
||||||
|
for (const [agentId, approvals] of Object.entries(approvalsByAgentId)) {
|
||||||
|
const filtered = pruneExpiredPendingApprovals(approvals, params);
|
||||||
|
if (filtered.length !== approvals.length) {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (filtered.length > 0) {
|
||||||
|
next[agentId] = filtered;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed ? next : approvalsByAgentId;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const nextPendingApprovalPruneDelayMs = (params: {
|
||||||
|
approvalsByAgentId: Record<string, PendingExecApproval[]>;
|
||||||
|
unscopedApprovals: PendingExecApproval[];
|
||||||
|
nowMs: number;
|
||||||
|
graceMs: number;
|
||||||
|
}): number | null => {
|
||||||
|
let earliestExpiresMs = Number.POSITIVE_INFINITY;
|
||||||
|
for (const approvals of Object.values(params.approvalsByAgentId)) {
|
||||||
|
for (const approval of approvals) {
|
||||||
|
if (approval.expiresAtMs < earliestExpiresMs) {
|
||||||
|
earliestExpiresMs = approval.expiresAtMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const approval of params.unscopedApprovals) {
|
||||||
|
if (approval.expiresAtMs < earliestExpiresMs) {
|
||||||
|
earliestExpiresMs = approval.expiresAtMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(earliestExpiresMs)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Math.max(0, earliestExpiresMs + params.graceMs - params.nowMs);
|
||||||
|
};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export type ExecApprovalDecision = "allow-once" | "allow-always" | "deny";
|
||||||
|
|
||||||
|
export type PendingExecApproval = {
|
||||||
|
id: string;
|
||||||
|
agentId: string | null;
|
||||||
|
sessionKey: string | null;
|
||||||
|
command: string;
|
||||||
|
cwd: string | null;
|
||||||
|
host: string | null;
|
||||||
|
security: string | null;
|
||||||
|
ask: string | null;
|
||||||
|
resolvedPath: string | null;
|
||||||
|
createdAtMs: number;
|
||||||
|
expiresAtMs: number;
|
||||||
|
resolving: boolean;
|
||||||
|
error: string | null;
|
||||||
|
};
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
import { buildAvatarDataUrl } from "@/lib/avatars/multiavatar";
|
||||||
|
|
||||||
|
type AgentAvatarProps = {
|
||||||
|
seed: string;
|
||||||
|
name: string;
|
||||||
|
avatarUrl?: string | null;
|
||||||
|
size?: number;
|
||||||
|
isSelected?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AgentAvatar = ({
|
||||||
|
seed,
|
||||||
|
name,
|
||||||
|
avatarUrl,
|
||||||
|
size = 112,
|
||||||
|
isSelected = false,
|
||||||
|
}: AgentAvatarProps) => {
|
||||||
|
const src = useMemo(() => {
|
||||||
|
const trimmed = avatarUrl?.trim();
|
||||||
|
if (trimmed) return trimmed;
|
||||||
|
return buildAvatarDataUrl(seed);
|
||||||
|
}, [avatarUrl, seed]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex items-center justify-center overflow-hidden rounded-full border border-border/80 bg-card transition-transform duration-300 ${isSelected ? "agent-avatar-selected scale-[1.02]" : ""}`}
|
||||||
|
style={{ width: size, height: size }}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
className="pointer-events-none h-full w-full select-none"
|
||||||
|
src={src}
|
||||||
|
alt={`Avatar for ${name}`}
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
unoptimized
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { AgentAvatarProfile } from "@/lib/avatars/profile";
|
||||||
|
import { AgentAvatarEditorPanel } from "@/features/agents/components/AgentAvatarEditorPanel";
|
||||||
|
|
||||||
|
type AgentAvatarCreatorModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
agentId: string;
|
||||||
|
agentName: string;
|
||||||
|
initialProfile: AgentAvatarProfile | null | undefined;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (profile: AgentAvatarProfile) => Promise<void> | void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AgentAvatarCreatorModal = ({
|
||||||
|
open,
|
||||||
|
agentId,
|
||||||
|
agentName,
|
||||||
|
initialProfile,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
}: AgentAvatarCreatorModalProps) => {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[140] flex items-center justify-center bg-background/85 p-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={`Customize avatar for ${agentName}`}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="ui-panel grid w-full max-w-6xl gap-0 overflow-hidden shadow-xs xl:grid-cols-[360px_minmax(0,1fr)]"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<AgentAvatarEditorPanel
|
||||||
|
agentId={agentId}
|
||||||
|
agentName={agentName}
|
||||||
|
initialProfile={initialProfile}
|
||||||
|
onCancel={onClose}
|
||||||
|
onSave={onSave}
|
||||||
|
onSaved={onClose}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { RefreshCcw, Shuffle } from "lucide-react";
|
||||||
|
import {
|
||||||
|
AGENT_AVATAR_BOTTOM_STYLE_OPTIONS,
|
||||||
|
AGENT_AVATAR_CLOTHING_COLOR_OPTIONS,
|
||||||
|
AGENT_AVATAR_HAIR_COLOR_OPTIONS,
|
||||||
|
AGENT_AVATAR_HAIR_STYLE_OPTIONS,
|
||||||
|
AGENT_AVATAR_HAT_STYLE_OPTIONS,
|
||||||
|
AGENT_AVATAR_SHOE_COLOR_OPTIONS,
|
||||||
|
AGENT_AVATAR_SKIN_TONE_OPTIONS,
|
||||||
|
AGENT_AVATAR_TOP_STYLE_OPTIONS,
|
||||||
|
type AgentAvatarProfile,
|
||||||
|
createDefaultAgentAvatarProfile,
|
||||||
|
} from "@/lib/avatars/profile";
|
||||||
|
import { AgentAvatarPreview3D } from "@/features/agents/components/AgentAvatarPreview3D";
|
||||||
|
import { randomUUID } from "@/lib/uuid";
|
||||||
|
|
||||||
|
export type AgentAvatarEditorPanelProps = {
|
||||||
|
agentId: string;
|
||||||
|
agentName: string;
|
||||||
|
initialProfile: AgentAvatarProfile | null | undefined;
|
||||||
|
onSave: (profile: AgentAvatarProfile) => Promise<void> | void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
onSaved?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pillClassName =
|
||||||
|
"rounded-full border px-3 py-1.5 text-[11px] transition-colors";
|
||||||
|
|
||||||
|
const colorSwatchClassName =
|
||||||
|
"h-7 w-7 rounded-full border-2 transition-transform hover:scale-105";
|
||||||
|
|
||||||
|
export const AgentAvatarEditorPanel = ({
|
||||||
|
agentId,
|
||||||
|
agentName,
|
||||||
|
initialProfile,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
onSaved,
|
||||||
|
}: AgentAvatarEditorPanelProps) => {
|
||||||
|
const fallbackProfile = useMemo(
|
||||||
|
() => createDefaultAgentAvatarProfile(agentId),
|
||||||
|
[agentId]
|
||||||
|
);
|
||||||
|
const resolvedInitialProfile = initialProfile ?? fallbackProfile;
|
||||||
|
const [draft, setDraft] = useState<AgentAvatarProfile>(resolvedInitialProfile);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDraft(resolvedInitialProfile);
|
||||||
|
}, [resolvedInitialProfile]);
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (saving) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await onSave(draft);
|
||||||
|
onSaved?.();
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid h-full min-h-0 gap-0 xl:grid-cols-[360px_minmax(0,1fr)]">
|
||||||
|
<div className="border-b border-border/45 p-5 xl:border-b-0 xl:border-r">
|
||||||
|
<div className="font-mono text-[11px] font-semibold tracking-[0.06em] text-muted-foreground">
|
||||||
|
Avatar creator
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-lg font-semibold text-foreground">{agentName}</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Personalize this office avatar locally on this machine.
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 overflow-hidden rounded-xl border border-border/45 bg-[#070b16]">
|
||||||
|
<AgentAvatarPreview3D profile={draft} className="h-[360px] w-full" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ui-btn-secondary inline-flex items-center gap-2 px-3 py-2 text-xs"
|
||||||
|
onClick={() => setDraft(createDefaultAgentAvatarProfile(agentId))}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
<RefreshCcw className="h-3.5 w-3.5" />
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ui-btn-secondary inline-flex items-center gap-2 px-3 py-2 text-xs"
|
||||||
|
onClick={() => setDraft(createDefaultAgentAvatarProfile(randomUUID()))}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
<Shuffle className="h-3.5 w-3.5" />
|
||||||
|
Randomize
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 overflow-y-auto p-5">
|
||||||
|
<div className="mb-6 flex items-center justify-end gap-2 border-b border-border/40 pb-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ui-btn-ghost px-3 py-2 text-xs"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ui-btn-primary px-3 py-2 text-xs"
|
||||||
|
onClick={() => {
|
||||||
|
void save();
|
||||||
|
}}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving ? "Saving..." : "Save avatar"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-6 xl:grid-cols-2">
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h3 className="font-mono text-[11px] font-semibold tracking-[0.06em] text-muted-foreground">
|
||||||
|
Skin tone
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{AGENT_AVATAR_SKIN_TONE_OPTIONS.map((option) => {
|
||||||
|
const selected = draft.body.skinTone === option.color;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
type="button"
|
||||||
|
aria-label={option.label}
|
||||||
|
className={`${colorSwatchClassName} ${selected ? "border-white" : "border-white/15"}`}
|
||||||
|
style={{ backgroundColor: option.color }}
|
||||||
|
onClick={() =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
body: { ...current.body, skinTone: option.color },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h3 className="font-mono text-[11px] font-semibold tracking-[0.06em] text-muted-foreground">
|
||||||
|
Hair style
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{AGENT_AVATAR_HAIR_STYLE_OPTIONS.map((option) => {
|
||||||
|
const selected = draft.hair.style === option.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
type="button"
|
||||||
|
className={`${pillClassName} ${
|
||||||
|
selected
|
||||||
|
? "border-primary bg-primary/15 text-foreground"
|
||||||
|
: "border-border/50 bg-muted/30 text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
onClick={() =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
hair: { ...current.hair, style: option.id },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h3 className="font-mono text-[11px] font-semibold tracking-[0.06em] text-muted-foreground">
|
||||||
|
Hair color
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{AGENT_AVATAR_HAIR_COLOR_OPTIONS.map((option) => {
|
||||||
|
const selected = draft.hair.color === option.color;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
type="button"
|
||||||
|
aria-label={option.label}
|
||||||
|
className={`${colorSwatchClassName} ${selected ? "border-white" : "border-white/15"}`}
|
||||||
|
style={{ backgroundColor: option.color }}
|
||||||
|
onClick={() =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
hair: { ...current.hair, color: option.color },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h3 className="font-mono text-[11px] font-semibold tracking-[0.06em] text-muted-foreground">
|
||||||
|
Top style
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{AGENT_AVATAR_TOP_STYLE_OPTIONS.map((option) => {
|
||||||
|
const selected = draft.clothing.topStyle === option.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
type="button"
|
||||||
|
className={`${pillClassName} ${
|
||||||
|
selected
|
||||||
|
? "border-primary bg-primary/15 text-foreground"
|
||||||
|
: "border-border/50 bg-muted/30 text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
onClick={() =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
clothing: { ...current.clothing, topStyle: option.id },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h3 className="font-mono text-[11px] font-semibold tracking-[0.06em] text-muted-foreground">
|
||||||
|
Top color
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{AGENT_AVATAR_CLOTHING_COLOR_OPTIONS.map((option) => {
|
||||||
|
const selected = draft.clothing.topColor === option.color;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`top-${option.id}`}
|
||||||
|
type="button"
|
||||||
|
aria-label={option.label}
|
||||||
|
className={`${colorSwatchClassName} ${selected ? "border-white" : "border-white/15"}`}
|
||||||
|
style={{ backgroundColor: option.color }}
|
||||||
|
onClick={() =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
clothing: { ...current.clothing, topColor: option.color },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h3 className="font-mono text-[11px] font-semibold tracking-[0.06em] text-muted-foreground">
|
||||||
|
Bottom style
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{AGENT_AVATAR_BOTTOM_STYLE_OPTIONS.map((option) => {
|
||||||
|
const selected = draft.clothing.bottomStyle === option.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
type="button"
|
||||||
|
className={`${pillClassName} ${
|
||||||
|
selected
|
||||||
|
? "border-primary bg-primary/15 text-foreground"
|
||||||
|
: "border-border/50 bg-muted/30 text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
onClick={() =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
clothing: { ...current.clothing, bottomStyle: option.id },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h3 className="font-mono text-[11px] font-semibold tracking-[0.06em] text-muted-foreground">
|
||||||
|
Bottom color
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{AGENT_AVATAR_CLOTHING_COLOR_OPTIONS.map((option) => {
|
||||||
|
const selected = draft.clothing.bottomColor === option.color;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`bottom-${option.id}`}
|
||||||
|
type="button"
|
||||||
|
aria-label={option.label}
|
||||||
|
className={`${colorSwatchClassName} ${selected ? "border-white" : "border-white/15"}`}
|
||||||
|
style={{ backgroundColor: option.color }}
|
||||||
|
onClick={() =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
clothing: { ...current.clothing, bottomColor: option.color },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h3 className="font-mono text-[11px] font-semibold tracking-[0.06em] text-muted-foreground">
|
||||||
|
Shoe color
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{AGENT_AVATAR_SHOE_COLOR_OPTIONS.map((option) => {
|
||||||
|
const selected = draft.clothing.shoesColor === option.color;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`shoes-${option.id}`}
|
||||||
|
type="button"
|
||||||
|
aria-label={option.label}
|
||||||
|
className={`${colorSwatchClassName} ${selected ? "border-white" : "border-white/15"}`}
|
||||||
|
style={{ backgroundColor: option.color }}
|
||||||
|
onClick={() =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
clothing: { ...current.clothing, shoesColor: option.color },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h3 className="font-mono text-[11px] font-semibold tracking-[0.06em] text-muted-foreground">
|
||||||
|
Hat
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{AGENT_AVATAR_HAT_STYLE_OPTIONS.map((option) => {
|
||||||
|
const selected = draft.accessories.hatStyle === option.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
type="button"
|
||||||
|
className={`${pillClassName} ${
|
||||||
|
selected
|
||||||
|
? "border-primary bg-primary/15 text-foreground"
|
||||||
|
: "border-border/50 bg-muted/30 text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
onClick={() =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
accessories: { ...current.accessories, hatStyle: option.id },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-3 xl:col-span-2">
|
||||||
|
<h3 className="font-mono text-[11px] font-semibold tracking-[0.06em] text-muted-foreground">
|
||||||
|
Accessories
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
key: "glasses" as const,
|
||||||
|
label: "Glasses",
|
||||||
|
enabled: draft.accessories.glasses,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "headset" as const,
|
||||||
|
label: "Headset",
|
||||||
|
enabled: draft.accessories.headset,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "backpack" as const,
|
||||||
|
label: "Backpack",
|
||||||
|
enabled: draft.accessories.backpack,
|
||||||
|
},
|
||||||
|
].map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.key}
|
||||||
|
type="button"
|
||||||
|
className={`${pillClassName} ${
|
||||||
|
option.enabled
|
||||||
|
? "border-primary bg-primary/15 text-foreground"
|
||||||
|
: "border-border/50 bg-muted/30 text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
onClick={() =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
accessories: {
|
||||||
|
...current.accessories,
|
||||||
|
[option.key]: !current.accessories[option.key],
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Environment, OrbitControls } from "@react-three/drei";
|
||||||
|
import { Canvas, useFrame } from "@react-three/fiber";
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import * as THREE from "three";
|
||||||
|
import {
|
||||||
|
type AgentAvatarProfile,
|
||||||
|
createDefaultAgentAvatarProfile,
|
||||||
|
} from "@/lib/avatars/profile";
|
||||||
|
|
||||||
|
const PreviewFigure = ({
|
||||||
|
profile,
|
||||||
|
onFirstFrame,
|
||||||
|
}: {
|
||||||
|
profile: AgentAvatarProfile;
|
||||||
|
onFirstFrame: () => void;
|
||||||
|
}) => {
|
||||||
|
const groupRef = useRef<THREE.Group>(null);
|
||||||
|
const reportedReadyRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reportedReadyRef.current = false;
|
||||||
|
}, [profile]);
|
||||||
|
|
||||||
|
useFrame((state) => {
|
||||||
|
if (!reportedReadyRef.current) {
|
||||||
|
reportedReadyRef.current = true;
|
||||||
|
onFirstFrame();
|
||||||
|
}
|
||||||
|
if (!groupRef.current) return;
|
||||||
|
groupRef.current.rotation.y = Math.sin(state.clock.elapsedTime * 0.45) * 0.35 + 0.25;
|
||||||
|
});
|
||||||
|
|
||||||
|
const skin = profile.body.skinTone;
|
||||||
|
const topColor = profile.clothing.topColor;
|
||||||
|
const bottomColor = profile.clothing.bottomColor;
|
||||||
|
const shoeColor = profile.clothing.shoesColor;
|
||||||
|
const hairColor = profile.hair.color;
|
||||||
|
const accessoryColor = topColor;
|
||||||
|
const sleeveColor = profile.clothing.topStyle === "jacket" ? "#dbe4ff" : topColor;
|
||||||
|
const cuffColor = profile.clothing.topStyle === "hoodie" ? "#d1d5db" : sleeveColor;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group ref={groupRef} position={[0, -0.72, 0]} scale={[1.45, 1.45, 1.45]}>
|
||||||
|
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.01, 0]}>
|
||||||
|
<circleGeometry args={[0.22, 24]} />
|
||||||
|
<meshBasicMaterial color="#000000" transparent opacity={0.16} />
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{profile.accessories.backpack ? (
|
||||||
|
<group position={[0, 0.31, -0.08]}>
|
||||||
|
<mesh>
|
||||||
|
<boxGeometry args={[0.16, 0.2, 0.06]} />
|
||||||
|
<meshLambertMaterial color={accessoryColor} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<group position={[-0.05, 0.12, 0]}>
|
||||||
|
{profile.clothing.bottomStyle === "shorts" ? (
|
||||||
|
<>
|
||||||
|
<mesh position={[0, 0.03, 0]}>
|
||||||
|
<boxGeometry args={[0.07, 0.08, 0.08]} />
|
||||||
|
<meshLambertMaterial color={bottomColor} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, -0.045, 0]}>
|
||||||
|
<boxGeometry args={[0.05, 0.06, 0.05]} />
|
||||||
|
<meshLambertMaterial color={skin} />
|
||||||
|
</mesh>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<mesh>
|
||||||
|
<boxGeometry args={[0.07, 0.14, 0.08]} />
|
||||||
|
<meshLambertMaterial color={bottomColor} />
|
||||||
|
</mesh>
|
||||||
|
)}
|
||||||
|
<mesh position={[0, -0.09, 0]}>
|
||||||
|
<boxGeometry args={[0.07, 0.05, 0.12]} />
|
||||||
|
<meshLambertMaterial color={shoeColor} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
<group position={[0.05, 0.12, 0]}>
|
||||||
|
{profile.clothing.bottomStyle === "shorts" ? (
|
||||||
|
<>
|
||||||
|
<mesh position={[0, 0.03, 0]}>
|
||||||
|
<boxGeometry args={[0.07, 0.08, 0.08]} />
|
||||||
|
<meshLambertMaterial color={bottomColor} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, -0.045, 0]}>
|
||||||
|
<boxGeometry args={[0.05, 0.06, 0.05]} />
|
||||||
|
<meshLambertMaterial color={skin} />
|
||||||
|
</mesh>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<mesh>
|
||||||
|
<boxGeometry args={[0.07, 0.14, 0.08]} />
|
||||||
|
<meshLambertMaterial color={bottomColor} />
|
||||||
|
</mesh>
|
||||||
|
)}
|
||||||
|
<mesh position={[0, -0.09, 0]}>
|
||||||
|
<boxGeometry args={[0.07, 0.05, 0.12]} />
|
||||||
|
<meshLambertMaterial color={shoeColor} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<mesh position={[0, 0.3, 0]}>
|
||||||
|
<boxGeometry args={[0.2, 0.22, 0.1]} />
|
||||||
|
<meshLambertMaterial color={topColor} />
|
||||||
|
</mesh>
|
||||||
|
{profile.clothing.topStyle === "hoodie" ? (
|
||||||
|
<>
|
||||||
|
<mesh position={[0, 0.37, -0.045]}>
|
||||||
|
<boxGeometry args={[0.18, 0.1, 0.03]} />
|
||||||
|
<meshLambertMaterial color={topColor} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, 0.23, 0.056]}>
|
||||||
|
<boxGeometry args={[0.11, 0.03, 0.012]} />
|
||||||
|
<meshLambertMaterial color={cuffColor} />
|
||||||
|
</mesh>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{profile.clothing.topStyle === "jacket" ? (
|
||||||
|
<>
|
||||||
|
<mesh position={[0, 0.3, 0.056]}>
|
||||||
|
<boxGeometry args={[0.202, 0.23, 0.012]} />
|
||||||
|
<meshLambertMaterial color="#1f2937" />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, 0.3, 0.063]}>
|
||||||
|
<boxGeometry args={[0.038, 0.21, 0.01]} />
|
||||||
|
<meshLambertMaterial color="#f8fafc" />
|
||||||
|
</mesh>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<group position={[-0.13, 0.3, 0]}>
|
||||||
|
<mesh position={[0, -0.08, 0]}>
|
||||||
|
<boxGeometry args={[0.06, 0.16, 0.06]} />
|
||||||
|
<meshLambertMaterial color={sleeveColor} />
|
||||||
|
</mesh>
|
||||||
|
{profile.clothing.topStyle === "hoodie" ? (
|
||||||
|
<mesh position={[0, -0.145, 0]}>
|
||||||
|
<boxGeometry args={[0.064, 0.03, 0.064]} />
|
||||||
|
<meshLambertMaterial color={cuffColor} />
|
||||||
|
</mesh>
|
||||||
|
) : null}
|
||||||
|
<mesh position={[0, -0.17, 0]}>
|
||||||
|
<boxGeometry args={[0.05, 0.05, 0.05]} />
|
||||||
|
<meshLambertMaterial color={skin} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
<group position={[0.13, 0.3, 0]}>
|
||||||
|
<mesh position={[0, -0.08, 0]}>
|
||||||
|
<boxGeometry args={[0.06, 0.16, 0.06]} />
|
||||||
|
<meshLambertMaterial color={sleeveColor} />
|
||||||
|
</mesh>
|
||||||
|
{profile.clothing.topStyle === "hoodie" ? (
|
||||||
|
<mesh position={[0, -0.145, 0]}>
|
||||||
|
<boxGeometry args={[0.064, 0.03, 0.064]} />
|
||||||
|
<meshLambertMaterial color={cuffColor} />
|
||||||
|
</mesh>
|
||||||
|
) : null}
|
||||||
|
<mesh position={[0, -0.17, 0]}>
|
||||||
|
<boxGeometry args={[0.05, 0.05, 0.05]} />
|
||||||
|
<meshLambertMaterial color={skin} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<mesh position={[0, 0.42, 0]}>
|
||||||
|
<boxGeometry args={[0.07, 0.05, 0.07]} />
|
||||||
|
<meshLambertMaterial color={skin} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, 0.5, 0]}>
|
||||||
|
<boxGeometry args={[0.17, 0.17, 0.15]} />
|
||||||
|
<meshLambertMaterial color={skin} />
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{profile.hair.style === "short" ? (
|
||||||
|
<mesh position={[0, 0.59, 0]}>
|
||||||
|
<boxGeometry args={[0.18, 0.05, 0.15]} />
|
||||||
|
<meshLambertMaterial color={hairColor} />
|
||||||
|
</mesh>
|
||||||
|
) : null}
|
||||||
|
{profile.hair.style === "parted" ? (
|
||||||
|
<>
|
||||||
|
<mesh position={[0, 0.585, 0]}>
|
||||||
|
<boxGeometry args={[0.18, 0.045, 0.15]} />
|
||||||
|
<meshLambertMaterial color={hairColor} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[-0.03, 0.62, 0.01]} rotation={[0.1, 0, -0.2]}>
|
||||||
|
<boxGeometry args={[0.12, 0.03, 0.08]} />
|
||||||
|
<meshLambertMaterial color={hairColor} />
|
||||||
|
</mesh>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{profile.hair.style === "spiky" ? (
|
||||||
|
<>
|
||||||
|
<mesh position={[0, 0.58, 0]}>
|
||||||
|
<boxGeometry args={[0.17, 0.035, 0.14]} />
|
||||||
|
<meshLambertMaterial color={hairColor} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[-0.05, 0.62, 0]} rotation={[0, 0, -0.2]}>
|
||||||
|
<boxGeometry args={[0.04, 0.06, 0.04]} />
|
||||||
|
<meshLambertMaterial color={hairColor} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, 0.635, 0]}>
|
||||||
|
<boxGeometry args={[0.04, 0.08, 0.04]} />
|
||||||
|
<meshLambertMaterial color={hairColor} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0.05, 0.62, 0]} rotation={[0, 0, 0.2]}>
|
||||||
|
<boxGeometry args={[0.04, 0.06, 0.04]} />
|
||||||
|
<meshLambertMaterial color={hairColor} />
|
||||||
|
</mesh>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{profile.hair.style === "bun" ? (
|
||||||
|
<>
|
||||||
|
<mesh position={[0, 0.58, 0]}>
|
||||||
|
<boxGeometry args={[0.18, 0.04, 0.15]} />
|
||||||
|
<meshLambertMaterial color={hairColor} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, 0.63, -0.03]}>
|
||||||
|
<sphereGeometry args={[0.045, 16, 16]} />
|
||||||
|
<meshLambertMaterial color={hairColor} />
|
||||||
|
</mesh>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{profile.accessories.hatStyle === "cap" ? (
|
||||||
|
<>
|
||||||
|
<mesh position={[0, 0.63, 0]}>
|
||||||
|
<boxGeometry args={[0.18, 0.03, 0.16]} />
|
||||||
|
<meshLambertMaterial color={accessoryColor} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, 0.615, 0.07]}>
|
||||||
|
<boxGeometry args={[0.09, 0.012, 0.05]} />
|
||||||
|
<meshLambertMaterial color={accessoryColor} />
|
||||||
|
</mesh>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{profile.accessories.hatStyle === "beanie" ? (
|
||||||
|
<mesh position={[0, 0.63, 0]}>
|
||||||
|
<boxGeometry args={[0.19, 0.06, 0.17]} />
|
||||||
|
<meshLambertMaterial color={accessoryColor} />
|
||||||
|
</mesh>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{profile.accessories.headset ? (
|
||||||
|
<>
|
||||||
|
<mesh position={[0, 0.6, 0]} rotation={[0, 0, Math.PI / 2]}>
|
||||||
|
<torusGeometry args={[0.095, 0.008, 8, 24, Math.PI]} />
|
||||||
|
<meshLambertMaterial color="#94a3b8" />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[-0.105, 0.51, 0]}>
|
||||||
|
<boxGeometry args={[0.018, 0.05, 0.028]} />
|
||||||
|
<meshLambertMaterial color="#475569" />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0.105, 0.51, 0]}>
|
||||||
|
<boxGeometry args={[0.018, 0.05, 0.028]} />
|
||||||
|
<meshLambertMaterial color="#475569" />
|
||||||
|
</mesh>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<mesh position={[-0.04, 0.505, 0.078]}>
|
||||||
|
<boxGeometry args={[0.03, 0.03, 0.01]} />
|
||||||
|
<meshBasicMaterial color="#111827" />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0.04, 0.505, 0.078]}>
|
||||||
|
<boxGeometry args={[0.03, 0.03, 0.01]} />
|
||||||
|
<meshBasicMaterial color="#111827" />
|
||||||
|
</mesh>
|
||||||
|
{profile.accessories.glasses ? (
|
||||||
|
<>
|
||||||
|
<mesh position={[-0.04, 0.505, 0.084]}>
|
||||||
|
<boxGeometry args={[0.05, 0.05, 0.01]} />
|
||||||
|
<meshBasicMaterial color="#111827" wireframe />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0.04, 0.505, 0.084]}>
|
||||||
|
<boxGeometry args={[0.05, 0.05, 0.01]} />
|
||||||
|
<meshBasicMaterial color="#111827" wireframe />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, 0.505, 0.084]}>
|
||||||
|
<boxGeometry args={[0.02, 0.008, 0.01]} />
|
||||||
|
<meshBasicMaterial color="#111827" />
|
||||||
|
</mesh>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<mesh position={[0, 0.46, 0.079]}>
|
||||||
|
<boxGeometry args={[0.05, 0.014, 0.01]} />
|
||||||
|
<meshBasicMaterial color="#9c4a4a" />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AgentAvatarPreview3D = ({
|
||||||
|
profile,
|
||||||
|
className = "",
|
||||||
|
}: {
|
||||||
|
profile: AgentAvatarProfile | null | undefined;
|
||||||
|
className?: string;
|
||||||
|
}) => {
|
||||||
|
const resolvedProfile = useMemo(
|
||||||
|
() => profile ?? createDefaultAgentAvatarProfile("preview"),
|
||||||
|
[profile]
|
||||||
|
);
|
||||||
|
const [isReady, setIsReady] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsReady(false);
|
||||||
|
}, [resolvedProfile]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative ${className}`}>
|
||||||
|
{!isReady ? (
|
||||||
|
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-[#070b16] text-white/70">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-white/15 border-t-cyan-300" />
|
||||||
|
<div className="font-mono text-[11px] tracking-[0.08em] text-white/55">
|
||||||
|
Loading avatar...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<Canvas camera={{ position: [0, 0.7, 2.5], fov: 34 }}>
|
||||||
|
<color attach="background" args={["#070b16"]} />
|
||||||
|
<ambientLight intensity={1.4} />
|
||||||
|
<directionalLight position={[3, 4, 5]} intensity={2.4} />
|
||||||
|
<directionalLight position={[-4, 2, 3]} intensity={0.9} color="#89a6ff" />
|
||||||
|
<PreviewFigure
|
||||||
|
profile={resolvedProfile}
|
||||||
|
onFirstFrame={() => {
|
||||||
|
setIsReady(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Environment preset="city" />
|
||||||
|
<OrbitControls enablePan={false} enableZoom={false} maxPolarAngle={1.8} minPolarAngle={1.1} />
|
||||||
|
</Canvas>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,157 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Shuffle } from "lucide-react";
|
||||||
|
import type { AgentCreateModalSubmitPayload } from "@/features/agents/creation/types";
|
||||||
|
import { AgentAvatar } from "@/features/agents/components/AgentAvatar";
|
||||||
|
import { randomUUID } from "@/lib/uuid";
|
||||||
|
|
||||||
|
type AgentCreateModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
suggestedName: string;
|
||||||
|
busy?: boolean;
|
||||||
|
submitError?: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (payload: AgentCreateModalSubmitPayload) => Promise<void> | void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fieldClassName =
|
||||||
|
"ui-input w-full rounded-md px-3 py-2 text-xs text-foreground outline-none";
|
||||||
|
const labelClassName =
|
||||||
|
"font-mono text-[11px] font-semibold tracking-[0.05em] text-muted-foreground";
|
||||||
|
|
||||||
|
const resolveInitialName = (suggestedName: string): string => {
|
||||||
|
const trimmed = suggestedName.trim();
|
||||||
|
if (!trimmed) return "New Agent";
|
||||||
|
return trimmed;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AgentCreateModalContent = ({
|
||||||
|
suggestedName,
|
||||||
|
busy,
|
||||||
|
submitError,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}: Omit<AgentCreateModalProps, "open">) => {
|
||||||
|
const [name, setName] = useState(() => resolveInitialName(suggestedName));
|
||||||
|
const [avatarSeed, setAvatarSeed] = useState(() => randomUUID());
|
||||||
|
|
||||||
|
const canSubmit = name.trim().length > 0;
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!canSubmit || busy) return;
|
||||||
|
const trimmedName = name.trim();
|
||||||
|
if (!trimmedName) return;
|
||||||
|
void onSubmit({ name: trimmedName, avatarSeed });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[120] flex items-center justify-center bg-background/80 p-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Create agent"
|
||||||
|
onClick={busy ? undefined : onClose}
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
className="ui-panel w-full max-w-2xl shadow-xs"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
handleSubmit();
|
||||||
|
}}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
data-testid="agent-create-modal"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between border-b border-border/35 px-6 py-6">
|
||||||
|
<div>
|
||||||
|
<div className="font-mono text-[11px] font-semibold tracking-[0.06em] text-muted-foreground">
|
||||||
|
New agent
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-base font-semibold text-foreground">Launch agent</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">Name it and activate immediately.</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ui-btn-ghost px-3 py-1.5 font-mono text-[11px] font-semibold tracking-[0.06em] disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 px-6 py-5">
|
||||||
|
<label className={labelClassName}>
|
||||||
|
Name
|
||||||
|
<input
|
||||||
|
aria-label="Agent name"
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
className={`mt-1 ${fieldClassName}`}
|
||||||
|
placeholder="My agent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="-mt-2 text-[11px] text-muted-foreground">
|
||||||
|
You can rename this agent from the main chat header.
|
||||||
|
</div>
|
||||||
|
<div className="grid justify-items-center gap-2 border-t border-border/40 pt-3">
|
||||||
|
<div className={labelClassName}>Choose avatar</div>
|
||||||
|
<AgentAvatar
|
||||||
|
seed={avatarSeed}
|
||||||
|
name={name.trim() || "New Agent"}
|
||||||
|
size={64}
|
||||||
|
isSelected
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Shuffle avatar selection"
|
||||||
|
className="ui-btn-secondary inline-flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground"
|
||||||
|
onClick={() => setAvatarSeed(randomUUID())}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
<Shuffle className="h-3.5 w-3.5" />
|
||||||
|
Shuffle
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{submitError ? (
|
||||||
|
<div className="ui-alert-danger rounded-md px-3 py-2 text-xs">
|
||||||
|
{submitError}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between border-t border-border/45 px-6 pb-4 pt-5">
|
||||||
|
<div className="text-[11px] text-muted-foreground">Authority can be configured after launch.</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="ui-btn-primary px-3 py-1.5 font-mono text-[11px] font-semibold tracking-[0.06em] disabled:cursor-not-allowed disabled:border-border disabled:bg-muted disabled:text-muted-foreground"
|
||||||
|
disabled={!canSubmit || busy}
|
||||||
|
>
|
||||||
|
{busy ? "Launching..." : "Launch agent"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AgentCreateModal = ({
|
||||||
|
open,
|
||||||
|
suggestedName,
|
||||||
|
busy = false,
|
||||||
|
submitError = null,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}: AgentCreateModalProps) => {
|
||||||
|
if (!open) return null;
|
||||||
|
return (
|
||||||
|
<AgentCreateModalContent
|
||||||
|
suggestedName={suggestedName}
|
||||||
|
busy={busy}
|
||||||
|
submitError={submitError}
|
||||||
|
onClose={onClose}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Brain,
|
||||||
|
Database,
|
||||||
|
FileText,
|
||||||
|
HeartPulse,
|
||||||
|
Palette,
|
||||||
|
Shield,
|
||||||
|
UserRound,
|
||||||
|
Wrench,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { AgentState } from "@/features/agents/state/store";
|
||||||
|
import { AgentAvatarEditorPanel } from "@/features/agents/components/AgentAvatarEditorPanel";
|
||||||
|
import { AgentBrainPanel } from "@/features/agents/components/inspect/AgentBrainPanel";
|
||||||
|
import type { AgentAvatarProfile } from "@/lib/avatars/profile";
|
||||||
|
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||||
|
import type { AgentFileName } from "@/lib/agents/agentFiles";
|
||||||
|
import { AGENT_FILE_META } from "@/lib/agents/agentFiles";
|
||||||
|
import { renameGatewayAgent } from "@/lib/gateway/agentConfig";
|
||||||
|
|
||||||
|
export type AgentEditorSection = "avatar" | AgentFileName;
|
||||||
|
|
||||||
|
type AgentEditorModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
client: GatewayClient | null;
|
||||||
|
agents: AgentState[];
|
||||||
|
agent: AgentState;
|
||||||
|
initialSection?: AgentEditorSection;
|
||||||
|
onClose: () => void;
|
||||||
|
onAvatarSave: (agentId: string, profile: AgentAvatarProfile) => Promise<void> | void;
|
||||||
|
onRename?: (agentId: string, name: string) => Promise<boolean>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const menuButtonClassName =
|
||||||
|
"flex w-full items-center gap-3 rounded-xl border px-3 py-3 text-left transition-colors";
|
||||||
|
|
||||||
|
const editorSections: Array<{
|
||||||
|
id: AgentEditorSection;
|
||||||
|
label: string;
|
||||||
|
hint: string;
|
||||||
|
icon: typeof Palette;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
id: "IDENTITY.md",
|
||||||
|
label: "Identity",
|
||||||
|
hint: AGENT_FILE_META["IDENTITY.md"].hint,
|
||||||
|
icon: FileText,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "avatar",
|
||||||
|
label: "Avatar",
|
||||||
|
hint: "Office appearance.",
|
||||||
|
icon: Palette,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "SOUL.md",
|
||||||
|
label: "Soul",
|
||||||
|
hint: AGENT_FILE_META["SOUL.md"].hint,
|
||||||
|
icon: Brain,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "AGENTS.md",
|
||||||
|
label: "Agents",
|
||||||
|
hint: AGENT_FILE_META["AGENTS.md"].hint,
|
||||||
|
icon: Shield,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "USER.md",
|
||||||
|
label: "User",
|
||||||
|
hint: AGENT_FILE_META["USER.md"].hint,
|
||||||
|
icon: UserRound,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "TOOLS.md",
|
||||||
|
label: "Tools",
|
||||||
|
hint: AGENT_FILE_META["TOOLS.md"].hint,
|
||||||
|
icon: Wrench,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "MEMORY.md",
|
||||||
|
label: "Memory",
|
||||||
|
hint: AGENT_FILE_META["MEMORY.md"].hint,
|
||||||
|
icon: Database,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "HEARTBEAT.md",
|
||||||
|
label: "Heartbeat",
|
||||||
|
hint: AGENT_FILE_META["HEARTBEAT.md"].hint,
|
||||||
|
icon: HeartPulse,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const AgentEditorModal = ({
|
||||||
|
open,
|
||||||
|
client,
|
||||||
|
agents,
|
||||||
|
agent,
|
||||||
|
initialSection = "avatar",
|
||||||
|
onClose,
|
||||||
|
onAvatarSave,
|
||||||
|
onRename,
|
||||||
|
}: AgentEditorModalProps) => {
|
||||||
|
const [activeSection, setActiveSection] = useState<AgentEditorSection>(initialSection);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setActiveSection(initialSection);
|
||||||
|
}, [initialSection, open, agent.agentId]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[145] flex items-center justify-center bg-background/88 p-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={`Edit ${agent.name}`}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative w-full max-w-7xl"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute -right-3 -top-3 z-20 inline-flex h-10 w-10 items-center justify-center rounded-full border border-border/50 bg-background/92 text-muted-foreground shadow-lg transition-colors hover:text-foreground"
|
||||||
|
aria-label="Close agent editor"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<div className="ui-panel flex h-[min(90vh,920px)] w-full overflow-hidden shadow-xs">
|
||||||
|
<aside className="flex w-[240px] shrink-0 flex-col border-r border-border/50 bg-muted/20">
|
||||||
|
<div className="border-b border-border/40 px-5 py-4">
|
||||||
|
<div className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||||
|
Agent editor
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 truncate text-lg font-semibold text-foreground">
|
||||||
|
{agent.name}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Edit avatar and agent brain settings from the office.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 space-y-2 overflow-y-auto p-3">
|
||||||
|
{editorSections.map((section) => {
|
||||||
|
const Icon = section.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={section.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveSection(section.id)}
|
||||||
|
className={`${menuButtonClassName} ${
|
||||||
|
activeSection === section.id
|
||||||
|
? "border-primary/40 bg-primary/10 text-foreground"
|
||||||
|
: "border-border/45 bg-background/40 text-muted-foreground hover:border-border hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium">{section.label}</div>
|
||||||
|
<div className="text-xs opacity-75">{section.hint}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="flex min-w-0 flex-1 flex-col">
|
||||||
|
{activeSection === "avatar" ? (
|
||||||
|
<AgentAvatarEditorPanel
|
||||||
|
agentId={agent.agentId}
|
||||||
|
agentName={agent.name}
|
||||||
|
initialProfile={agent.avatarProfile}
|
||||||
|
onCancel={onClose}
|
||||||
|
onSave={(profile) => onAvatarSave(agent.agentId, profile)}
|
||||||
|
/>
|
||||||
|
) : client ? (
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
|
<div className="border-b border-border/40 px-6 py-4">
|
||||||
|
<div className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||||
|
Agent file editor
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Edit one agent file at a time and save it through the gateway.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1">
|
||||||
|
<AgentBrainPanel
|
||||||
|
client={client}
|
||||||
|
agents={agents}
|
||||||
|
selectedAgentId={agent.agentId}
|
||||||
|
activeSection={activeSection}
|
||||||
|
onCancel={onClose}
|
||||||
|
onRename={
|
||||||
|
onRename ??
|
||||||
|
(async (agentId, name) => {
|
||||||
|
if (!client) return false;
|
||||||
|
try {
|
||||||
|
await renameGatewayAgent({ client, agentId, name });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center p-8 text-sm text-muted-foreground">
|
||||||
|
Connect to a gateway to edit brain files.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
export { AgentBrainPanel, type AgentBrainPanelProps } from "@/features/agents/components/inspect/AgentBrainPanel";
|
||||||
|
export {
|
||||||
|
AgentSettingsPanel,
|
||||||
|
type AgentSettingsPanelProps,
|
||||||
|
} from "@/features/agents/components/inspect/AgentSettingsPanel";
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import type { SkillStatusReport } from "@/lib/skills/types";
|
||||||
|
import {
|
||||||
|
buildAgentSkillsAllowlistSet,
|
||||||
|
buildSkillMissingDetails,
|
||||||
|
deriveAgentSkillDisplayState,
|
||||||
|
deriveAgentSkillsAccessMode,
|
||||||
|
deriveSkillReadinessState,
|
||||||
|
type AgentSkillDisplayState,
|
||||||
|
} from "@/lib/skills/presentation";
|
||||||
|
|
||||||
|
type SkillRowFilter = "all" | AgentSkillDisplayState;
|
||||||
|
|
||||||
|
type AgentSkillsPanelProps = {
|
||||||
|
skillsReport?: SkillStatusReport | null;
|
||||||
|
skillsLoading?: boolean;
|
||||||
|
skillsError?: string | null;
|
||||||
|
skillsBusy?: boolean;
|
||||||
|
skillsBusyKey?: string | null;
|
||||||
|
skillsAllowlist?: string[] | undefined;
|
||||||
|
onSetSkillEnabled: (skillName: string, enabled: boolean) => Promise<void> | void;
|
||||||
|
onOpenSystemSetup: (skillKey?: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FILTERS: Array<{ id: SkillRowFilter; label: string }> = [
|
||||||
|
{ id: "all", label: "All" },
|
||||||
|
{ id: "ready", label: "Ready" },
|
||||||
|
{ id: "setup-required", label: "Setup required" },
|
||||||
|
{ id: "not-supported", label: "Not supported" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DISPLAY_LABELS: Record<AgentSkillDisplayState, string> = {
|
||||||
|
ready: "Ready",
|
||||||
|
"setup-required": "Setup required",
|
||||||
|
"not-supported": "Not supported",
|
||||||
|
};
|
||||||
|
|
||||||
|
const DISPLAY_CLASSES: Record<AgentSkillDisplayState, string> = {
|
||||||
|
ready: "ui-badge-status-running",
|
||||||
|
"setup-required": "ui-badge-status-error",
|
||||||
|
"not-supported": "ui-badge-status-error",
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveHint = (
|
||||||
|
skill: SkillStatusReport["skills"][number],
|
||||||
|
displayState: AgentSkillDisplayState
|
||||||
|
): string | null => {
|
||||||
|
if (displayState === "ready") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (displayState === "not-supported") {
|
||||||
|
if (skill.blockedByAllowlist) {
|
||||||
|
return "Blocked by bundled skills policy.";
|
||||||
|
}
|
||||||
|
return buildSkillMissingDetails(skill).find((line) => line.startsWith("Requires OS:")) ?? "Not supported.";
|
||||||
|
}
|
||||||
|
const readiness = deriveSkillReadinessState(skill);
|
||||||
|
if (readiness === "disabled-globally") {
|
||||||
|
return "Disabled globally. Enable it in System setup.";
|
||||||
|
}
|
||||||
|
return buildSkillMissingDetails(skill)[0] ?? "Requires setup in System setup.";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AgentSkillsPanel = ({
|
||||||
|
skillsReport = null,
|
||||||
|
skillsLoading = false,
|
||||||
|
skillsError = null,
|
||||||
|
skillsBusy = false,
|
||||||
|
skillsBusyKey = null,
|
||||||
|
skillsAllowlist,
|
||||||
|
onSetSkillEnabled,
|
||||||
|
onOpenSystemSetup,
|
||||||
|
}: AgentSkillsPanelProps) => {
|
||||||
|
const [skillsFilter, setSkillsFilter] = useState("");
|
||||||
|
const [rowFilter, setRowFilter] = useState<SkillRowFilter>("all");
|
||||||
|
|
||||||
|
const skillEntries = useMemo(() => skillsReport?.skills ?? [], [skillsReport]);
|
||||||
|
const accessMode = deriveAgentSkillsAccessMode(skillsAllowlist);
|
||||||
|
const allowlistSet = useMemo(() => buildAgentSkillsAllowlistSet(skillsAllowlist), [skillsAllowlist]);
|
||||||
|
const anySkillBusy = skillsBusy || Boolean(skillsBusyKey);
|
||||||
|
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
return skillEntries.map((skill) => {
|
||||||
|
const normalizedName = skill.name.trim();
|
||||||
|
const allowed =
|
||||||
|
accessMode === "all" ? true : accessMode === "none" ? false : allowlistSet.has(normalizedName);
|
||||||
|
const readiness = deriveSkillReadinessState(skill);
|
||||||
|
return {
|
||||||
|
skill,
|
||||||
|
allowed,
|
||||||
|
displayState: deriveAgentSkillDisplayState(readiness),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [accessMode, allowlistSet, skillEntries]);
|
||||||
|
|
||||||
|
const searchedRows = useMemo(() => {
|
||||||
|
const query = skillsFilter.trim().toLowerCase();
|
||||||
|
if (!query) {
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
return rows.filter((entry) =>
|
||||||
|
[entry.skill.name, entry.skill.description, entry.skill.source, entry.skill.skillKey]
|
||||||
|
.join(" ")
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(query)
|
||||||
|
);
|
||||||
|
}, [rows, skillsFilter]);
|
||||||
|
|
||||||
|
const filteredRows = useMemo(() => {
|
||||||
|
if (rowFilter === "all") {
|
||||||
|
return searchedRows;
|
||||||
|
}
|
||||||
|
return searchedRows.filter((entry) => entry.displayState === rowFilter);
|
||||||
|
}, [rowFilter, searchedRows]);
|
||||||
|
|
||||||
|
const filterCounts = useMemo(
|
||||||
|
() =>
|
||||||
|
searchedRows.reduce(
|
||||||
|
(counts, entry) => {
|
||||||
|
counts.all += 1;
|
||||||
|
counts[entry.displayState] += 1;
|
||||||
|
return counts;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
all: 0,
|
||||||
|
ready: 0,
|
||||||
|
"setup-required": 0,
|
||||||
|
"not-supported": 0,
|
||||||
|
} satisfies Record<SkillRowFilter, number>
|
||||||
|
),
|
||||||
|
[searchedRows]
|
||||||
|
);
|
||||||
|
|
||||||
|
const enabledCount = useMemo(
|
||||||
|
() => rows.reduce((count, entry) => count + (entry.allowed ? 1 : 0), 0),
|
||||||
|
[rows]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="sidebar-section" data-testid="agent-settings-skills">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<h3 className="sidebar-section-title">Skills</h3>
|
||||||
|
<div className="font-mono text-[10px] text-muted-foreground">
|
||||||
|
{enabledCount}/{skillEntries.length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-[11px] text-muted-foreground">Skill access controls apply to this agent.</div>
|
||||||
|
{accessMode === "selected" ? (
|
||||||
|
<div className="mt-2 text-[10px] text-muted-foreground/80">
|
||||||
|
This agent is using selected skills only.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="mt-3">
|
||||||
|
<input
|
||||||
|
value={skillsFilter}
|
||||||
|
onChange={(event) => setSkillsFilter(event.target.value)}
|
||||||
|
placeholder="Search skills"
|
||||||
|
className="w-full rounded-md border border-border/60 bg-surface-1 px-3 py-2 text-[11px] text-foreground outline-none transition focus:border-border"
|
||||||
|
aria-label="Search skills"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1">
|
||||||
|
{FILTERS.map((filter) => {
|
||||||
|
const selected = rowFilter === filter.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={filter.id}
|
||||||
|
type="button"
|
||||||
|
className="ui-btn-secondary px-2 py-1 text-[9px] font-semibold disabled:cursor-not-allowed disabled:opacity-65"
|
||||||
|
data-active={selected ? "true" : "false"}
|
||||||
|
disabled={skillsLoading}
|
||||||
|
onClick={() => {
|
||||||
|
setRowFilter(filter.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filter.label} ({filterCounts[filter.id]})
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{skillsLoading ? <div className="mt-3 text-[11px] text-muted-foreground">Loading skills...</div> : null}
|
||||||
|
{!skillsLoading && skillsError ? (
|
||||||
|
<div className="ui-alert-danger mt-3 rounded-md px-3 py-2 text-xs">{skillsError}</div>
|
||||||
|
) : null}
|
||||||
|
{!skillsLoading && !skillsError && filteredRows.length === 0 ? (
|
||||||
|
<div className="mt-3 text-[11px] text-muted-foreground">No matching skills.</div>
|
||||||
|
) : null}
|
||||||
|
{!skillsLoading && !skillsError && filteredRows.length > 0 ? (
|
||||||
|
<div className="mt-3 flex flex-col gap-2">
|
||||||
|
{filteredRows.map((entry) => {
|
||||||
|
const statusLabel = DISPLAY_LABELS[entry.displayState];
|
||||||
|
const statusClassName = DISPLAY_CLASSES[entry.displayState];
|
||||||
|
const canConfigureInSystem = entry.displayState === "setup-required";
|
||||||
|
const switchDisabled = anySkillBusy || entry.displayState === "not-supported";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${entry.skill.source}:${entry.skill.skillKey}`}
|
||||||
|
className="ui-settings-row flex min-h-[68px] flex-col gap-3 px-4 py-3 sm:flex-row sm:items-start sm:justify-between"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="truncate text-[11px] font-medium text-foreground/88">{entry.skill.name}</span>
|
||||||
|
<span className="rounded bg-surface-2 px-1.5 py-0.5 font-mono text-[9px] text-muted-foreground">
|
||||||
|
{entry.skill.source}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`rounded border px-1.5 py-0.5 font-mono text-[10px] font-semibold ${statusClassName}`}
|
||||||
|
>
|
||||||
|
{statusLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[10px] text-muted-foreground/70">{entry.skill.description}</div>
|
||||||
|
{entry.displayState !== "ready" ? (
|
||||||
|
<div className="mt-1 text-[10px] text-muted-foreground/80">
|
||||||
|
{resolveHint(entry.skill, entry.displayState)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full items-center justify-between gap-2 sm:w-[240px] sm:justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-label={`Skill ${entry.skill.name}`}
|
||||||
|
aria-checked={entry.allowed}
|
||||||
|
className={`ui-switch self-start ${entry.allowed ? "ui-switch--on" : ""}`}
|
||||||
|
disabled={switchDisabled}
|
||||||
|
onClick={() => {
|
||||||
|
void onSetSkillEnabled(entry.skill.name, !entry.allowed);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="ui-switch-thumb" />
|
||||||
|
</button>
|
||||||
|
{canConfigureInSystem ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ui-btn-secondary px-2 py-1 text-[9px] font-semibold"
|
||||||
|
onClick={() => {
|
||||||
|
onOpenSystemSetup(entry.skill.skillKey);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Open System Setup
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
import type { SkillStatusEntry } from "@/lib/skills/types";
|
||||||
|
import {
|
||||||
|
buildSkillMissingDetails,
|
||||||
|
canRemoveSkill,
|
||||||
|
deriveSkillReadinessState,
|
||||||
|
resolvePreferredInstallOption,
|
||||||
|
} from "@/lib/skills/presentation";
|
||||||
|
|
||||||
|
type SkillSetupMessage = { kind: "success" | "error"; message: string };
|
||||||
|
|
||||||
|
type AgentSkillsSetupModalProps = {
|
||||||
|
skill: SkillStatusEntry | null;
|
||||||
|
skillsBusy: boolean;
|
||||||
|
skillsBusyKey: string | null;
|
||||||
|
skillMessage: SkillSetupMessage | null;
|
||||||
|
apiKeyDraft: string;
|
||||||
|
defaultAgentScopeWarning?: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onInstallSkill: (skillKey: string, name: string, installId: string) => Promise<void> | void;
|
||||||
|
onSetSkillGlobalEnabled: (skillKey: string, enabled: boolean) => Promise<void> | void;
|
||||||
|
onRemoveSkill: (
|
||||||
|
skill: { skillKey: string; source: string; baseDir: string }
|
||||||
|
) => Promise<void> | void;
|
||||||
|
onSkillApiKeyChange: (skillKey: string, value: string) => Promise<void> | void;
|
||||||
|
onSaveSkillApiKey: (skillKey: string) => Promise<void> | void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const READINESS_LABELS = {
|
||||||
|
ready: "Ready",
|
||||||
|
"needs-setup": "Needs setup",
|
||||||
|
unavailable: "Unavailable",
|
||||||
|
"disabled-globally": "Disabled globally",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const READINESS_CLASSES = {
|
||||||
|
ready: "ui-badge-status-running",
|
||||||
|
"needs-setup": "ui-badge-status-error",
|
||||||
|
unavailable: "ui-badge-status-error",
|
||||||
|
"disabled-globally": "ui-badge-status-error",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const AgentSkillsSetupModal = ({
|
||||||
|
skill,
|
||||||
|
skillsBusy,
|
||||||
|
skillsBusyKey,
|
||||||
|
skillMessage,
|
||||||
|
apiKeyDraft,
|
||||||
|
defaultAgentScopeWarning = null,
|
||||||
|
onClose,
|
||||||
|
onInstallSkill,
|
||||||
|
onSetSkillGlobalEnabled,
|
||||||
|
onRemoveSkill,
|
||||||
|
onSkillApiKeyChange,
|
||||||
|
onSaveSkillApiKey,
|
||||||
|
}: AgentSkillsSetupModalProps) => {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!skill) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key !== "Escape") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [onClose, skill]);
|
||||||
|
|
||||||
|
if (!skill) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const readiness = deriveSkillReadinessState(skill);
|
||||||
|
const readinessLabel = READINESS_LABELS[readiness];
|
||||||
|
const readinessClassName = READINESS_CLASSES[readiness];
|
||||||
|
const missingDetails = buildSkillMissingDetails(skill);
|
||||||
|
const installOption = resolvePreferredInstallOption(skill);
|
||||||
|
const canDeleteSkill = canRemoveSkill(skill);
|
||||||
|
const busyForSkill = skillsBusyKey === skill.skillKey;
|
||||||
|
const anySkillBusy = skillsBusy || Boolean(skillsBusyKey);
|
||||||
|
const trimmedApiKey = apiKeyDraft.trim();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/80 p-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={`Setup ${skill.name}`}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="ui-panel w-full max-w-2xl bg-card shadow-xs"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3 px-6 py-5">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-[11px] font-medium tracking-[0.01em] text-muted-foreground/80">
|
||||||
|
System setup
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-base font-semibold text-foreground">{skill.name}</span>
|
||||||
|
<span
|
||||||
|
className={`rounded border px-1.5 py-0.5 font-mono text-[10px] font-semibold ${readinessClassName}`}
|
||||||
|
>
|
||||||
|
{readinessLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-[10px] text-muted-foreground/80">
|
||||||
|
Changes affect all agents on this gateway.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sidebar-btn-ghost px-3 font-mono text-[10px] font-semibold tracking-[0.06em]"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3 px-6 pb-3 text-[11px] text-muted-foreground">
|
||||||
|
{defaultAgentScopeWarning ? (
|
||||||
|
<div className="rounded-md border border-border/60 bg-surface-1/65 px-3 py-2 text-[10px] text-muted-foreground/80">
|
||||||
|
{defaultAgentScopeWarning}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div>{skill.description}</div>
|
||||||
|
{skill.blockedByAllowlist ? (
|
||||||
|
<div className="text-[10px] text-muted-foreground/80">
|
||||||
|
Blocked by bundled skills policy (`skills.allowBundled`).
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{missingDetails.map((line) => (
|
||||||
|
<div key={`${skill.skillKey}:${line}`} className="text-[10px] text-muted-foreground/80">
|
||||||
|
{line}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{skillMessage ? (
|
||||||
|
<div
|
||||||
|
className={`text-[10px] ${skillMessage.kind === "error" ? "ui-text-danger" : "ui-text-success"}`}
|
||||||
|
>
|
||||||
|
{skillMessage.message}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="space-y-2 rounded-md border border-border/60 bg-surface-1/65 px-3 py-3">
|
||||||
|
{installOption ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ui-btn-secondary w-full px-3 py-2 text-[10px] font-medium disabled:cursor-not-allowed disabled:opacity-65"
|
||||||
|
disabled={anySkillBusy}
|
||||||
|
onClick={() => {
|
||||||
|
void onInstallSkill(skill.skillKey, skill.name, installOption.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{busyForSkill ? "Working..." : installOption.label}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ui-btn-secondary w-full px-3 py-2 text-[10px] font-medium disabled:cursor-not-allowed disabled:opacity-65"
|
||||||
|
disabled={anySkillBusy}
|
||||||
|
onClick={() => {
|
||||||
|
void onSetSkillGlobalEnabled(skill.skillKey, skill.disabled);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{busyForSkill
|
||||||
|
? "Working..."
|
||||||
|
: skill.disabled
|
||||||
|
? "Enable globally"
|
||||||
|
: "Disable globally"}
|
||||||
|
</button>
|
||||||
|
{skill.primaryEnv ? (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={apiKeyDraft}
|
||||||
|
onChange={(event) => {
|
||||||
|
void onSkillApiKeyChange(skill.skillKey, event.target.value);
|
||||||
|
}}
|
||||||
|
disabled={anySkillBusy}
|
||||||
|
className="w-full rounded-md border border-border/60 bg-surface-1 px-3 py-2 text-[10px] text-foreground outline-none transition focus:border-border"
|
||||||
|
placeholder={`Set ${skill.primaryEnv}`}
|
||||||
|
aria-label={`API key for ${skill.name}`}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ui-btn-secondary w-full px-3 py-2 text-[10px] font-medium disabled:cursor-not-allowed disabled:opacity-65"
|
||||||
|
disabled={anySkillBusy || trimmedApiKey.length === 0}
|
||||||
|
onClick={() => {
|
||||||
|
if (trimmedApiKey.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void onSaveSkillApiKey(skill.skillKey);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{busyForSkill ? "Working..." : `Save ${skill.primaryEnv}`}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{canDeleteSkill ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ui-btn-secondary ui-btn-danger w-full px-3 py-2 text-[10px] font-medium disabled:cursor-not-allowed disabled:opacity-65"
|
||||||
|
disabled={anySkillBusy}
|
||||||
|
onClick={() => {
|
||||||
|
const approved = window.confirm(
|
||||||
|
`Remove ${skill.name} from the gateway? This affects all agents.`
|
||||||
|
);
|
||||||
|
if (!approved) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void onRemoveSkill({
|
||||||
|
skillKey: skill.skillKey,
|
||||||
|
source: skill.source,
|
||||||
|
baseDir: skill.baseDir,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove skill from gateway
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user