mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6536757428 | ||
|
|
a0dc4540ca | ||
|
|
7e4df5c3e9 | ||
|
|
4d939e5722 | ||
|
|
2e9aaf4993 | ||
|
|
34154ee3f5 | ||
|
|
29cc74a170 | ||
|
|
d2b66d9d2c | ||
|
|
d70e7f7f05 | ||
|
|
72a2093cd6 |
10
.gitignore
vendored
10
.gitignore
vendored
@@ -13,7 +13,6 @@ src/utils/vendor/
|
||||
# AI tool runtime directories
|
||||
.agents/
|
||||
.claude/
|
||||
.codex/
|
||||
.omx/
|
||||
.docs/task/
|
||||
# Binary / screenshot files (root only)
|
||||
@@ -30,3 +29,12 @@ __pycache__/
|
||||
logs
|
||||
|
||||
data
|
||||
.omc
|
||||
.codex/*
|
||||
!.codex/agents/
|
||||
!.codex/agents/**
|
||||
!.codex/skills/
|
||||
!.codex/skills/**
|
||||
.codex/skills/.system/**
|
||||
!.codex/prompts/
|
||||
!.codex/prompts/**
|
||||
|
||||
78
.impeccable.md
Normal file
78
.impeccable.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# Impeccable Design Context
|
||||
|
||||
## Users
|
||||
|
||||
**Primary**: Technical teams and enterprises using AI-assisted coding in production workflows.
|
||||
- DevOps engineers managing remote agents via RCS dashboard
|
||||
- Development teams collaborating through shared sessions
|
||||
- Individual developers using terminal CLI daily
|
||||
|
||||
**Context**: Used during focused work sessions — debugging, code review, agent orchestration. Users are in "get things done" mode, not browsing. They value efficiency but also appreciate warmth and personality.
|
||||
|
||||
**Job to be done**: Make advanced AI coding tools accessible and controllable, especially features that normally require enterprise accounts or Anthropic OAuth.
|
||||
|
||||
## Brand Personality
|
||||
|
||||
**3 words**: Warm, Considered, Human
|
||||
|
||||
**Voice**: Like a knowledgeable colleague who's genuinely enthusiastic about the craft — not a corporate product manager. Community-first, open, slightly playful. Chinese developer community culture (贴吧/discord 温暖氛围).
|
||||
|
||||
**Emotional goals**: Confidence (this tool is solid), Warmth (this community is welcoming), Delight (small moments of personality make the difference).
|
||||
|
||||
**References**:
|
||||
- **Anthropic's own design language** — their clean, considered aesthetic with warm undertones. The terra cotta/burnt orange as a human accent. Lots of breathing room. Typography-forward.
|
||||
- **NOT**: Generic AI product (no ChatGPT blue, no gradient text, no "AI slop"). NOT corporate SaaS (no Salesforce-blue dashboards, no enterprise sterility).
|
||||
|
||||
**Anti-references**: Corporate enterprise dashboards, generic AI product pages, anything that looks like it was "designed by committee."
|
||||
|
||||
## Aesthetic Direction
|
||||
|
||||
**Theme**: Light + Dark dual mode (user/system preference switch)
|
||||
|
||||
**Tone**: Anthropomorphic warmth meets terminal precision. The brand orange (Claude's terra cotta) is the thread that ties everything together — it's the human element in a technical world.
|
||||
|
||||
**Typography**: Clean, considered, with good hierarchy. Terminal-native for CLI; modern web fonts for Web UI (RCS dashboard, docs). Favor readability and personality.
|
||||
|
||||
**Color**:
|
||||
- Primary: Claude orange family (`#D77757` / terra cotta)
|
||||
- Accent: Warm neutrals tinted toward orange
|
||||
- Semantic: Success/Error/Warning following Anthropic's established palette
|
||||
- Dark mode: Warm dark surfaces (not cold blue-black)
|
||||
|
||||
**Differentiation**: The CCB brand sits at the intersection of "serious tool" and "community project." It should feel like Anthropic's design principles applied to an open-source context — less corporate polish, more human craft. The mascot "Clawd" and the playful "踩踩背" naming hint at personality that the design should honor.
|
||||
|
||||
**Scope**: All Web UI — RCS control panel, documentation site, landing pages.
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. **Considered over clever** — Every design choice should feel intentional, not trendy. If it doesn't serve the user, it doesn't ship.
|
||||
2. **Warmth through subtlety** — Orange tints on neutrals, breathing room in layouts, personality in copy. Not giant emoji or aggressive color.
|
||||
3. **Density with clarity** — Technical users need information density, but not chaos. Every pixel earns its place.
|
||||
4. **Community voice** — The design should feel like it was made by people who use it, not by a distant design team. Slightly rough edges are fine if they're honest.
|
||||
5. **Anthropic's shadow** — When in doubt, follow Anthropic's design instincts — the clean layouts, the generous spacing, the warm color temperature. Then add the community touch.
|
||||
|
||||
## Existing Design Assets
|
||||
|
||||
### Brand Colors (from theme system)
|
||||
- Claude Orange: `rgb(215,119,87)` / `#D77757`
|
||||
- Claude Blue: `rgb(87,105,247)` / `#5769F7`
|
||||
- Permission Blue: `rgb(87,105,247)`
|
||||
- Auto Accept Violet: `rgb(135,0,255)`
|
||||
- Plan Mode Teal: `rgb(0,102,102)`
|
||||
- Success: `rgb(78,186,101)`
|
||||
- Error: `rgb(255,107,128)`
|
||||
- Warning: `rgb(255,193,7)`
|
||||
|
||||
### Logo
|
||||
- CCB text + orange play button icon
|
||||
- Dark/Light SVG variants in `docs/logo/`
|
||||
- Favicon: Orange circle `#D97706` with white play triangle
|
||||
|
||||
### Mascot
|
||||
- "Clawd" — terminal-art character with multiple poses
|
||||
- Theme-aware coloring
|
||||
|
||||
### Theme System
|
||||
- 7 variants: dark, light, dark-ansi, light-ansi, dark-daltonized, light-daltonized, auto
|
||||
- 89+ semantic color tokens
|
||||
- Full documentation in `packages/@ant/ink/docs/04-theme-system.md`
|
||||
102
CLAUDE.md
102
CLAUDE.md
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project Overview
|
||||
|
||||
This is a **reverse-engineered / decompiled** version of Anthropic's official Claude Code CLI tool. The goal is to restore core functionality while trimming secondary capabilities. Many modules are stubbed or feature-flagged off. TypeScript strict mode is enforced — **`bunx tsc --noEmit` must pass with zero errors**.
|
||||
This is a **reverse-engineered / decompiled** version of Anthropic's official Claude Code CLI tool. The goal is to restore core functionality while trimming secondary capabilities. Many modules are stubbed or feature-flagged off. TypeScript strict mode is enforced(见 Working with This Codebase 段的 tsc 要求)。
|
||||
|
||||
## Git Commit Message Convention
|
||||
|
||||
@@ -39,8 +39,11 @@ echo "say hello" | bun run src/entrypoints/cli.tsx -p
|
||||
# Build (code splitting, outputs dist/cli.js + chunk files)
|
||||
bun run build
|
||||
|
||||
# Build with Vite (alternative build pipeline)
|
||||
bun run build:vite
|
||||
|
||||
# Test
|
||||
bun test # run all tests (2453 tests / 137 files / 0 fail)
|
||||
bun test # run all tests (3175 tests / 207 files / 0 fail)
|
||||
bun test src/utils/__tests__/hash.test.ts # run single file
|
||||
bun test --coverage # with coverage report
|
||||
|
||||
@@ -74,14 +77,14 @@ bun run docs:dev
|
||||
- **Build**: `build.ts` 执行 `Bun.build()` with `splitting: true`,入口 `src/entrypoints/cli.tsx`,输出 `dist/cli.js` + chunk files。Build 默认启用 19 个 feature(见下方 Feature Flag 段)。构建后自动替换 `import.meta.require` 为 Node.js 兼容版本(产物 bun/node 都可运行)。
|
||||
- **Dev mode**: `scripts/dev.ts` 通过 Bun `-d` flag 注入 `MACRO.*` defines,运行 `src/entrypoints/cli.tsx`。默认启用全部 feature。
|
||||
- **Module system**: ESM (`"type": "module"`), TSX with `react-jsx` transform.
|
||||
- **Monorepo**: Bun workspaces — 14 个 internal packages in `packages/` resolved via `workspace:*`。
|
||||
- **Monorepo**: Bun workspaces — 15 个 workspace packages + 若干辅助目录 in `packages/` resolved via `workspace:*`。
|
||||
- **Lint/Format**: Biome (`biome.json`)。`bun run lint` / `bun run lint:fix` / `bun run format`。
|
||||
- **Defines**: 集中管理在 `scripts/defines.ts`。当前版本 `2.1.888`。
|
||||
- **CI**: GitHub Actions — `ci.yml`(构建+测试)、`release-rcs.yml`(RCS 发布)、`update-contributors.yml`(自动更新贡献者)。
|
||||
|
||||
### Entry & Bootstrap
|
||||
|
||||
1. **`src/entrypoints/cli.tsx`** (323 行) — True entrypoint。`main()` 函数按优先级处理多条快速路径:
|
||||
1. **`src/entrypoints/cli.tsx`** (373 行) — True entrypoint。`main()` 函数按优先级处理多条快速路径:
|
||||
- `--version` / `-v` — 零模块加载
|
||||
- `--dump-system-prompt` — feature-gated (DUMP_SYSTEM_PROMPT)
|
||||
- `--claude-in-chrome-mcp` / `--chrome-native-host`
|
||||
@@ -94,7 +97,7 @@ bun run docs:dev
|
||||
- `environment-runner` / `self-hosted-runner` — BYOC runner
|
||||
- `--tmux` + `--worktree` 组合
|
||||
- 默认路径:加载 `main.tsx` 启动完整 CLI
|
||||
2. **`src/main.tsx`** (~6970 行) — Commander.js CLI definition。注册大量 subcommands:`mcp` (serve/add/remove/list...)、`server`、`ssh`、`open`、`auth`、`plugin`、`agents`、`auto-mode`、`doctor`、`update` 等。主 `.action()` 处理器负责权限、MCP、会话恢复、REPL/Headless 模式分发。
|
||||
2. **`src/main.tsx`** (~6981 行) — Commander.js CLI definition。注册大量 subcommands:`mcp` (serve/add/remove/list...)、`server`、`ssh`、`open`、`auth`、`plugin`、`agents`、`auto-mode`、`doctor`、`update` 等。主 `.action()` 处理器负责权限、MCP、会话恢复、REPL/Headless 模式分发。
|
||||
3. **`src/entrypoints/init.ts`** — One-time initialization (telemetry, config, trust dialog)。
|
||||
|
||||
### Core Loop
|
||||
@@ -112,8 +115,8 @@ bun run docs:dev
|
||||
### Tool System
|
||||
|
||||
- **`src/Tool.ts`** — Tool interface definition (`Tool` type) and utilities (`findToolByName`, `toolMatchesName`).
|
||||
- **`src/tools.ts`** (387 行) — Tool registry. Assembles the tool list; some tools are conditionally loaded via `feature()` flags or `process.env.USER_TYPE`.
|
||||
- **`src/tools/<ToolName>/`** — 55 个 tool 目录。主要分类:
|
||||
- **`src/tools.ts`** (392 行) — Tool registry. Assembles the tool list; tools are imported from `@claude-code-best/builtin-tools` package. Some tools are conditionally loaded via `feature()` flags or `process.env.USER_TYPE`.
|
||||
- **`packages/builtin-tools/src/tools/`** — 59 个子目录(含 shared/testing 等工具目录),通过 `@claude-code-best/builtin-tools` 包导出。主要分类:
|
||||
- **文件操作**: FileEditTool, FileReadTool, FileWriteTool, GlobTool, GrepTool
|
||||
- **Shell/执行**: BashTool, PowerShellTool, REPLTool
|
||||
- **Agent 系统**: AgentTool, TaskCreateTool, TaskUpdateTool, TaskListTool, TaskGetTool
|
||||
@@ -121,7 +124,6 @@ bun run docs:dev
|
||||
- **Web/MCP**: WebFetchTool, WebSearchTool, MCPTool, McpAuthTool
|
||||
- **调度**: CronCreateTool, CronDeleteTool, CronListTool
|
||||
- **其他**: LSPTool, ConfigTool, SkillTool, EnterWorktreeTool, ExitWorktreeTool 等
|
||||
- **`src/tools/shared/`** — Tool 共享工具函数。
|
||||
|
||||
### UI Layer (Ink)
|
||||
|
||||
@@ -152,9 +154,17 @@ bun run docs:dev
|
||||
| `packages/@ant/computer-use-input/` | 键鼠模拟(dispatcher + darwin/win32/linux backend) |
|
||||
| `packages/@ant/computer-use-swift/` | 截图 + 应用管理(dispatcher + per-platform backend) |
|
||||
| `packages/@ant/claude-for-chrome-mcp/` | Chrome 浏览器控制(通过 `--chrome` 启用) |
|
||||
| `packages/remote-control-server/` | 自托管 Remote Control Server(Docker 部署,含 Web UI) |
|
||||
| `packages/swarm/` | Swarm 解耦模块 |
|
||||
| `packages/shell/` | Shell 抽象 |
|
||||
| `packages/@ant/model-provider/` | Model provider 抽象层 |
|
||||
| `packages/builtin-tools/` | 内置工具集(60 个 tool 实现,通过 `@claude-code-best/builtin-tools` 导出) |
|
||||
| `packages/agent-tools/` | Agent 工具集 |
|
||||
| `packages/acp-link/` | ACP 代理服务器(WebSocket → ACP agent 桥接) |
|
||||
| `packages/cc-knowledge/` | Claude Code 知识库(非 workspace 包) |
|
||||
| `packages/langfuse-dashboard/` | Langfuse 可观测性面板(非 workspace 包) |
|
||||
| `packages/mcp-client/` | MCP 客户端库 |
|
||||
| `packages/mcp-server/` | MCP 服务端库(非 workspace 包) |
|
||||
| `packages/remote-control-server/` | 自托管 Remote Control Server(Docker 部署,含 Web UI)— Web UI 已重构为 React + Vite + Radix UI,支持 ACP agent 接入 |
|
||||
| `packages/swarm/` | Swarm 解耦模块(非 workspace 包) |
|
||||
| `packages/shell/` | Shell 抽象(非 workspace 包) |
|
||||
| `packages/audio-capture-napi/` | 原生音频捕获(已恢复) |
|
||||
| `packages/color-diff-napi/` | 颜色差异计算(完整实现,11 tests) |
|
||||
| `packages/image-processor-napi/` | 图像处理(已恢复) |
|
||||
@@ -163,11 +173,18 @@ bun run docs:dev
|
||||
|
||||
### Bridge / Remote Control
|
||||
|
||||
- **`src/bridge/`** (~37 files) — Remote Control / Bridge 模式。feature-gated by `BRIDGE_MODE`。包含 bridge API、会话管理、JWT 认证、消息传输、权限回调等。Entry: `bridgeMain.ts`。
|
||||
- **`packages/remote-control-server/`** — 自托管 RCS,支持 Docker 部署,含 Web UI 控制面板。通过 `bun run rcs` 启动。
|
||||
- **`src/bridge/`** (~38 files) — Remote Control / Bridge 模式。feature-gated by `BRIDGE_MODE`。包含 bridge API、会话管理、JWT 认证、消息传输、权限回调等。Entry: `bridgeMain.ts`。
|
||||
- **`packages/remote-control-server/`** — 自托管 RCS,支持 Docker 部署,含 Web UI 控制面板(React 19 + Vite + Radix UI)。支持 ACP agent 通过 acp-link 接入(ACP WebSocket handler、relay handler、SSE event stream)。通过 `bun run rcs` 启动。
|
||||
- CLI 快速路径: `claude remote-control` / `claude rc` / `claude bridge`。
|
||||
- 详见 `docs/features/remote-control-self-hosting.md`。
|
||||
|
||||
### ACP Protocol (Agent Client Protocol)
|
||||
|
||||
- **`src/services/acp/`** — ACP agent 实现,包含 `agent.ts`(AcpAgent 类)、`bridge.ts`(Claude Code ↔ ACP 桥接)、`permissions.ts`(权限处理)、`entry.ts`(入口)。
|
||||
- **`packages/acp-link/`** — ACP 代理服务器,将 WebSocket 客户端桥接到 ACP agent。提供 `acp-link` CLI 命令,支持自定义端口/HTTPS/认证/会话管理、RCS 集成(REST 注册 + WS identify 两步流程)、权限模式透传(fallback: 客户端传值 > config > `ACP_PERMISSION_MODE` 环境变量)。
|
||||
- ACP 权限管道改进:`createAcpCanUseTool` 统一权限流水线,`applySessionMode` 模式同步,`bypassPermissions` 可用性检测(非 root/sandbox 环境)。
|
||||
- ACP Plan 可视化已支持 `session/update plan` 类型的消息展示(PlanView 组件,含进度条/状态图标/优先级标签)。
|
||||
|
||||
### Daemon Mode
|
||||
|
||||
- **`src/daemon/`** — Daemon 模式(长驻 supervisor)。feature-gated by `DAEMON`。包含 `main.ts`(entry)和 `workerRegistry.ts`(worker 管理)。
|
||||
@@ -198,30 +215,13 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
|
||||
|
||||
### Multi-API 兼容层
|
||||
|
||||
所有兼容层均采用流适配器模式:将第三方 API 格式转为 Anthropic 内部格式,下游代码完全不改。
|
||||
支持 OpenAI、Gemini、Grok 三种第三方 API,通过 `/login` 命令配置,均采用流适配器模式转为 Anthropic 内部格式。详见各兼容层的 docs 文档。
|
||||
|
||||
#### OpenAI 兼容层
|
||||
### 穷鬼模式(Budget Mode)
|
||||
|
||||
通过 `CLAUDE_CODE_USE_OPENAI=1` 启用,支持 Ollama/DeepSeek/vLLM 等任意 OpenAI Chat Completions 协议端点。含 DeepSeek thinking mode 支持。
|
||||
|
||||
- **`src/services/api/openai/`** — client、消息/工具转换、流适配、模型映射
|
||||
- 关键环境变量:`CLAUDE_CODE_USE_OPENAI`、`OPENAI_API_KEY`、`OPENAI_BASE_URL`、`OPENAI_MODEL`
|
||||
|
||||
#### Gemini 兼容层
|
||||
|
||||
通过 `CLAUDE_CODE_USE_GEMINI=1` 启用。独立环境变量体系。
|
||||
|
||||
- **`src/services/api/gemini/`** — client、模型映射、类型定义
|
||||
- 关键环境变量:`GEMINI_API_KEY`(必填)、`GEMINI_MODEL`(直接指定)、`GEMINI_DEFAULT_SONNET_MODEL`/`GEMINI_DEFAULT_OPUS_MODEL`(按能力映射)
|
||||
- 模型映射优先级:`GEMINI_MODEL` > `GEMINI_DEFAULT_*_MODEL` > `ANTHROPIC_DEFAULT_*_MODEL`(已废弃) > 原样返回
|
||||
|
||||
#### Grok 兼容层
|
||||
|
||||
通过 `CLAUDE_CODE_USE_GROK=1` 启用。自定义模型映射支持 xAI Grok API。
|
||||
|
||||
- **`src/services/api/grok/`** — client、模型映射
|
||||
|
||||
详见各兼容层的 docs 文档。
|
||||
- 通过 `/poor` 命令切换,持久化到 `settings.json`。
|
||||
- 启用后跳过 `extract_memories`、`prompt_suggestion` 和 `verification_agent`,显著减少 token 消耗。
|
||||
- 实现在 `src/commands/poor/poorMode.ts`。
|
||||
|
||||
### Stubbed/Deleted Modules
|
||||
|
||||
@@ -247,7 +247,7 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
|
||||
## Testing
|
||||
|
||||
- **框架**: `bun:test`(内置断言 + mock)
|
||||
- **当前状态**: 2992 tests / 188 files / 0 fail
|
||||
- **当前状态**: 3175 tests / 207 files / 0 fail
|
||||
- **单元测试**: 就近放置于 `src/**/__tests__/`,文件名 `<module>.test.ts`
|
||||
- **集成测试**: `tests/integration/` — 4 个文件(cli-arguments, context-build, message-pipeline, tool-chain)
|
||||
- **共享 mock/fixture**: `tests/mocks/`(api-responses, file-system, fixtures/)
|
||||
@@ -269,7 +269,7 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
|
||||
项目使用 TypeScript strict 模式,**tsc 必须零错误**。每次修改后运行:
|
||||
|
||||
```bash
|
||||
bunx tsc --noEmit
|
||||
bun run typecheck # equivalent to bun run typecheck
|
||||
```
|
||||
|
||||
**类型规范**:
|
||||
@@ -282,7 +282,7 @@ bunx tsc --noEmit
|
||||
|
||||
## Working with This Codebase
|
||||
|
||||
- **tsc must pass** — `bunx tsc --noEmit` 必须零错误,任何修改都不能引入新的类型错误。
|
||||
- **tsc must pass** — `bun run typecheck` 必须零错误,任何修改都不能引入新的类型错误。
|
||||
- **Feature flags** — 默认全部关闭(`feature()` 返回 `false`)。Dev/build 各有自己的默认启用列表。不要在 `cli.tsx` 中重定义 `feature` 函数。
|
||||
- **React Compiler output** — Components have decompiled memoization boilerplate (`const $ = _c(N)`). This is normal.
|
||||
- **`bun:bundle` import** — `import { feature } from 'bun:bundle'` 是 Bun 内置模块,由运行时/构建器解析。不要用自定义函数替代它。**`feature()` 只能直接用在 `if` 语句或三元表达式的条件位置**(Bun 编译器限制),不能赋值给变量、不能放在箭头函数体里、不能作为 `&&` 链的一部分。正确:`if (feature('X')) {}` 或 `feature('X') ? a : b`。
|
||||
@@ -292,3 +292,29 @@ bunx tsc --noEmit
|
||||
- **Biome 配置** — 大量 lint 规则被关闭(decompiled 代码不适合严格 lint)。`.tsx` 文件用 120 行宽 + 强制分号;其他文件 80 行宽 + 按需分号。
|
||||
- **Ink 框架在 `packages/@ant/ink/`** — 不是 `src/ink/`(该目录不存在)。Ink 相关的组件、hooks、keybindings 都在 packages 中。
|
||||
- **Provider 优先级** — `modelType` 参数 > 环境变量 > 默认 `firstParty`。新增 provider 需在 `src/utils/model/providers.ts` 注册。
|
||||
|
||||
## Design Context
|
||||
|
||||
Impeccable 设计上下文保存在 `.impeccable.md` 中。设计 Web UI(RCS 控制面板、文档站、着陆页)时必须参考该文件。
|
||||
|
||||
### 核心设计原则
|
||||
|
||||
1. **Considered over clever** — 每个设计选择都应感觉有意为之,而非追逐潮流
|
||||
2. **Warmth through subtlety** — 通过橙色色调的中性色、留白布局、有温度的文案来传达温暖
|
||||
3. **Density with clarity** — 技术用户需要信息密度,但不能混乱
|
||||
4. **Community voice** — 设计应感觉是由使用者创造的,而非遥远的设计团队
|
||||
5. **Anthropic's shadow** — 遵循 Anthropic 的设计直觉:干净的布局、充足的间距、温暖的色温
|
||||
|
||||
### 品牌色
|
||||
|
||||
- 主色:Claude Orange `#D77757`(terra cotta)
|
||||
- 辅色:Claude Blue `#5769F7`
|
||||
- 暗色模式使用温暖的深色表面(非冷蓝黑色)
|
||||
|
||||
### 目标用户
|
||||
|
||||
技术团队/企业,在专业工作流中使用 AI 辅助编程。友好的开源社区氛围,非企业 SaaS 风格。
|
||||
|
||||
### 视觉参考
|
||||
|
||||
Anthropic 公司的设计风格 — 干净、考究、温暖的底色。大量留白,以排版为核心。避免 AI 产品常见的设计套路(渐变文字、玻璃态、霓虹色)。
|
||||
|
||||
1
build.ts
1
build.ts
@@ -11,6 +11,7 @@ rmSync(outdir, { recursive: true, force: true })
|
||||
// Default features that match the official CLI build.
|
||||
// Additional features can be enabled via FEATURE_<NAME>=1 env vars.
|
||||
const DEFAULT_BUILD_FEATURES = [
|
||||
'BUDDY', 'TRANSCRIPT_CLASSIFIER', 'BRIDGE_MODE',
|
||||
'AGENT_TRIGGERS_REMOTE',
|
||||
'CHICAGO_MCP',
|
||||
'VOICE_MODE',
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 1.6 MiB |
205
docs/features/acp-link.md
Normal file
205
docs/features/acp-link.md
Normal file
@@ -0,0 +1,205 @@
|
||||
# acp-link — ACP 代理服务器
|
||||
|
||||
> 源码目录:`packages/acp-link/`
|
||||
> PR: #292
|
||||
> 新增时间:2026-04-18
|
||||
|
||||
## 一、功能概述
|
||||
|
||||
`acp-link` 是一个 ACP (Agent Client Protocol) 代理服务器,将 WebSocket 客户端桥接到 ACP agent 的 stdio 接口。它让 ACP agent(如 Claude Code)可以通过 WebSocket 远程访问,而不仅限于本地 stdio。
|
||||
|
||||
### 核心特性
|
||||
|
||||
- **WebSocket → stdio 桥接**:将浏览器/远程客户端的 WebSocket 连接转换为 ACP agent 的 stdin/stdout NDJSON 流
|
||||
- **会话管理**:创建、加载、恢复、列出、关闭会话
|
||||
- **权限审批流程**:客户端可远程审批 agent 的工具权限请求
|
||||
- **RCS 集成**:可与 Remote Control Server (RCS) 连接,将 ACP agent 注册到 RCS 并通过 Web UI 交互
|
||||
- **HTTPS 支持**:内置自签名证书生成,支持安全连接
|
||||
- **Token 认证**:自动生成或通过环境变量配置认证 token
|
||||
|
||||
## 二、架构
|
||||
|
||||
### 独立模式
|
||||
|
||||
```
|
||||
┌──────────────────┐ WebSocket ┌──────────────────┐ stdio/NDJSON ┌──────────────┐
|
||||
│ 浏览器/客户端 │ ◄──────────────►│ acp-link │ ◄────────────────►│ ACP Agent │
|
||||
│ (WS Client) │ ws://host:port │ (Proxy Server) │ spawn subprocess │ (Claude等) │
|
||||
└──────────────────┘ └──────────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
### RCS 集成模式
|
||||
|
||||
```
|
||||
┌──────────────┐ WebSocket ┌──────────────────┐ stdio/NDJSON ┌──────────────┐
|
||||
│ RCS Web UI │ ◄──────────────►│ Remote Control │ ◄─────────────────►│ acp-link │
|
||||
│ (/code/*) │ ACP Relay WS │ Server (RCS) │ ACP events │ + Agent │
|
||||
└──────────────┘ └──────────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
### 文件结构
|
||||
|
||||
```
|
||||
packages/acp-link/
|
||||
├── src/
|
||||
│ ├── server.ts # 主服务器:WS 连接管理、会话管理、权限处理、消息桥接
|
||||
│ ├── rcs-upstream.ts # RCS 上游客户端:REST 注册 + WS identify 两步流程
|
||||
│ ├── cert.ts # TLS 证书生成(自签名)
|
||||
│ ├── logger.ts # 日志模块
|
||||
│ ├── types.ts # JSON-RPC 和 ACP 协议类型定义
|
||||
│ ├── cli/
|
||||
│ │ ├── bin.ts # CLI 入口
|
||||
│ │ ├── command.ts # 命令行参数解析
|
||||
│ │ ├── app.ts # 应用启动
|
||||
│ │ └── context.ts # 上下文配置
|
||||
│ └── __tests__/ # 测试(cert, server, types)
|
||||
├── package.json
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
## 三、安装与使用
|
||||
|
||||
### 基本用法
|
||||
|
||||
```bash
|
||||
# 直接运行(在 monorepo 中)
|
||||
# 注意:claude 本身不支持 ACP,需要用 ccb-bun --acp 启动 ACP agent
|
||||
bun packages/acp-link/src/cli/bin.ts ccb-bun -- --acp
|
||||
|
||||
# 指定端口和主机
|
||||
acp-link --port 9000 --host 0.0.0.0 ccb-bun -- --acp
|
||||
|
||||
# 启用 HTTPS(自签名证书)
|
||||
acp-link --https ccb-bun -- --acp
|
||||
|
||||
# 调试模式
|
||||
acp-link --debug ccb-bun -- --acp
|
||||
```
|
||||
|
||||
### CLI 参考
|
||||
|
||||
```
|
||||
USAGE
|
||||
acp-link [--port value] [--host value] [--debug] [--no-auth] [--https] <command>...
|
||||
acp-link --help
|
||||
acp-link --version
|
||||
|
||||
FLAGS
|
||||
[--port] Port to listen on [default = 9315]
|
||||
[--host] Host to bind to [default = localhost]
|
||||
[--debug] Enable debug logging to file
|
||||
[--no-auth] Disable authentication (dangerous)
|
||||
[--https] Enable HTTPS with self-signed cert
|
||||
-h --help Print help information and exit
|
||||
-v --version Print version information and exit
|
||||
|
||||
ARGUMENTS
|
||||
command... Agent command followed by its arguments (e.g. "ccb-bun -- --acp")
|
||||
```
|
||||
|
||||
## 四、认证
|
||||
|
||||
默认启动时自动生成随机 token。客户端连接时需通过 query 参数传递:
|
||||
|
||||
```
|
||||
ws://localhost:9315/ws?token=<your-token>
|
||||
```
|
||||
|
||||
配置固定 token:
|
||||
|
||||
```bash
|
||||
ACP_AUTH_TOKEN=my-fixed-token acp-link ccb-bun -- --acp
|
||||
```
|
||||
|
||||
禁用认证(不推荐,仅用于开发):
|
||||
|
||||
```bash
|
||||
acp-link --no-auth ccb-bun -- --acp
|
||||
```
|
||||
|
||||
## 五、RCS 集成
|
||||
|
||||
acp-link 支持将 ACP agent 注册到 Remote Control Server,通过 Web UI 远程操控。
|
||||
|
||||
### 连接方式
|
||||
|
||||
```bash
|
||||
# 通过环境变量配置 RCS 连接
|
||||
ACP_RCS_URL=http://localhost:3000 \
|
||||
ACP_RCS_TOKEN=sk-rcs-your-key \
|
||||
ACP_RCS_NAME=my-agent \
|
||||
acp-link ccb-bun -- --acp
|
||||
```
|
||||
|
||||
### 注册流程(两步)
|
||||
|
||||
1. **REST 注册**:通过 `POST /v1/environments/bridge` 向 RCS 注册环境
|
||||
2. **WS identify**:建立 WebSocket 连接后发送 `identify` 消息(携带 agentId),替代完整 `register`
|
||||
|
||||
```
|
||||
acp-link RCS
|
||||
│ │
|
||||
│── POST /v1/environments/bridge ──►│ (REST 注册)
|
||||
│◄── { agentId, sessionId } ───────│
|
||||
│ │
|
||||
│── WS connect ─────────────────►│ (WebSocket)
|
||||
│── identify { agentId } ────────►│ (WS 标识)
|
||||
│◄── registered ─────────────────│
|
||||
│ │
|
||||
│── ACP events ─────────────────►│ (双向消息转发)
|
||||
│◄── user prompts/permissions ───│
|
||||
```
|
||||
|
||||
## 六、权限模式
|
||||
|
||||
### permissionMode 传递链
|
||||
|
||||
权限模式通过整条链路传递:Web UI → RCS → acp-link → ACP agent。
|
||||
|
||||
支持的权限模式:
|
||||
- `default` — 每次请求权限确认
|
||||
- `auto` — 自动判断
|
||||
- `acceptEdits` — 自动接受编辑
|
||||
- `plan` — 规划模式
|
||||
- `dontAsk` — 不询问
|
||||
- `bypassPermissions` — 绕过权限(需 sandbox 环境)
|
||||
|
||||
### fallback 链
|
||||
|
||||
当客户端未显式传递 permissionMode 时,使用以下 fallback 链:
|
||||
|
||||
```
|
||||
客户端传值 > config.permissionMode > ACP_PERMISSION_MODE 环境变量
|
||||
```
|
||||
|
||||
示例:
|
||||
|
||||
```bash
|
||||
ACP_PERMISSION_MODE=auto acp-link ccb-bun -- --acp
|
||||
```
|
||||
|
||||
## 七、权限管道(2026-04-18 改进)
|
||||
|
||||
### 模式同步
|
||||
|
||||
`applySessionMode` 在 agent 切换权限模式时同步 `appState.toolPermissionContext.mode`,确保内部权限上下文与 ACP 客户端状态一致。
|
||||
|
||||
### 统一权限流水线
|
||||
|
||||
`createAcpCanUseTool` 接入 `hasPermissionsToUseTool` 统一权限流水线,替代原来分散的处理逻辑。支持 `onModeChange` 回调,模式变更时实时同步。
|
||||
|
||||
### bypass 检测
|
||||
|
||||
`bypassPermissions` 模式增加可用性检测 — 仅在非 root 或 sandbox 环境中允许启用,防止权限绕过的安全风险。
|
||||
|
||||
## 八、环境变量
|
||||
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `ACP_AUTH_TOKEN` | 固定认证 token(默认自动生成) |
|
||||
| `ACP_PERMISSION_MODE` | 默认权限模式 fallback |
|
||||
| `ACP_RCS_URL` | RCS 服务器地址(启用 RCS 集成) |
|
||||
| `ACP_RCS_TOKEN` | RCS API token |
|
||||
| `ACP_RCS_NAME` | Agent 名称(在 RCS 中显示) |
|
||||
| `ACP_RCS_CHANNEL_GROUP` | Channel group ID |
|
||||
| `ACP_MAX_SESSIONS` | 最大会话数 |
|
||||
@@ -1,7 +1,7 @@
|
||||
# KAIROS — 常驻助手模式
|
||||
|
||||
> Feature Flag: `FEATURE_KAIROS=1`(及子 Feature)
|
||||
> 实现状态:核心框架完整,部分子模块为 stub
|
||||
> 实现状态:核心框架完整,部分子模块为 stub;proactive/sleep 节奏控制已可用
|
||||
> 引用数:154(全库最大)
|
||||
|
||||
## 一、功能概述
|
||||
@@ -74,8 +74,9 @@ KAIROS 在系统提示中注入两大段落:
|
||||
|
||||
SleepTool 是 KAIROS/Proactive 的节奏控制核心。工具描述让模型理解"休眠"概念:
|
||||
- 工具名:`Sleep`
|
||||
- 功能:等待指定时间后响应 tick prompt
|
||||
- 功能:等待指定时间后响应 tick prompt;若队列出现新工作或 proactive 被关闭,会提前唤醒
|
||||
- 与 `<tick_tag>` 配合实现心跳式自主工作
|
||||
- 远程控制 surfaces 可通过 `automation_state` 看到 `standby` / `sleeping` 两种状态
|
||||
|
||||
### 3.3 Bridge 集成
|
||||
|
||||
@@ -172,8 +173,10 @@ FEATURE_KAIROS=1 FEATURE_TOKEN_BUDGET=1 bun run dev
|
||||
| `src/assistant/AssistantSessionChooser.ts` | — | Session 选择 UI(stub) |
|
||||
| `src/tools/BriefTool/` | — | BriefTool 实现(stub) |
|
||||
| `src/tools/SleepTool/prompt.ts` | ~30 | SleepTool 工具提示 |
|
||||
| `src/tools/SleepTool/SleepTool.ts` | ~200 | 休眠/唤醒与 automation metadata |
|
||||
| `src/services/mcp/channelNotification.ts` | 5 | 频道消息接入(stub) |
|
||||
| `src/memdir/memdir.ts` | — | 记忆目录管理(stub) |
|
||||
| `src/constants/prompts.ts:552-554,843-914` | 72 | 系统提示注入 |
|
||||
| `src/components/tasks/src/tasks/DreamTask/` | 3 | Dream 任务(stub) |
|
||||
| `src/proactive/index.ts` | — | Proactive 核心(stub,KAIROS 共享) |
|
||||
| `src/proactive/index.ts` | — | Proactive 核心(KAIROS 共享) |
|
||||
| `src/utils/sessionState.ts` | — | 向 bridge/CCR 暴露 automation 状态 |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# PROACTIVE — 主动模式
|
||||
|
||||
> Feature Flag: `FEATURE_PROACTIVE=1`(与 `FEATURE_KAIROS=1` 共享功能)
|
||||
> 实现状态:核心模块全部 Stub,布线完整
|
||||
> 实现状态:核心循环与 SleepTool 已落地,部分外围文档仍在补齐
|
||||
> 引用数:37
|
||||
|
||||
## 一、功能概述
|
||||
@@ -21,13 +21,13 @@ PROACTIVE 实现 Tick 驱动的自主代理。CLI 在用户不输入时也能持
|
||||
|
||||
| 模块 | 文件 | 状态 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 核心逻辑 | `src/proactive/index.ts` | **Stub** | `activateProactive()`、`deactivateProactive()`、`isProactiveActive() => false` |
|
||||
| 核心逻辑 | `src/proactive/index.ts` | **已实现** | `activateProactive()`、`deactivateProactive()`、`pause/resume`、`nextTickAt` 调度状态 |
|
||||
| SleepTool 提示 | `src/tools/SleepTool/prompt.ts` | **完整** | 工具提示定义(工具名:`Sleep`) |
|
||||
| 命令注册 | `src/commands.ts:62-65` | **布线** | 动态加载 `./commands/proactive.js` |
|
||||
| 工具注册 | `src/tools.ts:26-28` | **布线** | SleepTool 动态加载 |
|
||||
| REPL 集成 | `src/screens/REPL.tsx` | **布线** | tick 驱动逻辑、占位符、页脚 UI |
|
||||
| REPL 集成 | `src/screens/REPL.tsx` | **已实现** | tick 驱动、standby/sleeping 状态、页脚与 bridge automation metadata 上报 |
|
||||
| 系统提示 | `src/constants/prompts.ts:860-914` | **完整** | 自主工作行为指令(~55 行详细 prompt) |
|
||||
| 会话存储 | `src/utils/sessionStorage.ts:4892-4912` | **布线** | tick 消息注入对话流 |
|
||||
| 远控状态镜像 | `src/utils/sessionState.ts` | **已实现** | 向 remote-control/CCR 暴露 `automation_state` 元数据 |
|
||||
|
||||
### 2.2 系统提示内容
|
||||
|
||||
@@ -46,7 +46,7 @@ PROACTIVE 实现 Tick 驱动的自主代理。CLI 在用户不输入时也能持
|
||||
### 2.3 数据流
|
||||
|
||||
```
|
||||
activateProactive() [需要实现]
|
||||
activateProactive()
|
||||
│
|
||||
▼
|
||||
Tick 调度器启动
|
||||
@@ -62,20 +62,22 @@ Tick 调度器启动
|
||||
└── 无事可做 → 必须调用 SleepTool
|
||||
│
|
||||
▼
|
||||
SleepTool 等待 [需要实现]
|
||||
SleepTool 等待
|
||||
│
|
||||
├── 用户插入新工作 / 队列中有命令 → 立即唤醒
|
||||
├── proactive 被关闭 → 立即中断
|
||||
└── 进入休眠时向远端 surfaces 上报 `automation_state = sleeping`
|
||||
│
|
||||
▼
|
||||
下一个 tick 到达
|
||||
```
|
||||
|
||||
## 三、需要补全的内容
|
||||
## 三、当前行为补充
|
||||
|
||||
| 优先级 | 模块 | 工作量 | 说明 |
|
||||
|--------|------|--------|------|
|
||||
| 1 | `src/proactive/index.ts` | 中 | Tick 调度器、activate/deactivate 状态机、pause/resume |
|
||||
| 2 | `src/tools/SleepTool/SleepTool.ts` | 小 | 工具执行(等待指定时间后触发 tick) |
|
||||
| 3 | `src/commands/proactive.js` | 小 | `/proactive` 斜杠命令处理器 |
|
||||
| 4 | `src/hooks/useProactive.ts` | 中 | React hook(REPL 引用但不存在) |
|
||||
- `standby`:proactive 已开启,当前没有执行中的 turn,且已调度下一个 tick。
|
||||
- `sleeping`:模型显式调用 `SleepTool` 进入等待窗口。
|
||||
- remote-control/CCR 通过 `external_metadata.automation_state` 接收这两个状态,用于 Web UI 的 Autopilot 状态显示。
|
||||
- `SleepTool` 现在不是纯定时器;它会在共享命令队列出现新工作时提前醒来。
|
||||
|
||||
## 四、关键设计决策
|
||||
|
||||
@@ -101,9 +103,11 @@ FEATURE_PROACTIVE=1 FEATURE_KAIROS=1 FEATURE_KAIROS_BRIEF=1 bun run dev
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `src/proactive/index.ts` | 核心逻辑(stub) |
|
||||
| `src/proactive/index.ts` | 核心逻辑与 next-tick 状态 |
|
||||
| `src/tools/SleepTool/prompt.ts` | SleepTool 工具提示 |
|
||||
| `src/tools/SleepTool/SleepTool.ts` | 休眠/唤醒执行逻辑 |
|
||||
| `src/constants/prompts.ts:860-914` | 自主工作系统提示 |
|
||||
| `src/screens/REPL.tsx` | REPL tick 集成 |
|
||||
| `src/screens/REPL.tsx` | REPL tick 集成与 automation 状态上报 |
|
||||
| `src/utils/sessionStorage.ts:4892-4912` | Tick 消息注入 |
|
||||
| `src/utils/sessionState.ts` | bridge/CCR metadata 镜像 |
|
||||
| `src/components/PromptInput/PromptInputFooterLeftSide.tsx` | 页脚 UI 状态 |
|
||||
|
||||
@@ -13,17 +13,22 @@
|
||||
┌──────────────────┐ HTTP/SSE │ │ In-Memory │ │
|
||||
│ Web UI 控制面板 │ ◄─────────────── │ │ Store │ │
|
||||
│ (/code/*) │ │ └──────────────┘ │
|
||||
└──────────────────┘ │ ┌──────────────┐ │
|
||||
│ │ JWT Auth │ │
|
||||
│ (React + Vite) │ │ ┌──────────────┐ │
|
||||
└──────────────────┘ │ │ JWT Auth │ │
|
||||
│ └──────────────┘ │
|
||||
└──────────────────────┘
|
||||
┌──────────────────┐ │ ┌──────────────┐ │
|
||||
│ acp-link │ ◄── ACP Relay ─── │ │ ACP Handler │ │
|
||||
│ + ACP Agent │ WebSocket │ └──────────────┘ │
|
||||
└──────────────────┘ └──────────────────────┘
|
||||
```
|
||||
|
||||
**RCS 是一个纯内存的中间服务**,它的职责是:
|
||||
- 接收 Claude Code CLI 的环境注册和工作轮询
|
||||
- 接收 acp-link 的 ACP agent 注册,支持 WebSocket relay 桥接
|
||||
- 提供 Web UI 供操作者远程监控和审批
|
||||
- 通过 WebSocket/SSE 双向传输消息
|
||||
- 管理会话、环境、权限请求
|
||||
- 提供 ACP SSE event stream 供外部消费者订阅 channel group 事件
|
||||
|
||||
## 前置条件
|
||||
|
||||
@@ -169,15 +174,70 @@ claude bridge
|
||||
|
||||
## Web UI 控制面板
|
||||
|
||||
通过 `/remote-control` 命令获取 URL 后,在浏览器打开即可使用。功能:
|
||||
通过 `/remote-control` 命令获取 URL 后,在浏览器打开即可使用。
|
||||
|
||||
- 查看已注册的运行环境(environment 模式)
|
||||
### 技术栈(v2,2026-04-18 重构)
|
||||
|
||||
Web UI 已从原生 JS 重构为 **React + Vite + Radix UI**:
|
||||
|
||||
- **框架**: React 19 + Vite 构建,TypeScript
|
||||
- **UI 组件**: Radix UI primitives(Dialog、Tabs、Select、Popover 等)
|
||||
- **聊天组件**: 完整的 ACP 聊天界面,支持 Plan 可视化、工具调用展示、权限审批
|
||||
- **AI Elements**: 独立的 AI 交互组件库(message、reasoning、tool、code-block、prompt-input 等)
|
||||
- **ACP 直连**: 支持 QR 码扫描自动跳转 ACP 直连视图(`ACPDirectView`)
|
||||
- **主题系统**: 暗色/亮色主题切换,遵循 Impeccable 设计系统
|
||||
|
||||
### 功能
|
||||
|
||||
- 查看已注册的运行环境(environment 模式),区分 ACP Agent 和 Claude Code 类型
|
||||
- 创建和管理会话
|
||||
- 实时查看对话消息和工具调用
|
||||
- 查看 Autopilot 状态(`standby` / `sleeping`)和自动运行指示
|
||||
- 查看 authoritative task snapshots 驱动的 Tasks 面板
|
||||
- 审批 Claude Code 的工具权限请求
|
||||
- 权限模式选择器(6 种模式:默认/自动接受编辑/跳过权限/规划/不询问/自动判断)
|
||||
- 模型选择器(可选可用模型)
|
||||
- Plan 可视化(进度条、状态图标、优先级标签)
|
||||
- ACP QR 扫描自动跳转到 ACP 聊天界面
|
||||
|
||||
Web UI 使用 UUID 认证(无需用户账户),适合受信任网络环境。
|
||||
|
||||
## ACP 支持
|
||||
|
||||
RCS 支持 ACP (Agent Client Protocol) agent 通过 `acp-link` 包接入。
|
||||
|
||||
### 架构
|
||||
|
||||
```
|
||||
acp-link ──REST注册──► RCS POST /v1/environments/bridge
|
||||
acp-link ──WS identify──► RCS WebSocket (携带 agentId)
|
||||
acp-link ◄──ACP relay──► RCS ◄──Web UI WS──► 浏览器
|
||||
```
|
||||
|
||||
### 后端组件
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `src/routes/acp/index.ts` | ACP REST 路由:agents 列表、channel groups、relay |
|
||||
| `src/transport/acp-ws-handler.ts` | ACP WebSocket 处理:agent 注册、心跳、消息转发 |
|
||||
| `src/transport/acp-relay-handler.ts` | 前端 WS → acp-link 透传 + EventBus inbound 转发 |
|
||||
| `src/transport/acp-sse-writer.ts` | SSE event stream 供外部消费者订阅 |
|
||||
|
||||
### acp-link 连接
|
||||
|
||||
详见 [acp-link 文档](./acp-link.md)。
|
||||
|
||||
```bash
|
||||
# 在 RCS 环境中启动 acp-link
|
||||
# 注意:claude 本身不支持 ACP,需要用 ccb-bun --acp
|
||||
ACP_RCS_URL=http://localhost:3000 \
|
||||
ACP_RCS_TOKEN=sk-rcs-your-key \
|
||||
ACP_RCS_NAME=my-agent \
|
||||
acp-link ccb-bun -- --acp
|
||||
```
|
||||
|
||||
ACP session 在 Web UI 中显示紫色标签,与普通 Claude Code session 区分。
|
||||
|
||||
## 工作流程详解
|
||||
|
||||
```
|
||||
@@ -215,6 +275,7 @@ Web UI 使用 UUID 认证(无需用户账户),适合受信任网络环境
|
||||
9. 双向通信
|
||||
CLI ──消息/工具调用结果──► RCS ──► Browser
|
||||
CLI ◄──权限审批/指令───── RCS ◄──── Browser
|
||||
CLI ──automation_state / task_state──► RCS ──► Browser
|
||||
|
||||
10. 心跳保活(每 20 秒)
|
||||
CLI ──POST /v1/environments/:id/work/:workId/heartbeat──► RCS
|
||||
@@ -224,6 +285,13 @@ Web UI 使用 UUID 认证(无需用户账户),适合受信任网络环境
|
||||
|
||||
## 故障排查
|
||||
|
||||
### Web UI 看不到当前 Autopilot 状态
|
||||
|
||||
- `standby`:proactive 已开启,正在等待下一个 tick
|
||||
- `sleeping`:模型正在 `SleepTool` 等待窗口中
|
||||
|
||||
这两个状态通过 worker `external_metadata.automation_state` 上报。如果页面只显示普通 working spinner,优先检查 CLI 和 RCS 之间的 worker metadata PUT 是否成功。
|
||||
|
||||
### CLI 无法连接
|
||||
|
||||
```
|
||||
|
||||
@@ -138,6 +138,7 @@
|
||||
"docs/features/voice-mode",
|
||||
"docs/features/bridge-mode",
|
||||
"docs/features/remote-control-self-hosting",
|
||||
"docs/features/acp-link",
|
||||
"docs/features/proactive",
|
||||
"docs/features/ultraplan"
|
||||
]
|
||||
|
||||
57
package.json
57
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-code-best",
|
||||
"version": "1.4.1",
|
||||
"version": "1.4.4",
|
||||
"description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal",
|
||||
"type": "module",
|
||||
"author": "claude-code-best <claude-code-best@proton.me>",
|
||||
@@ -37,6 +37,7 @@
|
||||
"files": [
|
||||
"dist",
|
||||
"scripts/postinstall.cjs",
|
||||
"scripts/run-parallel.mjs",
|
||||
"scripts/setup-chrome-mcp.mjs"
|
||||
],
|
||||
"scripts": {
|
||||
@@ -72,20 +73,20 @@
|
||||
"@ant/computer-use-mcp": "workspace:*",
|
||||
"@ant/computer-use-swift": "workspace:*",
|
||||
"@anthropic-ai/bedrock-sdk": "^0.26.4",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.87",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.114",
|
||||
"@anthropic-ai/foundry-sdk": "^0.2.3",
|
||||
"@anthropic-ai/mcpb": "^2.1.2",
|
||||
"@anthropic-ai/sandbox-runtime": "^0.0.44",
|
||||
"@anthropic-ai/sdk": "^0.80.0",
|
||||
"@anthropic-ai/vertex-sdk": "^0.14.4",
|
||||
"@anthropic/ink": "workspace:*",
|
||||
"@aws-sdk/client-bedrock": "^3.1020.0",
|
||||
"@aws-sdk/client-bedrock-runtime": "^3.1020.0",
|
||||
"@aws-sdk/client-sts": "^3.1020.0",
|
||||
"@aws-sdk/credential-provider-node": "^3.972.28",
|
||||
"@aws-sdk/credential-providers": "^3.1020.0",
|
||||
"@aws-sdk/client-bedrock": "^3.1032.0",
|
||||
"@aws-sdk/client-bedrock-runtime": "^3.1032.0",
|
||||
"@aws-sdk/client-sts": "^3.1032.0",
|
||||
"@aws-sdk/credential-provider-node": "^3.972.32",
|
||||
"@aws-sdk/credential-providers": "^3.1032.0",
|
||||
"@azure/identity": "^4.13.1",
|
||||
"@biomejs/biome": "^2.4.10",
|
||||
"@biomejs/biome": "^2.4.12",
|
||||
"@claude-code-best/agent-tools": "workspace:*",
|
||||
"@claude-code-best/builtin-tools": "workspace:*",
|
||||
"@claude-code-best/mcp-client": "workspace:*",
|
||||
@@ -96,7 +97,7 @@
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@opentelemetry/api": "^1.9.1",
|
||||
"@opentelemetry/api-logs": "^0.214.0",
|
||||
"@opentelemetry/core": "^2.6.1",
|
||||
"@opentelemetry/core": "^2.7.0",
|
||||
"@opentelemetry/exporter-logs-otlp-grpc": "^0.214.0",
|
||||
"@opentelemetry/exporter-logs-otlp-http": "^0.214.0",
|
||||
"@opentelemetry/exporter-logs-otlp-proto": "^0.214.0",
|
||||
@@ -107,14 +108,14 @@
|
||||
"@opentelemetry/exporter-trace-otlp-grpc": "^0.214.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.214.0",
|
||||
"@opentelemetry/exporter-trace-otlp-proto": "^0.214.0",
|
||||
"@opentelemetry/resources": "^2.6.1",
|
||||
"@opentelemetry/resources": "^2.7.0",
|
||||
"@opentelemetry/sdk-logs": "^0.214.0",
|
||||
"@opentelemetry/sdk-metrics": "^2.6.1",
|
||||
"@opentelemetry/sdk-trace-base": "^2.6.1",
|
||||
"@opentelemetry/sdk-metrics": "^2.7.0",
|
||||
"@opentelemetry/sdk-trace-base": "^2.7.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.40.0",
|
||||
"@sentry/node": "^10.47.0",
|
||||
"@smithy/core": "^3.23.13",
|
||||
"@smithy/node-http-handler": "^4.5.1",
|
||||
"@sentry/node": "^10.49.0",
|
||||
"@smithy/core": "^3.23.15",
|
||||
"@smithy/node-http-handler": "^4.5.3",
|
||||
"@types/bun": "^1.3.12",
|
||||
"@types/cacache": "^20.0.1",
|
||||
"@types/he": "^1.2.3",
|
||||
@@ -136,7 +137,7 @@
|
||||
"asciichart": "^1.5.25",
|
||||
"audio-capture-napi": "workspace:*",
|
||||
"auto-bind": "^5.0.1",
|
||||
"axios": "^1.14.0",
|
||||
"axios": "^1.15.0",
|
||||
"bidi-js": "^1.0.3",
|
||||
"cacache": "^20.0.4",
|
||||
"chalk": "^5.6.2",
|
||||
@@ -151,7 +152,7 @@
|
||||
"execa": "^9.6.1",
|
||||
"fflate": "^0.8.2",
|
||||
"figures": "^6.1.0",
|
||||
"fuse.js": "^7.1.0",
|
||||
"fuse.js": "^7.3.0",
|
||||
"get-east-asian-width": "^1.5.0",
|
||||
"google-auth-library": "^10.6.2",
|
||||
"he": "^1.2.0",
|
||||
@@ -161,21 +162,21 @@
|
||||
"image-processor-napi": "workspace:*",
|
||||
"indent-string": "^5.0.0",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"knip": "^6.1.1",
|
||||
"lodash-es": "^4.17.23",
|
||||
"lru-cache": "^11.2.7",
|
||||
"marked": "^17.0.5",
|
||||
"knip": "^6.4.1",
|
||||
"lodash-es": "^4.18.1",
|
||||
"lru-cache": "^11.3.5",
|
||||
"marked": "^17.0.6",
|
||||
"modifiers-napi": "workspace:*",
|
||||
"openai": "^6.33.0",
|
||||
"openai": "^6.34.0",
|
||||
"p-map": "^7.0.4",
|
||||
"picomatch": "^4.0.4",
|
||||
"plist": "^3.1.0",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.4",
|
||||
"react": "^19.2.5",
|
||||
"react-compiler-runtime": "^1.0.0",
|
||||
"react-reconciler": "^0.33.0",
|
||||
"rollup": "^4.60.1",
|
||||
"rollup": "^4.60.2",
|
||||
"semver": "^7.7.4",
|
||||
"sharp": "^0.34.5",
|
||||
"shell-quote": "^1.8.3",
|
||||
@@ -184,10 +185,10 @@
|
||||
"strip-ansi": "^7.2.0",
|
||||
"supports-hyperlinks": "^4.4.0",
|
||||
"tree-kill": "^1.2.2",
|
||||
"turndown": "^7.2.2",
|
||||
"type-fest": "^5.5.0",
|
||||
"typescript": "^6.0.2",
|
||||
"undici": "^7.24.6",
|
||||
"turndown": "^7.2.4",
|
||||
"type-fest": "^5.6.0",
|
||||
"typescript": "^6.0.3",
|
||||
"undici": "^7.25.0",
|
||||
"url-handler-napi": "workspace:*",
|
||||
"usehooks-ts": "^3.1.1",
|
||||
"vite": "^8.0.8",
|
||||
|
||||
34
packages/acp-link/.gitignore
vendored
Normal file
34
packages/acp-link/.gitignore
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
# dependencies (bun install)
|
||||
node_modules
|
||||
|
||||
# output
|
||||
out
|
||||
dist
|
||||
*.tgz
|
||||
|
||||
# code coverage
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# logs
|
||||
logs
|
||||
_.log
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# caches
|
||||
.eslintcache
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
89
packages/acp-link/README.md
Normal file
89
packages/acp-link/README.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# acp-link
|
||||
|
||||
ACP proxy server that bridges WebSocket clients to ACP (Agent Client Protocol) agents.
|
||||
|
||||
> Source code adapted from [chrome-acp](https://github.com/Areo-Joe/chrome-acp).
|
||||
|
||||
## Installation
|
||||
|
||||
### From source
|
||||
|
||||
```bash
|
||||
# From monorepo root
|
||||
bun install
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Via global install
|
||||
acp-link /path/to/agent
|
||||
|
||||
# Via source
|
||||
bun src/cli/bin.ts /path/to/agent
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
# Basic usage
|
||||
acp-link /path/to/agent
|
||||
|
||||
# With custom port and host
|
||||
acp-link --port 9000 --host 0.0.0.0 /path/to/agent
|
||||
|
||||
# With debug logging
|
||||
acp-link --debug /path/to/agent
|
||||
|
||||
# Enable HTTPS with self-signed certificate
|
||||
acp-link --https /path/to/agent
|
||||
|
||||
# Disable authentication (dangerous)
|
||||
acp-link --no-auth /path/to/agent
|
||||
|
||||
# Pass arguments to the agent (use -- to separate)
|
||||
acp-link /path/to/agent -- --verbose --model gpt-4
|
||||
```
|
||||
|
||||
## CLI Reference
|
||||
|
||||
```
|
||||
USAGE
|
||||
acp-link [--port value] [--host value] [--debug] [--no-auth] [--https] <command>...
|
||||
acp-link --help
|
||||
acp-link --version
|
||||
|
||||
FLAGS
|
||||
[--port] Port to listen on [default = 9315]
|
||||
[--host] Host to bind to [default = localhost]
|
||||
[--debug] Enable debug logging to file
|
||||
[--no-auth] Disable authentication (dangerous)
|
||||
[--https] Enable HTTPS with self-signed cert
|
||||
-h --help Print help information and exit
|
||||
-v --version Print version information and exit
|
||||
|
||||
ARGUMENTS
|
||||
command... Agent command followed by its arguments
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. Listens for WebSocket connections from clients
|
||||
2. When a "connect" message is received, spawns the configured ACP agent as a subprocess
|
||||
3. Bridges messages between the WebSocket (client) and stdin/stdout (agent via ACP protocol)
|
||||
4. Supports session management: create, load, resume, list sessions
|
||||
5. Handles permission approval flow and heartbeat keepalive
|
||||
|
||||
## Authentication
|
||||
|
||||
By default, a random token is auto-generated on startup. Pass it as a query parameter:
|
||||
|
||||
```
|
||||
ws://localhost:9315/ws?token=<your-token>
|
||||
```
|
||||
|
||||
Set `ACP_AUTH_TOKEN` env var to use a fixed token, or use `--no-auth` to disable (not recommended).
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
39
packages/acp-link/package.json
Normal file
39
packages/acp-link/package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "acp-link",
|
||||
"version": "1.0.1",
|
||||
"description": "ACP proxy server that bridges WebSocket clients to ACP agents",
|
||||
"author": "claude-code-best",
|
||||
"type": "module",
|
||||
"main": "./dist/server.js",
|
||||
"types": "./dist/server.d.ts",
|
||||
"bin": {
|
||||
"acp-link": "dist/cli/bin.js"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "bun run src/cli/bin.ts",
|
||||
"prepublishOnly": "bun run build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/selfsigned": "^2.0.4",
|
||||
"@types/ws": "^8.18.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.19.0",
|
||||
"@hono/node-server": "^1.13.8",
|
||||
"@hono/node-ws": "^1.0.5",
|
||||
"@stricli/auto-complete": "^1.2.4",
|
||||
"@stricli/core": "^1.2.4",
|
||||
"hono": "^4.7.0",
|
||||
"pino": "^10.3.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"selfsigned": "^5.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
28
packages/acp-link/src/__tests__/cert.test.ts
Normal file
28
packages/acp-link/src/__tests__/cert.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { getLanIPs } from "../cert.js";
|
||||
|
||||
describe("getLanIPs", () => {
|
||||
test("returns an array", () => {
|
||||
const ips = getLanIPs();
|
||||
expect(Array.isArray(ips)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns only IPv4 addresses", () => {
|
||||
const ips = getLanIPs();
|
||||
for (const ip of ips) {
|
||||
// IPv4 format: x.x.x.x
|
||||
expect(ip).toMatch(/^\d+\.\d+\.\d+\.\d+$/);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not include loopback addresses", () => {
|
||||
const ips = getLanIPs();
|
||||
expect(ips).not.toContain("127.0.0.1");
|
||||
});
|
||||
|
||||
test("may be empty in isolated environments", () => {
|
||||
// This test just ensures it doesn't throw
|
||||
const ips = getLanIPs();
|
||||
expect(ips.length).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
75
packages/acp-link/src/__tests__/server.test.ts
Normal file
75
packages/acp-link/src/__tests__/server.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import type { ServerConfig } from "../server.js";
|
||||
|
||||
describe("Server HTTP endpoints", () => {
|
||||
test("package.json has correct bin and main entries", async () => {
|
||||
const pkg = await import("../../package.json", { with: { type: "json" } });
|
||||
expect(pkg.default.name).toBe("acp-link");
|
||||
expect(pkg.default.main).toBe("./dist/server.js");
|
||||
expect(pkg.default.bin).toBeDefined();
|
||||
expect(pkg.default.bin["acp-link"]).toBe("dist/cli/bin.js");
|
||||
});
|
||||
|
||||
test("ServerConfig interface accepts all expected fields", () => {
|
||||
const config: ServerConfig = {
|
||||
port: 9315,
|
||||
host: "localhost",
|
||||
command: "echo",
|
||||
args: [],
|
||||
cwd: "/tmp",
|
||||
debug: false,
|
||||
token: "test-token",
|
||||
https: false,
|
||||
};
|
||||
expect(config.port).toBe(9315);
|
||||
expect(config.token).toBe("test-token");
|
||||
});
|
||||
|
||||
test("ServerConfig allows optional fields to be omitted", () => {
|
||||
const config: ServerConfig = {
|
||||
port: 9315,
|
||||
host: "localhost",
|
||||
command: "echo",
|
||||
args: [],
|
||||
cwd: "/tmp",
|
||||
};
|
||||
expect(config.debug).toBeUndefined();
|
||||
expect(config.token).toBeUndefined();
|
||||
expect(config.https).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WebSocket message types", () => {
|
||||
const clientMessageTypes = [
|
||||
"connect",
|
||||
"disconnect",
|
||||
"new_session",
|
||||
"prompt",
|
||||
"permission_response",
|
||||
"cancel",
|
||||
"set_session_model",
|
||||
"list_sessions",
|
||||
"load_session",
|
||||
"resume_session",
|
||||
"ping",
|
||||
];
|
||||
|
||||
test("all client message types are recognized", () => {
|
||||
expect(clientMessageTypes.length).toBe(11);
|
||||
expect(clientMessageTypes).toContain("ping");
|
||||
expect(clientMessageTypes).toContain("connect");
|
||||
expect(clientMessageTypes).toContain("cancel");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Heartbeat constants", () => {
|
||||
test("PERMISSION_TIMEOUT_MS is 5 minutes", () => {
|
||||
const PERMISSION_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
expect(PERMISSION_TIMEOUT_MS).toBe(300_000);
|
||||
});
|
||||
|
||||
test("HEARTBEAT_INTERVAL_MS is 30 seconds", () => {
|
||||
const HEARTBEAT_INTERVAL_MS = 30_000;
|
||||
expect(HEARTBEAT_INTERVAL_MS).toBe(30_000);
|
||||
});
|
||||
});
|
||||
69
packages/acp-link/src/__tests__/types.test.ts
Normal file
69
packages/acp-link/src/__tests__/types.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { isRequest, isResponse, isNotification } from "../types.js";
|
||||
import type { JsonRpcRequest, JsonRpcResponse, JsonRpcNotification } from "../types.js";
|
||||
|
||||
describe("isRequest", () => {
|
||||
test("returns true for a valid JSON-RPC request", () => {
|
||||
const msg: JsonRpcRequest = { jsonrpc: "2.0", id: 1, method: "test" };
|
||||
expect(isRequest(msg)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for request with params", () => {
|
||||
const msg = { jsonrpc: "2.0" as const, id: "abc", method: "test", params: { x: 1 } };
|
||||
expect(isRequest(msg)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for response (no method)", () => {
|
||||
const msg: JsonRpcResponse = { jsonrpc: "2.0", id: 1, result: {} };
|
||||
expect(isRequest(msg)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for notification (no id)", () => {
|
||||
const msg: JsonRpcNotification = { jsonrpc: "2.0", method: "notify" };
|
||||
expect(isRequest(msg)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isResponse", () => {
|
||||
test("returns true for a valid JSON-RPC response with result", () => {
|
||||
const msg: JsonRpcResponse = { jsonrpc: "2.0", id: 1, result: "ok" };
|
||||
expect(isResponse(msg)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for a valid JSON-RPC error response", () => {
|
||||
const msg: JsonRpcResponse = { jsonrpc: "2.0", id: 2, error: { code: -32600, message: "bad" } };
|
||||
expect(isResponse(msg)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for request (has method)", () => {
|
||||
const msg: JsonRpcRequest = { jsonrpc: "2.0", id: 1, method: "test" };
|
||||
expect(isResponse(msg)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for notification", () => {
|
||||
const msg: JsonRpcNotification = { jsonrpc: "2.0", method: "notify" };
|
||||
expect(isResponse(msg)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isNotification", () => {
|
||||
test("returns true for a valid JSON-RPC notification", () => {
|
||||
const msg: JsonRpcNotification = { jsonrpc: "2.0", method: "update" };
|
||||
expect(isNotification(msg)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for notification with params", () => {
|
||||
const msg = { jsonrpc: "2.0" as const, method: "progress", params: { pct: 50 } };
|
||||
expect(isNotification(msg)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for request (has id)", () => {
|
||||
const msg: JsonRpcRequest = { jsonrpc: "2.0", id: 1, method: "test" };
|
||||
expect(isNotification(msg)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for response (no method)", () => {
|
||||
const msg: JsonRpcResponse = { jsonrpc: "2.0", id: 1, result: null };
|
||||
expect(isNotification(msg)).toBe(false);
|
||||
});
|
||||
});
|
||||
174
packages/acp-link/src/cert.ts
Normal file
174
packages/acp-link/src/cert.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Self-signed certificate generation for HTTPS support
|
||||
*/
|
||||
|
||||
import { X509Certificate } from "node:crypto";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { homedir, networkInterfaces } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { generate } from "selfsigned";
|
||||
|
||||
/**
|
||||
* Get all LAN IPv4 addresses
|
||||
*/
|
||||
export function getLanIPs(): string[] {
|
||||
const ips: string[] = [];
|
||||
const nets = networkInterfaces();
|
||||
for (const name of Object.keys(nets)) {
|
||||
for (const net of nets[name] || []) {
|
||||
// Skip internal (loopback) and non-IPv4 addresses
|
||||
if (!net.internal && net.family === "IPv4") {
|
||||
ips.push(net.address);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ips;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract IP addresses from certificate's Subject Alternative Name (SAN)
|
||||
* SAN format: "IP Address:192.168.1.100, IP Address:127.0.0.1, DNS:localhost"
|
||||
*/
|
||||
function extractSanIPs(x509: X509Certificate): string[] {
|
||||
const san = x509.subjectAltName;
|
||||
if (!san) return [];
|
||||
|
||||
const ips: string[] = [];
|
||||
// Parse "IP Address:x.x.x.x" entries from SAN string
|
||||
const parts = san.split(", ");
|
||||
for (const part of parts) {
|
||||
const match = part.match(/^IP Address:(.+)$/);
|
||||
if (match && match[1]) {
|
||||
ips.push(match[1]);
|
||||
}
|
||||
}
|
||||
return ips;
|
||||
}
|
||||
|
||||
const CERT_DIR = join(homedir(), ".acp-proxy");
|
||||
const KEY_PATH = join(CERT_DIR, "key.pem");
|
||||
const CERT_PATH = join(CERT_DIR, "cert.pem");
|
||||
|
||||
// Certificate validity in days
|
||||
const CERT_VALIDITY_DAYS = 365;
|
||||
|
||||
export interface TlsOptions {
|
||||
key: string;
|
||||
cert: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or generate self-signed certificate
|
||||
* Certificates are cached in ~/.acp-proxy/
|
||||
*/
|
||||
export async function getOrCreateCertificate(): Promise<TlsOptions> {
|
||||
// Ensure directory exists
|
||||
if (!existsSync(CERT_DIR)) {
|
||||
mkdirSync(CERT_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Check if certificates already exist and are still valid
|
||||
if (existsSync(KEY_PATH) && existsSync(CERT_PATH)) {
|
||||
const certPem = readFileSync(CERT_PATH, "utf-8");
|
||||
const keyPem = readFileSync(KEY_PATH, "utf-8");
|
||||
|
||||
try {
|
||||
const x509 = new X509Certificate(certPem);
|
||||
const validTo = new Date(x509.validTo);
|
||||
const now = new Date();
|
||||
|
||||
// Check if cert is expired or will expire within 7 days
|
||||
const daysUntilExpiry = Math.floor((validTo.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (daysUntilExpiry <= 7) {
|
||||
// Certificate expired or expiring soon
|
||||
console.log(`⚠️ Certificate ${daysUntilExpiry <= 0 ? "expired" : `expires in ${daysUntilExpiry} days`}, regenerating...`);
|
||||
} else {
|
||||
// Check if current LAN IPs are in the certificate's SAN
|
||||
const currentLanIPs = getLanIPs();
|
||||
const certSanIPs = extractSanIPs(x509);
|
||||
|
||||
// Check if all current LAN IPs are covered by the certificate
|
||||
const missingIPs = currentLanIPs.filter(ip => !certSanIPs.includes(ip));
|
||||
|
||||
if (missingIPs.length === 0) {
|
||||
console.log(`🔐 Using existing certificate from ${CERT_DIR}`);
|
||||
console.log(` Valid for ${daysUntilExpiry} more days`);
|
||||
return { key: keyPem, cert: certPem };
|
||||
}
|
||||
|
||||
// LAN IP changed, regenerate
|
||||
console.log(`⚠️ LAN IP changed (missing: ${missingIPs.join(", ")}), regenerating certificate...`);
|
||||
}
|
||||
} catch {
|
||||
// Failed to parse certificate, regenerate
|
||||
console.log(`⚠️ Invalid certificate, regenerating...`);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate new self-signed certificate
|
||||
console.log(`🔐 Generating self-signed certificate...`);
|
||||
|
||||
const attrs = [{ name: "commonName", value: "ACP Proxy Server" }];
|
||||
|
||||
// Calculate expiry date
|
||||
const notAfterDate = new Date();
|
||||
notAfterDate.setDate(notAfterDate.getDate() + CERT_VALIDITY_DAYS);
|
||||
|
||||
// Build altNames: localhost + loopback + all LAN IPs
|
||||
const altNames: Array<{ type: 1 | 2 | 6 | 7; value?: string; ip?: string }> = [
|
||||
{ type: 2, value: "localhost" },
|
||||
{ type: 7, ip: "127.0.0.1" },
|
||||
{ type: 7, ip: "::1" },
|
||||
];
|
||||
|
||||
// Add all current LAN IPs
|
||||
const lanIPs = getLanIPs();
|
||||
for (const ip of lanIPs) {
|
||||
altNames.push({ type: 7, ip });
|
||||
}
|
||||
|
||||
if (lanIPs.length > 0) {
|
||||
console.log(` Including LAN IPs: ${lanIPs.join(", ")}`);
|
||||
}
|
||||
|
||||
const pems = await generate(attrs, {
|
||||
keySize: 2048,
|
||||
notAfterDate,
|
||||
algorithm: "sha256",
|
||||
extensions: [
|
||||
{
|
||||
name: "basicConstraints",
|
||||
cA: true,
|
||||
},
|
||||
{
|
||||
name: "keyUsage",
|
||||
keyCertSign: true,
|
||||
digitalSignature: true,
|
||||
keyEncipherment: true,
|
||||
},
|
||||
{
|
||||
name: "extKeyUsage",
|
||||
serverAuth: true,
|
||||
},
|
||||
{
|
||||
name: "subjectAltName",
|
||||
altNames,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Save certificates
|
||||
writeFileSync(KEY_PATH, pems.private);
|
||||
writeFileSync(CERT_PATH, pems.cert);
|
||||
|
||||
console.log(`✅ Certificate saved to ${CERT_DIR}`);
|
||||
console.log(` Valid for ${CERT_VALIDITY_DAYS} days`);
|
||||
console.log(` ⚠️ First access will show a security warning - click "Advanced" → "Proceed"`);
|
||||
|
||||
return {
|
||||
key: pems.private,
|
||||
cert: pems.cert,
|
||||
};
|
||||
}
|
||||
|
||||
18
packages/acp-link/src/cli/app.ts
Normal file
18
packages/acp-link/src/cli/app.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { buildApplication } from "@stricli/core";
|
||||
import { createRequire } from "node:module";
|
||||
import { command } from "./command.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const pkg = require("../../package.json") as { version: string };
|
||||
|
||||
export const app = buildApplication(command, {
|
||||
name: "acp-link",
|
||||
versionInfo: {
|
||||
currentVersion: pkg.version,
|
||||
},
|
||||
scanner: {
|
||||
caseStyle: "allow-kebab-for-camel",
|
||||
allowArgumentEscapeSequence: true,
|
||||
},
|
||||
});
|
||||
|
||||
7
packages/acp-link/src/cli/bin.ts
Normal file
7
packages/acp-link/src/cli/bin.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
import { run } from "@stricli/core";
|
||||
import { app } from "./app.js";
|
||||
import { buildContext } from "./context.js";
|
||||
|
||||
await run(app, process.argv.slice(2), buildContext());
|
||||
|
||||
90
packages/acp-link/src/cli/command.ts
Normal file
90
packages/acp-link/src/cli/command.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { buildCommand, numberParser } from "@stricli/core";
|
||||
import type { LocalContext } from "./context.js";
|
||||
|
||||
export const command = buildCommand({
|
||||
docs: {
|
||||
brief: "Start the ACP proxy server",
|
||||
fullDescription:
|
||||
"Starts a WebSocket proxy server that bridges clients to ACP agents. " +
|
||||
"The agent command is spawned as a subprocess and communicates via stdin/stdout.\n\n" +
|
||||
"Use -- to pass arguments to the agent:\n" +
|
||||
" acp-link /path/to/agent -- --verbose --model gpt-4\n\n" +
|
||||
"For remote access, set ACP_AUTH_TOKEN environment variable or let it auto-generate.",
|
||||
},
|
||||
parameters: {
|
||||
flags: {
|
||||
port: {
|
||||
kind: "parsed",
|
||||
parse: numberParser,
|
||||
brief: "Port to listen on",
|
||||
default: "9315",
|
||||
},
|
||||
host: {
|
||||
kind: "parsed",
|
||||
parse: String,
|
||||
brief: "Host to bind to (use 0.0.0.0 for remote access)",
|
||||
default: "localhost",
|
||||
},
|
||||
debug: {
|
||||
kind: "boolean",
|
||||
brief: "Enable debug logging to file",
|
||||
default: false,
|
||||
},
|
||||
"no-auth": {
|
||||
kind: "boolean",
|
||||
brief: "DANGEROUS: Disable authentication (not recommended)",
|
||||
default: false,
|
||||
},
|
||||
https: {
|
||||
kind: "boolean",
|
||||
brief: "Enable HTTPS with auto-generated self-signed certificate",
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
positional: {
|
||||
kind: "array",
|
||||
parameter: {
|
||||
brief: "Agent command and arguments (use -- before agent flags)",
|
||||
parse: String,
|
||||
placeholder: "command",
|
||||
},
|
||||
minimum: 1,
|
||||
},
|
||||
},
|
||||
func: async function (
|
||||
this: LocalContext,
|
||||
flags: { port: number; host: string; debug: boolean; "no-auth": boolean; https: boolean },
|
||||
...args: readonly string[]
|
||||
) {
|
||||
const port = flags.port;
|
||||
const host = flags.host;
|
||||
const debug = flags.debug;
|
||||
const noAuth = flags["no-auth"];
|
||||
const https = flags.https;
|
||||
const [command, ...agentArgs] = args;
|
||||
const cwd = process.cwd();
|
||||
|
||||
// Determine auth token
|
||||
// Priority: ACP_AUTH_TOKEN env var > auto-generate (unless --no-auth)
|
||||
let token: string | undefined;
|
||||
if (noAuth) {
|
||||
console.warn("⚠️ WARNING: Authentication disabled. This is dangerous for remote access!");
|
||||
token = undefined;
|
||||
} else {
|
||||
token = process.env.ACP_AUTH_TOKEN;
|
||||
if (!token) {
|
||||
// Auto-generate random token
|
||||
const { randomBytes } = await import("node:crypto");
|
||||
token = randomBytes(32).toString("hex");
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize logger
|
||||
const { initLogger } = await import("../logger.js");
|
||||
initLogger({ debug });
|
||||
|
||||
// Import and run the server
|
||||
const { startServer } = await import("../server.js");
|
||||
await startServer({ port, host, command: command!, args: [...agentArgs], cwd, debug, token, https });
|
||||
},
|
||||
});
|
||||
10
packages/acp-link/src/cli/context.ts
Normal file
10
packages/acp-link/src/cli/context.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { CommandContext } from "@stricli/core";
|
||||
|
||||
export interface LocalContext extends CommandContext {}
|
||||
|
||||
export function buildContext(): LocalContext {
|
||||
return {
|
||||
process,
|
||||
};
|
||||
}
|
||||
|
||||
83
packages/acp-link/src/logger.ts
Normal file
83
packages/acp-link/src/logger.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import pino from "pino";
|
||||
import { join } from "node:path";
|
||||
import { mkdirSync, existsSync } from "node:fs";
|
||||
|
||||
let rootLogger: pino.Logger;
|
||||
|
||||
export interface LoggerConfig {
|
||||
debug: boolean;
|
||||
logDir?: string;
|
||||
}
|
||||
|
||||
/** Pretty-print config for console output */
|
||||
const PRETTY_CONFIG = {
|
||||
colorize: true,
|
||||
translateTime: "SYS:HH:MM:ss.l",
|
||||
ignore: "pid,hostname",
|
||||
} as const;
|
||||
|
||||
export function initLogger(config: LoggerConfig): pino.Logger {
|
||||
const { debug, logDir } = config;
|
||||
|
||||
if (debug) {
|
||||
const dir = logDir || join(process.cwd(), ".acp-proxy");
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const timestamp = now.toISOString()
|
||||
.replace(/T/, "_")
|
||||
.replace(/:/g, "-")
|
||||
.replace(/\..+/, "");
|
||||
const logFile = join(dir, `acp-proxy-${timestamp}.log`);
|
||||
|
||||
// Debug mode: JSON to file + pretty to console (multistream)
|
||||
rootLogger = pino(
|
||||
{
|
||||
level: "trace",
|
||||
timestamp: pino.stdTimeFunctions.isoTime,
|
||||
},
|
||||
pino.transport({
|
||||
targets: [
|
||||
{ target: "pino/file", options: { destination: logFile } },
|
||||
{ target: "pino-pretty", options: { ...PRETTY_CONFIG, destination: 1 } },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
console.log(`📝 Debug logging enabled: ${logFile}`);
|
||||
} else {
|
||||
rootLogger = pino(
|
||||
{ level: "info", timestamp: pino.stdTimeFunctions.isoTime },
|
||||
pino.transport({
|
||||
target: "pino-pretty",
|
||||
options: { ...PRETTY_CONFIG, destination: 1 },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return rootLogger;
|
||||
}
|
||||
|
||||
/** Get the root logger (auto-creates a default one if not initialized). */
|
||||
export function getLogger(): pino.Logger {
|
||||
if (!rootLogger) {
|
||||
rootLogger = pino(
|
||||
{ level: "info" },
|
||||
pino.transport({
|
||||
target: "pino-pretty",
|
||||
options: { ...PRETTY_CONFIG, destination: 1 },
|
||||
}),
|
||||
);
|
||||
}
|
||||
return rootLogger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a child logger scoped to a module.
|
||||
* Usage: `const log = createLogger("agent"); log.info({ pid }, "spawned")`
|
||||
*/
|
||||
export function createLogger(module: string): pino.Logger {
|
||||
return getLogger().child({ module });
|
||||
}
|
||||
258
packages/acp-link/src/rcs-upstream.ts
Normal file
258
packages/acp-link/src/rcs-upstream.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import { createLogger } from "./logger.js";
|
||||
|
||||
export interface RcsUpstreamConfig {
|
||||
rcsUrl: string; // e.g. "http://localhost:3000"
|
||||
apiToken: string;
|
||||
agentName: string;
|
||||
channelGroupId?: string;
|
||||
capabilities?: Record<string, unknown>;
|
||||
maxSessions?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* RCS upstream client — connects acp-link to a Remote Control Server.
|
||||
*
|
||||
* Lifecycle:
|
||||
* 1. connect() — opens WS to RCS
|
||||
* 2. Sends register message
|
||||
* 3. Waits for registered response
|
||||
* 4. Forwards all ACP events via send()
|
||||
* 5. Reconnects with exponential backoff on failure
|
||||
*/
|
||||
export class RcsUpstreamClient {
|
||||
private static log = createLogger("rcs-upstream");
|
||||
private ws: WebSocket | null = null;
|
||||
private registered = false;
|
||||
private reconnectAttempts = 0;
|
||||
private closed = false;
|
||||
private readonly maxReconnectDelay = 30_000;
|
||||
private readonly baseReconnectDelay = 1_000;
|
||||
/** Agent ID obtained from REST registration */
|
||||
private agentId: string | null = null;
|
||||
/** Session ID from REST registration (ACP agents auto-create a session) */
|
||||
private sessionId: string | undefined;
|
||||
|
||||
/** Handler for incoming ACP messages from RCS relay */
|
||||
private messageHandler: ((message: Record<string, unknown>) => void) | null = null;
|
||||
|
||||
constructor(private config: RcsUpstreamConfig) {}
|
||||
|
||||
/** Get the agent ID from REST registration */
|
||||
getAgentId(): string | null {
|
||||
return this.agentId;
|
||||
}
|
||||
|
||||
/** Set handler for incoming ACP messages from RCS relay */
|
||||
setMessageHandler(handler: (message: Record<string, unknown>) => void): void {
|
||||
this.messageHandler = handler;
|
||||
}
|
||||
|
||||
/** Register via REST API before establishing WS connection */
|
||||
private async registerViaRest(): Promise<string> {
|
||||
const baseUrl = this.config.rcsUrl
|
||||
.replace(/^ws:\/\//, "http://")
|
||||
.replace(/^wss:\/\//, "https://")
|
||||
.replace(/\/acp\/ws.*$/, "")
|
||||
.replace(/\/$/, "");
|
||||
|
||||
const url = `${baseUrl}/v1/environments/bridge`;
|
||||
RcsUpstreamClient.log.info({ url }, "REST register");
|
||||
|
||||
const resp = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${this.config.apiToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
machine_name: this.config.agentName,
|
||||
worker_type: "acp",
|
||||
bridge_id: this.config.channelGroupId || undefined,
|
||||
max_sessions: this.config.maxSessions,
|
||||
capabilities: this.config.capabilities,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text();
|
||||
throw new Error(`REST register failed (${resp.status}): ${text}`);
|
||||
}
|
||||
|
||||
const data = await resp.json() as { environment_id: string; environment_secret: string; status: string; session_id?: string };
|
||||
this.agentId = data.environment_id;
|
||||
this.sessionId = data.session_id;
|
||||
RcsUpstreamClient.log.info({ agentId: this.agentId, sessionId: this.sessionId }, "REST register success");
|
||||
return data.environment_id;
|
||||
}
|
||||
|
||||
/** Normalize RCS URL: accept http(s) base URL and convert to ws(s) + /acp/ws path */
|
||||
private buildWsUrl(): string {
|
||||
let raw = this.config.rcsUrl;
|
||||
raw = raw.replace(/^http:\/\//, "ws://").replace(/^https:\/\//, "wss://");
|
||||
const url = new URL(raw);
|
||||
const path = url.pathname.replace(/\/+$/, "");
|
||||
if (!path || path === "/") {
|
||||
url.pathname = "/acp/ws";
|
||||
}
|
||||
if (this.config.apiToken) {
|
||||
url.searchParams.set("token", this.config.apiToken);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/** Open connection to RCS: REST register → WS identify */
|
||||
async connect(): Promise<void> {
|
||||
if (this.closed) return;
|
||||
|
||||
// Step 1: REST registration
|
||||
try {
|
||||
await this.registerViaRest();
|
||||
} catch (err) {
|
||||
RcsUpstreamClient.log.error({ err }, "REST registration failed");
|
||||
if (!this.closed) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: WebSocket connection with identify
|
||||
const wsUrl = this.buildWsUrl();
|
||||
RcsUpstreamClient.log.info({ url: wsUrl }, "connecting WS");
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
RcsUpstreamClient.log.debug("ws open — sending identify");
|
||||
this.ws!.send(
|
||||
JSON.stringify({
|
||||
type: "identify",
|
||||
agent_id: this.agentId,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
let data: Record<string, unknown>;
|
||||
try {
|
||||
data = JSON.parse(event.data as string);
|
||||
} catch {
|
||||
RcsUpstreamClient.log.warn({ raw: String(event.data).slice(0, 200) }, "invalid JSON from server");
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === "identified") {
|
||||
RcsUpstreamClient.log.info({ agent_id: data.agent_id, channel_group_id: data.channel_group_id }, "identified");
|
||||
this.registered = true;
|
||||
this.reconnectAttempts = 0;
|
||||
const webBase = this.config.rcsUrl
|
||||
.replace(/^ws:\/\//, "http://")
|
||||
.replace(/^wss:\/\//, "https://")
|
||||
.replace(/\/acp\/ws.*$/, "")
|
||||
.replace(/\/$/, "");
|
||||
console.log();
|
||||
if (this.sessionId) {
|
||||
console.log(` 🔗 Dashboard: ${webBase}/code/?sid=${this.sessionId}`);
|
||||
} else {
|
||||
console.log(` 🔗 Dashboard: ${webBase}/code/`);
|
||||
}
|
||||
if (this.agentId) {
|
||||
console.log(` Agent ID: ${this.agentId}`);
|
||||
}
|
||||
console.log();
|
||||
resolve();
|
||||
} else if (data.type === "registered") {
|
||||
// Legacy fallback: server still uses old register flow
|
||||
RcsUpstreamClient.log.info({ agent_id: data.agent_id }, "registered (legacy)");
|
||||
this.agentId = (data.agent_id as string) || this.agentId;
|
||||
this.registered = true;
|
||||
this.reconnectAttempts = 0;
|
||||
resolve();
|
||||
} else if (data.type === "error") {
|
||||
RcsUpstreamClient.log.error({ message: data.message }, "server error");
|
||||
if (!this.registered) {
|
||||
reject(new Error(data.message as string));
|
||||
}
|
||||
} else if (data.type === "keep_alive") {
|
||||
// ignore keepalive
|
||||
} else {
|
||||
// Forward ACP protocol messages to handler (for RCS relay support)
|
||||
RcsUpstreamClient.log.debug({ type: data.type }, "forwarding to relay handler");
|
||||
this.messageHandler?.(data);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = () => {
|
||||
// onclose fires after onerror with the actual close code, so we log there
|
||||
if (!this.registered) {
|
||||
reject(new Error("WebSocket connection failed"));
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = (event) => {
|
||||
RcsUpstreamClient.log.info({ code: event.code, reason: event.reason || undefined }, "ws closed");
|
||||
this.registered = false;
|
||||
this.ws = null;
|
||||
if (!this.closed) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
RcsUpstreamClient.log.error({ err }, "connect threw");
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Send an ACP message to RCS for broadcast */
|
||||
send(message: object): void {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN || !this.registered) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.ws.send(JSON.stringify(message));
|
||||
} catch (err) {
|
||||
RcsUpstreamClient.log.error({ err }, "send failed");
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if registered with RCS */
|
||||
isRegistered(): boolean {
|
||||
return this.registered && this.ws !== null && this.ws.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
/** Close the RCS connection permanently */
|
||||
async close(): Promise<void> {
|
||||
this.closed = true;
|
||||
this.registered = false;
|
||||
if (this.ws) {
|
||||
this.ws.close(1000, "client shutdown");
|
||||
this.ws = null;
|
||||
}
|
||||
RcsUpstreamClient.log.info("closed");
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.closed) return;
|
||||
|
||||
const delay = Math.min(
|
||||
this.baseReconnectDelay * 2 ** this.reconnectAttempts,
|
||||
this.maxReconnectDelay,
|
||||
);
|
||||
const jitter = delay * Math.random() * 0.2;
|
||||
const actualDelay = delay + jitter;
|
||||
this.reconnectAttempts++;
|
||||
|
||||
RcsUpstreamClient.log.warn({ attempt: this.reconnectAttempts, delayMs: Math.round(actualDelay) }, "reconnecting");
|
||||
|
||||
setTimeout(async () => {
|
||||
if (this.closed) return;
|
||||
try {
|
||||
await this.connect();
|
||||
} catch {
|
||||
// connect() itself logs the error; nothing to add here
|
||||
}
|
||||
}, actualDelay);
|
||||
}
|
||||
}
|
||||
895
packages/acp-link/src/server.ts
Normal file
895
packages/acp-link/src/server.ts
Normal file
@@ -0,0 +1,895 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { createServer as createHttpsServer } from "node:https";
|
||||
import { Writable, Readable } from "node:stream";
|
||||
import * as acp from "@agentclientprotocol/sdk";
|
||||
import { Hono } from "hono";
|
||||
import { serve } from "@hono/node-server";
|
||||
import { createNodeWebSocket } from "@hono/node-ws";
|
||||
import type { WSContext } from "hono/ws";
|
||||
import type { WebSocket as RawWebSocket } from "ws";
|
||||
import { createLogger } from "./logger.js";
|
||||
import { getOrCreateCertificate, getLanIPs } from "./cert.js";
|
||||
import { RcsUpstreamClient, type RcsUpstreamConfig } from "./rcs-upstream.js";
|
||||
|
||||
export interface ServerConfig {
|
||||
port: number;
|
||||
host: string;
|
||||
command: string;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
debug?: boolean;
|
||||
token?: string;
|
||||
https?: boolean;
|
||||
/** Default permission mode for new sessions (e.g. "auto", "default", "bypassPermissions") */
|
||||
permissionMode?: string;
|
||||
}
|
||||
|
||||
// Pending permission request
|
||||
interface PendingPermission {
|
||||
resolve: (outcome: { outcome: "cancelled" } | { outcome: "selected"; optionId: string }) => void;
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
// PromptCapabilities from ACP protocol
|
||||
// Reference: Zed's prompt_capabilities to check image support
|
||||
interface PromptCapabilities {
|
||||
audio?: boolean;
|
||||
embeddedContext?: boolean;
|
||||
image?: boolean;
|
||||
}
|
||||
|
||||
// SessionModelState from ACP protocol
|
||||
// Reference: Zed's AgentModelSelector reads from state.available_models
|
||||
interface SessionModelState {
|
||||
availableModels: Array<{
|
||||
modelId: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
}>;
|
||||
currentModelId: string;
|
||||
}
|
||||
|
||||
// AgentCapabilities from ACP protocol
|
||||
// Reference: Zed's AcpConnection.agent_capabilities
|
||||
// Matches SDK's AgentCapabilities exactly
|
||||
interface AgentCapabilities {
|
||||
_meta?: Record<string, unknown> | null;
|
||||
loadSession?: boolean;
|
||||
mcpCapabilities?: {
|
||||
_meta?: Record<string, unknown> | null;
|
||||
clientServers?: boolean;
|
||||
};
|
||||
promptCapabilities?: PromptCapabilities;
|
||||
sessionCapabilities?: {
|
||||
_meta?: Record<string, unknown> | null;
|
||||
fork?: Record<string, unknown> | null;
|
||||
list?: Record<string, unknown> | null;
|
||||
resume?: Record<string, unknown> | null;
|
||||
};
|
||||
}
|
||||
|
||||
// Track connected clients and their agent connections
|
||||
interface ClientState {
|
||||
process: ChildProcess | null;
|
||||
connection: acp.ClientSideConnection | null;
|
||||
sessionId: string | null;
|
||||
pendingPermissions: Map<string, PendingPermission>;
|
||||
agentCapabilities: AgentCapabilities | null;
|
||||
promptCapabilities: PromptCapabilities | null;
|
||||
modelState: SessionModelState | null;
|
||||
isAlive: boolean;
|
||||
}
|
||||
|
||||
// Module-level state (set when server starts)
|
||||
let AGENT_COMMAND: string;
|
||||
let AGENT_ARGS: string[];
|
||||
let AGENT_CWD: string;
|
||||
let SERVER_PORT: number;
|
||||
let SERVER_HOST: string;
|
||||
let AUTH_TOKEN: string | undefined;
|
||||
let DEFAULT_PERMISSION_MODE: string | undefined;
|
||||
|
||||
const clients = new Map<WSContext, ClientState>();
|
||||
|
||||
// Module-scoped child loggers
|
||||
const logWs = createLogger("ws");
|
||||
const logAgent = createLogger("agent");
|
||||
const logSession = createLogger("session");
|
||||
const logPrompt = createLogger("prompt");
|
||||
const logPerm = createLogger("perm");
|
||||
const logRelay = createLogger("relay");
|
||||
const logServer = createLogger("server");
|
||||
|
||||
// RCS upstream client (optional — enabled via ACP_RCS_URL env var)
|
||||
let rcsUpstream: RcsUpstreamClient | null = null;
|
||||
|
||||
/**
|
||||
* Create a virtual WSContext for RCS relay messages.
|
||||
* Responses via send() go to RCS upstream (not a local WS).
|
||||
*/
|
||||
function createRelayWs(): WSContext {
|
||||
return {
|
||||
get readyState() { return 1; }, // always OPEN
|
||||
send: () => {}, // no-op — responses go through rcsUpstream.send()
|
||||
close: () => {},
|
||||
raw: null,
|
||||
isInner: false,
|
||||
url: "",
|
||||
origin: "",
|
||||
protocol: "",
|
||||
} as unknown as WSContext;
|
||||
}
|
||||
|
||||
// Permission request timeout (5 minutes)
|
||||
const PERMISSION_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
// Heartbeat interval for WebSocket ping/pong (30 seconds)
|
||||
const HEARTBEAT_INTERVAL_MS = 30_000;
|
||||
|
||||
// Generate unique request ID
|
||||
function generateRequestId(): string {
|
||||
return `perm_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
|
||||
}
|
||||
|
||||
// Send a message to the WebSocket client (and optionally forward to RCS upstream)
|
||||
function send(ws: WSContext, type: string, payload?: unknown): void {
|
||||
if (ws.readyState === 1) {
|
||||
// WebSocket.OPEN
|
||||
ws.send(JSON.stringify({ type, payload }));
|
||||
}
|
||||
// Forward to RCS upstream if connected
|
||||
if (rcsUpstream?.isRegistered()) {
|
||||
rcsUpstream.send({ type, payload });
|
||||
}
|
||||
}
|
||||
|
||||
// Create a Client implementation that forwards events to WebSocket
|
||||
function createClient(ws: WSContext, clientState: ClientState): acp.Client {
|
||||
return {
|
||||
async requestPermission(params) {
|
||||
const requestId = generateRequestId();
|
||||
logPerm.debug({ requestId, title: params.toolCall.title }, "requested");
|
||||
|
||||
const outcomePromise = new Promise<{ outcome: "cancelled" } | { outcome: "selected"; optionId: string }>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
logPerm.warn({ requestId }, "timed out");
|
||||
clientState.pendingPermissions.delete(requestId);
|
||||
resolve({ outcome: "cancelled" });
|
||||
}, PERMISSION_TIMEOUT_MS);
|
||||
|
||||
clientState.pendingPermissions.set(requestId, { resolve, timeout });
|
||||
});
|
||||
|
||||
send(ws, "permission_request", {
|
||||
requestId,
|
||||
sessionId: params.sessionId,
|
||||
options: params.options,
|
||||
toolCall: params.toolCall,
|
||||
});
|
||||
|
||||
const outcome = await outcomePromise;
|
||||
logPerm.debug({ requestId, outcome: outcome.outcome }, "resolved");
|
||||
|
||||
return { outcome };
|
||||
},
|
||||
|
||||
async sessionUpdate(params) {
|
||||
send(ws, "session_update", params);
|
||||
},
|
||||
|
||||
async readTextFile(params) {
|
||||
logWs.debug({ path: params.path }, "readTextFile");
|
||||
return { content: "" };
|
||||
},
|
||||
|
||||
async writeTextFile(params) {
|
||||
logWs.debug({ path: params.path }, "writeTextFile");
|
||||
return {};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Handle permission response from client
|
||||
function handlePermissionResponse(ws: WSContext, payload: { requestId: string; outcome: { outcome: "cancelled" } | { outcome: "selected"; optionId: string } }): void {
|
||||
const state = clients.get(ws);
|
||||
if (!state) {
|
||||
logPerm.warn("response from unknown client");
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = state.pendingPermissions.get(payload.requestId);
|
||||
if (!pending) {
|
||||
logPerm.warn({ requestId: payload.requestId }, "response for unknown request");
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(pending.timeout);
|
||||
state.pendingPermissions.delete(payload.requestId);
|
||||
pending.resolve(payload.outcome);
|
||||
}
|
||||
|
||||
// Cancel all pending permissions for a client (called on disconnect)
|
||||
function cancelPendingPermissions(clientState: ClientState): void {
|
||||
for (const [requestId, pending] of clientState.pendingPermissions) {
|
||||
logPerm.debug({ requestId }, "cancelled on disconnect");
|
||||
clearTimeout(pending.timeout);
|
||||
pending.resolve({ outcome: "cancelled" });
|
||||
}
|
||||
clientState.pendingPermissions.clear();
|
||||
}
|
||||
|
||||
async function handleConnect(ws: WSContext): Promise<void> {
|
||||
const state = clients.get(ws);
|
||||
if (!state) return;
|
||||
|
||||
// If already connected to a running agent, just resend status
|
||||
// This handles frontend reconnections without restarting the agent process
|
||||
// Check both .killed and .exitCode to detect crashed processes
|
||||
if (state.connection && state.process && !state.process.killed && state.process.exitCode === null) {
|
||||
logAgent.info("already connected, resending status");
|
||||
send(ws, "status", {
|
||||
connected: true,
|
||||
agentInfo: { name: AGENT_COMMAND },
|
||||
capabilities: state.agentCapabilities,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Kill existing process if any (only if not healthy)
|
||||
if (state.process) {
|
||||
cancelPendingPermissions(state);
|
||||
state.process.kill();
|
||||
state.process = null;
|
||||
state.connection = null;
|
||||
}
|
||||
|
||||
try {
|
||||
logAgent.info({ command: AGENT_COMMAND, args: AGENT_ARGS }, "spawning");
|
||||
|
||||
const agentProcess = spawn(AGENT_COMMAND, AGENT_ARGS, {
|
||||
cwd: AGENT_CWD,
|
||||
stdio: ["pipe", "pipe", "inherit"],
|
||||
});
|
||||
|
||||
state.process = agentProcess;
|
||||
|
||||
// Clean up state when agent process exits unexpectedly
|
||||
agentProcess.on("exit", (code) => {
|
||||
logAgent.info({ exitCode: code }, "agent process exited");
|
||||
// Only clear if this is still the current process
|
||||
if (state.process === agentProcess) {
|
||||
state.process = null;
|
||||
state.connection = null;
|
||||
state.sessionId = null;
|
||||
}
|
||||
});
|
||||
|
||||
const input = Writable.toWeb(agentProcess.stdin!) as unknown as WritableStream<Uint8Array>;
|
||||
const output = Readable.toWeb(agentProcess.stdout!) as unknown as ReadableStream<Uint8Array>;
|
||||
|
||||
const stream = acp.ndJsonStream(input, output);
|
||||
const connection = new acp.ClientSideConnection(
|
||||
(_agent) => createClient(ws, state),
|
||||
stream,
|
||||
);
|
||||
|
||||
state.connection = connection;
|
||||
|
||||
const initResult = await connection.initialize({
|
||||
protocolVersion: acp.PROTOCOL_VERSION,
|
||||
clientInfo: { name: "zed", version: "1.0.0" },
|
||||
clientCapabilities: {
|
||||
fs: { readTextFile: true, writeTextFile: true },
|
||||
},
|
||||
});
|
||||
|
||||
const agentCaps = initResult.agentCapabilities;
|
||||
state.agentCapabilities = agentCaps ? {
|
||||
_meta: agentCaps._meta,
|
||||
loadSession: agentCaps.loadSession,
|
||||
mcpCapabilities: agentCaps.mcpCapabilities,
|
||||
promptCapabilities: agentCaps.promptCapabilities,
|
||||
sessionCapabilities: agentCaps.sessionCapabilities,
|
||||
} : null;
|
||||
state.promptCapabilities = agentCaps?.promptCapabilities ?? null;
|
||||
|
||||
logAgent.info({
|
||||
protocolVersion: initResult.protocolVersion,
|
||||
loadSession: !!state.agentCapabilities?.loadSession,
|
||||
sessionList: !!state.agentCapabilities?.sessionCapabilities?.list,
|
||||
sessionResume: !!state.agentCapabilities?.sessionCapabilities?.resume,
|
||||
hasMcp: !!state.agentCapabilities?.mcpCapabilities,
|
||||
}, "initialized");
|
||||
|
||||
send(ws, "status", {
|
||||
connected: true,
|
||||
agentInfo: initResult.agentInfo,
|
||||
capabilities: state.agentCapabilities,
|
||||
});
|
||||
|
||||
connection.closed.then(() => {
|
||||
logAgent.info("connection closed");
|
||||
state.connection = null;
|
||||
state.sessionId = null;
|
||||
send(ws, "status", { connected: false });
|
||||
});
|
||||
} catch (error) {
|
||||
logAgent.error({ error: (error as Error).message }, "connect failed");
|
||||
send(ws, "error", { message: `Failed to connect: ${(error as Error).message}` });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNewSession(
|
||||
ws: WSContext,
|
||||
params: { cwd?: string; permissionMode?: string },
|
||||
): Promise<void> {
|
||||
const state = clients.get(ws);
|
||||
if (!state?.connection) {
|
||||
logAgent.warn({ hasState: !!state, hasProcess: !!state?.process, processKilled: state?.process?.killed, exitCode: state?.process?.exitCode }, "handleNewSession: not connected to agent");
|
||||
send(ws, "error", { message: "Not connected to agent" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionCwd = params.cwd || AGENT_CWD;
|
||||
const permissionMode = params.permissionMode || DEFAULT_PERMISSION_MODE;
|
||||
const result = await state.connection.newSession({
|
||||
cwd: sessionCwd,
|
||||
mcpServers: [],
|
||||
...(permissionMode ? { _meta: { permissionMode } } : {}),
|
||||
});
|
||||
|
||||
state.sessionId = result.sessionId;
|
||||
state.modelState = result.models ?? null;
|
||||
logSession.info({ sessionId: result.sessionId, cwd: sessionCwd, hasModels: !!result.models }, "created");
|
||||
|
||||
send(ws, "session_created", {
|
||||
...result,
|
||||
promptCapabilities: state.promptCapabilities,
|
||||
models: state.modelState,
|
||||
});
|
||||
} catch (error) {
|
||||
logSession.error({ error: (error as Error).message }, "create failed");
|
||||
send(ws, "error", { message: `Failed to create session: ${(error as Error).message}` });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Session History Operations
|
||||
// Reference: Zed's AgentConnection trait - list_sessions, load_session, resume_session
|
||||
// ============================================================================
|
||||
|
||||
async function handleListSessions(
|
||||
ws: WSContext,
|
||||
params: { cwd?: string; cursor?: string },
|
||||
): Promise<void> {
|
||||
const state = clients.get(ws);
|
||||
if (!state?.connection) {
|
||||
logAgent.warn({ hasState: !!state, hasProcess: !!state?.process, processKilled: state?.process?.killed, exitCode: state?.process?.exitCode }, "handleListSessions: not connected to agent");
|
||||
send(ws, "error", { message: "Not connected to agent" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state.agentCapabilities?.sessionCapabilities?.list) {
|
||||
send(ws, "error", { message: "Listing sessions is not supported by this agent" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await state.connection.listSessions({
|
||||
cwd: params.cwd,
|
||||
cursor: params.cursor,
|
||||
});
|
||||
|
||||
const MAX_SESSIONS = 20;
|
||||
const sessions = result.sessions.slice(0, MAX_SESSIONS);
|
||||
logSession.info({ total: result.sessions.length, returned: sessions.length, hasMore: !!result.nextCursor }, "listed");
|
||||
|
||||
send(ws, "session_list", {
|
||||
sessions: sessions.map((s: acp.SessionInfo) => ({
|
||||
_meta: s._meta,
|
||||
cwd: s.cwd,
|
||||
sessionId: s.sessionId,
|
||||
title: s.title,
|
||||
updatedAt: s.updatedAt,
|
||||
})),
|
||||
nextCursor: result.nextCursor,
|
||||
_meta: result._meta,
|
||||
});
|
||||
} catch (error) {
|
||||
logSession.error({ error: (error as Error).message }, "list failed");
|
||||
send(ws, "error", { message: `Failed to list sessions: ${(error as Error).message}` });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLoadSession(
|
||||
ws: WSContext,
|
||||
params: { sessionId: string; cwd?: string },
|
||||
): Promise<void> {
|
||||
const state = clients.get(ws);
|
||||
if (!state?.connection) {
|
||||
logAgent.warn({ hasState: !!state, hasProcess: !!state?.process, processKilled: state?.process?.killed, exitCode: state?.process?.exitCode }, "handleLoadSession: not connected to agent");
|
||||
send(ws, "error", { message: "Not connected to agent" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state.agentCapabilities?.loadSession) {
|
||||
send(ws, "error", { message: "Loading sessions is not supported by this agent" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionCwd = params.cwd || AGENT_CWD;
|
||||
const sessionId = params.sessionId;
|
||||
const result = await state.connection.loadSession({
|
||||
sessionId,
|
||||
cwd: sessionCwd,
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
state.sessionId = sessionId;
|
||||
state.modelState = result.models ?? null;
|
||||
logSession.info({ sessionId, cwd: sessionCwd }, "loaded");
|
||||
|
||||
send(ws, "session_loaded", {
|
||||
sessionId,
|
||||
promptCapabilities: state.promptCapabilities,
|
||||
models: state.modelState,
|
||||
});
|
||||
} catch (error) {
|
||||
logSession.error({ error: (error as Error).message }, "load failed");
|
||||
send(ws, "error", { message: `Failed to load session: ${(error as Error).message}` });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResumeSession(
|
||||
ws: WSContext,
|
||||
params: { sessionId: string; cwd?: string },
|
||||
): Promise<void> {
|
||||
const state = clients.get(ws);
|
||||
if (!state?.connection) {
|
||||
logAgent.warn({ hasState: !!state, hasProcess: !!state?.process, processKilled: state?.process?.killed, exitCode: state?.process?.exitCode }, "handleResumeSession: not connected to agent");
|
||||
send(ws, "error", { message: "Not connected to agent" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state.agentCapabilities?.sessionCapabilities?.resume) {
|
||||
send(ws, "error", { message: "Resuming sessions is not supported by this agent" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionCwd = params.cwd || AGENT_CWD;
|
||||
const sessionId = params.sessionId;
|
||||
const result = await state.connection.unstable_resumeSession({
|
||||
sessionId,
|
||||
cwd: sessionCwd,
|
||||
});
|
||||
|
||||
state.sessionId = sessionId;
|
||||
state.modelState = result.models ?? null;
|
||||
logSession.info({ sessionId, cwd: sessionCwd }, "resumed");
|
||||
|
||||
send(ws, "session_resumed", {
|
||||
sessionId,
|
||||
promptCapabilities: state.promptCapabilities,
|
||||
models: state.modelState,
|
||||
});
|
||||
} catch (error) {
|
||||
logSession.error({ error: (error as Error).message }, "resume failed");
|
||||
send(ws, "error", { message: `Failed to resume session: ${(error as Error).message}` });
|
||||
}
|
||||
}
|
||||
|
||||
// Reference: Zed's AcpThread.send() forwards Vec<acp::ContentBlock> to agent
|
||||
async function handlePrompt(
|
||||
ws: WSContext,
|
||||
params: { content: ContentBlock[] },
|
||||
): Promise<void> {
|
||||
const state = clients.get(ws);
|
||||
if (!state?.connection || !state.sessionId) {
|
||||
send(ws, "error", { message: "No active session" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const firstText = params.content.find(b => b.type === "text")?.text;
|
||||
const images = params.content.filter(b => b.type === "image");
|
||||
logPrompt.debug({
|
||||
text: firstText?.slice(0, 100),
|
||||
imageCount: images.length,
|
||||
blockCount: params.content.length,
|
||||
}, "sending");
|
||||
|
||||
const result = await state.connection.prompt({
|
||||
sessionId: state.sessionId,
|
||||
prompt: params.content as acp.ContentBlock[],
|
||||
});
|
||||
|
||||
logPrompt.info({ stopReason: result.stopReason }, "completed");
|
||||
send(ws, "prompt_complete", result);
|
||||
} catch (error) {
|
||||
logPrompt.error({ error: (error as Error).message }, "failed");
|
||||
send(ws, "error", { message: `Prompt failed: ${(error as Error).message}` });
|
||||
}
|
||||
}
|
||||
|
||||
function handleDisconnect(ws: WSContext): void {
|
||||
const state = clients.get(ws);
|
||||
if (!state) return;
|
||||
|
||||
if (state.process) {
|
||||
state.process.kill();
|
||||
state.process = null;
|
||||
}
|
||||
state.connection = null;
|
||||
state.sessionId = null;
|
||||
|
||||
send(ws, "status", { connected: false });
|
||||
}
|
||||
|
||||
// Handle cancel request from client
|
||||
async function handleCancel(ws: WSContext): Promise<void> {
|
||||
const state = clients.get(ws);
|
||||
if (!state?.connection || !state.sessionId) {
|
||||
logWs.warn("cancel requested but no active session");
|
||||
return;
|
||||
}
|
||||
|
||||
logSession.info({ sessionId: state.sessionId }, "cancel requested");
|
||||
cancelPendingPermissions(state);
|
||||
|
||||
try {
|
||||
await state.connection.cancel({ sessionId: state.sessionId });
|
||||
logSession.info({ sessionId: state.sessionId }, "cancel sent");
|
||||
} catch (error) {
|
||||
logSession.error({ error: (error as Error).message }, "cancel failed");
|
||||
}
|
||||
}
|
||||
|
||||
// Reference: Zed's AgentModelSelector.select_model() calls connection.set_session_model()
|
||||
async function handleSetSessionModel(
|
||||
ws: WSContext,
|
||||
params: { modelId: string },
|
||||
): Promise<void> {
|
||||
const state = clients.get(ws);
|
||||
if (!state?.connection || !state.sessionId) {
|
||||
send(ws, "error", { message: "No active session" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state.modelState) {
|
||||
send(ws, "error", { message: "Model selection not supported by this agent" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
logSession.info({ sessionId: state.sessionId, modelId: params.modelId }, "setting model");
|
||||
await state.connection.unstable_setSessionModel({
|
||||
sessionId: state.sessionId,
|
||||
modelId: params.modelId,
|
||||
});
|
||||
state.modelState = { ...state.modelState, currentModelId: params.modelId };
|
||||
send(ws, "model_changed", { modelId: params.modelId });
|
||||
logSession.info({ modelId: params.modelId }, "model changed");
|
||||
} catch (error) {
|
||||
logSession.error({ error: (error as Error).message }, "set model failed");
|
||||
send(ws, "error", { message: `Failed to set model: ${(error as Error).message}` });
|
||||
}
|
||||
}
|
||||
|
||||
// ContentBlock type matching @agentclientprotocol/sdk
|
||||
interface ContentBlock {
|
||||
type: string;
|
||||
text?: string;
|
||||
data?: string;
|
||||
mimeType?: string;
|
||||
uri?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface ProxyMessage {
|
||||
type: "connect" | "disconnect" | "new_session" | "prompt" | "cancel" | "set_session_model";
|
||||
payload?: { cwd?: string } | { content: ContentBlock[] } | { modelId: string };
|
||||
}
|
||||
|
||||
export async function startServer(config: ServerConfig): Promise<void> {
|
||||
const { port, host, command, args, cwd, token, https } = config;
|
||||
|
||||
// Set module-level config
|
||||
AGENT_COMMAND = command;
|
||||
AGENT_ARGS = args;
|
||||
AGENT_CWD = cwd;
|
||||
SERVER_PORT = port;
|
||||
SERVER_HOST = host;
|
||||
AUTH_TOKEN = token;
|
||||
DEFAULT_PERMISSION_MODE = config.permissionMode || process.env.ACP_PERMISSION_MODE;
|
||||
|
||||
// Initialize RCS upstream client if configured
|
||||
const rcsUrl = process.env.ACP_RCS_URL;
|
||||
const rcsToken = process.env.ACP_RCS_TOKEN;
|
||||
if (rcsUrl) {
|
||||
rcsUpstream = new RcsUpstreamClient({
|
||||
rcsUrl,
|
||||
apiToken: rcsToken || "",
|
||||
agentName: command,
|
||||
maxSessions: 1,
|
||||
});
|
||||
|
||||
const relayWs = createRelayWs();
|
||||
const relayState: ClientState = {
|
||||
process: null,
|
||||
connection: null,
|
||||
sessionId: null,
|
||||
pendingPermissions: new Map(),
|
||||
agentCapabilities: null,
|
||||
promptCapabilities: null,
|
||||
modelState: null,
|
||||
isAlive: true,
|
||||
};
|
||||
clients.set(relayWs, relayState);
|
||||
|
||||
rcsUpstream.setMessageHandler(async (msg) => {
|
||||
try {
|
||||
logRelay.debug({ type: msg.type }, "processing");
|
||||
switch (msg.type) {
|
||||
case "connect":
|
||||
await handleConnect(relayWs);
|
||||
break;
|
||||
case "disconnect":
|
||||
handleDisconnect(relayWs);
|
||||
break;
|
||||
case "new_session":
|
||||
await handleNewSession(relayWs, (msg.payload as { cwd?: string; permissionMode?: string }) || {});
|
||||
break;
|
||||
case "prompt":
|
||||
await handlePrompt(relayWs, msg.payload as { content: ContentBlock[] });
|
||||
break;
|
||||
case "permission_response":
|
||||
handlePermissionResponse(relayWs, msg.payload as { requestId: string; outcome: { outcome: "cancelled" } | { outcome: "selected"; optionId: string } });
|
||||
break;
|
||||
case "cancel":
|
||||
await handleCancel(relayWs);
|
||||
break;
|
||||
case "set_session_model":
|
||||
await handleSetSessionModel(relayWs, msg.payload as { modelId: string });
|
||||
break;
|
||||
case "list_sessions":
|
||||
await handleListSessions(relayWs, (msg.payload as { cwd?: string; cursor?: string }) || {});
|
||||
break;
|
||||
case "load_session":
|
||||
await handleLoadSession(relayWs, msg.payload as { sessionId: string; cwd?: string });
|
||||
break;
|
||||
case "resume_session":
|
||||
await handleResumeSession(relayWs, msg.payload as { sessionId: string; cwd?: string });
|
||||
break;
|
||||
case "ping":
|
||||
send(relayWs, "pong");
|
||||
break;
|
||||
default:
|
||||
logRelay.warn({ type: msg.type }, "unknown message type");
|
||||
}
|
||||
} catch (error) {
|
||||
logRelay.error({ error: (error as Error).message }, "handler error");
|
||||
}
|
||||
});
|
||||
|
||||
rcsUpstream.connect().catch((err) => {
|
||||
logRelay.warn({ error: (err as Error).message }, "initial connection failed");
|
||||
});
|
||||
logRelay.info({ url: rcsUrl }, "upstream enabled");
|
||||
}
|
||||
|
||||
const app = new Hono();
|
||||
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app });
|
||||
|
||||
// Health check endpoint
|
||||
app.get("/health", (c) => {
|
||||
return c.json({ status: "ok" });
|
||||
});
|
||||
|
||||
// WebSocket endpoint with token validation
|
||||
app.get(
|
||||
"/ws",
|
||||
upgradeWebSocket((c) => {
|
||||
if (AUTH_TOKEN) {
|
||||
const url = new URL(c.req.url);
|
||||
const providedToken = url.searchParams.get("token");
|
||||
if (providedToken !== AUTH_TOKEN) {
|
||||
logWs.warn("connection rejected: invalid token");
|
||||
return {
|
||||
onOpen(_event, ws) {
|
||||
ws.close(4001, "Unauthorized: Invalid token");
|
||||
},
|
||||
onMessage() {},
|
||||
onClose() {},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
onOpen(_event, ws) {
|
||||
logWs.info("client connected");
|
||||
const state: ClientState = {
|
||||
process: null,
|
||||
connection: null,
|
||||
sessionId: null,
|
||||
pendingPermissions: new Map(),
|
||||
agentCapabilities: null,
|
||||
promptCapabilities: null,
|
||||
modelState: null,
|
||||
isAlive: true,
|
||||
};
|
||||
clients.set(ws, state);
|
||||
|
||||
const rawWs = ws.raw as RawWebSocket;
|
||||
rawWs.on("pong", () => {
|
||||
state.isAlive = true;
|
||||
});
|
||||
},
|
||||
async onMessage(event, ws) {
|
||||
try {
|
||||
const data = JSON.parse(event.data.toString());
|
||||
logWs.debug({ type: data.type }, "received");
|
||||
|
||||
switch (data.type) {
|
||||
case "connect":
|
||||
await handleConnect(ws);
|
||||
break;
|
||||
case "disconnect":
|
||||
handleDisconnect(ws);
|
||||
break;
|
||||
case "new_session":
|
||||
await handleNewSession(ws, (data.payload as { cwd?: string; permissionMode?: string }) || {});
|
||||
break;
|
||||
case "prompt":
|
||||
await handlePrompt(ws, data.payload as { content: ContentBlock[] });
|
||||
break;
|
||||
case "permission_response":
|
||||
handlePermissionResponse(ws, data.payload);
|
||||
break;
|
||||
case "cancel":
|
||||
await handleCancel(ws);
|
||||
break;
|
||||
case "set_session_model":
|
||||
await handleSetSessionModel(ws, data.payload as { modelId: string });
|
||||
break;
|
||||
case "list_sessions":
|
||||
await handleListSessions(ws, (data.payload as { cwd?: string; cursor?: string }) || {});
|
||||
break;
|
||||
case "load_session":
|
||||
await handleLoadSession(ws, data.payload as { sessionId: string; cwd?: string });
|
||||
break;
|
||||
case "resume_session":
|
||||
await handleResumeSession(ws, data.payload as { sessionId: string; cwd?: string });
|
||||
break;
|
||||
case "ping":
|
||||
send(ws, "pong");
|
||||
break;
|
||||
default:
|
||||
send(ws, "error", { message: `Unknown message type: ${data.type}` });
|
||||
}
|
||||
} catch (error) {
|
||||
logWs.error({ error: (error as Error).message }, "message error");
|
||||
send(ws, "error", { message: `Error: ${(error as Error).message}` });
|
||||
}
|
||||
},
|
||||
onClose(_event, ws) {
|
||||
logWs.info("client disconnected");
|
||||
const state = clients.get(ws);
|
||||
if (state) {
|
||||
cancelPendingPermissions(state);
|
||||
}
|
||||
handleDisconnect(ws);
|
||||
clients.delete(ws);
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
// Create server with optional HTTPS
|
||||
let server;
|
||||
if (https) {
|
||||
const tlsOptions = await getOrCreateCertificate();
|
||||
server = serve({
|
||||
fetch: app.fetch,
|
||||
port,
|
||||
hostname: host,
|
||||
createServer: createHttpsServer,
|
||||
serverOptions: tlsOptions,
|
||||
});
|
||||
} else {
|
||||
server = serve({ fetch: app.fetch, port, hostname: host });
|
||||
}
|
||||
injectWebSocket(server);
|
||||
|
||||
// Heartbeat: periodically ping all connected clients
|
||||
setInterval(() => {
|
||||
for (const [ws, state] of clients) {
|
||||
// Skip virtual relay connections (no raw socket, always alive)
|
||||
if (!ws.raw && state.isAlive) continue;
|
||||
if (!ws.raw) {
|
||||
// Connection already closed, clean up
|
||||
clients.delete(ws);
|
||||
continue;
|
||||
}
|
||||
if (!state.isAlive) {
|
||||
logWs.info("heartbeat timeout, terminating");
|
||||
(ws.raw as RawWebSocket).terminate();
|
||||
continue;
|
||||
}
|
||||
state.isAlive = false;
|
||||
(ws.raw as RawWebSocket).ping();
|
||||
}
|
||||
}, HEARTBEAT_INTERVAL_MS);
|
||||
|
||||
// Protocol strings based on HTTPS mode
|
||||
const wsProtocol = https ? "wss" : "ws";
|
||||
|
||||
// Get actual LAN IP when binding to 0.0.0.0
|
||||
let displayHost = host;
|
||||
if (host === "0.0.0.0") {
|
||||
const lanIPs = getLanIPs();
|
||||
displayHost = lanIPs[0] || "localhost";
|
||||
}
|
||||
|
||||
// Build URLs
|
||||
const localWsUrl = `${wsProtocol}://localhost:${port}/ws`;
|
||||
const networkWsUrl = `${wsProtocol}://${displayHost}:${port}/ws`;
|
||||
|
||||
// Print startup banner
|
||||
console.log();
|
||||
console.log(` 🚀 ACP Proxy Server${https ? " (HTTPS)" : ""}`);
|
||||
console.log();
|
||||
console.log(` Connection:`);
|
||||
if (host === "0.0.0.0") {
|
||||
console.log(` URL: ${networkWsUrl}`);
|
||||
} else {
|
||||
console.log(` URL: ${localWsUrl}`);
|
||||
}
|
||||
if (AUTH_TOKEN) {
|
||||
console.log(` Token: ${AUTH_TOKEN}`);
|
||||
}
|
||||
console.log();
|
||||
if (!AUTH_TOKEN) {
|
||||
console.log(` ⚠️ Authentication disabled (--no-auth)`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
const agentDisplay = AGENT_ARGS.length > 0
|
||||
? `${AGENT_COMMAND} ${AGENT_ARGS.join(" ")}`
|
||||
: AGENT_COMMAND;
|
||||
console.log(` 📦 Agent: ${agentDisplay}`);
|
||||
console.log(` CWD: ${AGENT_CWD}`);
|
||||
console.log();
|
||||
console.log(` Press Ctrl+C to stop`);
|
||||
console.log();
|
||||
|
||||
logServer.info({
|
||||
port,
|
||||
host,
|
||||
https,
|
||||
wsEndpoint: `${wsProtocol}://${displayHost}:${port}/ws`,
|
||||
agent: AGENT_COMMAND,
|
||||
agentArgs: AGENT_ARGS,
|
||||
cwd: AGENT_CWD,
|
||||
authEnabled: !!AUTH_TOKEN,
|
||||
}, "started");
|
||||
|
||||
// Keep the server running
|
||||
await new Promise(() => {});
|
||||
}
|
||||
|
||||
// Graceful shutdown — close RCS upstream on process exit
|
||||
process.on("SIGINT", async () => {
|
||||
if (rcsUpstream) {
|
||||
await rcsUpstream.close();
|
||||
}
|
||||
process.exit(0);
|
||||
});
|
||||
process.on("SIGTERM", async () => {
|
||||
if (rcsUpstream) {
|
||||
await rcsUpstream.close();
|
||||
}
|
||||
process.exit(0);
|
||||
});
|
||||
150
packages/acp-link/src/types.ts
Normal file
150
packages/acp-link/src/types.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
// JSON-RPC 2.0 Types
|
||||
export interface JsonRpcRequest {
|
||||
jsonrpc: "2.0";
|
||||
id: string | number;
|
||||
method: string;
|
||||
params?: unknown;
|
||||
}
|
||||
|
||||
export interface JsonRpcResponse {
|
||||
jsonrpc: "2.0";
|
||||
id: string | number;
|
||||
result?: unknown;
|
||||
error?: JsonRpcError;
|
||||
}
|
||||
|
||||
export interface JsonRpcNotification {
|
||||
jsonrpc: "2.0";
|
||||
method: string;
|
||||
params?: unknown;
|
||||
}
|
||||
|
||||
export interface JsonRpcError {
|
||||
code: number;
|
||||
message: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
export type JsonRpcMessage =
|
||||
| JsonRpcRequest
|
||||
| JsonRpcResponse
|
||||
| JsonRpcNotification;
|
||||
|
||||
// Helper to check message types
|
||||
export function isRequest(msg: JsonRpcMessage): msg is JsonRpcRequest {
|
||||
return "method" in msg && "id" in msg;
|
||||
}
|
||||
|
||||
export function isResponse(msg: JsonRpcMessage): msg is JsonRpcResponse {
|
||||
return "id" in msg && !("method" in msg);
|
||||
}
|
||||
|
||||
export function isNotification(
|
||||
msg: JsonRpcMessage,
|
||||
): msg is JsonRpcNotification {
|
||||
return "method" in msg && !("id" in msg);
|
||||
}
|
||||
|
||||
// ACP Protocol Types
|
||||
|
||||
// Client -> Server messages (from extension to proxy)
|
||||
export interface ProxyConnectParams {
|
||||
command: string; // Command to launch the agent (e.g., "claude-agent")
|
||||
args?: string[]; // Optional arguments
|
||||
cwd?: string; // Working directory for the agent
|
||||
}
|
||||
|
||||
export interface ProxyMessage {
|
||||
type: "connect" | "disconnect" | "message";
|
||||
payload?: ProxyConnectParams | JsonRpcMessage;
|
||||
}
|
||||
|
||||
// Server -> Client messages (from proxy to extension)
|
||||
export interface ProxyStatus {
|
||||
type: "status";
|
||||
connected: boolean;
|
||||
agentInfo?: {
|
||||
name?: string;
|
||||
version?: string;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ProxyAgentMessage {
|
||||
type: "agent_message";
|
||||
payload: JsonRpcMessage;
|
||||
}
|
||||
|
||||
export interface ProxyError {
|
||||
type: "error";
|
||||
message: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export type ProxyResponse = ProxyStatus | ProxyAgentMessage | ProxyError;
|
||||
|
||||
// ACP Initialization
|
||||
export interface InitializeParams {
|
||||
protocolVersion: string;
|
||||
clientInfo: {
|
||||
name: string;
|
||||
version: string;
|
||||
};
|
||||
capabilities?: ClientCapabilities;
|
||||
}
|
||||
|
||||
export interface ClientCapabilities {
|
||||
streaming?: boolean;
|
||||
toolApproval?: boolean;
|
||||
}
|
||||
|
||||
export interface InitializeResult {
|
||||
protocolVersion: string;
|
||||
serverInfo: {
|
||||
name: string;
|
||||
version: string;
|
||||
};
|
||||
capabilities?: ServerCapabilities;
|
||||
}
|
||||
|
||||
export interface ServerCapabilities {
|
||||
streaming?: boolean;
|
||||
tools?: boolean;
|
||||
}
|
||||
|
||||
// ACP Session
|
||||
export interface SessionSetupParams {
|
||||
sessionId?: string;
|
||||
context?: SessionContext;
|
||||
}
|
||||
|
||||
export interface SessionContext {
|
||||
workingDirectory?: string;
|
||||
files?: string[];
|
||||
}
|
||||
|
||||
// ACP Prompt
|
||||
export interface PromptParams {
|
||||
sessionId: string;
|
||||
messages: PromptMessage[];
|
||||
}
|
||||
|
||||
export interface PromptMessage {
|
||||
role: "user" | "assistant";
|
||||
content: string | ContentPart[];
|
||||
}
|
||||
|
||||
export interface ContentPart {
|
||||
type: "text" | "image" | "file";
|
||||
text?: string;
|
||||
data?: string;
|
||||
mimeType?: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
// Content streaming notification
|
||||
export interface ContentNotification {
|
||||
sessionId: string;
|
||||
content: string;
|
||||
done?: boolean;
|
||||
}
|
||||
37
packages/acp-link/tsconfig.json
Normal file
37
packages/acp-link/tsconfig.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": ["ESNext"],
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleDetection": "force",
|
||||
"allowJs": true,
|
||||
|
||||
// Node.js module resolution
|
||||
"moduleResolution": "NodeNext",
|
||||
"verbatimModuleSyntax": true,
|
||||
|
||||
// Output
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "src/__tests__"]
|
||||
}
|
||||
@@ -3,8 +3,11 @@ import { z } from 'zod/v4'
|
||||
import type { ToolResultBlockParam } from 'src/Tool.js'
|
||||
import { buildTool } from 'src/Tool.js'
|
||||
import { lazySchema } from 'src/utils/lazySchema.js'
|
||||
import { notifyAutomationStateChanged } from 'src/utils/sessionState.js'
|
||||
import { SLEEP_TOOL_NAME, DESCRIPTION, SLEEP_TOOL_PROMPT } from './prompt.js'
|
||||
|
||||
const SLEEP_WAKE_CHECK_INTERVAL_MS = 500
|
||||
|
||||
const inputSchema = lazySchema(() =>
|
||||
z.strictObject({
|
||||
duration_seconds: z
|
||||
@@ -19,6 +22,36 @@ type SleepInput = z.infer<InputSchema>
|
||||
|
||||
type SleepOutput = { slept_seconds: number; interrupted: boolean }
|
||||
|
||||
function isProactiveAutomationEnabled(): boolean {
|
||||
if (!(feature('PROACTIVE') || feature('KAIROS'))) {
|
||||
return false
|
||||
}
|
||||
|
||||
const mod =
|
||||
require('src/proactive/index.js') as typeof import('src/proactive/index.js')
|
||||
return mod.isProactiveActive()
|
||||
}
|
||||
|
||||
function isProactiveSleepAllowed(): boolean {
|
||||
if (!(feature('PROACTIVE') || feature('KAIROS'))) {
|
||||
return true
|
||||
}
|
||||
|
||||
const mod =
|
||||
require('src/proactive/index.js') as typeof import('src/proactive/index.js')
|
||||
return mod.isProactiveActive()
|
||||
}
|
||||
|
||||
function hasQueuedWakeSignal(): boolean {
|
||||
const queue =
|
||||
require('src/utils/messageQueueManager.js') as typeof import('src/utils/messageQueueManager.js')
|
||||
return queue.hasCommandsInQueue()
|
||||
}
|
||||
|
||||
function shouldInterruptSleep(): boolean {
|
||||
return !isProactiveSleepAllowed() || hasQueuedWakeSignal()
|
||||
}
|
||||
|
||||
export const SleepTool = buildTool({
|
||||
name: SLEEP_TOOL_NAME,
|
||||
searchHint: 'wait pause sleep rest idle duration timer',
|
||||
@@ -42,6 +75,9 @@ export const SleepTool = buildTool({
|
||||
isReadOnly() {
|
||||
return true
|
||||
},
|
||||
interruptBehavior() {
|
||||
return 'cancel'
|
||||
},
|
||||
|
||||
userFacingName() {
|
||||
return SLEEP_TOOL_NAME
|
||||
@@ -67,53 +103,84 @@ export const SleepTool = buildTool({
|
||||
},
|
||||
|
||||
async call(input: SleepInput, context) {
|
||||
// Refuse to sleep when proactive mode is off — prevents the model from
|
||||
// re-issuing Sleep after an interruption caused by /proactive disable.
|
||||
if (feature('PROACTIVE') || feature('KAIROS')) {
|
||||
const mod =
|
||||
require('src/proactive/index.js') as typeof import('src/proactive/index.js')
|
||||
if (!mod.isProactiveActive()) {
|
||||
return {
|
||||
data: {
|
||||
slept_seconds: 0,
|
||||
interrupted: true,
|
||||
},
|
||||
}
|
||||
// Don't enter sleep if proactive was disabled or new work arrived while
|
||||
// the model was deciding to wait.
|
||||
if (shouldInterruptSleep()) {
|
||||
return {
|
||||
data: {
|
||||
slept_seconds: 0,
|
||||
interrupted: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const { duration_seconds } = input
|
||||
const startTime = Date.now()
|
||||
const sleepUntil = startTime + duration_seconds * 1000
|
||||
|
||||
if (isProactiveAutomationEnabled()) {
|
||||
notifyAutomationStateChanged({
|
||||
enabled: true,
|
||||
phase: 'sleeping',
|
||||
next_tick_at: null,
|
||||
sleep_until: sleepUntil,
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(resolve, duration_seconds * 1000)
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let wakeCheck: ReturnType<typeof setInterval> | null = null
|
||||
let settled = false
|
||||
|
||||
const cleanup = () => {
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
if (wakeCheck !== null) {
|
||||
clearInterval(wakeCheck)
|
||||
wakeCheck = null
|
||||
}
|
||||
context.abortController.signal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
|
||||
const finish = () => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
cleanup()
|
||||
resolve()
|
||||
}
|
||||
|
||||
const interrupt = () => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
cleanup()
|
||||
reject(new Error('interrupted'))
|
||||
}
|
||||
|
||||
const onAbort = () => {
|
||||
interrupt()
|
||||
}
|
||||
|
||||
timer = setTimeout(finish, duration_seconds * 1000)
|
||||
|
||||
// Abort via user interrupt
|
||||
context.abortController.signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
clearTimeout(timer)
|
||||
clearInterval(proactiveCheck)
|
||||
reject(new Error('interrupted'))
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
if (context.abortController.signal.aborted) {
|
||||
interrupt()
|
||||
return
|
||||
}
|
||||
context.abortController.signal.addEventListener('abort', onAbort, {
|
||||
once: true,
|
||||
})
|
||||
|
||||
// Poll proactive state — if deactivated mid-sleep, interrupt early
|
||||
// so the user doesn't have to wait for the full duration.
|
||||
const proactiveCheck =
|
||||
feature('PROACTIVE') || feature('KAIROS')
|
||||
? setInterval(() => {
|
||||
const mod =
|
||||
require('src/proactive/index.js') as typeof import('src/proactive/index.js')
|
||||
if (!mod.isProactiveActive()) {
|
||||
clearTimeout(timer)
|
||||
clearInterval(proactiveCheck)
|
||||
reject(new Error('interrupted'))
|
||||
}
|
||||
}, 500)
|
||||
: (null as unknown as ReturnType<typeof setInterval>)
|
||||
// Poll proactive state and the shared command queue so new work can
|
||||
// wake Sleep without waiting for the full duration.
|
||||
wakeCheck = setInterval(() => {
|
||||
if (shouldInterruptSleep()) {
|
||||
interrupt()
|
||||
}
|
||||
}, SLEEP_WAKE_CHECK_INTERVAL_MS)
|
||||
})
|
||||
return {
|
||||
data: {
|
||||
@@ -129,6 +196,17 @@ export const SleepTool = buildTool({
|
||||
interrupted: true,
|
||||
},
|
||||
}
|
||||
} finally {
|
||||
notifyAutomationStateChanged(
|
||||
isProactiveAutomationEnabled()
|
||||
? {
|
||||
enabled: true,
|
||||
phase: null,
|
||||
next_tick_at: null,
|
||||
sleep_until: null,
|
||||
}
|
||||
: null,
|
||||
)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { beforeEach, describe, expect, test } from 'bun:test'
|
||||
import { SleepTool } from '../SleepTool'
|
||||
import {
|
||||
enqueue,
|
||||
getCommandQueue,
|
||||
resetCommandQueue,
|
||||
} from 'src/utils/messageQueueManager.js'
|
||||
|
||||
describe('SleepTool', () => {
|
||||
beforeEach(() => {
|
||||
resetCommandQueue()
|
||||
})
|
||||
|
||||
test('declares cancel interrupt behavior', () => {
|
||||
expect(SleepTool.interruptBehavior()).toBe('cancel')
|
||||
})
|
||||
|
||||
test('wakes early when queued work arrives', async () => {
|
||||
const sleepPromise = SleepTool.call(
|
||||
{ duration_seconds: 10 },
|
||||
{ abortController: new AbortController() } as any,
|
||||
)
|
||||
|
||||
setTimeout(() => {
|
||||
enqueue({
|
||||
value: 'wake up',
|
||||
mode: 'prompt',
|
||||
})
|
||||
}, 20)
|
||||
|
||||
const result = await sleepPromise
|
||||
|
||||
expect(result.data.interrupted).toBe(true)
|
||||
expect(result.data.slept_seconds).toBeLessThan(10)
|
||||
expect(getCommandQueue()).toHaveLength(1)
|
||||
expect(getCommandQueue()[0]).toMatchObject({
|
||||
value: 'wake up',
|
||||
mode: 'prompt',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -4,10 +4,21 @@ WORKDIR /app
|
||||
|
||||
ARG VERSION=0.1.0
|
||||
|
||||
# Copy package files for install
|
||||
COPY packages/remote-control-server/package.json ./package.json
|
||||
|
||||
# Install all dependencies (including devDeps for vite build)
|
||||
RUN bun install
|
||||
|
||||
# Copy source code
|
||||
COPY packages/remote-control-server/src ./src
|
||||
COPY packages/remote-control-server/tsconfig.json ./tsconfig.json
|
||||
|
||||
# Copy web frontend source and build it
|
||||
COPY packages/remote-control-server/web ./web
|
||||
RUN bun run build:web
|
||||
|
||||
# Build backend
|
||||
RUN bun build src/index.ts --outfile=dist/server.js --target=bun \
|
||||
--define "process.env.RCS_VERSION=\"${VERSION}\""
|
||||
|
||||
@@ -19,8 +30,9 @@ ENV RCS_VERSION=${VERSION}
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy built artifacts
|
||||
COPY --from=builder /app/dist/server.js ./dist/server.js
|
||||
COPY packages/remote-control-server/web ./web
|
||||
COPY --from=builder /app/web/dist ./web/dist
|
||||
|
||||
VOLUME /app/data
|
||||
|
||||
|
||||
@@ -99,6 +99,13 @@ volumes:
|
||||
rcs-data:
|
||||
```
|
||||
|
||||
## ACP 兼容的 remote-control
|
||||
|
||||
|
||||
```sh
|
||||
ACP_RCS_URL=http://localhost:3000 ACP_RCS_TOKEN=test-my-key acp-link ccb-bun -- --acp
|
||||
```
|
||||
|
||||
## 反向代理配置
|
||||
|
||||
使用 Nginx 或 Caddy 反向代理时,需要支持 WebSocket 升级:
|
||||
|
||||
23
packages/remote-control-server/components.json
Normal file
23
packages/remote-control-server/components.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/styles/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
|
||||
@@ -4,24 +4,60 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun run --watch src/index.ts",
|
||||
"dev:web": "cd web && bunx vite",
|
||||
"start": "bun run src/index.ts",
|
||||
"build:web": "cd web && bun run build",
|
||||
"build:web": "cd web && bunx vite build",
|
||||
"preview:web": "cd web && bunx vite preview",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/react": "^3.0.170",
|
||||
"ai": "^6.0.168",
|
||||
"hono": "^4.7.0",
|
||||
"uuid": "^11.0.0"
|
||||
"jsqr": "^1.4.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"uuid": "^11.0.0",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||
"browser-image-compression": "^2.0.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"lucide-react": "^0.555.0",
|
||||
"motion": "^12.29.2",
|
||||
"nanoid": "^5.1.6",
|
||||
"qr-scanner": "^1.4.2",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19",
|
||||
"react-dom": "^19",
|
||||
"react-resizable-panels": "^4",
|
||||
"shiki": "^3.17.0",
|
||||
"streamdown": "^1.6.8",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"use-stick-to-bottom": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.0.0",
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"@tailwindcss/vite": "^4.0.0"
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.0.0",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import {
|
||||
getAutomationStateSnapshot,
|
||||
getAutomationStateEventPayload,
|
||||
automationStatesEqual,
|
||||
} from "../services/automationState";
|
||||
import type { AutomationStateResponse } from "../types/api";
|
||||
|
||||
// =============================================================================
|
||||
// normalizeAutomationState (via getAutomationStateSnapshot)
|
||||
// =============================================================================
|
||||
|
||||
describe("normalizeAutomationState", () => {
|
||||
test("returns undefined when metadata has no automation_state key", () => {
|
||||
expect(getAutomationStateSnapshot({})).toBeUndefined();
|
||||
expect(getAutomationStateSnapshot({ other: true })).toBeUndefined();
|
||||
expect(getAutomationStateSnapshot(null)).toBeUndefined();
|
||||
expect(getAutomationStateSnapshot(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns disabled state for null automation_state", () => {
|
||||
const result = getAutomationStateSnapshot({ automation_state: null });
|
||||
expect(result).toEqual({
|
||||
enabled: false,
|
||||
phase: null,
|
||||
next_tick_at: null,
|
||||
sleep_until: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns disabled state for non-object automation_state", () => {
|
||||
for (const val of ["string", 123, true, []]) {
|
||||
const result = getAutomationStateSnapshot({ automation_state: val });
|
||||
expect(result?.enabled).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test("normalizes enabled: true correctly", () => {
|
||||
const result = getAutomationStateSnapshot({ automation_state: { enabled: true } });
|
||||
expect(result?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
test("normalizes enabled to false for non-true values", () => {
|
||||
const result = getAutomationStateSnapshot({ automation_state: { enabled: "yes" } });
|
||||
expect(result?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts phase: standby", () => {
|
||||
const result = getAutomationStateSnapshot({ automation_state: { enabled: true, phase: "standby" } });
|
||||
expect(result?.phase).toBe("standby");
|
||||
});
|
||||
|
||||
test("accepts phase: sleeping", () => {
|
||||
const result = getAutomationStateSnapshot({ automation_state: { enabled: true, phase: "sleeping" } });
|
||||
expect(result?.phase).toBe("sleeping");
|
||||
});
|
||||
|
||||
test("rejects invalid phase values", () => {
|
||||
for (const phase of ["running", "idle", "active", "", null]) {
|
||||
const result = getAutomationStateSnapshot({ automation_state: { enabled: true, phase } });
|
||||
expect(result?.phase).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test("normalizes next_tick_at as number", () => {
|
||||
const result = getAutomationStateSnapshot({ automation_state: { enabled: true, next_tick_at: 12345 } });
|
||||
expect(result?.next_tick_at).toBe(12345);
|
||||
});
|
||||
|
||||
test("normalizes next_tick_at as null for non-number", () => {
|
||||
const result = getAutomationStateSnapshot({ automation_state: { enabled: true, next_tick_at: "soon" } });
|
||||
expect(result?.next_tick_at).toBeNull();
|
||||
});
|
||||
|
||||
test("normalizes sleep_until as number", () => {
|
||||
const result = getAutomationStateSnapshot({ automation_state: { enabled: true, sleep_until: 99999 } });
|
||||
expect(result?.sleep_until).toBe(99999);
|
||||
});
|
||||
|
||||
test("normalizes sleep_until as null for non-number", () => {
|
||||
const result = getAutomationStateSnapshot({ automation_state: { enabled: true, sleep_until: false } });
|
||||
expect(result?.sleep_until).toBeNull();
|
||||
});
|
||||
|
||||
test("fully normalizes a complete valid state", () => {
|
||||
const result = getAutomationStateSnapshot({
|
||||
automation_state: { enabled: true, phase: "sleeping", next_tick_at: 100, sleep_until: 200 },
|
||||
});
|
||||
expect(result).toEqual({
|
||||
enabled: true,
|
||||
phase: "sleeping",
|
||||
next_tick_at: 100,
|
||||
sleep_until: 200,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// getAutomationStateEventPayload
|
||||
// =============================================================================
|
||||
|
||||
describe("getAutomationStateEventPayload", () => {
|
||||
test("returns disabled default when no automation_state in metadata", () => {
|
||||
const result = getAutomationStateEventPayload({});
|
||||
expect(result).toEqual({
|
||||
enabled: false,
|
||||
phase: null,
|
||||
next_tick_at: null,
|
||||
sleep_until: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns disabled default for null metadata", () => {
|
||||
const result = getAutomationStateEventPayload(null);
|
||||
expect(result).toEqual({
|
||||
enabled: false,
|
||||
phase: null,
|
||||
next_tick_at: null,
|
||||
sleep_until: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns normalized state when automation_state present", () => {
|
||||
const result = getAutomationStateEventPayload({
|
||||
automation_state: { enabled: true, phase: "standby", next_tick_at: 50, sleep_until: 60 },
|
||||
});
|
||||
expect(result).toEqual({
|
||||
enabled: true,
|
||||
phase: "standby",
|
||||
next_tick_at: 50,
|
||||
sleep_until: 60,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns a new object each call (not frozen reference)", () => {
|
||||
const a = getAutomationStateEventPayload({});
|
||||
const b = getAutomationStateEventPayload({});
|
||||
expect(a).toEqual(b);
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// automationStatesEqual
|
||||
// =============================================================================
|
||||
|
||||
describe("automationStatesEqual", () => {
|
||||
const base: AutomationStateResponse = {
|
||||
enabled: true,
|
||||
phase: "standby",
|
||||
next_tick_at: 100,
|
||||
sleep_until: 200,
|
||||
};
|
||||
|
||||
test("returns true for identical states", () => {
|
||||
expect(automationStatesEqual(base, { ...base })).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false when enabled differs", () => {
|
||||
expect(automationStatesEqual(base, { ...base, enabled: false })).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false when phase differs", () => {
|
||||
expect(automationStatesEqual(base, { ...base, phase: "sleeping" })).toBe(false);
|
||||
expect(automationStatesEqual(base, { ...base, phase: null })).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false when next_tick_at differs", () => {
|
||||
expect(automationStatesEqual(base, { ...base, next_tick_at: 999 })).toBe(false);
|
||||
expect(automationStatesEqual(base, { ...base, next_tick_at: null })).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false when sleep_until differs", () => {
|
||||
expect(automationStatesEqual(base, { ...base, sleep_until: 999 })).toBe(false);
|
||||
expect(automationStatesEqual(base, { ...base, sleep_until: null })).toBe(false);
|
||||
});
|
||||
|
||||
test("returns true when both are disabled defaults", () => {
|
||||
const disabled: AutomationStateResponse = { enabled: false, phase: null, next_tick_at: null, sleep_until: null };
|
||||
expect(automationStatesEqual(disabled, { ...disabled })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { toClientPayload } from "../transport/client-payload";
|
||||
import type { SessionEvent } from "../transport/event-bus";
|
||||
|
||||
function makeEvent(overrides: Partial<SessionEvent> & Pick<SessionEvent, "type" | "sessionId">): SessionEvent {
|
||||
return {
|
||||
id: "evt-1",
|
||||
payload: null,
|
||||
direction: "inbound",
|
||||
seqNum: 1,
|
||||
createdAt: Date.now(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// user / user_message
|
||||
// =============================================================================
|
||||
|
||||
describe("toClientPayload — user message", () => {
|
||||
test("maps user type with content", () => {
|
||||
const event = makeEvent({
|
||||
type: "user",
|
||||
sessionId: "sess-1",
|
||||
payload: { content: "hello" },
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect(result.type).toBe("user");
|
||||
expect(result.session_id).toBe("sess-1");
|
||||
expect((result as any).message.role).toBe("user");
|
||||
expect((result as any).message.content).toBe("hello");
|
||||
});
|
||||
|
||||
test("maps user_message type same as user", () => {
|
||||
const event = makeEvent({
|
||||
type: "user_message",
|
||||
sessionId: "sess-2",
|
||||
payload: { content: "world" },
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect(result.type).toBe("user");
|
||||
expect(result.session_id).toBe("sess-2");
|
||||
});
|
||||
|
||||
test("falls back to message field when content is missing", () => {
|
||||
const event = makeEvent({
|
||||
type: "user",
|
||||
sessionId: "sess-3",
|
||||
payload: { message: "fallback msg" },
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect((result as any).message.content).toBe("fallback msg");
|
||||
});
|
||||
|
||||
test("falls back to empty string when both content and message missing", () => {
|
||||
const event = makeEvent({
|
||||
type: "user",
|
||||
sessionId: "sess-4",
|
||||
payload: {},
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect((result as any).message.content).toBe("");
|
||||
});
|
||||
|
||||
test("includes isSynthetic when true", () => {
|
||||
const event = makeEvent({
|
||||
type: "user",
|
||||
sessionId: "sess-5",
|
||||
payload: { content: "auto", isSynthetic: true },
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect((result as any).isSynthetic).toBe(true);
|
||||
});
|
||||
|
||||
test("does not include isSynthetic when false", () => {
|
||||
const event = makeEvent({
|
||||
type: "user",
|
||||
sessionId: "sess-6",
|
||||
payload: { content: "manual", isSynthetic: false },
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect((result as any).isSynthetic).toBeUndefined();
|
||||
});
|
||||
|
||||
test("uses payload.uuid when present", () => {
|
||||
const event = makeEvent({
|
||||
type: "user",
|
||||
sessionId: "sess-7",
|
||||
payload: { content: "hi", uuid: "custom-uuid" },
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect(result.uuid).toBe("custom-uuid");
|
||||
});
|
||||
|
||||
test("falls back to event.id when payload.uuid is missing", () => {
|
||||
const event = makeEvent({
|
||||
type: "user",
|
||||
sessionId: "sess-8",
|
||||
payload: { content: "hi" },
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect(result.uuid).toBe("evt-1");
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// permission_response / control_response
|
||||
// =============================================================================
|
||||
|
||||
describe("toClientPayload — permission response", () => {
|
||||
test("approved=true maps to allow behavior", () => {
|
||||
const event = makeEvent({
|
||||
type: "permission_response",
|
||||
sessionId: "sess-1",
|
||||
payload: { approved: true, request_id: "req-1" },
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect(result.type).toBe("control_response");
|
||||
const resp = (result as any).response;
|
||||
expect(resp.subtype).toBe("success");
|
||||
expect(resp.request_id).toBe("req-1");
|
||||
expect(resp.response.behavior).toBe("allow");
|
||||
});
|
||||
|
||||
test("approved=false maps to deny behavior with error", () => {
|
||||
const event = makeEvent({
|
||||
type: "permission_response",
|
||||
sessionId: "sess-2",
|
||||
payload: { approved: false, request_id: "req-2" },
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect(result.type).toBe("control_response");
|
||||
const resp = (result as any).response;
|
||||
expect(resp.subtype).toBe("error");
|
||||
expect(resp.error).toBe("Permission denied by user");
|
||||
expect(resp.response.behavior).toBe("deny");
|
||||
});
|
||||
|
||||
test("approved=false includes feedback message when provided", () => {
|
||||
const event = makeEvent({
|
||||
type: "permission_response",
|
||||
sessionId: "sess-3",
|
||||
payload: { approved: false, request_id: "req-3", message: "please revise" },
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect((result as any).response.message).toBe("please revise");
|
||||
});
|
||||
|
||||
test("passes through existingResponse directly", () => {
|
||||
const existingResponse = { subtype: "success", custom: true };
|
||||
const event = makeEvent({
|
||||
type: "control_response",
|
||||
sessionId: "sess-4",
|
||||
payload: { approved: true, response: existingResponse },
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect(result.type).toBe("control_response");
|
||||
expect((result as any).response).toBe(existingResponse);
|
||||
});
|
||||
|
||||
test("includes updatedInput when approved with updated_input", () => {
|
||||
const updatedInput = { file_path: "/new/path" };
|
||||
const event = makeEvent({
|
||||
type: "permission_response",
|
||||
sessionId: "sess-5",
|
||||
payload: { approved: true, request_id: "req-5", updated_input: updatedInput },
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect((result as any).response.response.updatedInput).toEqual(updatedInput);
|
||||
});
|
||||
|
||||
test("includes updatedPermissions when approved with updated_permissions", () => {
|
||||
const perms = [{ type: "allow", tool: "bash" }];
|
||||
const event = makeEvent({
|
||||
type: "permission_response",
|
||||
sessionId: "sess-6",
|
||||
payload: { approved: true, request_id: "req-6", updated_permissions: perms },
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect((result as any).response.response.updatedPermissions).toEqual(perms);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// interrupt
|
||||
// =============================================================================
|
||||
|
||||
describe("toClientPayload — interrupt", () => {
|
||||
test("maps interrupt to control_request with subtype interrupt", () => {
|
||||
const event = makeEvent({
|
||||
type: "interrupt",
|
||||
sessionId: "sess-1",
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect(result.type).toBe("control_request");
|
||||
expect((result as any).request_id).toBe("evt-1");
|
||||
expect((result as any).request.subtype).toBe("interrupt");
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// control_request
|
||||
// =============================================================================
|
||||
|
||||
describe("toClientPayload — control_request", () => {
|
||||
test("passes through request_id and request from payload", () => {
|
||||
const event = makeEvent({
|
||||
type: "control_request",
|
||||
sessionId: "sess-1",
|
||||
payload: { request_id: "req-99", request: { subtype: "permission", tool: "bash" } },
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect(result.type).toBe("control_request");
|
||||
expect((result as any).request_id).toBe("req-99");
|
||||
expect((result as any).request.subtype).toBe("permission");
|
||||
});
|
||||
|
||||
test("falls back request to payload when no request field", () => {
|
||||
const event = makeEvent({
|
||||
type: "control_request",
|
||||
sessionId: "sess-2",
|
||||
payload: { request_id: "req-10", custom: "data" },
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect((result as any).request).toEqual({ request_id: "req-10", custom: "data" });
|
||||
});
|
||||
|
||||
test("falls back request_id to event.id when missing", () => {
|
||||
const event = makeEvent({
|
||||
type: "control_request",
|
||||
sessionId: "sess-3",
|
||||
payload: { request: { subtype: "test" } },
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect((result as any).request_id).toBe("evt-1");
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// default fallback
|
||||
// =============================================================================
|
||||
|
||||
describe("toClientPayload — default types", () => {
|
||||
test("passes through unknown type with type/uuid/session_id/message", () => {
|
||||
const event = makeEvent({
|
||||
type: "assistant",
|
||||
sessionId: "sess-1",
|
||||
payload: { uuid: "u-1", content: "response text" },
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect(result.type).toBe("assistant");
|
||||
expect(result.uuid).toBe("u-1");
|
||||
expect(result.session_id).toBe("sess-1");
|
||||
expect(result.message).toEqual({ uuid: "u-1", content: "response text" });
|
||||
});
|
||||
});
|
||||
@@ -678,6 +678,44 @@ describe("Web Session Routes", () => {
|
||||
expect(getRes.status).toBe(200);
|
||||
});
|
||||
|
||||
test("GET /web/sessions/:id — includes automation_state snapshot when worker metadata has it", async () => {
|
||||
const createRes = await app.request("/v1/code/sessions", {
|
||||
method: "POST",
|
||||
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const {
|
||||
session: { id },
|
||||
} = await createRes.json();
|
||||
storeBindSession(id, "user-1");
|
||||
|
||||
await app.request(`/v1/code/sessions/${id}/worker`, {
|
||||
method: "PUT",
|
||||
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
worker_epoch: 1,
|
||||
external_metadata: {
|
||||
automation_state: {
|
||||
enabled: true,
|
||||
phase: "standby",
|
||||
next_tick_at: 123456,
|
||||
sleep_until: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const getRes = await app.request(`/web/sessions/${toWebSessionId(id)}?uuid=user-1`);
|
||||
expect(getRes.status).toBe(200);
|
||||
const body = await getRes.json();
|
||||
expect(body.automation_state).toEqual({
|
||||
enabled: true,
|
||||
phase: "standby",
|
||||
next_tick_at: 123456,
|
||||
sleep_until: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("GET /web/sessions/:id — 403 for non-owner", async () => {
|
||||
const createRes = await app.request("/web/sessions?uuid=user-1", {
|
||||
method: "POST",
|
||||
@@ -704,6 +742,35 @@ describe("Web Session Routes", () => {
|
||||
expect(body.events).toEqual([]);
|
||||
});
|
||||
|
||||
test("GET /web/sessions/:id/history — returns task_state snapshots", async () => {
|
||||
const createRes = await app.request("/web/sessions?uuid=user-1", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const { id } = await createRes.json();
|
||||
|
||||
publishSessionEvent(
|
||||
id,
|
||||
"task_state",
|
||||
{
|
||||
task_list_id: "team-alpha",
|
||||
tasks: [{ id: "1", subject: "Investigate", status: "pending" }],
|
||||
},
|
||||
"inbound",
|
||||
);
|
||||
|
||||
const histRes = await app.request(`/web/sessions/${id}/history?uuid=user-1`);
|
||||
expect(histRes.status).toBe(200);
|
||||
const body = await histRes.json();
|
||||
expect(body.events).toHaveLength(1);
|
||||
expect(body.events[0]?.type).toBe("task_state");
|
||||
expect(body.events[0]?.payload.task_list_id).toBe("team-alpha");
|
||||
expect(body.events[0]?.payload.tasks).toEqual([
|
||||
{ id: "1", subject: "Investigate", status: "pending" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("GET /web/sessions/:id and history — supports compat code session IDs", async () => {
|
||||
const codeSession = storeCreateSession({ idPrefix: "cse_" });
|
||||
storeBindSession(codeSession.id, "user-1");
|
||||
@@ -1218,7 +1285,15 @@ describe("V2 Worker Events Routes", () => {
|
||||
body: JSON.stringify({
|
||||
worker_epoch: 1,
|
||||
worker_status: "running",
|
||||
external_metadata: { permission_mode: "default" },
|
||||
external_metadata: {
|
||||
permission_mode: "default",
|
||||
automation_state: {
|
||||
enabled: true,
|
||||
phase: "sleeping",
|
||||
next_tick_at: null,
|
||||
sleep_until: 123456,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(putRes.status).toBe(200);
|
||||
@@ -1230,6 +1305,21 @@ describe("V2 Worker Events Routes", () => {
|
||||
const body = await getRes.json();
|
||||
expect(body.worker.worker_status).toBe("running");
|
||||
expect(body.worker.external_metadata.permission_mode).toBe("default");
|
||||
expect(body.worker.external_metadata.automation_state).toEqual({
|
||||
enabled: true,
|
||||
phase: "sleeping",
|
||||
next_tick_at: null,
|
||||
sleep_until: 123456,
|
||||
});
|
||||
|
||||
const events = getEventBus(id).getEventsSince(0);
|
||||
expect(events.some((event) => event.type === "automation_state")).toBe(true);
|
||||
expect(events.at(-1)?.payload).toEqual({
|
||||
enabled: true,
|
||||
phase: "sleeping",
|
||||
next_tick_at: null,
|
||||
sleep_until: 123456,
|
||||
});
|
||||
});
|
||||
|
||||
test("POST /v1/code/sessions/:id/worker/heartbeat — updates heartbeat", async () => {
|
||||
@@ -1284,6 +1374,123 @@ describe("V2 Worker Events Routes", () => {
|
||||
reader.cancel();
|
||||
});
|
||||
|
||||
test("GET /v1/code/sessions/:id/worker/events/stream — normalizes web permission approvals to control_response", async () => {
|
||||
const createRes = await app.request("/web/sessions?uuid=user-1", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const { id } = await createRes.json();
|
||||
|
||||
const streamRes = await app.request(`/v1/code/sessions/${id}/worker/events/stream`, {
|
||||
headers: AUTH_HEADERS,
|
||||
});
|
||||
expect(streamRes.status).toBe(200);
|
||||
|
||||
const reader = streamRes.body?.getReader();
|
||||
expect(reader).toBeTruthy();
|
||||
if (!reader) return;
|
||||
|
||||
await reader.read(); // initial keepalive
|
||||
|
||||
const controlRes = await app.request(`/web/sessions/${id}/control?uuid=user-1`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "permission_response",
|
||||
approved: true,
|
||||
request_id: "req-1",
|
||||
}),
|
||||
});
|
||||
expect(controlRes.status).toBe(200);
|
||||
|
||||
const chunk = await reader.read();
|
||||
const frame = new TextDecoder().decode(chunk.value!);
|
||||
expect(frame).toContain("event: client_event");
|
||||
expect(frame).toContain("\"event_type\":\"permission_response\"");
|
||||
expect(frame).toContain("\"payload\":{\"type\":\"control_response\"");
|
||||
expect(frame).toContain("\"request_id\":\"req-1\"");
|
||||
expect(frame).toContain("\"behavior\":\"allow\"");
|
||||
reader.cancel();
|
||||
});
|
||||
|
||||
test("GET /v1/code/sessions/:id/worker/events/stream — normalizes web plan rejection feedback to deny control_response", async () => {
|
||||
const createRes = await app.request("/web/sessions?uuid=user-1", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const { id } = await createRes.json();
|
||||
|
||||
const streamRes = await app.request(`/v1/code/sessions/${id}/worker/events/stream`, {
|
||||
headers: AUTH_HEADERS,
|
||||
});
|
||||
expect(streamRes.status).toBe(200);
|
||||
|
||||
const reader = streamRes.body?.getReader();
|
||||
expect(reader).toBeTruthy();
|
||||
if (!reader) return;
|
||||
|
||||
await reader.read(); // initial keepalive
|
||||
|
||||
const controlRes = await app.request(`/web/sessions/${id}/control?uuid=user-1`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "permission_response",
|
||||
approved: false,
|
||||
request_id: "req-2",
|
||||
message: "Need more detail",
|
||||
}),
|
||||
});
|
||||
expect(controlRes.status).toBe(200);
|
||||
|
||||
const chunk = await reader.read();
|
||||
const frame = new TextDecoder().decode(chunk.value!);
|
||||
expect(frame).toContain("event: client_event");
|
||||
expect(frame).toContain("\"event_type\":\"permission_response\"");
|
||||
expect(frame).toContain("\"payload\":{\"type\":\"control_response\"");
|
||||
expect(frame).toContain("\"request_id\":\"req-2\"");
|
||||
expect(frame).toContain("\"subtype\":\"error\"");
|
||||
expect(frame).toContain("\"behavior\":\"deny\"");
|
||||
expect(frame).toContain("\"message\":\"Need more detail\"");
|
||||
reader.cancel();
|
||||
});
|
||||
|
||||
test("GET /v1/code/sessions/:id/worker/events/stream — normalizes web interrupts to control_request", async () => {
|
||||
const createRes = await app.request("/web/sessions?uuid=user-1", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const { id } = await createRes.json();
|
||||
|
||||
const streamRes = await app.request(`/v1/code/sessions/${id}/worker/events/stream`, {
|
||||
headers: AUTH_HEADERS,
|
||||
});
|
||||
expect(streamRes.status).toBe(200);
|
||||
|
||||
const reader = streamRes.body?.getReader();
|
||||
expect(reader).toBeTruthy();
|
||||
if (!reader) return;
|
||||
|
||||
await reader.read(); // initial keepalive
|
||||
|
||||
const interruptRes = await app.request(`/web/sessions/${id}/interrupt?uuid=user-1`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
expect(interruptRes.status).toBe(200);
|
||||
|
||||
const chunk = await reader.read();
|
||||
const frame = new TextDecoder().decode(chunk.value!);
|
||||
expect(frame).toContain("event: client_event");
|
||||
expect(frame).toContain("\"event_type\":\"interrupt\"");
|
||||
expect(frame).toContain("\"payload\":{\"type\":\"control_request\"");
|
||||
expect(frame).toContain("\"subtype\":\"interrupt\"");
|
||||
reader.cancel();
|
||||
});
|
||||
|
||||
test("PUT /v1/code/sessions/:id/worker/state — updates session status", async () => {
|
||||
const sessRes = await app.request("/v1/sessions", {
|
||||
method: "POST",
|
||||
|
||||
@@ -353,6 +353,14 @@ describe("Transport Service", () => {
|
||||
expect(result.uuid).toBe("msg_123");
|
||||
});
|
||||
|
||||
test("preserves isSynthetic field", () => {
|
||||
const result = normalizePayload("user", {
|
||||
content: "scheduled job: refresh analytics cache",
|
||||
isSynthetic: true,
|
||||
});
|
||||
expect(result.isSynthetic).toBe(true);
|
||||
});
|
||||
|
||||
test("uses name as tool_name fallback", () => {
|
||||
const result = normalizePayload("tool", { name: "Read" });
|
||||
expect(result.tool_name).toBe("Read");
|
||||
@@ -370,6 +378,28 @@ describe("Transport Service", () => {
|
||||
expect(result.content).toBe("");
|
||||
});
|
||||
|
||||
test("preserves task_state fields", () => {
|
||||
const result = normalizePayload("task_state", {
|
||||
task_list_id: "team-alpha",
|
||||
tasks: [{ id: "1", subject: "Task 1", status: "pending" }],
|
||||
});
|
||||
expect(result.task_list_id).toBe("team-alpha");
|
||||
expect(result.tasks).toEqual([
|
||||
{ id: "1", subject: "Task 1", status: "pending" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("preserves status metadata for conversation reset events", () => {
|
||||
const result = normalizePayload("status", {
|
||||
status: "conversation_cleared",
|
||||
subtype: "status",
|
||||
message: "conversation_cleared",
|
||||
});
|
||||
expect(result.status).toBe("conversation_cleared");
|
||||
expect(result.subtype).toBe("status");
|
||||
expect(result.message).toBe("conversation_cleared");
|
||||
});
|
||||
|
||||
test("handles undefined payload", () => {
|
||||
const result = normalizePayload("user", undefined);
|
||||
expect(result.content).toBe("");
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
|
||||
const { normalizePayload } = await import("../services/transport");
|
||||
|
||||
// extractContent is not exported; we test it via normalizePayload's content field
|
||||
|
||||
// =============================================================================
|
||||
// extractContent (via normalizePayload content field)
|
||||
// =============================================================================
|
||||
|
||||
describe("extractContent", () => {
|
||||
test("returns empty string for null payload", () => {
|
||||
const result = normalizePayload("assistant", null);
|
||||
expect(result.content).toBe("");
|
||||
});
|
||||
|
||||
test("returns empty string for undefined payload", () => {
|
||||
const result = normalizePayload("assistant", undefined);
|
||||
expect(result.content).toBe("");
|
||||
});
|
||||
|
||||
test("returns the string for string payload", () => {
|
||||
const result = normalizePayload("assistant", "hello world");
|
||||
expect(result.content).toBe("hello world");
|
||||
});
|
||||
|
||||
test("extracts content field from object payload", () => {
|
||||
const result = normalizePayload("assistant", { content: "direct content" });
|
||||
expect(result.content).toBe("direct content");
|
||||
});
|
||||
|
||||
test("extracts message.content string from object payload", () => {
|
||||
const result = normalizePayload("assistant", { message: { content: "msg content" } });
|
||||
expect(result.content).toBe("msg content");
|
||||
});
|
||||
|
||||
test("extracts text blocks from message.content array", () => {
|
||||
const payload = {
|
||||
message: {
|
||||
content: [
|
||||
{ type: "text", text: "Hello " },
|
||||
{ type: "text", text: "World" },
|
||||
],
|
||||
},
|
||||
};
|
||||
const result = normalizePayload("assistant", payload);
|
||||
expect(result.content).toBe("Hello World");
|
||||
});
|
||||
|
||||
test("ignores non-text blocks in message.content array", () => {
|
||||
const payload = {
|
||||
message: {
|
||||
content: [
|
||||
{ type: "image", url: "http://example.com/img.png" },
|
||||
{ type: "text", text: "only this" },
|
||||
],
|
||||
},
|
||||
};
|
||||
const result = normalizePayload("assistant", payload);
|
||||
expect(result.content).toBe("only this");
|
||||
});
|
||||
|
||||
test("returns empty string when no extractable content", () => {
|
||||
const result = normalizePayload("assistant", { foo: "bar" });
|
||||
expect(result.content).toBe("");
|
||||
});
|
||||
|
||||
test("prefers direct content over message.content", () => {
|
||||
const result = normalizePayload("assistant", { content: "direct", message: { content: "nested" } });
|
||||
expect(result.content).toBe("direct");
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// normalizePayload — field preservation
|
||||
// =============================================================================
|
||||
|
||||
describe("normalizePayload — field preservation", () => {
|
||||
test("preserves raw payload", () => {
|
||||
const payload = { content: "test", extra: true };
|
||||
const result = normalizePayload("assistant", payload);
|
||||
expect(result.raw).toBe(payload);
|
||||
});
|
||||
|
||||
test("preserves uuid field", () => {
|
||||
const result = normalizePayload("assistant", { uuid: "u-123" });
|
||||
expect(result.uuid).toBe("u-123");
|
||||
});
|
||||
|
||||
test("does not preserve uuid when empty string", () => {
|
||||
const result = normalizePayload("assistant", { uuid: "" });
|
||||
expect(result.uuid).toBeUndefined();
|
||||
});
|
||||
|
||||
test("preserves isSynthetic boolean", () => {
|
||||
const result = normalizePayload("assistant", { isSynthetic: true });
|
||||
expect(result.isSynthetic).toBe(true);
|
||||
});
|
||||
|
||||
test("preserves status string", () => {
|
||||
const result = normalizePayload("assistant", { status: "running" });
|
||||
expect(result.status).toBe("running");
|
||||
});
|
||||
|
||||
test("preserves subtype string", () => {
|
||||
const result = normalizePayload("assistant", { subtype: "progress" });
|
||||
expect(result.subtype).toBe("progress");
|
||||
});
|
||||
|
||||
test("preserves tool_name from tool_name field", () => {
|
||||
const result = normalizePayload("tool", { tool_name: "bash" });
|
||||
expect(result.tool_name).toBe("bash");
|
||||
});
|
||||
|
||||
test("preserves tool_name from name field", () => {
|
||||
const result = normalizePayload("tool", { name: "read" });
|
||||
expect(result.tool_name).toBe("read");
|
||||
});
|
||||
|
||||
test("preserves tool_input from tool_input field", () => {
|
||||
const input = { command: "ls" };
|
||||
const result = normalizePayload("tool", { tool_input: input });
|
||||
expect(result.tool_input).toEqual(input);
|
||||
});
|
||||
|
||||
test("preserves tool_input from input field", () => {
|
||||
const input = { path: "/tmp" };
|
||||
const result = normalizePayload("tool", { input });
|
||||
expect(result.tool_input).toEqual(input);
|
||||
});
|
||||
|
||||
test("preserves request_id", () => {
|
||||
const result = normalizePayload("permission", { request_id: "req-1" });
|
||||
expect(result.request_id).toBe("req-1");
|
||||
});
|
||||
|
||||
test("preserves request object", () => {
|
||||
const req = { subtype: "permission" };
|
||||
const result = normalizePayload("permission", { request: req });
|
||||
expect(result.request).toEqual(req);
|
||||
});
|
||||
|
||||
test("preserves approved field", () => {
|
||||
const result = normalizePayload("permission", { approved: true });
|
||||
expect(result.approved).toBe(true);
|
||||
});
|
||||
|
||||
test("preserves updated_input", () => {
|
||||
const input = { command: "rm -rf" };
|
||||
const result = normalizePayload("permission", { updated_input: input });
|
||||
expect(result.updated_input).toEqual(input);
|
||||
});
|
||||
|
||||
test("preserves message field for backward compat", () => {
|
||||
const msg = { role: "user", content: "hi" };
|
||||
const result = normalizePayload("assistant", { message: msg });
|
||||
expect(result.message).toEqual(msg);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// normalizePayload — task_state special handling
|
||||
// =============================================================================
|
||||
|
||||
describe("normalizePayload — task_state type", () => {
|
||||
test("preserves task_list_id (snake_case)", () => {
|
||||
const result = normalizePayload("task_state", { task_list_id: "tl-1" });
|
||||
expect(result.task_list_id).toBe("tl-1");
|
||||
});
|
||||
|
||||
test("preserves taskListId (camelCase)", () => {
|
||||
const result = normalizePayload("task_state", { taskListId: "tl-2" });
|
||||
expect(result.taskListId).toBe("tl-2");
|
||||
});
|
||||
|
||||
test("preserves tasks array", () => {
|
||||
const tasks = [{ id: "t1", title: "Task 1" }];
|
||||
const result = normalizePayload("task_state", { tasks });
|
||||
expect(result.tasks).toEqual(tasks);
|
||||
});
|
||||
|
||||
test("does not preserve task fields for non-task_state type", () => {
|
||||
const result = normalizePayload("assistant", { task_list_id: "tl-1", taskListId: "tl-2", tasks: [] });
|
||||
expect(result.task_list_id).toBeUndefined();
|
||||
expect(result.taskListId).toBeUndefined();
|
||||
expect(result.tasks).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -69,6 +69,19 @@ describe("ws-handler", () => {
|
||||
expect((events[0] as any).direction).toBe("inbound");
|
||||
});
|
||||
|
||||
test("preserves synthetic flag on inbound user messages", () => {
|
||||
const bus = getEventBus("s1");
|
||||
const events: unknown[] = [];
|
||||
bus.subscribe((e) => events.push(e));
|
||||
ingestBridgeMessage("s1", {
|
||||
message: { role: "user", content: "scheduled job: refresh analytics cache" },
|
||||
uuid: "u_synth",
|
||||
isSynthetic: true,
|
||||
});
|
||||
expect(events).toHaveLength(1);
|
||||
expect((events[0] as any).payload.isSynthetic).toBe(true);
|
||||
});
|
||||
|
||||
test("derives type from message.role for assistant messages", () => {
|
||||
const bus = getEventBus("s1");
|
||||
const events: unknown[] = [];
|
||||
@@ -163,6 +176,24 @@ describe("ws-handler", () => {
|
||||
expect(msg.type).toBe("user");
|
||||
});
|
||||
|
||||
test("replays synthetic user metadata back to the bridge", () => {
|
||||
const bus = getEventBus("s3");
|
||||
bus.publish({
|
||||
id: "e1",
|
||||
sessionId: "s3",
|
||||
type: "user",
|
||||
payload: { content: "scheduled job: refresh analytics cache", isSynthetic: true },
|
||||
direction: "outbound",
|
||||
});
|
||||
|
||||
const ws = createMockWs();
|
||||
handleWebSocketOpen(ws, "s3");
|
||||
|
||||
const msg = JSON.parse(ws.getSentData()[0]);
|
||||
expect(msg.type).toBe("user");
|
||||
expect(msg.isSynthetic).toBe(true);
|
||||
});
|
||||
|
||||
test("replaces existing connection for same session", () => {
|
||||
const ws1 = createMockWs();
|
||||
const ws2 = createMockWs();
|
||||
|
||||
@@ -8,9 +8,16 @@ export const config = {
|
||||
heartbeatInterval: parseInt(process.env.RCS_HEARTBEAT_INTERVAL || "20"),
|
||||
jwtExpiresIn: parseInt(process.env.RCS_JWT_EXPIRES_IN || "3600"),
|
||||
disconnectTimeout: parseInt(process.env.RCS_DISCONNECT_TIMEOUT || "300"),
|
||||
/** Bun WebSocket idle timeout (seconds). Bun sends protocol-level pings after
|
||||
* this many seconds of no received data. Must be shorter than any reverse
|
||||
* proxy's idle timeout (nginx default 60s, Cloudflare 100s). Default 30s. */
|
||||
wsIdleTimeout: parseInt(process.env.RCS_WS_IDLE_TIMEOUT || "30"),
|
||||
/** Server→client keep_alive data-frame interval (seconds). Keeps reverse
|
||||
* proxies from closing idle connections. Default 20s. */
|
||||
wsKeepaliveInterval: parseInt(process.env.RCS_WS_KEEPALIVE_INTERVAL || "20"),
|
||||
} as const;
|
||||
|
||||
export function getBaseUrl(): string {
|
||||
if (config.baseUrl) return config.baseUrl;
|
||||
return `http://localhost:${config.port}`;
|
||||
const url = config.baseUrl || `http://localhost:${config.port}`;
|
||||
return url.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
@@ -4,15 +4,20 @@ import { logger } from "hono/logger";
|
||||
import { serveStatic } from "hono/bun";
|
||||
import { config } from "./config";
|
||||
import { closeAllConnections } from "./transport/ws-handler";
|
||||
import { closeAllAcpConnections } from "./transport/acp-ws-handler";
|
||||
import { closeAllRelayConnections } from "./transport/acp-relay-handler";
|
||||
import { startDisconnectMonitor } from "./services/disconnect-monitor";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import acpRoutes from "./routes/acp";
|
||||
|
||||
// Routes
|
||||
import v1Environments from "./routes/v1/environments";
|
||||
import v1EnvironmentsWork from "./routes/v1/environments.work";
|
||||
import v1Sessions from "./routes/v1/sessions";
|
||||
import v1SessionIngress, { websocket } from "./routes/v1/session-ingress";
|
||||
import v1SessionIngress from "./routes/v1/session-ingress";
|
||||
import { websocket } from "./transport/ws-shared";
|
||||
import v2CodeSessions from "./routes/v2/code-sessions";
|
||||
import v2Worker from "./routes/v2/worker";
|
||||
import v2WorkerEventsStream from "./routes/v2/worker-events-stream";
|
||||
@@ -28,14 +33,27 @@ const app = new Hono();
|
||||
|
||||
// Middleware
|
||||
app.use("*", logger());
|
||||
app.use("*", async (c, next) => {
|
||||
// Normalize double slashes in path (e.g. //v1/environments/bridge → /v1/environments/bridge)
|
||||
const path = new URL(c.req.url).pathname;
|
||||
if (path.includes("//")) {
|
||||
const normalized = path.replace(/\/+/g, "/");
|
||||
const url = new URL(c.req.url);
|
||||
url.pathname = normalized;
|
||||
return app.fetch(new Request(url.toString(), c.req.raw));
|
||||
}
|
||||
await next();
|
||||
});
|
||||
app.use("/web/*", cors());
|
||||
|
||||
// Health check
|
||||
app.get("/health", (c) => c.json({ status: "ok", version: config.version }));
|
||||
|
||||
// Static files — serve web/ directory under /code path
|
||||
// Static files — serve built web UI under /code path
|
||||
// Uses web/dist/ if it exists (production), otherwise falls back to web/ (dev/fallback)
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const webDir = resolve(__dirname, "../web");
|
||||
const distDir = resolve(__dirname, "../web/dist");
|
||||
const webDir = existsSync(resolve(distDir, "index.html")) ? distDir : resolve(__dirname, "../web");
|
||||
|
||||
const stripCodePrefix = (p: string) => p.replace(/^\/code/, "");
|
||||
|
||||
@@ -70,6 +88,10 @@ app.route("/web", webSessions);
|
||||
app.route("/web", webControl);
|
||||
app.route("/web", webEnvironments);
|
||||
|
||||
// ACP protocol routes
|
||||
console.log("[RCS] ACP support enabled");
|
||||
app.route("/acp", acpRoutes);
|
||||
|
||||
const port = config.port;
|
||||
const host = config.host;
|
||||
|
||||
@@ -77,6 +99,8 @@ console.log(`[RCS] Remote Control Server starting on ${host}:${port}`);
|
||||
console.log("[RCS] API key configuration loaded");
|
||||
console.log(`[RCS] Base URL: ${config.baseUrl || `http://localhost:${port}`}`);
|
||||
console.log(`[RCS] Disconnect timeout: ${config.disconnectTimeout}s`);
|
||||
console.log(`[RCS] WebSocket idle timeout: ${config.wsIdleTimeout}s (protocol-level pings)`);
|
||||
console.log(`[RCS] WebSocket keepalive interval: ${config.wsKeepaliveInterval}s (data frames)`);
|
||||
|
||||
// Start disconnect monitor
|
||||
startDisconnectMonitor();
|
||||
@@ -87,15 +111,17 @@ export default {
|
||||
fetch: app.fetch,
|
||||
websocket: {
|
||||
...websocket,
|
||||
idleTimeout: 255, // WS idle timeout (seconds) — must be inside websocket object
|
||||
idleTimeout: config.wsIdleTimeout, // Bun sends protocol pings after this many seconds of silence
|
||||
},
|
||||
idleTimeout: 255, // HTTP server idle timeout (seconds) — needed for long-polling endpoints
|
||||
idleTimeout: config.wsIdleTimeout, // HTTP server idle timeout (seconds)
|
||||
};
|
||||
|
||||
// Graceful shutdown
|
||||
async function gracefulShutdown(signal: string) {
|
||||
console.log(`\n[RCS] Received ${signal}, shutting down...`);
|
||||
closeAllConnections();
|
||||
closeAllAcpConnections();
|
||||
closeAllRelayConnections();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
|
||||
214
packages/remote-control-server/src/routes/acp/index.ts
Normal file
214
packages/remote-control-server/src/routes/acp/index.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { Hono } from "hono";
|
||||
import { upgradeWebSocket } from "../../transport/ws-shared";
|
||||
import { apiKeyAuth } from "../../auth/middleware";
|
||||
import { validateApiKey } from "../../auth/api-key";
|
||||
import {
|
||||
handleAcpWsOpen,
|
||||
handleAcpWsMessage,
|
||||
handleAcpWsClose,
|
||||
} from "../../transport/acp-ws-handler";
|
||||
import {
|
||||
handleRelayOpen,
|
||||
handleRelayMessage,
|
||||
handleRelayClose,
|
||||
} from "../../transport/acp-relay-handler";
|
||||
import {
|
||||
storeListAcpAgents,
|
||||
storeListAcpAgentsByChannelGroup,
|
||||
storeGetEnvironment,
|
||||
} from "../../store";
|
||||
import { createAcpSSEStream } from "../../transport/acp-sse-writer";
|
||||
import { log, error as logError } from "../../logger";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
/** Maximum WebSocket message size: 10 MB */
|
||||
const MAX_WS_MESSAGE_SIZE = 10 * 1024 * 1024;
|
||||
|
||||
/** Response shape for an ACP agent */
|
||||
function toAcpAgentResponse(env: ReturnType<typeof storeGetEnvironment> & {}) {
|
||||
if (!env) return null;
|
||||
return {
|
||||
id: env.id,
|
||||
agent_name: env.machineName,
|
||||
channel_group_id: env.bridgeId,
|
||||
status: env.status === "active" ? "online" : "offline",
|
||||
max_sessions: env.maxSessions,
|
||||
last_seen_at: env.lastPollAt ? env.lastPollAt.getTime() / 1000 : null,
|
||||
created_at: env.createdAt.getTime() / 1000,
|
||||
};
|
||||
}
|
||||
|
||||
/** GET /acp/agents — List all registered ACP agents (UUID or API key auth) */
|
||||
app.get("/agents", async (c) => {
|
||||
// Require at least UUID auth
|
||||
const uuid = c.req.query("uuid");
|
||||
const authHeader = c.req.header("Authorization");
|
||||
const queryToken = c.req.query("token");
|
||||
const token = authHeader?.replace("Bearer ", "") || queryToken;
|
||||
if (!uuid && !(token && validateApiKey(token))) {
|
||||
return c.json({ error: { type: "unauthorized", message: "Missing auth" } }, 401);
|
||||
}
|
||||
const agents = storeListAcpAgents();
|
||||
return c.json(agents.map((a) => toAcpAgentResponse(a)).filter(Boolean));
|
||||
});
|
||||
|
||||
/** GET /acp/channel-groups — List all channel groups with member agents (UUID or API key auth) */
|
||||
app.get("/channel-groups", async (c) => {
|
||||
const uuid = c.req.query("uuid");
|
||||
const authHeader = c.req.header("Authorization");
|
||||
const queryToken = c.req.query("token");
|
||||
const token = authHeader?.replace("Bearer ", "") || queryToken;
|
||||
if (!uuid && !(token && validateApiKey(token))) {
|
||||
return c.json({ error: { type: "unauthorized", message: "Missing auth" } }, 401);
|
||||
}
|
||||
const agents = storeListAcpAgents();
|
||||
const groupMap = new Map<string, typeof agents>();
|
||||
for (const agent of agents) {
|
||||
const groupId = agent.bridgeId || "default";
|
||||
if (!groupMap.has(groupId)) {
|
||||
groupMap.set(groupId, []);
|
||||
}
|
||||
groupMap.get(groupId)!.push(agent);
|
||||
}
|
||||
const groups = [...groupMap.entries()].map(([id, members]) => ({
|
||||
channel_group_id: id,
|
||||
member_count: members.length,
|
||||
members: members.map((m) => toAcpAgentResponse(m)).filter(Boolean),
|
||||
}));
|
||||
return c.json(groups);
|
||||
});
|
||||
|
||||
/** GET /acp/channel-groups/:id — Specific channel group detail (no auth for web UI) */
|
||||
app.get("/channel-groups/:id", async (c) => {
|
||||
const groupId = c.req.param("id")!;
|
||||
const members = storeListAcpAgentsByChannelGroup(groupId);
|
||||
if (members.length === 0) {
|
||||
return c.json({ error: { type: "not_found", message: "Channel group not found" } }, 404);
|
||||
}
|
||||
return c.json({
|
||||
channel_group_id: groupId,
|
||||
member_count: members.length,
|
||||
members: members.map((m) => toAcpAgentResponse(m)).filter(Boolean),
|
||||
});
|
||||
});
|
||||
|
||||
/** SSE /acp/channel-groups/:id/events — Event stream for external consumers (no auth for web UI) */
|
||||
app.get("/channel-groups/:id/events", async (c) => {
|
||||
const groupId = c.req.param("id")!;
|
||||
|
||||
// Support Last-Event-ID / from_sequence_num for reconnection
|
||||
const lastEventId = c.req.header("Last-Event-ID");
|
||||
const fromSeq = c.req.query("from_sequence_num");
|
||||
const fromSeqNum = fromSeq ? parseInt(fromSeq) : lastEventId ? parseInt(lastEventId) : 0;
|
||||
|
||||
return createAcpSSEStream(c, groupId, fromSeqNum);
|
||||
});
|
||||
|
||||
/** WS /acp/ws — WebSocket endpoint for acp-link connections */
|
||||
app.get(
|
||||
"/ws",
|
||||
upgradeWebSocket(async (c) => {
|
||||
// Authenticate via API key in query param or header
|
||||
const authHeader = c.req.header("Authorization");
|
||||
const queryToken = c.req.query("token");
|
||||
const token = authHeader?.replace("Bearer ", "") || queryToken;
|
||||
|
||||
if (!token || !validateApiKey(token)) {
|
||||
log("[ACP-WS] Upgrade rejected: unauthorized");
|
||||
return {
|
||||
onOpen(_evt: any, ws: any) {
|
||||
ws.close(4003, "unauthorized");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Generate unique wsId for this connection
|
||||
const { v4: uuid } = await import("uuid");
|
||||
const wsId = `acp_ws_${uuid().replace(/-/g, "")}`;
|
||||
|
||||
log(`[ACP-WS] Upgrade accepted: wsId=${wsId}`);
|
||||
return {
|
||||
onOpen(_evt: any, ws: any) {
|
||||
handleAcpWsOpen(ws, wsId);
|
||||
},
|
||||
onMessage(evt: any, ws: any) {
|
||||
const data =
|
||||
typeof evt.data === "string"
|
||||
? evt.data
|
||||
: new TextDecoder().decode(evt.data as ArrayBuffer);
|
||||
if (data.length > MAX_WS_MESSAGE_SIZE) {
|
||||
logError(`[ACP-WS] Message too large on wsId=${wsId}: ${data.length} bytes`);
|
||||
ws.close(1009, "message too large");
|
||||
return;
|
||||
}
|
||||
handleAcpWsMessage(ws, wsId, data);
|
||||
},
|
||||
onClose(evt: any, ws: any) {
|
||||
const closeEvt = evt as unknown as CloseEvent;
|
||||
handleAcpWsClose(ws, wsId, closeEvt?.code, closeEvt?.reason);
|
||||
},
|
||||
onError(evt: any, ws: any) {
|
||||
logError(`[ACP-WS] Error on wsId=${wsId}:`, evt);
|
||||
handleAcpWsClose(ws, wsId, 1006, "websocket error");
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
/** WS /acp/relay/:agentId — WebSocket relay for frontend to interact with an agent */
|
||||
app.get(
|
||||
"/relay/:agentId",
|
||||
upgradeWebSocket(async (c) => {
|
||||
// Authenticate via UUID (web frontend) or API key (legacy)
|
||||
const clientUuid = c.req.query("uuid");
|
||||
const authHeader = c.req.header("Authorization");
|
||||
const queryToken = c.req.query("token");
|
||||
const token = authHeader?.replace("Bearer ", "") || queryToken;
|
||||
|
||||
const hasUuid = !!clientUuid;
|
||||
const hasApiKey = !!token && validateApiKey(token);
|
||||
|
||||
if (!hasUuid && !hasApiKey) {
|
||||
log("[ACP-Relay] Upgrade rejected: unauthorized");
|
||||
return {
|
||||
onOpen(_evt: any, ws: any) {
|
||||
ws.close(4003, "unauthorized");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const agentId = c.req.param("agentId")!;
|
||||
const { v4: uuid } = await import("uuid");
|
||||
const relayWsId = `relay_${uuid().replace(/-/g, "")}`;
|
||||
|
||||
log(`[ACP-Relay] Upgrade accepted: relayWsId=${relayWsId} agentId=${agentId}`);
|
||||
return {
|
||||
onOpen(_evt: any, ws: any) {
|
||||
handleRelayOpen(ws, relayWsId, agentId);
|
||||
},
|
||||
onMessage(evt: any, ws: any) {
|
||||
const data =
|
||||
typeof evt.data === "string"
|
||||
? evt.data
|
||||
: new TextDecoder().decode(evt.data as ArrayBuffer);
|
||||
if (data.length > MAX_WS_MESSAGE_SIZE) {
|
||||
logError(`[ACP-Relay] Message too large on relayWsId=${relayWsId}: ${data.length} bytes`);
|
||||
ws.close(1009, "message too large");
|
||||
return;
|
||||
}
|
||||
handleRelayMessage(ws, relayWsId, data);
|
||||
},
|
||||
onClose(evt: any, ws: any) {
|
||||
const closeEvt = evt as unknown as CloseEvent;
|
||||
handleRelayClose(ws, relayWsId, closeEvt?.code, closeEvt?.reason);
|
||||
},
|
||||
onError(evt: any, ws: any) {
|
||||
logError(`[ACP-Relay] Error on relayWsId=${relayWsId}:`, evt);
|
||||
handleRelayClose(ws, relayWsId, 1006, "websocket error");
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
export default app;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { log, error as logError } from "../../logger";
|
||||
import { Hono } from "hono";
|
||||
import { createBunWebSocket } from "hono/bun";
|
||||
import { upgradeWebSocket, websocket } from "../../transport/ws-shared";
|
||||
import { validateApiKey } from "../../auth/api-key";
|
||||
import { verifyWorkerJwt } from "../../auth/jwt";
|
||||
import {
|
||||
@@ -11,8 +11,6 @@ import {
|
||||
} from "../../transport/ws-handler";
|
||||
import { getSession, resolveExistingSessionId } from "../../services/session";
|
||||
|
||||
const { upgradeWebSocket, websocket } = createBunWebSocket();
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
/** Authenticate via API key or worker JWT in Authorization header or ?token= query param */
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { Hono } from "hono";
|
||||
import { getSession, incrementEpoch, touchSession, updateSessionStatus } from "../../services/session";
|
||||
import {
|
||||
automationStatesEqual,
|
||||
getAutomationStateEventPayload,
|
||||
} from "../../services/automationState";
|
||||
import { apiKeyAuth, acceptCliHeaders, sessionIngressAuth } from "../../auth/middleware";
|
||||
import { getEventBus } from "../../transport/event-bus";
|
||||
import { storeGetSessionWorker, storeUpsertSessionWorker } from "../../store";
|
||||
import { v4 as uuid } from "uuid";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
@@ -33,6 +39,9 @@ app.put("/:id/worker", acceptCliHeaders, sessionIngressAuth, async (c) => {
|
||||
}
|
||||
|
||||
const body = await c.req.json();
|
||||
const prevAutomationState = getAutomationStateEventPayload(
|
||||
storeGetSessionWorker(sessionId)?.externalMetadata,
|
||||
);
|
||||
if (body.worker_status) {
|
||||
updateSessionStatus(sessionId, body.worker_status);
|
||||
} else {
|
||||
@@ -44,6 +53,17 @@ app.put("/:id/worker", acceptCliHeaders, sessionIngressAuth, async (c) => {
|
||||
externalMetadata: body.external_metadata,
|
||||
requiresActionDetails: body.requires_action_details,
|
||||
});
|
||||
const nextAutomationState = getAutomationStateEventPayload(worker.externalMetadata);
|
||||
|
||||
if (!automationStatesEqual(prevAutomationState, nextAutomationState)) {
|
||||
getEventBus(sessionId).publish({
|
||||
id: uuid(),
|
||||
sessionId,
|
||||
type: "automation_state",
|
||||
payload: nextAutomationState,
|
||||
direction: "inbound",
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({
|
||||
status: "ok",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { log, error as logError } from "../../logger";
|
||||
import { Hono } from "hono";
|
||||
import { uuidAuth } from "../../auth/middleware";
|
||||
import { getAutomationStateSnapshot } from "../../services/automationState";
|
||||
import {
|
||||
createSession,
|
||||
getSession,
|
||||
@@ -10,7 +11,7 @@ import {
|
||||
resolveOwnedWebSessionId,
|
||||
toWebSessionResponse,
|
||||
} from "../../services/session";
|
||||
import { storeBindSession } from "../../store";
|
||||
import { storeBindSession, storeGetSessionWorker } from "../../store";
|
||||
import { createWorkItem } from "../../services/work-dispatch";
|
||||
import { createSSEStream } from "../../transport/sse-writer";
|
||||
import { getEventBus } from "../../transport/event-bus";
|
||||
@@ -68,7 +69,13 @@ app.get("/sessions/:id", uuidAuth, async (c) => {
|
||||
if (!session) {
|
||||
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
|
||||
}
|
||||
return c.json(toWebSessionResponse(session), 200);
|
||||
const worker = storeGetSessionWorker(sessionId);
|
||||
const automationState = getAutomationStateSnapshot(worker?.externalMetadata);
|
||||
const response = toWebSessionResponse(session);
|
||||
return c.json(
|
||||
automationState === undefined ? response : { ...response, automation_state: automationState },
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
/** GET /web/sessions/:id/history — Historical events for session */
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { AutomationStateResponse } from "../types/api";
|
||||
|
||||
const DISABLED_AUTOMATION_STATE: AutomationStateResponse = Object.freeze({
|
||||
enabled: false,
|
||||
phase: null,
|
||||
next_tick_at: null,
|
||||
sleep_until: null,
|
||||
});
|
||||
|
||||
function cloneAutomationState(state: AutomationStateResponse): AutomationStateResponse {
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
function normalizeAutomationState(raw: unknown): AutomationStateResponse {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return cloneAutomationState(DISABLED_AUTOMATION_STATE);
|
||||
}
|
||||
|
||||
const state = raw as Record<string, unknown>;
|
||||
return {
|
||||
enabled: state.enabled === true,
|
||||
phase: state.phase === "standby" || state.phase === "sleeping" ? state.phase : null,
|
||||
next_tick_at: typeof state.next_tick_at === "number" ? state.next_tick_at : null,
|
||||
sleep_until: typeof state.sleep_until === "number" ? state.sleep_until : null,
|
||||
};
|
||||
}
|
||||
|
||||
function readAutomationStateValue(metadata: Record<string, unknown> | null | undefined): unknown {
|
||||
if (!metadata || typeof metadata !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(metadata, "automation_state")) {
|
||||
return undefined;
|
||||
}
|
||||
return metadata.automation_state;
|
||||
}
|
||||
|
||||
export function getAutomationStateSnapshot(
|
||||
metadata: Record<string, unknown> | null | undefined,
|
||||
): AutomationStateResponse | undefined {
|
||||
const raw = readAutomationStateValue(metadata);
|
||||
if (raw === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return normalizeAutomationState(raw);
|
||||
}
|
||||
|
||||
export function getAutomationStateEventPayload(
|
||||
metadata: Record<string, unknown> | null | undefined,
|
||||
): AutomationStateResponse {
|
||||
return getAutomationStateSnapshot(metadata) ?? cloneAutomationState(DISABLED_AUTOMATION_STATE);
|
||||
}
|
||||
|
||||
export function automationStatesEqual(
|
||||
a: AutomationStateResponse,
|
||||
b: AutomationStateResponse,
|
||||
): boolean {
|
||||
return (
|
||||
a.enabled === b.enabled &&
|
||||
a.phase === b.phase &&
|
||||
a.next_tick_at === b.next_tick_at &&
|
||||
a.sleep_until === b.sleep_until
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { log, error as logError } from "../logger";
|
||||
import { storeListActiveEnvironments, storeUpdateEnvironment } from "../store";
|
||||
import { storeListActiveEnvironments, storeUpdateEnvironment, storeMarkAcpAgentOffline } from "../store";
|
||||
import { storeListSessions } from "../store";
|
||||
import { config } from "../config";
|
||||
import { updateSessionStatus } from "./session";
|
||||
@@ -10,6 +10,14 @@ export function runDisconnectMonitorSweep(now = Date.now()) {
|
||||
// Check environment heartbeat timeout
|
||||
const envs = storeListActiveEnvironments();
|
||||
for (const env of envs) {
|
||||
// Skip ACP agents — they use WS keepalive, not polling
|
||||
if (env.workerType === "acp") {
|
||||
if (env.lastPollAt && now - env.lastPollAt.getTime() > timeoutMs) {
|
||||
log(`[RCS] ACP agent ${env.id} timed out (no activity for ${Math.round((now - env.lastPollAt.getTime()) / 1000)}s)`);
|
||||
storeMarkAcpAgentOffline(env.id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (env.lastPollAt && now - env.lastPollAt.getTime() > timeoutMs) {
|
||||
log(`[RCS] Environment ${env.id} timed out (no poll for ${Math.round((now - env.lastPollAt.getTime()) / 1000)}s)`);
|
||||
storeUpdateEnvironment(env.id, { status: "disconnected" });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { config } from "../config";
|
||||
import {
|
||||
storeCreateEnvironment,
|
||||
storeCreateSession,
|
||||
storeGetEnvironment,
|
||||
storeUpdateEnvironment,
|
||||
storeListActiveEnvironments,
|
||||
@@ -18,6 +19,8 @@ function toResponse(row: EnvironmentRecord): EnvironmentResponse {
|
||||
status: row.status,
|
||||
username: row.username,
|
||||
last_poll_at: row.lastPollAt ? row.lastPollAt.getTime() / 1000 : null,
|
||||
worker_type: row.workerType,
|
||||
capabilities: row.capabilities,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -34,9 +37,21 @@ export function registerEnvironment(req: RegisterEnvironmentRequest & { metadata
|
||||
workerType,
|
||||
bridgeId: req.bridge_id,
|
||||
username: req.username,
|
||||
capabilities: req.capabilities,
|
||||
});
|
||||
|
||||
return { environment_id: record.id, environment_secret: record.secret, status: record.status as "active" };
|
||||
let sessionId: string | undefined;
|
||||
// ACP agents: auto-create a session so they appear in the dashboard sessions list
|
||||
if (workerType === "acp") {
|
||||
const session = storeCreateSession({
|
||||
environmentId: record.id,
|
||||
title: req.machine_name || "ACP Agent",
|
||||
source: "acp",
|
||||
});
|
||||
sessionId = session.id;
|
||||
}
|
||||
|
||||
return { environment_id: record.id, environment_secret: record.secret, status: record.status as "active", session_id: sessionId };
|
||||
}
|
||||
|
||||
export function deregisterEnvironment(envId: string) {
|
||||
|
||||
@@ -2,6 +2,8 @@ import {
|
||||
storeCreateSession,
|
||||
storeGetSession,
|
||||
storeIsSessionOwner,
|
||||
storeGetSessionOwners,
|
||||
storeBindSession,
|
||||
storeUpdateSession,
|
||||
storeListSessions,
|
||||
storeListSessionsByUsername,
|
||||
@@ -106,6 +108,16 @@ export function resolveOwnedWebSessionId(sessionId: string, uuid: string): strin
|
||||
return compatibleCodeSessionId;
|
||||
}
|
||||
|
||||
// Auto-bind: if the session exists but has no owner, claim it for the requesting user
|
||||
const existingId = resolveExistingSessionId(sessionId);
|
||||
if (existingId) {
|
||||
const owners = storeGetSessionOwners(existingId);
|
||||
if (!owners || owners.size === 0) {
|
||||
storeBindSession(existingId, uuid);
|
||||
return existingId;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,9 @@ export function normalizePayload(type: string, payload: unknown): Record<string,
|
||||
};
|
||||
|
||||
if (typeof p.uuid === "string" && p.uuid) normalized.uuid = p.uuid;
|
||||
if (typeof p.isSynthetic === "boolean") normalized.isSynthetic = p.isSynthetic;
|
||||
if (typeof p.status === "string") normalized.status = p.status;
|
||||
if (typeof p.subtype === "string") normalized.subtype = p.subtype;
|
||||
|
||||
// Preserve tool fields
|
||||
if (p.tool_name) normalized.tool_name = p.tool_name;
|
||||
@@ -68,6 +71,12 @@ export function normalizePayload(type: string, payload: unknown): Record<string,
|
||||
// Preserve message field for backward compat
|
||||
if (p.message) normalized.message = p.message;
|
||||
|
||||
if (type === "task_state") {
|
||||
if (typeof p.task_list_id === "string") normalized.task_list_id = p.task_list_id;
|
||||
if (typeof p.taskListId === "string") normalized.taskListId = p.taskListId;
|
||||
if (Array.isArray(p.tasks)) normalized.tasks = p.tasks;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface EnvironmentRecord {
|
||||
maxSessions: number;
|
||||
workerType: string;
|
||||
bridgeId: string | null;
|
||||
capabilities: Record<string, unknown> | null;
|
||||
status: string;
|
||||
username: string | null;
|
||||
lastPollAt: Date | null;
|
||||
@@ -97,6 +98,21 @@ export function storeDeleteToken(token: string): boolean {
|
||||
|
||||
// ---------- Environment ----------
|
||||
|
||||
/** Find an active environment by machineName (optionally filtered by workerType) */
|
||||
export function storeFindEnvironmentByMachineName(
|
||||
machineName: string,
|
||||
workerType?: string,
|
||||
): EnvironmentRecord | undefined {
|
||||
for (const rec of environments.values()) {
|
||||
if (rec.machineName === machineName && rec.status === "active") {
|
||||
if (!workerType || rec.workerType === workerType) {
|
||||
return rec;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function storeCreateEnvironment(req: {
|
||||
secret: string;
|
||||
machineName?: string;
|
||||
@@ -107,7 +123,25 @@ export function storeCreateEnvironment(req: {
|
||||
workerType?: string;
|
||||
bridgeId?: string;
|
||||
username?: string;
|
||||
capabilities?: Record<string, unknown>;
|
||||
}): EnvironmentRecord {
|
||||
// ACP: reuse existing active record by machineName
|
||||
if (req.workerType === "acp" && req.machineName) {
|
||||
const existing = storeFindEnvironmentByMachineName(req.machineName, "acp");
|
||||
if (existing) {
|
||||
Object.assign(existing, {
|
||||
status: "active",
|
||||
lastPollAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
maxSessions: req.maxSessions ?? existing.maxSessions,
|
||||
bridgeId: req.bridgeId ?? existing.bridgeId,
|
||||
capabilities: req.capabilities ?? existing.capabilities,
|
||||
username: req.username ?? existing.username,
|
||||
});
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
const id = `env_${uuid().replace(/-/g, "")}`;
|
||||
const now = new Date();
|
||||
const record: EnvironmentRecord = {
|
||||
@@ -120,6 +154,7 @@ export function storeCreateEnvironment(req: {
|
||||
maxSessions: req.maxSessions ?? 1,
|
||||
workerType: req.workerType ?? "claude_code",
|
||||
bridgeId: req.bridgeId ?? null,
|
||||
capabilities: req.capabilities ?? null,
|
||||
status: "active",
|
||||
username: req.username ?? null,
|
||||
lastPollAt: now,
|
||||
@@ -134,7 +169,7 @@ export function storeGetEnvironment(id: string): EnvironmentRecord | undefined {
|
||||
return environments.get(id);
|
||||
}
|
||||
|
||||
export function storeUpdateEnvironment(id: string, patch: Partial<Pick<EnvironmentRecord, "status" | "lastPollAt" | "updatedAt">>): boolean {
|
||||
export function storeUpdateEnvironment(id: string, patch: Partial<Pick<EnvironmentRecord, "status" | "lastPollAt" | "updatedAt" | "capabilities" | "machineName" | "maxSessions" | "bridgeId">>): boolean {
|
||||
const rec = environments.get(id);
|
||||
if (!rec) return false;
|
||||
Object.assign(rec, patch, { updatedAt: new Date() });
|
||||
@@ -272,6 +307,10 @@ export function storeIsSessionOwner(sessionId: string, uuid: string): boolean {
|
||||
return owners ? owners.has(uuid) : false;
|
||||
}
|
||||
|
||||
export function storeGetSessionOwners(sessionId: string): Set<string> | undefined {
|
||||
return sessionOwners.get(sessionId);
|
||||
}
|
||||
|
||||
export function storeListSessionsByOwnerUuid(uuid: string): SessionRecord[] {
|
||||
const result: SessionRecord[] = [];
|
||||
for (const [sessionId, owners] of sessionOwners) {
|
||||
@@ -325,6 +364,43 @@ export function storeUpdateWorkItem(id: string, patch: Partial<Pick<WorkItemReco
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------- ACP Agent (reuses EnvironmentRecord with workerType="acp") ----------
|
||||
|
||||
/** List all ACP agents (environments with workerType="acp") */
|
||||
export function storeListAcpAgents(): EnvironmentRecord[] {
|
||||
return [...environments.values()].filter((e) => e.workerType === "acp");
|
||||
}
|
||||
|
||||
/** List ACP agents by channel group (stored in bridgeId field) */
|
||||
export function storeListAcpAgentsByChannelGroup(channelGroupId: string): EnvironmentRecord[] {
|
||||
return [...environments.values()].filter(
|
||||
(e) => e.workerType === "acp" && e.bridgeId === channelGroupId,
|
||||
);
|
||||
}
|
||||
|
||||
/** List online ACP agents */
|
||||
export function storeListOnlineAcpAgents(): EnvironmentRecord[] {
|
||||
return [...environments.values()].filter(
|
||||
(e) => e.workerType === "acp" && e.status === "active",
|
||||
);
|
||||
}
|
||||
|
||||
/** Mark an ACP agent as offline */
|
||||
export function storeMarkAcpAgentOffline(id: string): boolean {
|
||||
const rec = environments.get(id);
|
||||
if (!rec || rec.workerType !== "acp") return false;
|
||||
Object.assign(rec, { status: "offline", updatedAt: new Date() });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Mark an ACP agent as online (on reconnect) */
|
||||
export function storeMarkAcpAgentOnline(id: string): boolean {
|
||||
const rec = environments.get(id);
|
||||
if (!rec || rec.workerType !== "acp") return false;
|
||||
Object.assign(rec, { status: "active", lastPollAt: new Date(), updatedAt: new Date() });
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------- Reset (for tests) ----------
|
||||
|
||||
export function storeReset() {
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { WSContext } from "hono/ws";
|
||||
import {
|
||||
findAcpConnectionByAgentId,
|
||||
sendToAgentWs,
|
||||
} from "./acp-ws-handler";
|
||||
import { getAcpEventBus } from "./event-bus";
|
||||
import type { SessionEvent } from "./event-bus";
|
||||
import { log, error as logError } from "../logger";
|
||||
|
||||
// Per-relay connection state
|
||||
interface RelayConnectionEntry {
|
||||
agentId: string;
|
||||
unsub: (() => void) | null;
|
||||
keepalive: ReturnType<typeof setInterval> | null;
|
||||
ws: WSContext;
|
||||
openTime: number;
|
||||
}
|
||||
|
||||
const relayConnections = new Map<string, RelayConnectionEntry>(); // key: relayWsId
|
||||
|
||||
const RELAY_KEEPALIVE_INTERVAL_MS = 20_000;
|
||||
|
||||
/** Send a JSON message to relay WS */
|
||||
function sendToRelayWs(ws: WSContext, msg: object): void {
|
||||
if (ws.readyState !== 1) return;
|
||||
try {
|
||||
ws.send(JSON.stringify(msg));
|
||||
} catch (err) {
|
||||
logError("[ACP-Relay] send error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Called from onOpen — finds target agent and bridges connection */
|
||||
export function handleRelayOpen(ws: WSContext, relayWsId: string, agentId: string): void {
|
||||
log(`[ACP-Relay] Relay connection opened: relayWsId=${relayWsId} agentId=${agentId}`);
|
||||
|
||||
// Check if agent is online
|
||||
const agentConn = findAcpConnectionByAgentId(agentId);
|
||||
if (!agentConn) {
|
||||
log(`[ACP-Relay] Agent ${agentId} not found or offline`);
|
||||
sendToRelayWs(ws, { type: "error", message: "Agent not found or offline" });
|
||||
ws.close(4004, "agent not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// Keepalive interval
|
||||
const keepalive = setInterval(() => {
|
||||
const entry = relayConnections.get(relayWsId);
|
||||
if (!entry || entry.ws.readyState !== 1) {
|
||||
clearInterval(keepalive);
|
||||
return;
|
||||
}
|
||||
sendToRelayWs(entry.ws, { type: "keep_alive" });
|
||||
}, RELAY_KEEPALIVE_INTERVAL_MS);
|
||||
|
||||
// Subscribe to channel group EventBus — forward agent responses to frontend
|
||||
const channelGroupId = agentConn.channelGroupId;
|
||||
const bus = getAcpEventBus(channelGroupId);
|
||||
const unsub = bus.subscribe((event: SessionEvent) => {
|
||||
if (ws.readyState !== 1) return;
|
||||
if (event.direction !== "inbound") return;
|
||||
// Handle agent disconnect specially: send status to frontend
|
||||
if (event.type === "agent_disconnect") {
|
||||
sendToRelayWs(ws, { type: "status", payload: { connected: false } });
|
||||
return;
|
||||
}
|
||||
// Forward agent responses to the frontend WebSocket
|
||||
sendToRelayWs(ws, event.payload as object);
|
||||
});
|
||||
|
||||
relayConnections.set(relayWsId, {
|
||||
agentId,
|
||||
unsub,
|
||||
keepalive,
|
||||
ws,
|
||||
openTime: Date.now(),
|
||||
});
|
||||
|
||||
// Don't send a synthetic status message here!
|
||||
// The frontend sends a "connect" command, which acp-link processes
|
||||
// and responds with a real status message including capabilities.
|
||||
// Sending a fake status would make the frontend think it's connected
|
||||
// before the agent process is actually ready.
|
||||
|
||||
log(`[ACP-Relay] Relay established: relayWsId=${relayWsId} → agentId=${agentId}`);
|
||||
}
|
||||
|
||||
/** Called from onMessage — forwards frontend messages to acp-link */
|
||||
export function handleRelayMessage(ws: WSContext, relayWsId: string, data: string): void {
|
||||
const entry = relayConnections.get(relayWsId);
|
||||
if (!entry) return;
|
||||
|
||||
const lines = data.split("\n").filter((l) => l.trim());
|
||||
for (const line of lines) {
|
||||
let msg: Record<string, unknown>;
|
||||
try {
|
||||
msg = JSON.parse(line);
|
||||
} catch {
|
||||
logError("[ACP-Relay] parse error:", line);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ignore keepalive responses
|
||||
if (msg.type === "keep_alive") continue;
|
||||
|
||||
// Forward to acp-link agent
|
||||
const sent = sendToAgentWs(entry.agentId, msg);
|
||||
if (!sent) {
|
||||
sendToRelayWs(ws, { type: "error", message: "Agent connection lost" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Called from onClose — cleans up relay connection */
|
||||
export function handleRelayClose(ws: WSContext, relayWsId: string, code?: number, reason?: string): void {
|
||||
const entry = relayConnections.get(relayWsId);
|
||||
if (!entry) return;
|
||||
|
||||
const duration = Math.round((Date.now() - entry.openTime) / 1000);
|
||||
log(`[ACP-Relay] Connection closed: relayWsId=${relayWsId} agentId=${entry.agentId} code=${code ?? "none"} reason=${reason || "(none)"} duration=${duration}s`);
|
||||
|
||||
if (entry.unsub) {
|
||||
entry.unsub();
|
||||
}
|
||||
if (entry.keepalive) {
|
||||
clearInterval(entry.keepalive);
|
||||
}
|
||||
|
||||
relayConnections.delete(relayWsId);
|
||||
}
|
||||
|
||||
/** Close all relay connections (for graceful shutdown) */
|
||||
export function closeAllRelayConnections(): void {
|
||||
if (relayConnections.size === 0) return;
|
||||
|
||||
log(`[ACP-Relay] Closing ${relayConnections.size} relay connection(s)...`);
|
||||
for (const [relayWsId, entry] of relayConnections) {
|
||||
try {
|
||||
if (entry.unsub) entry.unsub();
|
||||
if (entry.keepalive) clearInterval(entry.keepalive);
|
||||
if (entry.ws.readyState === 1) {
|
||||
entry.ws.close(1001, "server_shutdown");
|
||||
}
|
||||
} catch {
|
||||
// ignore errors during shutdown
|
||||
}
|
||||
}
|
||||
relayConnections.clear();
|
||||
log("[ACP-Relay] All relay connections closed");
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { log } from "../logger";
|
||||
import type { Context } from "hono";
|
||||
import type { SessionEvent } from "./event-bus";
|
||||
import { getAcpEventBus } from "./event-bus";
|
||||
|
||||
/** Create SSE response stream for an ACP channel group */
|
||||
export function createAcpSSEStream(c: Context, channelGroupId: string, fromSeqNum = 0) {
|
||||
const bus = getAcpEventBus(channelGroupId);
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
// Send historical events if reconnecting
|
||||
if (fromSeqNum > 0) {
|
||||
const missed = bus.getEventsSince(fromSeqNum);
|
||||
for (const event of missed) {
|
||||
const data = JSON.stringify({
|
||||
type: event.type,
|
||||
payload: event.payload,
|
||||
direction: event.direction,
|
||||
seqNum: event.seqNum,
|
||||
channel_group_id: channelGroupId,
|
||||
});
|
||||
controller.enqueue(encoder.encode(`id: ${event.seqNum}\nevent: message\ndata: ${data}\n\n`));
|
||||
}
|
||||
}
|
||||
|
||||
// Send initial keepalive
|
||||
controller.enqueue(encoder.encode(": keepalive\n\n"));
|
||||
|
||||
// Subscribe to new events
|
||||
const unsub = bus.subscribe((event) => {
|
||||
const data = JSON.stringify({
|
||||
type: event.type,
|
||||
payload: event.payload,
|
||||
direction: event.direction,
|
||||
seqNum: event.seqNum,
|
||||
channel_group_id: channelGroupId,
|
||||
});
|
||||
try {
|
||||
log(`[ACP-SSE] -> subscriber: channelGroup=${channelGroupId} type=${event.type} seq=${event.seqNum}`);
|
||||
controller.enqueue(encoder.encode(`id: ${event.seqNum}\nevent: message\ndata: ${data}\n\n`));
|
||||
} catch {
|
||||
unsub();
|
||||
}
|
||||
});
|
||||
|
||||
// Keepalive interval
|
||||
const keepalive = setInterval(() => {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(": keepalive\n\n"));
|
||||
} catch {
|
||||
clearInterval(keepalive);
|
||||
unsub();
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
// Cleanup on abort
|
||||
c.req.raw.signal.addEventListener("abort", () => {
|
||||
unsub();
|
||||
clearInterval(keepalive);
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
// already closed
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
});
|
||||
}
|
||||
313
packages/remote-control-server/src/transport/acp-ws-handler.ts
Normal file
313
packages/remote-control-server/src/transport/acp-ws-handler.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
import type { WSContext } from "hono/ws";
|
||||
import { v4 as uuid } from "uuid";
|
||||
import { getAcpEventBus } from "./event-bus";
|
||||
import type { SessionEvent } from "./event-bus";
|
||||
import {
|
||||
storeCreateEnvironment,
|
||||
storeGetEnvironment,
|
||||
storeMarkAcpAgentOffline,
|
||||
storeMarkAcpAgentOnline,
|
||||
storeUpdateEnvironment,
|
||||
} from "../store";
|
||||
import { config } from "../config";
|
||||
import { log, error as logError } from "../logger";
|
||||
|
||||
// Per-connection state
|
||||
interface AcpConnectionEntry {
|
||||
agentId: string | null; // Set after register message
|
||||
channelGroupId: string;
|
||||
unsub: (() => void) | null;
|
||||
keepalive: ReturnType<typeof setInterval> | null;
|
||||
ws: WSContext;
|
||||
openTime: number;
|
||||
lastClientActivity: number;
|
||||
capabilities: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
const connections = new Map<string, AcpConnectionEntry>(); // key: wsId
|
||||
|
||||
const SERVER_KEEPALIVE_INTERVAL_MS = config.wsKeepaliveInterval * 1000;
|
||||
const CLIENT_ACTIVITY_TIMEOUT_MS = SERVER_KEEPALIVE_INTERVAL_MS * 3;
|
||||
|
||||
/** Send a JSON message to a WS connection (NDJSON format) */
|
||||
function sendToWs(ws: WSContext, msg: object): void {
|
||||
if (ws.readyState !== 1) return;
|
||||
try {
|
||||
ws.send(JSON.stringify(msg) + "\n");
|
||||
} catch (err) {
|
||||
logError("[ACP-WS] send error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Called from onOpen — initializes connection tracking */
|
||||
export function handleAcpWsOpen(ws: WSContext, wsId: string): void {
|
||||
log(`[ACP-WS] Connection opened: wsId=${wsId}`);
|
||||
|
||||
const keepalive = setInterval(() => {
|
||||
const entry = connections.get(wsId);
|
||||
if (!entry || entry.ws.readyState !== 1) {
|
||||
clearInterval(keepalive);
|
||||
return;
|
||||
}
|
||||
const silenceMs = Date.now() - entry.lastClientActivity;
|
||||
if (silenceMs > CLIENT_ACTIVITY_TIMEOUT_MS) {
|
||||
log(`[ACP-WS] Client inactive for ${Math.round(silenceMs / 1000)}s, closing dead connection`);
|
||||
try {
|
||||
entry.ws.close(1000, "client inactive");
|
||||
} catch {
|
||||
clearInterval(keepalive);
|
||||
}
|
||||
return;
|
||||
}
|
||||
sendToWs(entry.ws, { type: "keep_alive" });
|
||||
}, SERVER_KEEPALIVE_INTERVAL_MS);
|
||||
|
||||
connections.set(wsId, {
|
||||
agentId: null,
|
||||
channelGroupId: "",
|
||||
unsub: null,
|
||||
keepalive,
|
||||
ws,
|
||||
openTime: Date.now(),
|
||||
lastClientActivity: Date.now(),
|
||||
capabilities: null,
|
||||
});
|
||||
}
|
||||
|
||||
/** Handle register message — legacy WS-only registration (still supported) */
|
||||
function handleRegister(wsId: string, msg: Record<string, unknown>): void {
|
||||
const entry = connections.get(wsId);
|
||||
if (!entry) return;
|
||||
|
||||
if (entry.agentId) {
|
||||
sendToWs(entry.ws, { type: "error", message: "Already registered" });
|
||||
return;
|
||||
}
|
||||
|
||||
const agentName = (msg.agent_name as string) || "unknown";
|
||||
const capabilities = msg.capabilities as Record<string, unknown> | undefined;
|
||||
const channelGroupId = (msg.channel_group_id as string) || `group_${uuid().replace(/-/g, "").slice(0, 12)}`;
|
||||
const acpLinkVersion = (msg.acp_link_version as string) || null;
|
||||
const maxSessions = typeof msg.max_sessions === "number" ? msg.max_sessions : 1;
|
||||
|
||||
// Create EnvironmentRecord with workerType="acp"
|
||||
const secret = config.apiKeys[0] || "";
|
||||
const record = storeCreateEnvironment({
|
||||
secret,
|
||||
machineName: agentName,
|
||||
workerType: "acp",
|
||||
bridgeId: channelGroupId,
|
||||
maxSessions,
|
||||
capabilities: capabilities || undefined,
|
||||
} as Parameters<typeof storeCreateEnvironment>[0]);
|
||||
|
||||
// Store ACP-specific metadata via environment update
|
||||
storeUpdateEnvironment(record.id, {
|
||||
status: "active",
|
||||
} as Parameters<typeof storeUpdateEnvironment>[1]);
|
||||
|
||||
entry.agentId = record.id;
|
||||
entry.channelGroupId = channelGroupId;
|
||||
entry.capabilities = capabilities || null;
|
||||
|
||||
// Subscribe to channel group EventBus — broadcast events to this WS
|
||||
const bus = getAcpEventBus(channelGroupId);
|
||||
const unsub = bus.subscribe((event: SessionEvent) => {
|
||||
if (entry.ws.readyState !== 1) return;
|
||||
if (event.direction !== "outbound") return;
|
||||
// Forward outbound events as raw ACP messages
|
||||
sendToWs(entry.ws, event.payload as object);
|
||||
});
|
||||
entry.unsub = unsub;
|
||||
|
||||
log(`[ACP-WS] Agent registered (legacy WS): agentId=${record.id} channelGroup=${channelGroupId} name=${agentName}`);
|
||||
sendToWs(entry.ws, {
|
||||
type: "registered",
|
||||
agent_id: record.id,
|
||||
channel_group_id: channelGroupId,
|
||||
});
|
||||
}
|
||||
|
||||
/** Handle identify message — binds WS to an existing agent registered via REST */
|
||||
function handleIdentify(wsId: string, msg: Record<string, unknown>): void {
|
||||
const entry = connections.get(wsId);
|
||||
if (!entry) return;
|
||||
|
||||
if (entry.agentId) {
|
||||
sendToWs(entry.ws, { type: "error", message: "Already identified" });
|
||||
return;
|
||||
}
|
||||
|
||||
const agentId = msg.agent_id as string;
|
||||
if (!agentId) {
|
||||
sendToWs(entry.ws, { type: "error", message: "Missing agent_id" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Look up the environment record (created via REST registration)
|
||||
const record = storeGetEnvironment(agentId);
|
||||
if (!record || record.workerType !== "acp") {
|
||||
sendToWs(entry.ws, { type: "error", message: "Agent not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Update status to active
|
||||
storeMarkAcpAgentOnline(agentId);
|
||||
|
||||
const channelGroupId = record.bridgeId || `group_${uuid().replace(/-/g, "").slice(0, 12)}`;
|
||||
|
||||
entry.agentId = record.id;
|
||||
entry.channelGroupId = channelGroupId;
|
||||
entry.capabilities = record.capabilities || null;
|
||||
|
||||
// Subscribe to channel group EventBus — broadcast events to this WS
|
||||
const bus = getAcpEventBus(channelGroupId);
|
||||
const unsub = bus.subscribe((event: SessionEvent) => {
|
||||
if (entry.ws.readyState !== 1) return;
|
||||
if (event.direction !== "outbound") return;
|
||||
sendToWs(entry.ws, event.payload as object);
|
||||
});
|
||||
entry.unsub = unsub;
|
||||
|
||||
log(`[ACP-WS] Agent identified (REST+WS): agentId=${record.id} channelGroup=${channelGroupId}`);
|
||||
sendToWs(entry.ws, {
|
||||
type: "identified",
|
||||
agent_id: record.id,
|
||||
channel_group_id: channelGroupId,
|
||||
});
|
||||
}
|
||||
|
||||
/** Called from onMessage — processes NDJSON lines */
|
||||
export function handleAcpWsMessage(ws: WSContext, wsId: string, data: string): void {
|
||||
const entry = connections.get(wsId);
|
||||
if (!entry) return;
|
||||
|
||||
entry.lastClientActivity = Date.now();
|
||||
|
||||
const lines = data.split("\n").filter((l) => l.trim());
|
||||
for (const line of lines) {
|
||||
let msg: Record<string, unknown>;
|
||||
try {
|
||||
msg = JSON.parse(line);
|
||||
} catch {
|
||||
logError("[ACP-WS] parse error:", line);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle keepalive
|
||||
if (msg.type === "keep_alive") {
|
||||
// Update last activity timestamp (only if registered)
|
||||
if (entry.agentId) {
|
||||
storeUpdateEnvironment(entry.agentId, { lastPollAt: new Date() } as Parameters<typeof storeUpdateEnvironment>[1]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle registration (legacy WS-only)
|
||||
if (msg.type === "register") {
|
||||
handleRegister(wsId, msg);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle identify (REST registration + WS binding)
|
||||
if (msg.type === "identify") {
|
||||
handleIdentify(wsId, msg);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Not registered yet — reject
|
||||
if (!entry.agentId) {
|
||||
sendToWs(entry.ws, { type: "error", message: "Not registered. Send register message first." });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Update agent activity
|
||||
storeUpdateEnvironment(entry.agentId, { lastPollAt: new Date() } as Parameters<typeof storeUpdateEnvironment>[1]);
|
||||
|
||||
// Pass-through: publish to channel group EventBus as inbound
|
||||
const bus = getAcpEventBus(entry.channelGroupId);
|
||||
bus.publish({
|
||||
id: uuid(),
|
||||
sessionId: entry.channelGroupId,
|
||||
type: (msg.type as string) || "acp_message",
|
||||
payload: msg,
|
||||
direction: "inbound",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Called from onClose — marks agent offline and cleans up */
|
||||
export function handleAcpWsClose(ws: WSContext, wsId: string, code?: number, reason?: string): void {
|
||||
const entry = connections.get(wsId);
|
||||
if (!entry) return;
|
||||
|
||||
const duration = Math.round((Date.now() - entry.openTime) / 1000);
|
||||
log(`[ACP-WS] Connection closed: wsId=${wsId} agentId=${entry.agentId} code=${code ?? "none"} reason=${reason || "(none)"} duration=${duration}s`);
|
||||
|
||||
if (entry.unsub) {
|
||||
entry.unsub();
|
||||
}
|
||||
if (entry.keepalive) {
|
||||
clearInterval(entry.keepalive);
|
||||
}
|
||||
|
||||
// Mark agent as offline (don't delete record — allow reconnect)
|
||||
if (entry.agentId) {
|
||||
storeMarkAcpAgentOffline(entry.agentId);
|
||||
|
||||
// Notify all relay connections that this agent is gone
|
||||
if (entry.channelGroupId) {
|
||||
const bus = getAcpEventBus(entry.channelGroupId);
|
||||
bus.publish({
|
||||
id: uuid(),
|
||||
sessionId: entry.channelGroupId,
|
||||
type: "agent_disconnect",
|
||||
payload: { agentId: entry.agentId },
|
||||
direction: "inbound",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
connections.delete(wsId);
|
||||
}
|
||||
|
||||
/** Find an active ACP connection by agent ID */
|
||||
export function findAcpConnectionByAgentId(agentId: string): AcpConnectionEntry | null {
|
||||
for (const entry of connections.values()) {
|
||||
if (entry.agentId === agentId && entry.ws.readyState === 1) {
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Send a JSON message directly to an agent's WebSocket connection */
|
||||
export function sendToAgentWs(agentId: string, msg: object): boolean {
|
||||
const entry = findAcpConnectionByAgentId(agentId);
|
||||
if (!entry) return false;
|
||||
sendToWs(entry.ws, msg);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Gracefully close all ACP WebSocket connections */
|
||||
export function closeAllAcpConnections(): void {
|
||||
if (connections.size === 0) return;
|
||||
|
||||
log(`[ACP-WS] Gracefully closing ${connections.size} ACP connection(s)...`);
|
||||
for (const [wsId, entry] of connections) {
|
||||
try {
|
||||
if (entry.unsub) entry.unsub();
|
||||
if (entry.keepalive) clearInterval(entry.keepalive);
|
||||
if (entry.ws.readyState === 1) {
|
||||
entry.ws.close(1001, "server_shutdown");
|
||||
}
|
||||
if (entry.agentId) {
|
||||
storeMarkAcpAgentOffline(entry.agentId);
|
||||
}
|
||||
} catch {
|
||||
// ignore errors during shutdown
|
||||
}
|
||||
}
|
||||
connections.clear();
|
||||
log("[ACP-WS] All connections closed");
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { SessionEvent } from "./event-bus";
|
||||
|
||||
/**
|
||||
* Convert an internal session event into the SDK/control message shape that
|
||||
* bridge workers consume on both the legacy WS path and the v2 worker SSE path.
|
||||
*/
|
||||
export function toClientPayload(event: SessionEvent): Record<string, unknown> {
|
||||
const payload = event.payload as Record<string, unknown> | null;
|
||||
const messageUuid =
|
||||
typeof payload?.uuid === "string" && payload.uuid ? payload.uuid : event.id;
|
||||
|
||||
if (event.type === "user" || event.type === "user_message") {
|
||||
return {
|
||||
type: "user",
|
||||
uuid: messageUuid,
|
||||
session_id: event.sessionId,
|
||||
...(payload?.isSynthetic === true ? { isSynthetic: true } : {}),
|
||||
message: {
|
||||
role: "user",
|
||||
content: payload?.content ?? payload?.message ?? "",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (event.type === "permission_response" || event.type === "control_response") {
|
||||
const approved = !!payload?.approved;
|
||||
const existingResponse = payload?.response as Record<string, unknown> | undefined;
|
||||
if (existingResponse) {
|
||||
return { type: "control_response", response: existingResponse };
|
||||
}
|
||||
|
||||
const updatedInput = payload?.updated_input as Record<string, unknown> | undefined;
|
||||
const updatedPermissions = payload?.updated_permissions as Record<string, unknown>[] | undefined;
|
||||
const feedbackMessage = payload?.message as string | undefined;
|
||||
|
||||
return {
|
||||
type: "control_response",
|
||||
response: {
|
||||
subtype: approved ? "success" : "error",
|
||||
request_id: payload?.request_id ?? "",
|
||||
...(approved
|
||||
? {
|
||||
response: {
|
||||
behavior: "allow" as const,
|
||||
...(updatedInput ? { updatedInput } : {}),
|
||||
...(updatedPermissions ? { updatedPermissions } : {}),
|
||||
},
|
||||
}
|
||||
: {
|
||||
error: "Permission denied by user",
|
||||
response: { behavior: "deny" as const },
|
||||
...(feedbackMessage ? { message: feedbackMessage } : {}),
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (event.type === "interrupt") {
|
||||
return {
|
||||
type: "control_request",
|
||||
request_id: event.id,
|
||||
request: { subtype: "interrupt" },
|
||||
};
|
||||
}
|
||||
|
||||
if (event.type === "control_request") {
|
||||
return {
|
||||
type: "control_request",
|
||||
request_id: payload?.request_id ?? event.id,
|
||||
request: payload?.request ?? payload,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: event.type,
|
||||
uuid: messageUuid,
|
||||
session_id: event.sessionId,
|
||||
message: payload,
|
||||
};
|
||||
}
|
||||
@@ -12,6 +12,8 @@ export interface SessionEvent {
|
||||
|
||||
type Subscriber = (event: SessionEvent) => void;
|
||||
|
||||
const MAX_EVENTS_PER_BUS = 5000;
|
||||
|
||||
export class EventBus {
|
||||
private subscribers = new Set<Subscriber>();
|
||||
private events: SessionEvent[] = [];
|
||||
@@ -35,7 +37,14 @@ export class EventBus {
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
this.events.push(full);
|
||||
log(`[RC-DEBUG] bus publish: sessionId=${event.sessionId} type=${event.type} dir=${event.direction} seq=${full.seqNum} subscribers=${this.subscribers.size}`);
|
||||
// Evict oldest events when exceeding limit
|
||||
if (this.events.length > MAX_EVENTS_PER_BUS) {
|
||||
this.events = this.events.slice(-Math.floor(MAX_EVENTS_PER_BUS / 2));
|
||||
}
|
||||
log(
|
||||
`[RC-DEBUG] bus publish: sessionId=${event.sessionId} type=${event.type} dir=${event.direction} seq=${full.seqNum} subscribers=${this.subscribers.size}`,
|
||||
event.type === "error" ? `payload=${JSON.stringify(event.payload)}` : "",
|
||||
);
|
||||
for (const cb of this.subscribers) {
|
||||
try {
|
||||
cb(full);
|
||||
@@ -85,3 +94,23 @@ export function removeEventBus(sessionId: string) {
|
||||
export function getAllEventBuses(): Map<string, EventBus> {
|
||||
return buses;
|
||||
}
|
||||
|
||||
/** Global registry of per-channel-group ACP event buses */
|
||||
const acpBuses = new Map<string, EventBus>();
|
||||
|
||||
export function getAcpEventBus(channelGroupId: string): EventBus {
|
||||
let bus = acpBuses.get(channelGroupId);
|
||||
if (!bus) {
|
||||
bus = new EventBus();
|
||||
acpBuses.set(channelGroupId, bus);
|
||||
}
|
||||
return bus;
|
||||
}
|
||||
|
||||
export function removeAcpEventBus(channelGroupId: string) {
|
||||
const bus = acpBuses.get(channelGroupId);
|
||||
if (bus) {
|
||||
bus.close();
|
||||
acpBuses.delete(channelGroupId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { log, error as logError } from "../logger";
|
||||
import type { Context } from "hono";
|
||||
import type { SessionEvent } from "./event-bus";
|
||||
import { getEventBus } from "./event-bus";
|
||||
import { toClientPayload } from "./client-payload";
|
||||
|
||||
export interface SSEWriter {
|
||||
send(event: SessionEvent): void;
|
||||
@@ -118,6 +119,15 @@ export function createSSEStream(c: Context, sessionId: string, fromSeqNum = 0) {
|
||||
}
|
||||
|
||||
function toWorkerClientPayload(event: SessionEvent): Record<string, unknown> {
|
||||
if (
|
||||
event.type === "permission_response" ||
|
||||
event.type === "control_response" ||
|
||||
event.type === "control_request" ||
|
||||
event.type === "interrupt"
|
||||
) {
|
||||
return toClientPayload(event);
|
||||
}
|
||||
|
||||
const normalized =
|
||||
event.payload && typeof event.payload === "object"
|
||||
? (event.payload as Record<string, unknown>)
|
||||
|
||||
@@ -3,6 +3,8 @@ import { getEventBus } from "./event-bus";
|
||||
import type { SessionEvent } from "./event-bus";
|
||||
import { publishSessionEvent } from "../services/transport";
|
||||
import { log, error as logError } from "../logger";
|
||||
import { toClientPayload } from "./client-payload";
|
||||
import { config } from "../config";
|
||||
|
||||
// Per-connection cleanup, keyed by sessionId (only one WS per session)
|
||||
interface CleanupEntry {
|
||||
@@ -10,94 +12,34 @@ interface CleanupEntry {
|
||||
keepalive: ReturnType<typeof setInterval>;
|
||||
ws: WSContext;
|
||||
openTime: number;
|
||||
lastClientActivity: number;
|
||||
}
|
||||
const cleanupBySession = new Map<string, CleanupEntry>();
|
||||
|
||||
// Track all active WS connections for graceful shutdown
|
||||
const activeConnections = new Set<WSContext>();
|
||||
|
||||
// Bridge sends keep_alive data frames every 120s. Send server-side keep_alive
|
||||
// every 60s to ensure the connection stays alive even without user messages.
|
||||
const SERVER_KEEPALIVE_INTERVAL_MS = 60_000;
|
||||
// Server-side keepalive interval (configurable via RCS_WS_KEEPALIVE_INTERVAL).
|
||||
// Sends data frames to keep reverse proxies from closing idle connections.
|
||||
const SERVER_KEEPALIVE_INTERVAL_MS = (config.wsKeepaliveInterval || 20) * 1000;
|
||||
|
||||
// If no client data received within this threshold, the connection is
|
||||
// considered dead. Set to 3x keepalive to tolerate one missed interval.
|
||||
const CLIENT_ACTIVITY_TIMEOUT_MS = SERVER_KEEPALIVE_INTERVAL_MS * 3;
|
||||
|
||||
/**
|
||||
* Convert internal EventBus event -> SDK message for bridge client.
|
||||
*/
|
||||
function toSDKMessage(event: SessionEvent): string {
|
||||
const payload = event.payload as Record<string, unknown> | null;
|
||||
const messageUuid = typeof payload?.uuid === "string" && payload.uuid ? payload.uuid : event.id;
|
||||
|
||||
let msg: Record<string, unknown>;
|
||||
|
||||
if (event.type === "user" || event.type === "user_message") {
|
||||
msg = {
|
||||
type: "user",
|
||||
uuid: messageUuid,
|
||||
session_id: event.sessionId,
|
||||
message: {
|
||||
role: "user",
|
||||
content: payload?.content ?? payload?.message ?? "",
|
||||
},
|
||||
};
|
||||
} else if (event.type === "permission_response" || event.type === "control_response") {
|
||||
const approved = !!payload?.approved;
|
||||
const existingResponse = payload?.response as Record<string, unknown> | undefined;
|
||||
if (existingResponse) {
|
||||
msg = { type: "control_response", response: existingResponse };
|
||||
} else {
|
||||
const updatedInput = payload?.updated_input as Record<string, unknown> | undefined;
|
||||
const updatedPermissions = payload?.updated_permissions as Record<string, unknown>[] | undefined;
|
||||
const feedbackMessage = payload?.message as string | undefined;
|
||||
msg = {
|
||||
type: "control_response",
|
||||
response: {
|
||||
subtype: approved ? "success" : "error",
|
||||
request_id: payload?.request_id ?? "",
|
||||
...(approved
|
||||
? {
|
||||
response: {
|
||||
behavior: "allow" as const,
|
||||
...(updatedInput ? { updatedInput } : {}),
|
||||
...(updatedPermissions ? { updatedPermissions } : {}),
|
||||
},
|
||||
}
|
||||
: {
|
||||
error: "Permission denied by user",
|
||||
response: { behavior: "deny" as const },
|
||||
...(feedbackMessage ? { message: feedbackMessage } : {}),
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
} else if (event.type === "interrupt") {
|
||||
msg = {
|
||||
type: "control_request",
|
||||
request_id: event.id,
|
||||
request: { subtype: "interrupt" },
|
||||
};
|
||||
} else if (event.type === "control_request") {
|
||||
msg = {
|
||||
type: "control_request",
|
||||
request_id: payload?.request_id ?? event.id,
|
||||
request: payload?.request ?? payload,
|
||||
};
|
||||
} else {
|
||||
msg = {
|
||||
type: event.type,
|
||||
uuid: messageUuid,
|
||||
session_id: event.sessionId,
|
||||
message: payload,
|
||||
};
|
||||
}
|
||||
|
||||
// NDJSON format: each message MUST end with \n so the child process's
|
||||
// line-based parser can split messages correctly.
|
||||
return JSON.stringify(msg) + "\n";
|
||||
return JSON.stringify(toClientPayload(event)) + "\n";
|
||||
}
|
||||
|
||||
/** Called from onOpen — subscribes to event bus, forwards outbound events to bridge WS */
|
||||
export function handleWebSocketOpen(ws: WSContext, sessionId: string) {
|
||||
const openTime = Date.now();
|
||||
const lastClientActivity = Date.now();
|
||||
log(`[RC-DEBUG] [WS] Open session=${sessionId}`);
|
||||
activeConnections.add(ws);
|
||||
|
||||
@@ -144,6 +86,17 @@ export function handleWebSocketOpen(ws: WSContext, sessionId: string) {
|
||||
clearInterval(keepalive);
|
||||
return;
|
||||
}
|
||||
// Check if client is still alive — close if no data received for too long
|
||||
const silenceMs = Date.now() - lastClientActivity;
|
||||
if (silenceMs > CLIENT_ACTIVITY_TIMEOUT_MS) {
|
||||
log(`[WS] Client inactive for ${Math.round(silenceMs / 1000)}s on session=${sessionId}, closing dead connection`);
|
||||
try {
|
||||
ws.close(1000, "client inactive");
|
||||
} catch {
|
||||
clearInterval(keepalive);
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ws.send('{"type":"keep_alive"}\n');
|
||||
} catch {
|
||||
@@ -151,13 +104,18 @@ export function handleWebSocketOpen(ws: WSContext, sessionId: string) {
|
||||
}
|
||||
}, SERVER_KEEPALIVE_INTERVAL_MS);
|
||||
|
||||
cleanupBySession.set(sessionId, { unsub, keepalive, ws, openTime });
|
||||
cleanupBySession.set(sessionId, { unsub, keepalive, ws, openTime, lastClientActivity });
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from onMessage — bridge sends newline-delimited JSON.
|
||||
*/
|
||||
export function handleWebSocketMessage(ws: WSContext, sessionId: string, data: string) {
|
||||
// Track client activity for dead-connection detection
|
||||
const entry = cleanupBySession.get(sessionId);
|
||||
if (entry) {
|
||||
entry.lastClientActivity = Date.now();
|
||||
}
|
||||
const lines = data.split("\n").filter((l) => l.trim());
|
||||
for (const line of lines) {
|
||||
try {
|
||||
@@ -236,7 +194,11 @@ export function ingestBridgeMessage(sessionId: string, msg: Record<string, unkno
|
||||
}
|
||||
payload = { message: msg.message, uuid: msg.uuid, content: text };
|
||||
} else if (eventType === "user" || eventType === "system") {
|
||||
payload = { message: msg.message, uuid: msg.uuid };
|
||||
payload = {
|
||||
message: msg.message,
|
||||
uuid: msg.uuid,
|
||||
...(typeof msg.isSynthetic === "boolean" ? { isSynthetic: msg.isSynthetic } : {}),
|
||||
};
|
||||
} else if (eventType === "control_request") {
|
||||
payload = { request_id: msg.request_id, request: msg.request };
|
||||
} else if (eventType === "control_response") {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { upgradeWebSocket, websocket } from "hono/bun";
|
||||
@@ -19,6 +19,7 @@ export interface RegisterEnvironmentRequest {
|
||||
max_sessions?: number;
|
||||
worker_type?: string;
|
||||
bridge_id?: string;
|
||||
capabilities?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface RegisterEnvironmentResponse {
|
||||
@@ -70,6 +71,14 @@ export interface SessionResponse {
|
||||
username: string | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
automation_state?: AutomationStateResponse;
|
||||
}
|
||||
|
||||
export interface AutomationStateResponse {
|
||||
enabled: boolean;
|
||||
phase: "standby" | "sleeping" | null;
|
||||
next_tick_at: number | null;
|
||||
sleep_until: number | null;
|
||||
}
|
||||
|
||||
// --- v2 Code Sessions ---
|
||||
@@ -97,6 +106,8 @@ export interface EnvironmentResponse {
|
||||
status: string;
|
||||
username: string | null;
|
||||
last_poll_at: number | null;
|
||||
worker_type?: string;
|
||||
capabilities?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface SessionSummaryResponse {
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface ControlRequest extends SDKMessage {
|
||||
export type SessionEventType =
|
||||
| "user"
|
||||
| "assistant"
|
||||
| "automation_state"
|
||||
| "permission_request"
|
||||
| "permission_response"
|
||||
| "control_request"
|
||||
@@ -49,6 +50,7 @@ export type SessionEventType =
|
||||
export interface NormalizedEventPayload {
|
||||
content: string;
|
||||
raw?: unknown;
|
||||
isSynthetic?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
/**
|
||||
* Remote Control — API Client (UUID-based auth)
|
||||
*/
|
||||
|
||||
const BASE = ""; // same origin
|
||||
|
||||
function generateUuid() {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
// Fallback for non-secure contexts (HTTP without localhost)
|
||||
return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, (c) =>
|
||||
(c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16),
|
||||
);
|
||||
}
|
||||
|
||||
export function getUuid() {
|
||||
let uuid = localStorage.getItem("rcs_uuid");
|
||||
if (!uuid) {
|
||||
uuid = generateUuid();
|
||||
localStorage.setItem("rcs_uuid", uuid);
|
||||
}
|
||||
return uuid;
|
||||
}
|
||||
|
||||
export function setUuid(uuid) {
|
||||
localStorage.setItem("rcs_uuid", uuid);
|
||||
}
|
||||
|
||||
async function api(method, path, body) {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
const uuid = getUuid();
|
||||
|
||||
// Append uuid as query param for auth
|
||||
const sep = path.includes("?") ? "&" : "?";
|
||||
const url = `${BASE}${path}${sep}uuid=${encodeURIComponent(uuid)}`;
|
||||
|
||||
const opts = { method, headers };
|
||||
if (body !== undefined) opts.body = JSON.stringify(body);
|
||||
|
||||
const res = await fetch(url, opts);
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
const err = data.error || { type: "unknown", message: res.statusText };
|
||||
throw new Error(err.message || err.type);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export function apiBind(sessionId) {
|
||||
return api("POST", "/web/bind", { sessionId });
|
||||
}
|
||||
|
||||
export function apiFetchSessions() {
|
||||
return api("GET", "/web/sessions");
|
||||
}
|
||||
|
||||
export function apiFetchAllSessions() {
|
||||
return api("GET", "/web/sessions/all");
|
||||
}
|
||||
|
||||
export function apiFetchSession(id) {
|
||||
return api("GET", `/web/sessions/${id}`);
|
||||
}
|
||||
|
||||
export function apiFetchSessionHistory(id) {
|
||||
return api("GET", `/web/sessions/${id}/history`);
|
||||
}
|
||||
|
||||
export function apiFetchEnvironments() {
|
||||
return api("GET", "/web/environments");
|
||||
}
|
||||
|
||||
export function apiSendEvent(sessionId, body) {
|
||||
return api("POST", `/web/sessions/${sessionId}/events`, body);
|
||||
}
|
||||
|
||||
export function apiSendControl(sessionId, body) {
|
||||
return api("POST", `/web/sessions/${sessionId}/control`, body);
|
||||
}
|
||||
|
||||
export function apiInterrupt(sessionId) {
|
||||
return api("POST", `/web/sessions/${sessionId}/interrupt`);
|
||||
}
|
||||
|
||||
export function apiCreateSession(body) {
|
||||
return api("POST", "/web/sessions", body);
|
||||
}
|
||||
@@ -1,702 +0,0 @@
|
||||
/**
|
||||
* Remote Control — Main App (Router + Orchestrator)
|
||||
* UUID-based auth — no login required
|
||||
*/
|
||||
import { getUuid, setUuid, apiBind, apiFetchSessions, apiFetchAllSessions, apiFetchEnvironments, apiFetchSession, apiFetchSessionHistory, apiSendEvent, apiSendControl, apiInterrupt, apiCreateSession } from "./api.js";
|
||||
import { connectSSE, disconnectSSE } from "./sse.js";
|
||||
import { appendEvent, showLoading, isLoading, removeLoading, resetReplayState, renderReplayPendingRequests } from "./render.js";
|
||||
import { initTaskPanel, toggleTaskPanel, resetTaskState } from "./task-panel.js";
|
||||
import { esc, formatTime, statusClass, isClosedSessionStatus } from "./utils.js";
|
||||
|
||||
// ============================================================
|
||||
// State
|
||||
// ============================================================
|
||||
|
||||
let currentSessionId = null;
|
||||
let currentSessionStatus = null;
|
||||
let dashboardInterval = null;
|
||||
let cachedEnvs = [];
|
||||
|
||||
function generateMessageUuid() {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `msg_${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Router
|
||||
// ============================================================
|
||||
|
||||
function getPathSessionId() {
|
||||
const match = window.location.pathname.match(/^\/code\/([^/]+)/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
function getUrlParam(name) {
|
||||
return new URLSearchParams(window.location.search).get(name);
|
||||
}
|
||||
|
||||
function showPage(name) {
|
||||
const pages = ["dashboard", "session"];
|
||||
for (const p of pages) {
|
||||
const el = document.getElementById(`page-${p}`);
|
||||
if (el) el.classList.toggle("hidden", p !== name);
|
||||
}
|
||||
}
|
||||
|
||||
function navigate(path) {
|
||||
history.pushState(null, "", path);
|
||||
handleRoute();
|
||||
}
|
||||
window.navigate = navigate;
|
||||
|
||||
function applySessionStatus(status) {
|
||||
currentSessionStatus = status || null;
|
||||
|
||||
const badge = document.getElementById("session-status");
|
||||
if (badge) {
|
||||
badge.textContent = status || "";
|
||||
badge.className = `status-badge status-${statusClass(status)}`;
|
||||
}
|
||||
|
||||
const closed = isClosedSessionStatus(status);
|
||||
const input = document.getElementById("msg-input");
|
||||
if (input) {
|
||||
input.disabled = closed;
|
||||
input.placeholder = closed ? "Session is closed" : "Type a message...";
|
||||
}
|
||||
|
||||
const actionBtn = document.getElementById("action-btn");
|
||||
if (actionBtn) {
|
||||
actionBtn.disabled = closed;
|
||||
actionBtn.title = closed ? "Session is closed" : "";
|
||||
}
|
||||
|
||||
if (closed) {
|
||||
removeLoading();
|
||||
window.__updateActionBtn?.(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSessionEvent(event) {
|
||||
if (event?.type === "session_status" && typeof event.payload?.status === "string") {
|
||||
applySessionStatus(event.payload.status);
|
||||
if (isClosedSessionStatus(event.payload.status)) {
|
||||
disconnectSSE();
|
||||
}
|
||||
}
|
||||
appendEvent(event);
|
||||
}
|
||||
|
||||
async function syncClosedSessionState(err, actionLabel) {
|
||||
if (!(err instanceof Error)) {
|
||||
alert(`${actionLabel}: unknown error`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentSessionId || !/session is /i.test(err.message)) {
|
||||
alert(`${actionLabel}: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await apiFetchSession(currentSessionId);
|
||||
applySessionStatus(session.status);
|
||||
if (isClosedSessionStatus(session.status)) {
|
||||
appendEvent({ type: "session_status", payload: { status: session.status } });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to the original error if the refresh also fails.
|
||||
}
|
||||
|
||||
alert(`${actionLabel}: ${err.message}`);
|
||||
}
|
||||
|
||||
async function handleRoute() {
|
||||
// Ensure we have a UUID
|
||||
getUuid();
|
||||
|
||||
// Check for UUID import from QR scan (?uuid=xxx)
|
||||
const importUuid = getUrlParam("uuid");
|
||||
if (importUuid) {
|
||||
setUuid(importUuid);
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.delete("uuid");
|
||||
history.replaceState(null, "", url);
|
||||
}
|
||||
|
||||
// Check for CLI session bind (?sid=xxx)
|
||||
const sid = getUrlParam("sid");
|
||||
if (sid) {
|
||||
try {
|
||||
await apiBind(sid);
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.delete("sid");
|
||||
history.replaceState(null, "", `/code/${sid}`);
|
||||
showPage("session");
|
||||
stopDashboardRefresh();
|
||||
renderSessionDetail(sid);
|
||||
return;
|
||||
} catch (err) {
|
||||
console.error("Failed to bind session:", err);
|
||||
alert("Session not found or bind failed: " + err.message);
|
||||
history.replaceState(null, "", "/code/");
|
||||
}
|
||||
}
|
||||
|
||||
// Path-based routing: /code/session_xxx → session detail
|
||||
const pathSessionId = getPathSessionId();
|
||||
if (pathSessionId) {
|
||||
try { await apiBind(pathSessionId); } catch { /* may already be bound */ }
|
||||
showPage("session");
|
||||
stopDashboardRefresh();
|
||||
renderSessionDetail(pathSessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Default: /code → dashboard
|
||||
currentSessionId = null;
|
||||
currentSessionStatus = null;
|
||||
showPage("dashboard");
|
||||
disconnectSSE();
|
||||
renderDashboard();
|
||||
startDashboardRefresh();
|
||||
}
|
||||
|
||||
window.addEventListener("popstate", handleRoute);
|
||||
|
||||
// ============================================================
|
||||
// Dashboard
|
||||
// ============================================================
|
||||
|
||||
async function renderDashboard() {
|
||||
try {
|
||||
const [sessions, envs] = await Promise.all([apiFetchAllSessions(), apiFetchEnvironments()]);
|
||||
cachedEnvs = envs || [];
|
||||
renderEnvironmentList(cachedEnvs);
|
||||
renderSessionList(sessions);
|
||||
} catch (err) {
|
||||
console.error("Dashboard render error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function renderEnvironmentList(envs) {
|
||||
const container = document.getElementById("env-list");
|
||||
if (!envs || envs.length === 0) {
|
||||
container.innerHTML = '<div class="empty-state">No active environments</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = envs.map((e) => `
|
||||
<div class="env-card">
|
||||
<div>
|
||||
<div class="env-name">${esc(e.machine_name || e.id)}</div>
|
||||
<div class="env-dir">${esc(e.directory || "")}</div>
|
||||
</div>
|
||||
<div style="text-align:right">
|
||||
<span class="status-badge status-${statusClass(e.status)}">${esc(e.status)}</span>
|
||||
<div class="env-branch">${e.branch ? esc(e.branch) : ""}</div>
|
||||
</div>
|
||||
</div>`).join("");
|
||||
}
|
||||
|
||||
function renderSessionList(sessions) {
|
||||
const container = document.getElementById("session-list");
|
||||
if (!sessions || sessions.length === 0) {
|
||||
container.innerHTML = '<div class="empty-state">No sessions</div>';
|
||||
return;
|
||||
}
|
||||
sessions.sort((a, b) => (b.updated_at || 0) - (a.updated_at || 0));
|
||||
container.innerHTML = sessions.map((s) => `
|
||||
<div class="session-card" onclick="navigate('/code/${esc(s.id)}')">
|
||||
<div>
|
||||
<div class="session-title-text">${esc(s.title || s.id)}</div>
|
||||
<div class="session-id-text">${esc(s.id)}</div>
|
||||
</div>
|
||||
<span class="status-badge status-${statusClass(s.status)}">${esc(s.status)}</span>
|
||||
<span class="meta-item">${formatTime(s.created_at || s.updated_at)}</span>
|
||||
</div>`).join("");
|
||||
}
|
||||
|
||||
function startDashboardRefresh() {
|
||||
stopDashboardRefresh();
|
||||
dashboardInterval = setInterval(renderDashboard, 10000);
|
||||
}
|
||||
function stopDashboardRefresh() {
|
||||
if (dashboardInterval) { clearInterval(dashboardInterval); dashboardInterval = null; }
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Session Detail
|
||||
// ============================================================
|
||||
|
||||
async function renderSessionDetail(id) {
|
||||
currentSessionId = id;
|
||||
|
||||
// Reset task state for new session and init panel
|
||||
resetTaskState();
|
||||
const taskPanelEl = document.getElementById("task-panel");
|
||||
if (taskPanelEl) initTaskPanel(taskPanelEl);
|
||||
|
||||
try {
|
||||
const session = await apiFetchSession(id);
|
||||
document.getElementById("session-title").textContent = session.title || session.id;
|
||||
document.getElementById("session-id").textContent = session.id;
|
||||
document.getElementById("session-env").textContent = session.environment_id || "";
|
||||
document.getElementById("session-time").textContent = formatTime(session.created_at);
|
||||
applySessionStatus(session.status);
|
||||
} catch (err) {
|
||||
alert("Failed to load session: " + err.message);
|
||||
navigate("/code/");
|
||||
return;
|
||||
}
|
||||
document.getElementById("event-stream").innerHTML = "";
|
||||
document.getElementById("permission-area").innerHTML = "";
|
||||
document.getElementById("permission-area").classList.add("hidden");
|
||||
|
||||
// Load historical events before connecting to live stream
|
||||
resetReplayState();
|
||||
let lastSeqNum = 0;
|
||||
try {
|
||||
const { events } = await apiFetchSessionHistory(id);
|
||||
if (events && events.length > 0) {
|
||||
for (const event of events) {
|
||||
appendEvent(event, { replay: true });
|
||||
if (event.seqNum && event.seqNum > lastSeqNum) lastSeqNum = event.seqNum;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Failed to load session history:", err);
|
||||
}
|
||||
// Re-render any still-unresolved permission prompts from history
|
||||
renderReplayPendingRequests();
|
||||
|
||||
if (isClosedSessionStatus(currentSessionStatus)) {
|
||||
appendEvent({ type: "session_status", payload: { status: currentSessionStatus } });
|
||||
disconnectSSE();
|
||||
return;
|
||||
}
|
||||
|
||||
connectSSE(id, handleSessionEvent, lastSeqNum);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Control Bar
|
||||
// ============================================================
|
||||
|
||||
function setupControlBar() {
|
||||
const input = document.getElementById("msg-input");
|
||||
const actionBtn = document.getElementById("action-btn");
|
||||
const iconSend = document.getElementById("action-icon-send");
|
||||
const iconStop = document.getElementById("action-icon-stop");
|
||||
|
||||
function setBtnState(loading) {
|
||||
actionBtn.classList.toggle("loading", loading);
|
||||
actionBtn.setAttribute("aria-label", loading ? "Stop" : "Send");
|
||||
iconSend.classList.toggle("hidden", loading);
|
||||
iconStop.classList.toggle("hidden", !loading);
|
||||
}
|
||||
|
||||
window.__updateActionBtn = setBtnState;
|
||||
|
||||
actionBtn.addEventListener("click", () => {
|
||||
if (isLoading()) {
|
||||
doInterrupt();
|
||||
} else {
|
||||
sendMessage();
|
||||
}
|
||||
});
|
||||
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); sendMessage(); }
|
||||
});
|
||||
}
|
||||
|
||||
async function doInterrupt() {
|
||||
if (!currentSessionId || isClosedSessionStatus(currentSessionStatus)) return;
|
||||
const btn = document.getElementById("action-btn");
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await apiInterrupt(currentSessionId);
|
||||
appendEvent({ type: "interrupt", payload: { message: "Session interrupted" } });
|
||||
} catch (err) {
|
||||
await syncClosedSessionState(err, "Interrupt failed");
|
||||
} finally {
|
||||
btn.disabled = isClosedSessionStatus(currentSessionStatus);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
const input = document.getElementById("msg-input");
|
||||
const text = input.value.trim();
|
||||
if (!text || !currentSessionId || isClosedSessionStatus(currentSessionStatus)) return;
|
||||
input.value = "";
|
||||
const uuid = generateMessageUuid();
|
||||
try {
|
||||
await apiSendEvent(currentSessionId, {
|
||||
type: "user",
|
||||
uuid,
|
||||
content: text,
|
||||
message: { content: text },
|
||||
});
|
||||
} catch (err) {
|
||||
input.value = text;
|
||||
await syncClosedSessionState(err, "Failed to send");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Permission Actions (exposed globally for onclick)
|
||||
// ============================================================
|
||||
|
||||
window._approvePerm = async function (requestId, btn) {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await apiSendControl(currentSessionId, { type: "permission_response", approved: true, request_id: requestId });
|
||||
removePermissionPrompt(btn);
|
||||
showLoading();
|
||||
} catch (err) { alert("Failed to approve: " + err.message); btn.disabled = false; }
|
||||
};
|
||||
|
||||
window._rejectPerm = async function (requestId, btn) {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await apiSendControl(currentSessionId, { type: "permission_response", approved: false, request_id: requestId });
|
||||
removePermissionPrompt(btn);
|
||||
} catch (err) { alert("Failed to reject: " + err.message); btn.disabled = false; }
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// AskUserQuestion interactions
|
||||
// ============================================================
|
||||
|
||||
window._selectOption = function (btn, qIdx, oIdx, multiSelect) {
|
||||
const panel = btn.closest(".ask-panel");
|
||||
if (!panel) return;
|
||||
if (!panel._answers) panel._answers = {};
|
||||
|
||||
if (multiSelect) {
|
||||
// Toggle multi-select
|
||||
btn.classList.toggle("selected");
|
||||
if (!panel._answers[qIdx]) panel._answers[qIdx] = [];
|
||||
const arr = panel._answers[qIdx];
|
||||
const pos = arr.indexOf(oIdx);
|
||||
if (pos >= 0) arr.splice(pos, 1);
|
||||
else arr.push(oIdx);
|
||||
} else {
|
||||
// Single select — deselect siblings
|
||||
const siblings = panel.querySelectorAll(`.ask-option[data-qidx="${qIdx}"]`);
|
||||
siblings.forEach((s) => s.classList.remove("selected"));
|
||||
btn.classList.add("selected");
|
||||
panel._answers[qIdx] = oIdx;
|
||||
}
|
||||
};
|
||||
|
||||
window._submitOther = function (btn, qIdx) {
|
||||
const row = btn.closest(".ask-other-row");
|
||||
const input = row.querySelector(".ask-other-input");
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
const panel = btn.closest(".ask-panel");
|
||||
if (!panel) return;
|
||||
if (!panel._answers) panel._answers = {};
|
||||
panel._answers[qIdx] = text;
|
||||
// Deselect any option buttons
|
||||
panel.querySelectorAll(`.ask-option[data-qidx="${qIdx}"]`).forEach((s) => s.classList.remove("selected"));
|
||||
input.value = "";
|
||||
btn.textContent = "Sent!";
|
||||
setTimeout(() => { btn.textContent = "Send"; }, 1000);
|
||||
};
|
||||
|
||||
window._switchAskTab = function (btn, idx) {
|
||||
const panel = btn.closest(".ask-panel");
|
||||
if (!panel) return;
|
||||
panel.querySelectorAll(".ask-tab").forEach((t) => t.classList.remove("active"));
|
||||
panel.querySelectorAll(".ask-tab-page").forEach((p) => p.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
const page = panel.querySelector(`.ask-tab-page[data-tab="${idx}"]`);
|
||||
if (page) page.classList.add("active");
|
||||
const total = panel.querySelectorAll(".ask-tab").length;
|
||||
const prog = panel.querySelector(".ask-progress");
|
||||
if (prog) prog.textContent = `${idx + 1} / ${total}`;
|
||||
};
|
||||
|
||||
window._submitAnswers = async function (requestId, btn) {
|
||||
btn.disabled = true;
|
||||
const panel = btn.closest(".ask-panel");
|
||||
const rawAnswers = panel?._answers || {};
|
||||
const questions = panel?._questions || [];
|
||||
|
||||
// Build updatedInput: merge original input with user's answers
|
||||
const answers = {};
|
||||
for (const [qIdx, val] of Object.entries(rawAnswers)) {
|
||||
const q = questions[parseInt(qIdx)];
|
||||
if (!q) continue;
|
||||
if (typeof val === "string") {
|
||||
// "Other" free-text answer
|
||||
answers[qIdx] = val;
|
||||
} else if (typeof val === "number") {
|
||||
// Selected option index — use label text
|
||||
const opt = q.options?.[val];
|
||||
answers[qIdx] = opt?.label || String(val);
|
||||
} else if (Array.isArray(val)) {
|
||||
// Multi-select — join labels
|
||||
answers[qIdx] = val.map((i) => q.options?.[i]?.label || String(i));
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await apiSendControl(currentSessionId, {
|
||||
type: "permission_response",
|
||||
approved: true,
|
||||
request_id: requestId,
|
||||
updated_input: { questions, answers },
|
||||
});
|
||||
removePermissionPrompt(btn);
|
||||
showLoading();
|
||||
} catch (err) { alert("Failed to submit: " + err.message); btn.disabled = false; }
|
||||
};
|
||||
|
||||
function removePermissionPrompt(btn) {
|
||||
const prompt = btn.closest(".permission-prompt, .ask-panel, .plan-panel");
|
||||
if (prompt) prompt.remove();
|
||||
const area = document.getElementById("permission-area");
|
||||
if (area && area.children.length === 0) area.classList.add("hidden");
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ExitPlanMode interactions
|
||||
// ============================================================
|
||||
|
||||
window._selectPlanOption = function (btn, value) {
|
||||
const panel = btn.closest(".plan-panel");
|
||||
if (!panel) return;
|
||||
|
||||
// Deselect all siblings
|
||||
panel.querySelectorAll(".plan-option").forEach((o) => o.classList.remove("selected"));
|
||||
btn.classList.add("selected");
|
||||
panel._selectedValue = value;
|
||||
|
||||
// Show/hide feedback textarea
|
||||
const feedbackArea = panel.querySelector(".plan-feedback-area");
|
||||
if (feedbackArea) {
|
||||
feedbackArea.classList.toggle("visible", value === "no");
|
||||
}
|
||||
};
|
||||
|
||||
window._submitPlanResponse = async function (requestId, btn) {
|
||||
const panel = btn.closest(".plan-panel");
|
||||
if (!panel) return;
|
||||
|
||||
const selectedValue = panel._selectedValue;
|
||||
if (!selectedValue) {
|
||||
alert("Please select an option first.");
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
|
||||
try {
|
||||
if (selectedValue === "no") {
|
||||
// Rejection with optional feedback
|
||||
const feedbackInput = panel.querySelector(".plan-feedback-input");
|
||||
const feedback = feedbackInput ? feedbackInput.value.trim() : "";
|
||||
await apiSendControl(currentSessionId, {
|
||||
type: "permission_response",
|
||||
approved: false,
|
||||
request_id: requestId,
|
||||
...(feedback ? { message: feedback } : {}),
|
||||
});
|
||||
removePermissionPrompt(btn);
|
||||
} else {
|
||||
// Approval with permission mode
|
||||
const modeMap = {
|
||||
"yes-accept-edits": "acceptEdits",
|
||||
"yes-default": "default",
|
||||
};
|
||||
const mode = modeMap[selectedValue] || "default";
|
||||
const planContent = panel._planContent || "";
|
||||
|
||||
await apiSendControl(currentSessionId, {
|
||||
type: "permission_response",
|
||||
approved: true,
|
||||
request_id: requestId,
|
||||
...(planContent ? { updated_input: { plan: planContent } } : {}),
|
||||
updated_permissions: [
|
||||
{ type: "setMode", mode, destination: "session" },
|
||||
],
|
||||
});
|
||||
removePermissionPrompt(btn);
|
||||
showLoading();
|
||||
}
|
||||
} catch (err) {
|
||||
alert("Failed to submit: " + err.message);
|
||||
btn.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// New Session Dialog
|
||||
// ============================================================
|
||||
|
||||
function setupNewSessionDialog() {
|
||||
const btn = document.getElementById("new-session-btn");
|
||||
const dialog = document.getElementById("new-session-dialog");
|
||||
const cancelBtn = document.getElementById("ns-cancel");
|
||||
const createBtn = document.getElementById("ns-create");
|
||||
const errorEl = document.getElementById("ns-error");
|
||||
const titleInput = document.getElementById("ns-title");
|
||||
const envSelect = document.getElementById("ns-env");
|
||||
|
||||
btn.addEventListener("click", () => {
|
||||
envSelect.innerHTML = '<option value="">-- None --</option>';
|
||||
for (const e of cachedEnvs) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = e.id;
|
||||
opt.textContent = `${e.machine_name || e.id} (${e.branch || "no branch"})`;
|
||||
envSelect.appendChild(opt);
|
||||
}
|
||||
errorEl.classList.add("hidden");
|
||||
titleInput.value = "";
|
||||
dialog.classList.remove("hidden");
|
||||
});
|
||||
|
||||
cancelBtn.addEventListener("click", () => dialog.classList.add("hidden"));
|
||||
|
||||
createBtn.addEventListener("click", async () => {
|
||||
createBtn.disabled = true;
|
||||
errorEl.classList.add("hidden");
|
||||
try {
|
||||
const body = {};
|
||||
if (titleInput.value.trim()) body.title = titleInput.value.trim();
|
||||
if (envSelect.value) body.environment_id = envSelect.value;
|
||||
const session = await apiCreateSession(body);
|
||||
dialog.classList.add("hidden");
|
||||
navigate(`/code/${session.id}`);
|
||||
} catch (err) {
|
||||
errorEl.textContent = err.message || "Failed to create session";
|
||||
errorEl.classList.remove("hidden");
|
||||
} finally {
|
||||
createBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Identity Panel (QR code display + scan)
|
||||
// ============================================================
|
||||
|
||||
function setupIdentityPanel() {
|
||||
const btn = document.getElementById("nav-identity");
|
||||
const panel = document.getElementById("identity-panel");
|
||||
const closeBtn = panel.querySelector(".panel-close");
|
||||
const uuidDisplay = document.getElementById("uuid-display");
|
||||
const qrContainer = document.getElementById("qr-display");
|
||||
|
||||
// Show panel and generate QR code
|
||||
btn.addEventListener("click", () => {
|
||||
const uuid = getUuid();
|
||||
uuidDisplay.textContent = uuid;
|
||||
const qrUrl = `${window.location.origin}/code?uuid=${encodeURIComponent(uuid)}`;
|
||||
qrContainer.innerHTML = "";
|
||||
if (typeof QRCode !== "undefined") {
|
||||
new QRCode(qrContainer, { text: qrUrl, width: 200, height: 200, correctLevel: QRCode.CorrectLevel.M });
|
||||
// qrcodejs generates both canvas and img, hide the duplicate img
|
||||
const img = qrContainer.querySelector("img");
|
||||
if (img) img.remove()
|
||||
}
|
||||
panel.classList.remove("hidden");
|
||||
});
|
||||
|
||||
closeBtn.addEventListener("click", () => panel.classList.add("hidden"));
|
||||
|
||||
// Click outside to close
|
||||
panel.addEventListener("click", (e) => {
|
||||
if (e.target === panel) panel.classList.add("hidden");
|
||||
});
|
||||
|
||||
// Copy UUID to clipboard
|
||||
document.getElementById("uuid-copy-btn").addEventListener("click", () => {
|
||||
const uuid = getUuid();
|
||||
navigator.clipboard.writeText(uuid).then(() => {
|
||||
const btn = document.getElementById("uuid-copy-btn");
|
||||
btn.textContent = "Copied!";
|
||||
setTimeout(() => { btn.textContent = "Copy"; }, 2000);
|
||||
});
|
||||
});
|
||||
|
||||
// Scan QR from uploaded image
|
||||
document.getElementById("qr-scan-btn").addEventListener("click", () => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "image/*";
|
||||
input.onchange = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.drawImage(img, 0, 0);
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
if (typeof jsQR !== "undefined") {
|
||||
const code = jsQR(imageData.data, imageData.width, imageData.height);
|
||||
if (code && code.data) {
|
||||
try {
|
||||
const url = new URL(code.data);
|
||||
const importedUuid = url.searchParams.get("uuid");
|
||||
if (importedUuid) {
|
||||
setUuid(importedUuid);
|
||||
panel.classList.add("hidden");
|
||||
navigate("/code/");
|
||||
renderDashboard();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Not a valid URL — try using raw data as UUID
|
||||
if (code.data.length >= 32) {
|
||||
setUuid(code.data);
|
||||
panel.classList.add("hidden");
|
||||
navigate("/code/");
|
||||
renderDashboard();
|
||||
return;
|
||||
}
|
||||
}
|
||||
alert("No valid UUID found in QR code");
|
||||
} else {
|
||||
alert("No QR code found in image");
|
||||
}
|
||||
}
|
||||
};
|
||||
img.src = URL.createObjectURL(file);
|
||||
};
|
||||
input.click();
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Task Panel Toggle
|
||||
// ============================================================
|
||||
|
||||
function setupTaskPanelToggle() {
|
||||
window.__toggleTaskPanel = toggleTaskPanel;
|
||||
const toggleBtn = document.getElementById("task-panel-toggle");
|
||||
if (toggleBtn) {
|
||||
toggleBtn.addEventListener("click", () => toggleTaskPanel());
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Init
|
||||
// ============================================================
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
setupControlBar();
|
||||
setupNewSessionDialog();
|
||||
setupIdentityPanel();
|
||||
setupTaskPanelToggle();
|
||||
handleRoute();
|
||||
});
|
||||
@@ -1,116 +0,0 @@
|
||||
/* === CSS Variables — Anthropic Design System === */
|
||||
:root {
|
||||
/* Core palette — warm terracotta system */
|
||||
--bg-primary: #FAF9F6;
|
||||
--bg-card: #FFFFFF;
|
||||
--bg-dark: #1A1612;
|
||||
--bg-dark-hover: #2A2520;
|
||||
--bg-dark-elevated: #332E28;
|
||||
--bg-input: #F2EFEA;
|
||||
--bg-input-focus: #FFFFFF;
|
||||
--bg-user-msg: #D97757;
|
||||
--bg-assistant-msg: #FFFFFF;
|
||||
--bg-tool-card: #F5F3EF;
|
||||
--bg-permission: #FFF9F0;
|
||||
--text-primary: #1A1612;
|
||||
--text-secondary: #6B6560;
|
||||
--text-light: #FFFFFF;
|
||||
--text-muted: #9B9590;
|
||||
--text-inverse: #FAF9F6;
|
||||
--border: #E8E4DF;
|
||||
--border-light: #F0ECE7;
|
||||
--border-focus: #D97757;
|
||||
--accent: #D97757;
|
||||
--accent-hover: #C4684A;
|
||||
--accent-subtle: #FDF0EB;
|
||||
--green: #3B8A6A;
|
||||
--green-bg: #E8F5EE;
|
||||
--yellow: #C49A2C;
|
||||
--yellow-bg: #FFF8E8;
|
||||
--orange: #D07A3A;
|
||||
--orange-bg: #FFF3E8;
|
||||
--red: #C44040;
|
||||
--red-bg: #FDE8E8;
|
||||
--blue: #4A7FC4;
|
||||
--blue-bg: #E8F0FD;
|
||||
--radius: 14px;
|
||||
--radius-sm: 10px;
|
||||
--radius-xs: 6px;
|
||||
--shadow-sm: 0 1px 2px rgba(26, 22, 18, 0.04);
|
||||
--shadow: 0 1px 3px rgba(26, 22, 18, 0.06), 0 2px 8px rgba(26, 22, 18, 0.04);
|
||||
--shadow-md: 0 4px 16px rgba(26, 22, 18, 0.08), 0 1px 4px rgba(26, 22, 18, 0.04);
|
||||
--shadow-lg: 0 8px 32px rgba(26, 22, 18, 0.10), 0 2px 8px rgba(26, 22, 18, 0.06);
|
||||
--font-display: "Bricolage Grotesque", system-ui, -apple-system, sans-serif;
|
||||
--font-sans: "Figtree", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--font-mono: "Fira Code", "SF Mono", Menlo, monospace;
|
||||
--max-width: 880px;
|
||||
--transition-fast: 0.15s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-base: 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-slow: 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* === Reset === */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html {
|
||||
font-size: 15px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
line-height: 1.6;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Subtle warm ambient light */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
width: 100%; height: 100%;
|
||||
background:
|
||||
radial-gradient(ellipse at 20% 50%, rgba(217, 119, 87, 0.03) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 80% 20%, rgba(217, 119, 87, 0.02) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 50% 80%, rgba(59, 138, 106, 0.02) 0%, transparent 50%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
body > * { position: relative; z-index: 1; }
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
a:hover { color: var(--accent-hover); }
|
||||
|
||||
button { cursor: pointer; font-family: inherit; }
|
||||
input, select, textarea { font-family: inherit; }
|
||||
|
||||
.hidden { display: none !important; }
|
||||
|
||||
/* === Selection === */
|
||||
::selection {
|
||||
background: rgba(217, 119, 87, 0.2);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* === Focus Ring === */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* === Scrollbar === */
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
|
||||
@@ -1,234 +0,0 @@
|
||||
/* === Navbar — Anthropic === */
|
||||
nav {
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
border-bottom: 1px solid var(--border);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.nav-inner {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 32px;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.nav-logo {
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
letter-spacing: -0.01em;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
.nav-logo:hover { opacity: 0.7; text-decoration: none; }
|
||||
.nav-logo svg { flex-shrink: 0; }
|
||||
|
||||
.nav-links { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
.nav-link {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.88rem;
|
||||
font-weight: 500;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius-xs);
|
||||
transition: all var(--transition-fast);
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
.nav-link:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-input);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-text { background: none; border: none; color: inherit; }
|
||||
|
||||
/* === Buttons — Anthropic === */
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: var(--text-light);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 11px 22px;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.005em;
|
||||
transition: all var(--transition-fast);
|
||||
box-shadow: 0 1px 2px rgba(217, 119, 87, 0.2);
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-hover);
|
||||
box-shadow: 0 2px 8px rgba(217, 119, 87, 0.3);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.btn-primary:active { transform: translateY(0); box-shadow: none; }
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--red);
|
||||
color: var(--text-light);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 11px 18px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.btn-danger:hover { background: #B33838; transform: translateY(-1px); }
|
||||
.btn-danger:active { transform: translateY(0); }
|
||||
|
||||
.btn-sm { padding: 8px 16px; font-size: 0.85rem; }
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 16px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.btn-outline:hover {
|
||||
background: var(--bg-input);
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
.btn-approve {
|
||||
background: var(--green);
|
||||
color: var(--text-light);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 9px 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.btn-approve:hover { background: #347A5E; transform: translateY(-1px); }
|
||||
.btn-approve:active { transform: translateY(0); }
|
||||
|
||||
.btn-reject {
|
||||
background: transparent;
|
||||
color: var(--red);
|
||||
border: 1.5px solid var(--red);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 9px 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.btn-reject:hover { background: var(--red-bg); transform: translateY(-1px); }
|
||||
.btn-reject:active { transform: translateY(0); }
|
||||
|
||||
/* === Status Badge — Anthropic === */
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.status-active, .status-running { background: var(--green-bg); color: var(--green); }
|
||||
.status-idle { background: var(--yellow-bg); color: var(--yellow); }
|
||||
.status-inactive { background: #F0ECE7; color: var(--text-secondary); }
|
||||
.status-requires_action { background: var(--orange-bg); color: var(--orange); }
|
||||
.status-archived { background: #F0ECE7; color: var(--text-secondary); }
|
||||
.status-error { background: var(--red-bg); color: var(--red); }
|
||||
.status-default { background: #F0ECE7; color: var(--text-muted); }
|
||||
|
||||
/* === Dialog — Anthropic === */
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
width: 100%; height: 100%;
|
||||
background: rgba(26, 22, 18, 0.3);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
animation: fadeIn var(--transition-fast) ease-out;
|
||||
}
|
||||
|
||||
.dialog-card {
|
||||
background: var(--bg-card);
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 32px;
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
border: 1px solid var(--border-light);
|
||||
animation: slideUp var(--transition-base) ease-out;
|
||||
}
|
||||
.dialog-card h3 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 20px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.dialog-card label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
margin-top: 16px;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.dialog-card input,
|
||||
.dialog-card select {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-input);
|
||||
font-size: 0.92rem;
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.dialog-card input:focus,
|
||||
.dialog-card select:focus {
|
||||
border-color: var(--accent);
|
||||
background: var(--bg-input-focus);
|
||||
box-shadow: 0 0 0 3px rgba(217, 119, 87, 0.12);
|
||||
}
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
/* === Animations === */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { opacity: 0; transform: translateY(8px) scale(0.98); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
466
packages/remote-control-server/web/components/ACPConnect.tsx
Normal file
466
packages/remote-control-server/web/components/ACPConnect.tsx
Normal file
@@ -0,0 +1,466 @@
|
||||
import { useState, useEffect, useLayoutEffect, useCallback, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Button } from "./ui/button";
|
||||
import { StatusDot } from "./ui/connection-status";
|
||||
import { ThemeToggle } from "./ui/theme-toggle";
|
||||
import { Label } from "./ui/label";
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
} from "./ui/input-group";
|
||||
import { ACPClient, DEFAULT_SETTINGS, DisconnectRequestedError } from "../src/acp";
|
||||
import type { ACPSettings, ConnectionState, BrowserToolParams, BrowserToolResult } from "../src/acp";
|
||||
import { ChevronDown, FolderOpen, Globe, Image, KeyRound, ScanLine, X } from "lucide-react";
|
||||
import { useQRScanner, type QRCodeData } from "../src/hooks";
|
||||
|
||||
// Get token from URL query param (for pre-filled URLs from server)
|
||||
function getTokenFromUrl(): string | undefined {
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
return url.searchParams.get("token") || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Infer WebSocket URL from current page URL (for pre-filled links from server)
|
||||
// e.g., http://localhost:9315/app?token=xxx -> ws://localhost:9315/ws
|
||||
function inferProxyUrlFromPage(): string | undefined {
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
// Only infer if we have a token param (indicates user came from server-printed URL)
|
||||
if (!url.searchParams.has("token")) {
|
||||
return undefined;
|
||||
}
|
||||
const protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${protocol}//${url.host}/ws`;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Get initial settings from defaults, with optional URL overrides
|
||||
function getInitialSettings(inferFromUrl: boolean): ACPSettings {
|
||||
const settings = { ...DEFAULT_SETTINGS };
|
||||
|
||||
// Override from URL if enabled (for pre-filled links from server)
|
||||
if (inferFromUrl) {
|
||||
const urlToken = getTokenFromUrl();
|
||||
const inferredUrl = inferProxyUrlFromPage();
|
||||
|
||||
if (urlToken) {
|
||||
settings.token = urlToken;
|
||||
}
|
||||
if (inferredUrl) {
|
||||
settings.proxyUrl = inferredUrl;
|
||||
}
|
||||
}
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
export interface ACPConnectProps {
|
||||
onClientReady?: (client: ACPClient | null) => void;
|
||||
expanded: boolean;
|
||||
onExpandedChange: (expanded: boolean) => void;
|
||||
/** Handler for browser tool calls (only Chrome extension can execute these) */
|
||||
browserToolHandler?: (params: BrowserToolParams) => Promise<BrowserToolResult>;
|
||||
/** Show token input field (for remote access) */
|
||||
showTokenInput?: boolean;
|
||||
/** Infer proxy URL and token from page URL (for PWA) */
|
||||
inferFromUrl?: boolean;
|
||||
/** Placeholder for proxy URL input */
|
||||
placeholder?: string;
|
||||
/** Show QR code scan button (for mobile) */
|
||||
showScanButton?: boolean;
|
||||
}
|
||||
|
||||
export function ACPConnect({
|
||||
onClientReady,
|
||||
expanded,
|
||||
onExpandedChange,
|
||||
browserToolHandler,
|
||||
showTokenInput = false,
|
||||
inferFromUrl = false,
|
||||
placeholder = "Proxy server URL",
|
||||
showScanButton = false,
|
||||
}: ACPConnectProps) {
|
||||
const [settings, setSettings] = useState<ACPSettings>(() => getInitialSettings(inferFromUrl));
|
||||
const [connectionState, setConnectionState] = useState<ConnectionState>("disconnected");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isShaking, setIsShaking] = useState(false);
|
||||
const [client, setClient] = useState<ACPClient | null>(null);
|
||||
const [maxHeight, setMaxHeight] = useState<number>(200);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const hasAutoCollapsedRef = useRef(false);
|
||||
const pendingAutoConnectRef = useRef(false);
|
||||
// Store initial settings in a ref to avoid eslint warning about empty deps
|
||||
const initialSettingsRef = useRef<ACPSettings>(settings);
|
||||
|
||||
// QR Scanner hook
|
||||
const handleQRScan = useCallback((data: QRCodeData) => {
|
||||
// Mark for auto-connect (will be triggered by settings useEffect)
|
||||
pendingAutoConnectRef.current = true;
|
||||
// Update settings - this will trigger auto-connect via useEffect
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
proxyUrl: data.url,
|
||||
token: data.token,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleQRError = useCallback((errorMsg: string) => {
|
||||
setError(errorMsg);
|
||||
}, []);
|
||||
|
||||
const { isScanning, videoRef, startScanning, stopScanning, scanFromFile } = useQRScanner({
|
||||
onScan: handleQRScan,
|
||||
onError: handleQRError,
|
||||
});
|
||||
|
||||
// Recalculate maxHeight after DOM updates (when expanded or isScanning changes)
|
||||
useLayoutEffect(() => {
|
||||
if (expanded && contentRef.current) {
|
||||
setMaxHeight(contentRef.current.scrollHeight);
|
||||
}
|
||||
}, [expanded, isScanning]);
|
||||
|
||||
// File input ref for album scanning
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Handle file selection from album
|
||||
const handleFileSelect = useCallback(
|
||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
await scanFromFile(file);
|
||||
stopScanning(); // Close the scanner overlay after album scan
|
||||
}
|
||||
// Reset input to allow re-selecting the same file
|
||||
e.target.value = "";
|
||||
},
|
||||
[scanFromFile, stopScanning]
|
||||
);
|
||||
|
||||
// Open file picker
|
||||
const handleSelectFromAlbum = useCallback(() => {
|
||||
fileInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
// Initialize client once on mount using initial settings from ref
|
||||
useEffect(() => {
|
||||
const acpClient = new ACPClient(initialSettingsRef.current);
|
||||
acpClient.setConnectionStateHandler((state, err) => {
|
||||
setConnectionState(state);
|
||||
setError(err || null);
|
||||
});
|
||||
|
||||
setClient(acpClient);
|
||||
|
||||
return () => {
|
||||
acpClient.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Register browser tool handler when it changes
|
||||
useEffect(() => {
|
||||
if (client && browserToolHandler) {
|
||||
client.setBrowserToolCallHandler(browserToolHandler);
|
||||
}
|
||||
}, [client, browserToolHandler]);
|
||||
|
||||
// Update client settings when settings change, and auto-connect if pending
|
||||
useEffect(() => {
|
||||
if (client) {
|
||||
client.updateSettings(settings);
|
||||
|
||||
// Auto-connect after QR scan (when pendingAutoConnectRef is set)
|
||||
if (pendingAutoConnectRef.current) {
|
||||
pendingAutoConnectRef.current = false;
|
||||
client.connect().catch((e) => {
|
||||
// Ignore disconnect requested - user cancelled intentionally
|
||||
if (e instanceof DisconnectRequestedError) {
|
||||
return;
|
||||
}
|
||||
setError((e as Error).message);
|
||||
setIsShaking(true);
|
||||
setTimeout(() => setIsShaking(false), 500);
|
||||
onExpandedChange(true);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [settings, client, onExpandedChange]);
|
||||
|
||||
// Notify parent when client is ready and auto-collapse on connect
|
||||
useEffect(() => {
|
||||
const isConnected = connectionState === "connected";
|
||||
onClientReady?.(isConnected ? client : null);
|
||||
|
||||
// Auto-collapse when connected for the first time
|
||||
if (isConnected && !hasAutoCollapsedRef.current) {
|
||||
hasAutoCollapsedRef.current = true;
|
||||
onExpandedChange(false);
|
||||
}
|
||||
|
||||
// Reset auto-collapse flag when disconnected
|
||||
if (connectionState === "disconnected") {
|
||||
hasAutoCollapsedRef.current = false;
|
||||
}
|
||||
}, [connectionState, client, onClientReady, onExpandedChange]);
|
||||
|
||||
const handleConnect = useCallback(async () => {
|
||||
// Prevent duplicate connect calls if already connecting or connected
|
||||
if (!client || connectionState === "connecting" || connectionState === "connected") {
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setIsShaking(false);
|
||||
try {
|
||||
await client.connect();
|
||||
} catch (e) {
|
||||
// Ignore disconnect requested - user cancelled intentionally
|
||||
if (e instanceof DisconnectRequestedError) {
|
||||
return;
|
||||
}
|
||||
const errorMessage = (e as Error).message;
|
||||
setError(errorMessage);
|
||||
// Trigger shake animation
|
||||
setIsShaking(true);
|
||||
setTimeout(() => setIsShaking(false), 500);
|
||||
// Ensure panel is expanded to show error
|
||||
onExpandedChange(true);
|
||||
}
|
||||
}, [client, connectionState, onExpandedChange]);
|
||||
|
||||
const handleDisconnect = useCallback(() => {
|
||||
client?.disconnect();
|
||||
}, [client]);
|
||||
|
||||
const updateSetting = <K extends keyof ACPSettings>(key: K, value: ACPSettings[K]) => {
|
||||
setSettings((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
// Clear error when starting to scan
|
||||
const handleStartScanning = useCallback(() => {
|
||||
setError(null);
|
||||
startScanning();
|
||||
}, [startScanning]);
|
||||
|
||||
const isConnected = connectionState === "connected";
|
||||
const isConnecting = connectionState === "connecting";
|
||||
|
||||
const handleInputKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !isConnected && !isConnecting) {
|
||||
e.preventDefault();
|
||||
handleConnect();
|
||||
}
|
||||
}, [isConnected, isConnecting, handleConnect]);
|
||||
|
||||
// Format URL for display
|
||||
const displayUrl = settings.proxyUrl.replace(/^wss?:\/\//, "").replace(/\/ws$/, "");
|
||||
|
||||
// Get status label
|
||||
const statusLabels: Record<ConnectionState, string> = {
|
||||
disconnected: "Disconnected",
|
||||
connecting: "Connecting...",
|
||||
connected: "Connected",
|
||||
error: "Error",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-background/80 backdrop-blur-sm">
|
||||
<div className="max-w-md mx-auto border-b">
|
||||
{/* Status Bar - Always visible */}
|
||||
<button
|
||||
onClick={() => onExpandedChange(!expanded)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusDot state={connectionState} />
|
||||
<span className="text-sm font-medium">{statusLabels[connectionState]}</span>
|
||||
{isConnected && displayUrl && (
|
||||
<span className="text-xs text-muted-foreground">• {displayUrl}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 text-muted-foreground transition-transform duration-200 ${
|
||||
expanded ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Expandable Settings Panel */}
|
||||
<div
|
||||
className="overflow-hidden transition-all duration-200 ease-out"
|
||||
style={{
|
||||
maxHeight: expanded ? maxHeight : 0,
|
||||
opacity: expanded ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<div ref={contentRef} className={`px-3 pb-3 pt-1 space-y-3 ${isShaking ? "animate-shake" : ""}`}>
|
||||
{/* Hidden file input for album scanning */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* QR Scanner View - Portal to body to escape backdrop-blur containing block */}
|
||||
{isScanning && createPortal(
|
||||
<div className="fixed inset-0 z-50 bg-black flex flex-col">
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="flex-1 w-full object-cover"
|
||||
/>
|
||||
<Button
|
||||
onClick={stopScanning}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute top-4 right-4 h-10 w-10 p-0 bg-black/50 hover:bg-black/70 text-white rounded-full"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
<div className="absolute bottom-16 left-0 right-0 flex flex-col items-center gap-3">
|
||||
<Button
|
||||
onClick={handleSelectFromAlbum}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-9 px-4"
|
||||
>
|
||||
<Image className="h-4 w-4 mr-2" />
|
||||
Select from Album
|
||||
</Button>
|
||||
<span className="text-sm text-white/80">
|
||||
or point camera at QR code
|
||||
</span>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* Connection Settings - use invisible (not hidden) to preserve scrollHeight for animation */}
|
||||
<div className={`space-y-3 ${isScanning ? "invisible" : ""}`}>
|
||||
{/* Server URL */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="proxy-url">Server</Label>
|
||||
<div className="flex gap-2">
|
||||
{showScanButton && !isConnected && !isConnecting && (
|
||||
<Button
|
||||
onClick={handleStartScanning}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 px-3"
|
||||
title="Scan QR code"
|
||||
type="button"
|
||||
>
|
||||
<ScanLine className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<InputGroup className="flex-1" data-disabled={isConnected || isConnecting}>
|
||||
<InputGroupAddon>
|
||||
<Globe />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
id="proxy-url"
|
||||
value={settings.proxyUrl}
|
||||
onChange={(e) => updateSetting("proxyUrl", e.target.value)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
placeholder={placeholder}
|
||||
disabled={isConnected || isConnecting}
|
||||
aria-invalid={!!error}
|
||||
/>
|
||||
</InputGroup>
|
||||
{!isConnected ? (
|
||||
<Button
|
||||
onClick={handleConnect}
|
||||
disabled={isConnecting}
|
||||
size="sm"
|
||||
className="h-9 px-4"
|
||||
type="button"
|
||||
>
|
||||
{isConnecting ? "..." : "Connect"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleDisconnect}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-9 px-4"
|
||||
type="button"
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auth Token - only shown if enabled */}
|
||||
{showTokenInput && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="auth-token">
|
||||
Auth Token
|
||||
<span className="text-muted-foreground font-normal ml-1.5">optional</span>
|
||||
</Label>
|
||||
<InputGroup data-disabled={isConnected || isConnecting}>
|
||||
<InputGroupAddon>
|
||||
<KeyRound />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
id="auth-token"
|
||||
value={settings.token || ""}
|
||||
onChange={(e) => updateSetting("token", e.target.value || undefined)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
placeholder="For remote access"
|
||||
disabled={isConnected || isConnecting}
|
||||
type="password"
|
||||
aria-invalid={!!error}
|
||||
className="font-mono"
|
||||
/>
|
||||
</InputGroup>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Working Directory */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="working-dir">
|
||||
Working Directory
|
||||
<span className="text-muted-foreground font-normal ml-1.5">optional</span>
|
||||
</Label>
|
||||
<InputGroup data-disabled={isConnected || isConnecting}>
|
||||
<InputGroupAddon>
|
||||
<FolderOpen />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
id="working-dir"
|
||||
value={settings.cwd || ""}
|
||||
onChange={(e) => updateSetting("cwd", e.target.value || undefined)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
placeholder="/path/to/project"
|
||||
disabled={isConnected || isConnecting}
|
||||
aria-invalid={!!error}
|
||||
className="font-mono"
|
||||
/>
|
||||
</InputGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="text-xs text-destructive bg-destructive/10 px-2 py-1.5 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
243
packages/remote-control-server/web/components/ACPMain.tsx
Normal file
243
packages/remote-control-server/web/components/ACPMain.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
import { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import type { ACPClient } from "../src/acp/client";
|
||||
import type { AgentSessionInfo } from "../src/acp/types";
|
||||
import { ChatInterface } from "./ChatInterface";
|
||||
import { cn } from "../src/lib/utils";
|
||||
import { MessageSquare, Plus, PanelLeftClose, PanelLeft } from "lucide-react";
|
||||
|
||||
interface ACPMainProps {
|
||||
client: ACPClient;
|
||||
agentId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main container — Anthropic sidebar + chat layout.
|
||||
* Sidebar: sectioned by recency, orange active state, warm raised bg.
|
||||
*/
|
||||
export function ACPMain({ client, agentId }: ACPMainProps) {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
|
||||
// Handle session selection
|
||||
const handleSelectSession = useCallback(async (session: AgentSessionInfo) => {
|
||||
try {
|
||||
if (client.supportsLoadSession) {
|
||||
await client.loadSession({ sessionId: session.sessionId, cwd: session.cwd });
|
||||
} else if (client.supportsResumeSession) {
|
||||
await client.resumeSession({ sessionId: session.sessionId, cwd: session.cwd });
|
||||
} else {
|
||||
throw new Error("Loading or resuming sessions is not supported by this agent.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load/resume session:", error);
|
||||
}
|
||||
}, [client]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full">
|
||||
{/* 侧边栏 — Anthropic warm sidebar, hidden on mobile */}
|
||||
<div
|
||||
className={cn(
|
||||
"hidden md:flex flex-col border-r border-border/60 bg-surface-1/50 transition-all duration-200 flex-shrink-0",
|
||||
sidebarCollapsed ? "w-12" : "w-64",
|
||||
)}
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between px-3 py-4">
|
||||
{!sidebarCollapsed && (
|
||||
<span className="text-xs font-display font-semibold text-text-muted uppercase tracking-widest px-1">会话</span>
|
||||
)}
|
||||
<div className={cn("flex items-center gap-0.5", sidebarCollapsed && "mx-auto")}>
|
||||
{!sidebarCollapsed && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// ChatInterface handles new session internally
|
||||
}}
|
||||
className="h-7 w-7 flex items-center justify-center rounded-lg text-text-muted hover:text-brand hover:bg-brand/10 transition-colors"
|
||||
title="新会话"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
className="h-7 w-7 flex items-center justify-center rounded-lg text-text-muted hover:text-text-primary hover:bg-surface-2 transition-colors"
|
||||
>
|
||||
{sidebarCollapsed ? (
|
||||
<PanelLeft className="h-4 w-4" />
|
||||
) : (
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 会话列表 */}
|
||||
{!sidebarCollapsed && (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<SidebarSessionList client={client} onSelectSession={handleSelectSession} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 聊天区域 */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<ChatInterface client={client} agentId={agentId} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 侧边栏会话列表 — Anthropic 分段式(今天/昨天/更早)
|
||||
// =============================================================================
|
||||
|
||||
function SidebarSessionList({
|
||||
client,
|
||||
onSelectSession,
|
||||
}: {
|
||||
client: ACPClient;
|
||||
onSelectSession: (session: AgentSessionInfo) => void;
|
||||
}) {
|
||||
const [sessions, setSessions] = useState<AgentSessionInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
if (!client.supportsSessionList) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await client.listSessions();
|
||||
setSessions(response.sessions);
|
||||
} catch (err) {
|
||||
console.warn("[SidebarSessionList] Failed to load:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [client]);
|
||||
|
||||
useEffect(() => {
|
||||
if (client.getState() === "connected" && client.supportsSessionList) {
|
||||
loadSessions();
|
||||
}
|
||||
}, [client, loadSessions]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (state: string) => {
|
||||
if (state === "connected") {
|
||||
setTimeout(loadSessions, 200);
|
||||
}
|
||||
};
|
||||
client.setConnectionStateHandler(handler);
|
||||
return () => client.removeConnectionStateHandler(handler);
|
||||
}, [client, loadSessions]);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(loadSessions, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadSessions]);
|
||||
|
||||
const sorted = useMemo(
|
||||
() =>
|
||||
[...sessions].sort((a, b) => {
|
||||
const dateA = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
|
||||
const dateB = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
|
||||
return dateB - dateA;
|
||||
}),
|
||||
[sessions],
|
||||
);
|
||||
|
||||
if (loading && sessions.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<span className="text-xs text-text-muted font-display">加载中...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (sessions.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<span className="text-xs text-text-muted font-display">暂无会话</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 按日期分组
|
||||
const groups = groupByRecency(sorted);
|
||||
|
||||
return (
|
||||
<nav className="py-1" aria-label="历史会话">
|
||||
{groups.map((group, gi) => (
|
||||
<div key={group.label}>
|
||||
{gi > 0 && <div className="mx-3 my-2 border-t border-border/40" />}
|
||||
<div className="px-4 py-2">
|
||||
<span className="text-[10px] font-display font-semibold uppercase tracking-widest text-text-muted/70">
|
||||
{group.label}
|
||||
</span>
|
||||
</div>
|
||||
{group.sessions.map((session) => (
|
||||
<button
|
||||
key={session.sessionId}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveId(session.sessionId);
|
||||
onSelectSession(session);
|
||||
}}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2.5 px-4 py-2 text-left transition-colors rounded-none",
|
||||
session.sessionId === activeId
|
||||
? "bg-brand/8 text-text-primary"
|
||||
: "text-text-secondary hover:bg-surface-2/60 hover:text-text-primary",
|
||||
)}
|
||||
title={session.title || session.sessionId}
|
||||
>
|
||||
<MessageSquare className="h-3.5 w-3.5 flex-shrink-0 opacity-50" />
|
||||
<span className="text-[13px] font-display truncate leading-snug">
|
||||
{session.title && session.title.trim() ? session.title : "新会话"}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 按日期分组:今天 / 昨天 / 更早
|
||||
// =============================================================================
|
||||
|
||||
interface SessionGroup {
|
||||
label: string;
|
||||
sessions: AgentSessionInfo[];
|
||||
}
|
||||
|
||||
function groupByRecency(sessions: AgentSessionInfo[]): SessionGroup[] {
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const yesterday = new Date(today.getTime() - 86400000);
|
||||
|
||||
const groups: SessionGroup[] = [
|
||||
{ label: "今天", sessions: [] },
|
||||
{ label: "昨天", sessions: [] },
|
||||
{ label: "更早", sessions: [] },
|
||||
];
|
||||
|
||||
for (const session of sessions) {
|
||||
const date = session.updatedAt ? new Date(session.updatedAt) : new Date(0);
|
||||
if (date >= today) {
|
||||
groups[0].sessions.push(session);
|
||||
} else if (date >= yesterday) {
|
||||
groups[1].sessions.push(session);
|
||||
} else {
|
||||
groups[2].sessions.push(session);
|
||||
}
|
||||
}
|
||||
|
||||
return groups.filter((g) => g.sessions.length > 0);
|
||||
}
|
||||
871
packages/remote-control-server/web/components/ChatInterface.tsx
Normal file
871
packages/remote-control-server/web/components/ChatInterface.tsx
Normal file
@@ -0,0 +1,871 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import imageCompression from "browser-image-compression";
|
||||
import type { ACPClient } from "../src/acp/client";
|
||||
import type { SessionUpdate, PermissionRequestPayload, PermissionOption, ContentBlock, ImageContent } from "../src/acp/types";
|
||||
import type { ThreadEntry, ToolCallStatus, ToolCallData, UserMessageImage, UserMessageEntry, AssistantMessageEntry, ToolCallEntry, ChatInputMessage, PendingPermission, PlanDisplayEntry } from "../src/lib/types";
|
||||
import { ChatView } from "./chat/ChatView";
|
||||
import { ChatInput } from "./chat/ChatInput";
|
||||
import { PermissionPanel } from "./chat/PermissionPanel";
|
||||
import { ModelSelectorPopover } from "./model-selector";
|
||||
import { useCommands } from "../src/hooks/useCommands";
|
||||
|
||||
// Image compression options
|
||||
// Claude API has a 5MB limit, so we target 2MB to be safe
|
||||
const IMAGE_COMPRESSION_OPTIONS = {
|
||||
maxSizeMB: 2, // Max output size in MB
|
||||
maxWidthOrHeight: 2048, // Max dimension (scales proportionally, no cropping)
|
||||
useWebWorker: true, // Non-blocking compression
|
||||
fileType: "image/jpeg" as const, // Convert to JPEG for better compression
|
||||
};
|
||||
|
||||
// Convert data URL to Blob without using fetch()
|
||||
// This is critical for Chrome extensions where fetch(dataUrl) violates CSP
|
||||
function dataUrlToBlob(dataUrl: string): Blob {
|
||||
// Parse the data URL: data:[<mediatype>][;base64],<data>
|
||||
const commaIndex = dataUrl.indexOf(",");
|
||||
if (commaIndex === -1) {
|
||||
throw new Error("Invalid data URL: missing comma separator");
|
||||
}
|
||||
|
||||
const header = dataUrl.slice(0, commaIndex);
|
||||
const base64Data = dataUrl.slice(commaIndex + 1);
|
||||
|
||||
// Extract MIME type from header (e.g., "data:image/png;base64")
|
||||
const mimeMatch = header.match(/^data:([^;,]+)/);
|
||||
const mimeType = mimeMatch ? mimeMatch[1] : "application/octet-stream";
|
||||
|
||||
// Decode base64 to binary
|
||||
const binaryString = atob(base64Data);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
|
||||
return new Blob([bytes], { type: mimeType });
|
||||
}
|
||||
|
||||
import { Plus, Shield, ChevronDown, ChevronUp, Check } from "lucide-react";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "./ui/tooltip";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
|
||||
// =============================================================================
|
||||
// Type Definitions - imported from shared types module
|
||||
// =============================================================================
|
||||
|
||||
interface ChatInterfaceProps {
|
||||
client: ACPClient;
|
||||
agentId?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Permission Mode Selector
|
||||
// =============================================================================
|
||||
|
||||
const PERMISSION_MODES = [
|
||||
{ value: "default", label: "默认", description: "手动审批权限请求" },
|
||||
{ value: "acceptEdits", label: "自动接受编辑", description: "自动允许文件编辑操作" },
|
||||
{ value: "bypassPermissions", label: "跳过权限", description: "跳过所有权限检查" },
|
||||
{ value: "plan", label: "规划模式", description: "仅规划,不执行工具" },
|
||||
{ value: "dontAsk", label: "不询问", description: "不弹出询问,自动拒绝" },
|
||||
{ value: "auto", label: "自动判断", description: "AI 自动判断是否批准" },
|
||||
] as const;
|
||||
|
||||
function PermissionModeSelector({
|
||||
mode,
|
||||
onModeChange,
|
||||
}: {
|
||||
mode: string;
|
||||
onModeChange: (mode: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const current = PERMISSION_MODES.find((m) => m.value === mode) ?? PERMISSION_MODES[0];
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1.5 text-muted-foreground hover:text-foreground h-7 px-2"
|
||||
>
|
||||
<Shield className="h-3 w-3" />
|
||||
<span className="max-w-24 truncate">{current.label}</span>
|
||||
{open ? (
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-1" align="start">
|
||||
{PERMISSION_MODES.map((m) => (
|
||||
<button
|
||||
key={m.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onModeChange(m.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="flex w-full items-start gap-2 rounded-md px-2.5 py-2 text-left hover:bg-surface-2 transition-colors"
|
||||
>
|
||||
<span className="mt-0.5 flex h-4 w-4 flex-shrink-0 items-center justify-center">
|
||||
{mode === m.value && <Check className="h-3.5 w-3.5 text-brand" />}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-text-primary">{m.label}</div>
|
||||
<div className="text-xs text-text-muted">{m.description}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Helper Functions
|
||||
// =============================================================================
|
||||
|
||||
// Map ACP status string to our status type
|
||||
function mapToolStatus(status: string): ToolCallStatus {
|
||||
if (status === "completed") return "complete";
|
||||
if (status === "failed") return "error";
|
||||
return "running";
|
||||
}
|
||||
|
||||
// Find tool call index in entries (search from end, like Zed)
|
||||
function findToolCallIndex(entries: ThreadEntry[], toolCallId: string): number {
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
const entry = entries[i];
|
||||
if (entry && entry.type === "tool_call" && entry.toolCall.id === toolCallId) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ChatInterface Component
|
||||
// =============================================================================
|
||||
|
||||
export function ChatInterface({ client, agentId }: ChatInterfaceProps) {
|
||||
// Flat list of entries (like Zed's entries: Vec<AgentThreadEntry>)
|
||||
const [entries, setEntries] = useState<ThreadEntry[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [sessionReady, setSessionReady] = useState(false);
|
||||
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
||||
const activeSessionIdRef = useRef<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const errorTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [permissionMode, setPermissionMode] = useState(() => localStorage.getItem("acp_permission_mode") || "default");
|
||||
// Reference: Zed's supports_images() checks prompt_capabilities.image
|
||||
const [supportsImages, setSupportsImages] = useState(false);
|
||||
const { commands: availableCommands } = useCommands(client);
|
||||
|
||||
useEffect(() => {
|
||||
activeSessionIdRef.current = activeSessionId;
|
||||
}, [activeSessionId]);
|
||||
|
||||
const resetThreadState = useCallback(() => {
|
||||
setEntries([]);
|
||||
setIsLoading(false);
|
||||
setSessionReady(false);
|
||||
}, []);
|
||||
|
||||
const storageKey = agentId ? `acp_last_session_${agentId}` : null;
|
||||
|
||||
const activateSession = useCallback((sessionId: string, options?: { resetEntries?: boolean }) => {
|
||||
const shouldResetEntries = options?.resetEntries ?? true;
|
||||
if (shouldResetEntries) {
|
||||
setEntries([]);
|
||||
setIsLoading(false);
|
||||
}
|
||||
setActiveSessionId(sessionId);
|
||||
setSessionReady(true);
|
||||
setSupportsImages(client.supportsImages);
|
||||
// Persist session ID for restoration on remount
|
||||
if (storageKey) {
|
||||
try { localStorage.setItem(storageKey, sessionId); } catch {}
|
||||
}
|
||||
console.log("[ChatInterface] Active session:", sessionId, "supportsImages:", client.supportsImages);
|
||||
}, [client, storageKey]);
|
||||
|
||||
// =============================================================================
|
||||
// Permission Request Handler
|
||||
// =============================================================================
|
||||
const handlePermissionRequest = useCallback((request: PermissionRequestPayload) => {
|
||||
if (activeSessionIdRef.current && request.sessionId !== activeSessionIdRef.current) {
|
||||
return;
|
||||
}
|
||||
console.log("[ChatInterface] Permission request:", request);
|
||||
|
||||
setEntries((prev) => {
|
||||
// Find matching tool call (search from end)
|
||||
const toolCallIndex = findToolCallIndex(prev, request.toolCall.toolCallId);
|
||||
|
||||
if (toolCallIndex >= 0) {
|
||||
// Update existing tool call's status
|
||||
return prev.map((entry, index) => {
|
||||
if (index !== toolCallIndex) return entry;
|
||||
if (entry.type !== "tool_call") return entry;
|
||||
if (entry.toolCall.status !== "running") return entry;
|
||||
|
||||
return {
|
||||
type: "tool_call",
|
||||
toolCall: {
|
||||
...entry.toolCall,
|
||||
status: "waiting_for_confirmation" as const,
|
||||
permissionRequest: {
|
||||
requestId: request.requestId,
|
||||
options: request.options,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
} else {
|
||||
// No matching tool call - create standalone permission request as new entry
|
||||
console.log("[ChatInterface] No matching tool call, creating standalone permission request");
|
||||
|
||||
const permissionToolCall: ToolCallEntry = {
|
||||
type: "tool_call",
|
||||
toolCall: {
|
||||
id: request.toolCall.toolCallId,
|
||||
title: request.toolCall.title || "Permission Request",
|
||||
status: "waiting_for_confirmation",
|
||||
permissionRequest: {
|
||||
requestId: request.requestId,
|
||||
options: request.options,
|
||||
},
|
||||
isStandalonePermission: true,
|
||||
},
|
||||
};
|
||||
|
||||
return [...prev, permissionToolCall];
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// =============================================================================
|
||||
// Session Update Handler (Zed-style: check last entry type)
|
||||
// =============================================================================
|
||||
const handleSessionUpdate = useCallback((sessionId: string, update: SessionUpdate) => {
|
||||
if (activeSessionIdRef.current && sessionId !== activeSessionIdRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle agent message chunk
|
||||
if (update.sessionUpdate === "agent_message_chunk") {
|
||||
const text = update.content.type === "text" && update.content.text ? update.content.text : "";
|
||||
if (!text) return;
|
||||
|
||||
setEntries((prev) => {
|
||||
const lastEntry = prev[prev.length - 1];
|
||||
|
||||
// If last entry is AssistantMessage, append to it
|
||||
if (lastEntry?.type === "assistant_message") {
|
||||
const lastChunk = lastEntry.chunks[lastEntry.chunks.length - 1];
|
||||
|
||||
// If last chunk is same type (message), append text
|
||||
if (lastChunk?.type === "message") {
|
||||
return [
|
||||
...prev.slice(0, -1),
|
||||
{
|
||||
...lastEntry,
|
||||
chunks: [
|
||||
...lastEntry.chunks.slice(0, -1),
|
||||
{ type: "message", text: lastChunk.text + text },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Otherwise add new message chunk
|
||||
return [
|
||||
...prev.slice(0, -1),
|
||||
{
|
||||
...lastEntry,
|
||||
chunks: [...lastEntry.chunks, { type: "message", text }],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Create new AssistantMessage entry
|
||||
const newEntry: AssistantMessageEntry = {
|
||||
type: "assistant_message",
|
||||
id: `assistant-${Date.now()}`,
|
||||
chunks: [{ type: "message", text }],
|
||||
};
|
||||
return [...prev, newEntry];
|
||||
});
|
||||
}
|
||||
// Handle agent thought chunk (NEW - was missing before)
|
||||
else if (update.sessionUpdate === "agent_thought_chunk") {
|
||||
const text = update.content.type === "text" && update.content.text ? update.content.text : "";
|
||||
if (!text) return;
|
||||
|
||||
setEntries((prev) => {
|
||||
const lastEntry = prev[prev.length - 1];
|
||||
|
||||
// If last entry is AssistantMessage, append to it
|
||||
if (lastEntry?.type === "assistant_message") {
|
||||
const lastChunk = lastEntry.chunks[lastEntry.chunks.length - 1];
|
||||
|
||||
// If last chunk is same type (thought), append text
|
||||
if (lastChunk?.type === "thought") {
|
||||
return [
|
||||
...prev.slice(0, -1),
|
||||
{
|
||||
...lastEntry,
|
||||
chunks: [
|
||||
...lastEntry.chunks.slice(0, -1),
|
||||
{ type: "thought", text: lastChunk.text + text },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Otherwise add new thought chunk
|
||||
return [
|
||||
...prev.slice(0, -1),
|
||||
{
|
||||
...lastEntry,
|
||||
chunks: [...lastEntry.chunks, { type: "thought", text }],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Create new AssistantMessage entry with thought
|
||||
const newEntry: AssistantMessageEntry = {
|
||||
type: "assistant_message",
|
||||
id: `assistant-${Date.now()}`,
|
||||
chunks: [{ type: "thought", text }],
|
||||
};
|
||||
return [...prev, newEntry];
|
||||
});
|
||||
}
|
||||
// Handle user message chunk (NEW - was missing before)
|
||||
else if (update.sessionUpdate === "user_message_chunk") {
|
||||
const text = update.content.type === "text" && update.content.text ? update.content.text : "";
|
||||
if (!text) return;
|
||||
|
||||
setEntries((prev) => {
|
||||
const lastEntry = prev[prev.length - 1];
|
||||
|
||||
// If last entry is UserMessage, append to it
|
||||
if (lastEntry?.type === "user_message") {
|
||||
return [
|
||||
...prev.slice(0, -1),
|
||||
{
|
||||
...lastEntry,
|
||||
content: lastEntry.content + text,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Create new UserMessage entry
|
||||
const newEntry: UserMessageEntry = {
|
||||
type: "user_message",
|
||||
id: `user-${Date.now()}`,
|
||||
content: text,
|
||||
};
|
||||
return [...prev, newEntry];
|
||||
});
|
||||
}
|
||||
// Handle tool call (UPSERT - update if exists, create if not)
|
||||
else if (update.sessionUpdate === "tool_call") {
|
||||
const toolCallData: ToolCallData = {
|
||||
id: update.toolCallId,
|
||||
title: update.title,
|
||||
status: mapToolStatus(update.status),
|
||||
content: update.content,
|
||||
rawInput: update.rawInput,
|
||||
rawOutput: update.rawOutput,
|
||||
};
|
||||
|
||||
setEntries((prev) => {
|
||||
// UPSERT: Check if tool call already exists
|
||||
const existingIndex = findToolCallIndex(prev, update.toolCallId);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
// UPDATE existing tool call
|
||||
return prev.map((entry, index) => {
|
||||
if (index !== existingIndex) return entry;
|
||||
if (entry.type !== "tool_call") return entry;
|
||||
|
||||
return {
|
||||
type: "tool_call",
|
||||
toolCall: {
|
||||
...entry.toolCall,
|
||||
...toolCallData,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// CREATE new tool call entry
|
||||
const newEntry: ToolCallEntry = {
|
||||
type: "tool_call",
|
||||
toolCall: toolCallData,
|
||||
};
|
||||
return [...prev, newEntry];
|
||||
});
|
||||
}
|
||||
// Handle tool call update (partial update)
|
||||
else if (update.sessionUpdate === "tool_call_update") {
|
||||
setEntries((prev) => {
|
||||
const existingIndex = findToolCallIndex(prev, update.toolCallId);
|
||||
|
||||
if (existingIndex < 0) {
|
||||
// Tool call not found - create a failed tool call entry (like Zed)
|
||||
console.warn(`[ChatInterface] Tool call not found for update: ${update.toolCallId}`);
|
||||
const failedEntry: ToolCallEntry = {
|
||||
type: "tool_call",
|
||||
toolCall: {
|
||||
id: update.toolCallId,
|
||||
title: update.title || "Tool call not found",
|
||||
status: "error",
|
||||
content: [{ type: "content", content: { type: "text", text: "Tool call not found" } }],
|
||||
},
|
||||
};
|
||||
return [...prev, failedEntry];
|
||||
}
|
||||
|
||||
return prev.map((entry, index) => {
|
||||
if (index !== existingIndex) return entry;
|
||||
if (entry.type !== "tool_call") return entry;
|
||||
|
||||
const newStatus = update.status ? mapToolStatus(update.status) : entry.toolCall.status;
|
||||
const mergedContent = update.content
|
||||
? [...(entry.toolCall.content || []), ...update.content]
|
||||
: entry.toolCall.content;
|
||||
|
||||
return {
|
||||
type: "tool_call",
|
||||
toolCall: {
|
||||
...entry.toolCall,
|
||||
status: newStatus,
|
||||
...(update.title && { title: update.title }),
|
||||
content: mergedContent,
|
||||
...(update.rawInput && { rawInput: update.rawInput }),
|
||||
...(update.rawOutput && { rawOutput: update.rawOutput }),
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
// Handle plan update (replace entire plan)
|
||||
else if (update.sessionUpdate === "plan") {
|
||||
setEntries((prev) => {
|
||||
// Empty entries → remove existing plan
|
||||
if (update.entries.length === 0) {
|
||||
return prev.filter((e) => e.type !== "plan");
|
||||
}
|
||||
|
||||
// Find last plan entry
|
||||
const lastPlanIndex = prev.reduce(
|
||||
(acc, entry, i) => (entry.type === "plan" ? i : acc),
|
||||
-1,
|
||||
);
|
||||
|
||||
if (lastPlanIndex >= 0) {
|
||||
// Update existing plan in place
|
||||
return prev.map((entry, index) =>
|
||||
index === lastPlanIndex
|
||||
? { ...entry, entries: update.entries }
|
||||
: entry,
|
||||
);
|
||||
}
|
||||
|
||||
// Create new plan entry
|
||||
const newPlanEntry: PlanDisplayEntry = {
|
||||
type: "plan",
|
||||
id: `plan-${Date.now()}`,
|
||||
entries: update.entries,
|
||||
};
|
||||
return [...prev, newPlanEntry];
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// =============================================================================
|
||||
// Setup Effect
|
||||
// =============================================================================
|
||||
useEffect(() => {
|
||||
client.setSessionCreatedHandler((sessionId) => {
|
||||
console.log("[ChatInterface] Session created:", sessionId);
|
||||
activateSession(sessionId);
|
||||
});
|
||||
|
||||
client.setSessionLoadedHandler((sessionId) => {
|
||||
console.log("[ChatInterface] Session loaded/resumed:", sessionId);
|
||||
activateSession(sessionId, { resetEntries: false });
|
||||
});
|
||||
|
||||
client.setSessionSwitchingHandler((sessionId) => {
|
||||
console.log("[ChatInterface] Switching to session:", sessionId);
|
||||
setActiveSessionId(sessionId);
|
||||
resetThreadState();
|
||||
});
|
||||
|
||||
client.setSessionUpdateHandler((sessionId: string, update: SessionUpdate) => {
|
||||
handleSessionUpdate(sessionId, update);
|
||||
});
|
||||
|
||||
client.setPromptCompleteHandler((stopReason) => {
|
||||
console.log("[ChatInterface] Prompt complete:", stopReason);
|
||||
// Always set isLoading=false when prompt completes
|
||||
// This includes stopReason="cancelled" (which is the expected response after client.cancel())
|
||||
// Note: Tool calls are already marked as "canceled" in handleCancel before this fires
|
||||
setIsLoading(false);
|
||||
});
|
||||
|
||||
client.setPermissionRequestHandler(handlePermissionRequest);
|
||||
|
||||
client.setErrorMessageHandler((msg) => {
|
||||
console.error("[ChatInterface] Agent error:", msg);
|
||||
setErrorMessage(msg);
|
||||
// Clear any existing timer
|
||||
if (errorTimerRef.current) clearTimeout(errorTimerRef.current);
|
||||
// Auto-clear after 5 seconds
|
||||
errorTimerRef.current = setTimeout(() => setErrorMessage(null), 5000);
|
||||
});
|
||||
|
||||
// Restore last session or create a new one
|
||||
const lastSessionId = storageKey ? localStorage.getItem(storageKey) : null;
|
||||
if (lastSessionId && (client.supportsLoadSession || client.supportsResumeSession)) {
|
||||
console.log("[ChatInterface] Restoring session:", lastSessionId);
|
||||
const restore = async () => {
|
||||
try {
|
||||
if (client.supportsLoadSession) {
|
||||
await client.loadSession({ sessionId: lastSessionId });
|
||||
} else {
|
||||
await client.resumeSession({ sessionId: lastSessionId });
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[ChatInterface] Failed to restore session, creating new one:", err);
|
||||
client.createSession(undefined, permissionMode);
|
||||
}
|
||||
};
|
||||
restore();
|
||||
} else {
|
||||
client.createSession(undefined, permissionMode);
|
||||
}
|
||||
return () => {
|
||||
if (errorTimerRef.current) clearTimeout(errorTimerRef.current);
|
||||
client.setSessionCreatedHandler(() => {});
|
||||
client.setSessionLoadedHandler(() => {});
|
||||
client.setSessionSwitchingHandler(null);
|
||||
client.setSessionUpdateHandler(() => {});
|
||||
client.setPromptCompleteHandler(() => {});
|
||||
client.setPermissionRequestHandler(() => {});
|
||||
client.setErrorMessageHandler(() => {});
|
||||
};
|
||||
}, [activateSession, client, handlePermissionRequest, handleSessionUpdate, resetThreadState]);
|
||||
|
||||
// =============================================================================
|
||||
// User Actions
|
||||
// =============================================================================
|
||||
|
||||
// Reference: Zed's ConnectionView.reset() + set_server_state() + _external_thread()
|
||||
// Creates a new session by clearing current state and calling new_session
|
||||
// This is the core of Zed's NewThread action
|
||||
const handleNewSession = useCallback(() => {
|
||||
console.log("[ChatInterface] Creating new session...");
|
||||
|
||||
// Reference: Zed's set_server_state() calls close_all_sessions() before setting new state
|
||||
// Cancel any ongoing request before creating new session
|
||||
if (isLoading) {
|
||||
client.cancel();
|
||||
}
|
||||
|
||||
// 1. Clear all entries (like Zed's set_server_state which creates new view)
|
||||
resetThreadState();
|
||||
setActiveSessionId(null);
|
||||
|
||||
// 3. Create new session (like Zed's initial_state -> connection.new_session())
|
||||
// The session_created handler will set sessionReady=true when ready
|
||||
client.createSession(undefined, permissionMode);
|
||||
}, [client, isLoading, resetThreadState, permissionMode]);
|
||||
|
||||
// Cancel handler - matches Zed's cancel() logic in acp_thread.rs
|
||||
// 1. Mark all pending/running/waiting_for_confirmation tool calls as canceled
|
||||
// 2. Send cancel notification to agent
|
||||
// 3. Do NOT set isLoading=false here - wait for prompt_complete with stopReason="cancelled"
|
||||
const handleCancel = () => {
|
||||
console.log("[ChatInterface] Cancel requested");
|
||||
|
||||
// Like Zed: iterate all entries, mark Pending/WaitingForConfirmation/InProgress tool calls as Canceled
|
||||
setEntries((prev) =>
|
||||
prev.map((entry) => {
|
||||
if (entry.type !== "tool_call") return entry;
|
||||
|
||||
// Check if status should be canceled (matches Zed's logic)
|
||||
const shouldCancel =
|
||||
entry.toolCall.status === "running" ||
|
||||
entry.toolCall.status === "waiting_for_confirmation";
|
||||
|
||||
if (!shouldCancel) return entry;
|
||||
|
||||
console.log("[ChatInterface] Marking tool call as canceled:", entry.toolCall.id);
|
||||
return {
|
||||
type: "tool_call",
|
||||
toolCall: {
|
||||
...entry.toolCall,
|
||||
status: "canceled" as ToolCallStatus,
|
||||
permissionRequest: undefined, // Clear any pending permission request
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
// Send cancel notification to server (which forwards to agent)
|
||||
client.cancel();
|
||||
// Note: Do NOT set isLoading=false here!
|
||||
// Wait for prompt_complete with stopReason="cancelled" from the agent
|
||||
};
|
||||
|
||||
const handlePermissionResponse = useCallback((requestId: string, optionId: string | null, optionKind: PermissionOption["kind"] | null) => {
|
||||
console.log("[ChatInterface] Permission response:", { requestId, optionId, optionKind });
|
||||
client.respondToPermission(requestId, optionId);
|
||||
|
||||
// Determine new status based on option kind
|
||||
const isRejected = optionKind === "reject_once" || optionKind === "reject_always" || optionId === null;
|
||||
|
||||
// Update the tool call status in entries
|
||||
setEntries((prev) =>
|
||||
prev.map((entry) => {
|
||||
if (entry.type !== "tool_call") return entry;
|
||||
if (entry.toolCall.permissionRequest?.requestId !== requestId) return entry;
|
||||
|
||||
// For standalone permission requests, mark as complete immediately when approved
|
||||
// For regular tool calls, mark as running (agent will update to complete later)
|
||||
let newStatus: ToolCallStatus;
|
||||
if (isRejected) {
|
||||
newStatus = "rejected";
|
||||
} else if (entry.toolCall.isStandalonePermission) {
|
||||
newStatus = "complete";
|
||||
} else {
|
||||
newStatus = "running";
|
||||
}
|
||||
|
||||
return {
|
||||
type: "tool_call",
|
||||
toolCall: {
|
||||
...entry.toolCall,
|
||||
status: newStatus,
|
||||
permissionRequest: undefined,
|
||||
isStandalonePermission: undefined,
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
}, [client]);
|
||||
|
||||
// =============================================================================
|
||||
// Render
|
||||
// =============================================================================
|
||||
|
||||
// Collect pending permissions from tool call entries
|
||||
const pendingPermissions: PendingPermission[] = entries
|
||||
.filter((e): e is ToolCallEntry => e.type === "tool_call" && e.toolCall.status === "waiting_for_confirmation" && !!e.toolCall.permissionRequest)
|
||||
.map((e) => ({
|
||||
requestId: e.toolCall.permissionRequest!.requestId,
|
||||
toolName: e.toolCall.title,
|
||||
toolInput: e.toolCall.rawInput || {},
|
||||
description: e.toolCall.title,
|
||||
options: e.toolCall.permissionRequest!.options,
|
||||
}));
|
||||
|
||||
// Handle permission respond for unified PermissionPanel
|
||||
const handlePermissionPanelRespond = useCallback((requestId: string, approved: boolean) => {
|
||||
// Find the matching permission request to get the real optionId
|
||||
const perm = pendingPermissions.find((p) => p.requestId === requestId);
|
||||
let optionId: string | null = null;
|
||||
let optionKind: PermissionOption["kind"] | null = null;
|
||||
|
||||
if (perm?.options && perm.options.length > 0) {
|
||||
if (approved) {
|
||||
// Pick the first allow option (prefer allow_once, then allow_always)
|
||||
const allowOpt = perm.options.find((o) => o.kind === "allow_once") ?? perm.options.find((o) => o.kind === "allow_always");
|
||||
if (allowOpt) {
|
||||
optionId = allowOpt.optionId;
|
||||
optionKind = allowOpt.kind;
|
||||
}
|
||||
} else {
|
||||
// Pick the first reject option
|
||||
const rejectOpt = perm.options.find((o) => o.kind === "reject_once") ?? perm.options.find((o) => o.kind === "reject_always");
|
||||
if (rejectOpt) {
|
||||
optionId = rejectOpt.optionId;
|
||||
optionKind = rejectOpt.kind;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: if no matching option found, use null (cancelled)
|
||||
if (!optionId) {
|
||||
optionKind = approved ? "allow_once" : "reject_once";
|
||||
}
|
||||
|
||||
handlePermissionResponse(requestId, optionId, optionKind);
|
||||
}, [handlePermissionResponse, pendingPermissions]);
|
||||
|
||||
// Handle ChatInput submit — convert ChatInputMessage to ContentBlock[]
|
||||
const handleChatInputSubmit = useCallback(async (message: ChatInputMessage) => {
|
||||
const text = message.text.trim();
|
||||
const images = message.images || [];
|
||||
|
||||
if ((!text && images.length === 0) || isLoading || !sessionReady) return;
|
||||
|
||||
const contentBlocks: ContentBlock[] = [];
|
||||
|
||||
if (text) {
|
||||
contentBlocks.push({ type: "text", text });
|
||||
}
|
||||
|
||||
// Convert images to ContentBlock
|
||||
const userImages: UserMessageImage[] = [];
|
||||
|
||||
for (const img of images) {
|
||||
try {
|
||||
const dataUrl = `data:${img.mimeType};base64,${img.data}`;
|
||||
let blob: Blob;
|
||||
if (dataUrl.startsWith("data:")) {
|
||||
blob = dataUrlToBlob(dataUrl);
|
||||
} else {
|
||||
const response = await fetch(dataUrl);
|
||||
blob = await response.blob();
|
||||
}
|
||||
|
||||
let finalBlob: Blob = blob;
|
||||
let finalMimeType = img.mimeType;
|
||||
|
||||
if (blob.size > 2 * 1024 * 1024) {
|
||||
const imageFile = new File([blob], "image.jpg", { type: blob.type });
|
||||
finalBlob = await imageCompression(imageFile, IMAGE_COMPRESSION_OPTIONS);
|
||||
finalMimeType = "image/jpeg";
|
||||
}
|
||||
|
||||
const base64Data = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const result = reader.result as string;
|
||||
const commaIndex = result.indexOf(",");
|
||||
resolve(commaIndex >= 0 ? result.slice(commaIndex + 1) : result);
|
||||
};
|
||||
reader.onerror = () => reject(new Error("FileReader error: " + reader.error?.message));
|
||||
reader.readAsDataURL(finalBlob);
|
||||
});
|
||||
|
||||
const imageContent: ImageContent = {
|
||||
type: "image",
|
||||
mimeType: finalMimeType,
|
||||
data: base64Data,
|
||||
};
|
||||
contentBlocks.push(imageContent);
|
||||
|
||||
userImages.push({
|
||||
mimeType: finalMimeType,
|
||||
data: base64Data,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[ChatInterface] Failed to process image:", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (contentBlocks.length === 0) return;
|
||||
|
||||
// Add user message entry
|
||||
const userEntry: UserMessageEntry = {
|
||||
type: "user_message",
|
||||
id: `user-${Date.now()}`,
|
||||
content: text,
|
||||
images: userImages.length > 0 ? userImages : undefined,
|
||||
};
|
||||
setEntries((prev) => [...prev, userEntry]);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await client.sendPrompt(contentBlocks);
|
||||
} catch (error) {
|
||||
console.error("[ChatInterface] Failed to send prompt:", error);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [isLoading, sessionReady, client]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Chat messages — unified ChatView */}
|
||||
<ChatView
|
||||
entries={entries}
|
||||
isLoading={isLoading && !sessionReady ? false : isLoading}
|
||||
onPermissionRespond={(requestId, optionId, optionKind) => {
|
||||
handlePermissionResponse(requestId, optionId, optionKind as PermissionOption["kind"] | null);
|
||||
}}
|
||||
emptyTitle={sessionReady ? "开始对话" : undefined}
|
||||
emptyDescription={sessionReady ? "输入消息开始与 ACP agent 聊天" : undefined}
|
||||
/>
|
||||
|
||||
{/* Permission panel — fixed above input */}
|
||||
<PermissionPanel
|
||||
requests={pendingPermissions}
|
||||
onRespond={handlePermissionPanelRespond}
|
||||
/>
|
||||
|
||||
{/* Error banner */}
|
||||
{errorMessage && (
|
||||
<div className="mx-auto max-w-3xl w-full px-4 sm:px-8 pb-1">
|
||||
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 px-3 py-2 text-sm text-red-700 dark:text-red-300 flex items-center justify-between">
|
||||
<span>{errorMessage}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setErrorMessage(null)}
|
||||
className="ml-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-200 flex-shrink-0"
|
||||
>
|
||||
{"\u00D7"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model selector + New thread + ChatInput */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className="max-w-3xl mx-auto w-full px-4 sm:px-8 pb-1 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<PermissionModeSelector mode={permissionMode} onModeChange={(m: string) => { setPermissionMode(m); localStorage.setItem("acp_permission_mode", m); }} />
|
||||
<ModelSelectorPopover client={client} />
|
||||
</div>
|
||||
{entries.length > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs text-text-muted hover:text-brand font-display gap-1"
|
||||
onClick={handleNewSession}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
新会话
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>New Thread</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<ChatInput
|
||||
onSubmit={handleChatInputSubmit}
|
||||
isLoading={isLoading}
|
||||
onInterrupt={handleCancel}
|
||||
disabled={!sessionReady}
|
||||
placeholder={sessionReady ? "给 Claude 发送消息…" : "等待会话..."}
|
||||
supportsImages={supportsImages}
|
||||
commands={availableCommands.length > 0 ? availableCommands : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { cn } from "../src/lib/utils";
|
||||
import { User, Bot, Wrench, Loader2 } from "lucide-react";
|
||||
|
||||
export interface ToolCall {
|
||||
id: string;
|
||||
title: string;
|
||||
status: "running" | "complete" | "error";
|
||||
}
|
||||
|
||||
export interface ChatMessageData {
|
||||
id: string;
|
||||
role: "user" | "agent";
|
||||
content: string;
|
||||
toolCalls?: ToolCall[];
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
interface ChatMessageProps {
|
||||
message: ChatMessageData;
|
||||
}
|
||||
|
||||
export function ChatMessage({ message }: ChatMessageProps) {
|
||||
const isUser = message.role === "user";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-3 p-4 rounded-lg",
|
||||
isUser ? "bg-muted/50" : "bg-background"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center",
|
||||
isUser ? "bg-primary text-primary-foreground" : "bg-secondary"
|
||||
)}
|
||||
>
|
||||
{isUser ? <User className="w-4 h-4" /> : <Bot className="w-4 h-4" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<div className="text-sm font-medium">
|
||||
{isUser ? "You" : "Agent"}
|
||||
</div>
|
||||
<div className="text-sm whitespace-pre-wrap break-words">
|
||||
{message.content}
|
||||
{message.isStreaming && (
|
||||
<span className="inline-block w-1.5 h-4 ml-0.5 bg-foreground animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
{message.toolCalls && message.toolCalls.length > 0 && (
|
||||
<div className="space-y-1.5 pt-2">
|
||||
{message.toolCalls.map((tool) => (
|
||||
<ToolCallDisplay key={tool.id} toolCall={tool} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToolCallDisplayProps {
|
||||
toolCall: ToolCall;
|
||||
}
|
||||
|
||||
function ToolCallDisplay({ toolCall }: ToolCallDisplayProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-xs px-2 py-1.5 rounded border",
|
||||
toolCall.status === "running" && "bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800",
|
||||
toolCall.status === "complete" && "bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800",
|
||||
toolCall.status === "error" && "bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800"
|
||||
)}
|
||||
>
|
||||
{toolCall.status === "running" ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin text-yellow-600 dark:text-yellow-400" />
|
||||
) : (
|
||||
<Wrench className={cn(
|
||||
"w-3 h-3",
|
||||
toolCall.status === "complete" && "text-green-600 dark:text-green-400",
|
||||
toolCall.status === "error" && "text-red-600 dark:text-red-400"
|
||||
)} />
|
||||
)}
|
||||
<span className="truncate">{toolCall.title}</span>
|
||||
<span className={cn(
|
||||
"ml-auto text-[10px] uppercase font-medium",
|
||||
toolCall.status === "running" && "text-yellow-600 dark:text-yellow-400",
|
||||
toolCall.status === "complete" && "text-green-600 dark:text-green-400",
|
||||
toolCall.status === "error" && "text-red-600 dark:text-red-400"
|
||||
)}>
|
||||
{toolCall.status}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
304
packages/remote-control-server/web/components/ThreadHistory.tsx
Normal file
304
packages/remote-control-server/web/components/ThreadHistory.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Search, Clock, RefreshCw } from "lucide-react";
|
||||
import type { ACPClient } from "../src/acp/client";
|
||||
import type { AgentSessionInfo } from "../src/acp/types";
|
||||
import { Input } from "./ui/input";
|
||||
import { ScrollArea } from "./ui/scroll-area";
|
||||
import { Button } from "./ui/button";
|
||||
import { cn } from "../src/lib/utils";
|
||||
|
||||
// Reference: Zed's TimeBucket in thread_history.rs
|
||||
type TimeBucket = "today" | "yesterday" | "thisWeek" | "pastWeek" | "all";
|
||||
|
||||
// Reference: Zed's Display impl for TimeBucket
|
||||
const BUCKET_LABELS: Record<TimeBucket, string> = {
|
||||
today: "Today",
|
||||
yesterday: "Yesterday",
|
||||
thisWeek: "This Week",
|
||||
pastWeek: "Past Week",
|
||||
all: "All", // Zed uses "All", not "Older"
|
||||
};
|
||||
|
||||
// Reference: Zed's TimeBucket::from_dates (line 1028-1051)
|
||||
// Rust's IsoWeek includes year, so we need to compare both year and week number
|
||||
function getTimeBucket(date: Date): TimeBucket {
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
const entryDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
|
||||
if (entryDate.getTime() === today.getTime()) return "today";
|
||||
if (entryDate.getTime() === yesterday.getTime()) return "yesterday";
|
||||
|
||||
// This week: same ISO week AND year
|
||||
const todayIsoWeek = getISOWeekYear(today);
|
||||
const entryIsoWeek = getISOWeekYear(entryDate);
|
||||
if (todayIsoWeek.year === entryIsoWeek.year && todayIsoWeek.week === entryIsoWeek.week) {
|
||||
return "thisWeek";
|
||||
}
|
||||
|
||||
// Past week: (reference - 7days).iso_week()
|
||||
const lastWeekDate = new Date(today);
|
||||
lastWeekDate.setDate(lastWeekDate.getDate() - 7);
|
||||
const lastWeekIsoWeek = getISOWeekYear(lastWeekDate);
|
||||
if (lastWeekIsoWeek.year === entryIsoWeek.year && lastWeekIsoWeek.week === entryIsoWeek.week) {
|
||||
return "pastWeek";
|
||||
}
|
||||
|
||||
return "all";
|
||||
}
|
||||
|
||||
// Returns ISO week number AND ISO week year (important for year boundaries)
|
||||
function getISOWeekYear(date: Date): { week: number; year: number } {
|
||||
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
||||
const dayNum = d.getUTCDay() || 7;
|
||||
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
||||
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||||
const week = Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
|
||||
return { week, year: d.getUTCFullYear() }; // ISO week year, not calendar year
|
||||
}
|
||||
|
||||
// Reference: Zed's formatted_time in HistoryEntryElement (line 904-921)
|
||||
// Exact format: Xd, Xh ago, Xm ago, Just now, Unknown
|
||||
function formatRelativeTime(date: Date | null): string {
|
||||
if (!date) return "Unknown"; // Zed uses "Unknown" for missing updatedAt
|
||||
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffMinutes = Math.floor(diffMs / (1000 * 60));
|
||||
|
||||
if (diffDays > 0) return `${diffDays}d`;
|
||||
if (diffHours > 0) return `${diffHours}h ago`;
|
||||
if (diffMinutes > 0) return `${diffMinutes}m ago`;
|
||||
return "Just now";
|
||||
}
|
||||
|
||||
interface ThreadHistoryProps {
|
||||
client: ACPClient;
|
||||
// Returns Promise to allow loading state tracking; resolves when session is loaded
|
||||
onSelectSession: (session: AgentSessionInfo) => void | Promise<void>;
|
||||
}
|
||||
|
||||
interface GroupedSessions {
|
||||
bucket: TimeBucket;
|
||||
sessions: AgentSessionInfo[];
|
||||
}
|
||||
|
||||
export function ThreadHistory({ client, onSelectSession }: ThreadHistoryProps) {
|
||||
const [sessions, setSessions] = useState<AgentSessionInfo[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
// Start with isLoading=true to prevent flash of "no threads" message
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
// Track which session is currently being loaded to show loading state and prevent double-clicks
|
||||
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
|
||||
|
||||
// Check if session history is supported
|
||||
const supportsHistory = client.supportsSessionHistory;
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
if (!client.supportsSessionList) {
|
||||
setError("Session list not supported by this agent");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await client.listSessions();
|
||||
setSessions(response.sessions);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [client]);
|
||||
|
||||
useEffect(() => {
|
||||
if (supportsHistory) {
|
||||
loadSessions();
|
||||
} else {
|
||||
// Not supported, clear loading state
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [supportsHistory, loadSessions]);
|
||||
|
||||
// Filter and group sessions
|
||||
// Reference: Zed's add_list_separators and filter_search_results
|
||||
const groupedSessions = useMemo((): GroupedSessions[] => {
|
||||
let filtered = sessions;
|
||||
|
||||
// Simple search filter (Zed uses fuzzy matching, we use substring for simplicity)
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = sessions.filter(
|
||||
(s) => s.title?.toLowerCase().includes(query) || s.sessionId.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
// Sort by updatedAt descending (most recent first)
|
||||
// Zed expects the API to return sorted data, but we ensure it client-side
|
||||
const sorted = [...filtered].sort((a, b) => {
|
||||
const dateA = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
|
||||
const dateB = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
|
||||
return dateB - dateA; // Descending
|
||||
});
|
||||
|
||||
// Group by time bucket (preserving sort order within each bucket)
|
||||
const groups = new Map<TimeBucket, AgentSessionInfo[]>();
|
||||
for (const session of sorted) {
|
||||
const date = session.updatedAt ? new Date(session.updatedAt) : new Date(0);
|
||||
const bucket = getTimeBucket(date);
|
||||
if (!groups.has(bucket)) groups.set(bucket, []);
|
||||
groups.get(bucket)!.push(session);
|
||||
}
|
||||
|
||||
// Return in chronological bucket order
|
||||
const bucketOrder: TimeBucket[] = ["today", "yesterday", "thisWeek", "pastWeek", "all"];
|
||||
return bucketOrder
|
||||
.filter((b) => groups.has(b))
|
||||
.map((bucket) => ({ bucket, sessions: groups.get(bucket)! }));
|
||||
}, [sessions, searchQuery]);
|
||||
|
||||
const handleSelectSession = useCallback(
|
||||
async (session: AgentSessionInfo) => {
|
||||
// Prevent double-clicks while loading
|
||||
if (loadingSessionId) return;
|
||||
|
||||
setLoadingSessionId(session.sessionId);
|
||||
try {
|
||||
await onSelectSession(session);
|
||||
} finally {
|
||||
setLoadingSessionId(null);
|
||||
}
|
||||
},
|
||||
[onSelectSession, loadingSessionId]
|
||||
);
|
||||
|
||||
if (!supportsHistory) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full p-4 text-center">
|
||||
<Clock className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground">Session history is not supported by this agent.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const flatItems = groupedSessions.flatMap((g) => g.sessions);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Search header - Reference: Zed's search_editor */}
|
||||
<div className="flex items-center gap-2 p-2 border-b border-border">
|
||||
<Search className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<Input
|
||||
placeholder="Search threads..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-8 border-0 focus-visible:ring-0 shadow-none"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={loadSessions}
|
||||
disabled={isLoading}
|
||||
className="shrink-0"
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", isLoading && "animate-spin")} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Session list */}
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
{error && (
|
||||
<div className="p-4 text-center text-destructive text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
{!error && isLoading && sessions.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
|
||||
<RefreshCw className="h-6 w-6 text-muted-foreground animate-spin mb-2" />
|
||||
<p className="text-muted-foreground text-sm">Loading threads...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && !isLoading && sessions.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
You don't have any past threads yet.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && sessions.length > 0 && groupedSessions.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No threads match your search.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* p-2 ensures rounded corners of buttons are not clipped */}
|
||||
<div className="p-2">
|
||||
{groupedSessions.map((group, groupIndex) => (
|
||||
<div key={group.bucket}>
|
||||
{/* Bucket separator - Reference: Zed's BucketSeparator */}
|
||||
<div className={cn("px-2 pb-1", groupIndex > 0 && "pt-3")}>
|
||||
<span className="text-xs text-muted-foreground font-medium">
|
||||
{BUCKET_LABELS[group.bucket]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Session entries */}
|
||||
{group.sessions.map((session) => {
|
||||
const globalIdx = flatItems.indexOf(session);
|
||||
const isSelected = globalIdx === selectedIndex;
|
||||
const isLoadingThis = loadingSessionId === session.sessionId;
|
||||
const isAnyLoading = loadingSessionId !== null;
|
||||
const date = session.updatedAt ? new Date(session.updatedAt) : null;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={session.sessionId}
|
||||
disabled={isAnyLoading}
|
||||
onClick={() => {
|
||||
setSelectedIndex(globalIdx);
|
||||
handleSelectSession(session);
|
||||
}}
|
||||
className={cn(
|
||||
// min-w-0 is required for truncate to work in flex containers
|
||||
"w-full min-w-0 flex items-center gap-2 px-3 py-2 rounded-md text-left transition-colors",
|
||||
"hover:bg-accent",
|
||||
isSelected && "bg-accent",
|
||||
isAnyLoading && !isLoadingThis && "opacity-50 cursor-not-allowed",
|
||||
isLoadingThis && "bg-accent"
|
||||
)}
|
||||
>
|
||||
{/* min-w-0 + truncate ensures long titles are clipped with ellipsis */}
|
||||
<span className="text-sm truncate flex-1 min-w-0">
|
||||
{session.title && session.title.trim() ? session.title : "New Thread"}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0 whitespace-nowrap">
|
||||
{isLoadingThis ? (
|
||||
<RefreshCw className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
formatRelativeTime(date)
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "../ui/button";
|
||||
import { cn } from "../../src/lib/utils";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
type HTMLAttributes,
|
||||
useContext,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
|
||||
code: string;
|
||||
language?: string;
|
||||
showLineNumbers?: boolean;
|
||||
};
|
||||
|
||||
type CodeBlockContextType = {
|
||||
code: string;
|
||||
};
|
||||
|
||||
const CodeBlockContext = createContext<CodeBlockContextType>({
|
||||
code: "",
|
||||
});
|
||||
|
||||
export const CodeBlock = ({
|
||||
code,
|
||||
language,
|
||||
showLineNumbers = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CodeBlockProps) => {
|
||||
const lines = code.split("\n");
|
||||
|
||||
return (
|
||||
<CodeBlockContext.Provider value={{ code }}>
|
||||
<div
|
||||
className={cn(
|
||||
"group relative w-full overflow-hidden rounded-md border bg-background text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<tbody>
|
||||
{lines.map((line, i) => (
|
||||
<tr key={i} className="border-0">
|
||||
{showLineNumbers && (
|
||||
<td className="w-10 select-none pr-4 text-right align-top text-muted-foreground text-xs">
|
||||
{i + 1}
|
||||
</td>
|
||||
)}
|
||||
<td className="p-0">
|
||||
<pre className="m-0 p-0 text-sm whitespace-pre font-mono">
|
||||
<code className="text-sm">{line || "\u00A0"}</code>
|
||||
</pre>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{children && (
|
||||
<div className="absolute top-2 right-2 flex items-center gap-2">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CodeBlockContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
|
||||
onCopy?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
export const CodeBlockCopyButton = ({
|
||||
onCopy,
|
||||
onError,
|
||||
timeout = 2000,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: CodeBlockCopyButtonProps) => {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const { code } = useContext(CodeBlockContext);
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
|
||||
onError?.(new Error("Clipboard API not available"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setIsCopied(true);
|
||||
onCopy?.();
|
||||
setTimeout(() => setIsCopied(false), timeout);
|
||||
} catch (error) {
|
||||
onError?.(error as Error);
|
||||
}
|
||||
};
|
||||
|
||||
const Icon = isCopied ? CheckIcon : CopyIcon;
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn("shrink-0", className)}
|
||||
onClick={copyToClipboard}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <Icon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,181 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "../ui/button";
|
||||
import { cn } from "../../src/lib/utils";
|
||||
import { ArrowDownIcon, UserIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
|
||||
|
||||
export type ConversationProps = ComponentProps<typeof StickToBottom>;
|
||||
|
||||
export const Conversation = ({ className, ...props }: ConversationProps) => (
|
||||
<StickToBottom
|
||||
className={cn("relative flex-1 overflow-y-hidden overflow-x-hidden", className)}
|
||||
initial="smooth"
|
||||
resize="smooth"
|
||||
role="log"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ConversationContentProps = ComponentProps<
|
||||
typeof StickToBottom.Content
|
||||
>;
|
||||
|
||||
export const ConversationContent = ({
|
||||
className,
|
||||
...props
|
||||
}: ConversationContentProps) => (
|
||||
<StickToBottom.Content
|
||||
className={cn("mx-auto flex max-w-3xl flex-col gap-2 px-4 py-8 sm:px-8 sm:py-12 min-w-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ConversationEmptyStateProps = ComponentProps<"div"> & {
|
||||
title?: string;
|
||||
description?: string;
|
||||
icon?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const ConversationEmptyState = ({
|
||||
className,
|
||||
title = "No messages yet",
|
||||
description = "Start a conversation to see messages here",
|
||||
icon,
|
||||
children,
|
||||
...props
|
||||
}: ConversationEmptyStateProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-full flex-col items-center justify-center gap-4 p-8 text-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
{icon && <div className="text-text-muted">{icon}</div>}
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-semibold text-base font-display text-text-primary">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-text-muted text-sm leading-relaxed max-w-xs">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
|
||||
|
||||
/**
|
||||
* Button to scroll to the bottom of the conversation.
|
||||
* Can be used standalone or within ConversationScrollButtons container.
|
||||
* When used standalone, it handles its own visibility based on isAtBottom.
|
||||
* When used in ConversationScrollButtons, the container manages visibility.
|
||||
*/
|
||||
export const ConversationScrollButton = ({
|
||||
className,
|
||||
...props
|
||||
}: ConversationScrollButtonProps) => {
|
||||
const { scrollToBottom } = useStickToBottomContext();
|
||||
|
||||
const handleScrollToBottom = useCallback(() => {
|
||||
scrollToBottom();
|
||||
}, [scrollToBottom]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
"rounded-full",
|
||||
className
|
||||
)}
|
||||
onClick={handleScrollToBottom}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
title="Scroll to bottom"
|
||||
{...props}
|
||||
>
|
||||
<ArrowDownIcon className="size-4" />
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Data attribute used to mark the last user message element.
|
||||
* ChatInterface adds this attribute to the last user message for scroll targeting.
|
||||
*/
|
||||
export const LAST_USER_MESSAGE_ATTR = "data-last-user-message";
|
||||
|
||||
export type ConversationScrollToLastUserMessageButtonProps = ComponentProps<typeof Button>;
|
||||
|
||||
/**
|
||||
* Button to scroll to the last user message in the conversation.
|
||||
* Reference: Issue #3 - Provide a feature to locate the last human message
|
||||
*/
|
||||
export const ConversationScrollToLastUserMessageButton = ({
|
||||
className,
|
||||
...props
|
||||
}: ConversationScrollToLastUserMessageButtonProps) => {
|
||||
const handleScrollToLastUserMessage = useCallback(() => {
|
||||
// Find the last user message element by data attribute
|
||||
const lastUserMessage = document.querySelector(`[${LAST_USER_MESSAGE_ATTR}="true"]`);
|
||||
if (lastUserMessage) {
|
||||
lastUserMessage.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
"rounded-full",
|
||||
className
|
||||
)}
|
||||
onClick={handleScrollToLastUserMessage}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
title="Scroll to last user message"
|
||||
{...props}
|
||||
>
|
||||
<UserIcon className="size-4" />
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type ConversationScrollButtonsProps = ComponentProps<"div"> & {
|
||||
/** Whether there are user messages to scroll to */
|
||||
hasUserMessages?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Container for scroll navigation buttons.
|
||||
* Renders scroll-to-last-user-message and scroll-to-bottom buttons side by side.
|
||||
* Reference: Issue #3 - Provide a feature to locate the last human message
|
||||
*/
|
||||
export const ConversationScrollButtons = ({
|
||||
className,
|
||||
hasUserMessages = false,
|
||||
...props
|
||||
}: ConversationScrollButtonsProps) => {
|
||||
const { isAtBottom } = useStickToBottomContext();
|
||||
|
||||
if (isAtBottom) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{hasUserMessages && <ConversationScrollToLastUserMessageButton />}
|
||||
<ConversationScrollButton />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export * from "./code-block";
|
||||
export * from "./conversation";
|
||||
export * from "./message";
|
||||
export * from "./permission-request";
|
||||
export * from "./prompt-input";
|
||||
export * from "./reasoning";
|
||||
export * from "./shimmer";
|
||||
export * from "./tool";
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "../ui/button";
|
||||
import {
|
||||
ButtonGroup,
|
||||
ButtonGroupText,
|
||||
} from "../ui/button-group";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "../ui/tooltip";
|
||||
import { cn } from "../../src/lib/utils";
|
||||
import type { FileUIPart, UIMessage } from "ai";
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
PaperclipIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
|
||||
import { createContext, lazy, memo, Suspense, useContext, useEffect, useState } from "react";
|
||||
|
||||
const LazyStreamdown = lazy(() => import("streamdown").then((m) => ({ default: m.Streamdown })));
|
||||
|
||||
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: UIMessage["role"];
|
||||
};
|
||||
|
||||
export const Message = ({ className, from, ...props }: MessageProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"group flex w-full max-w-[85%] min-w-0 flex-col gap-2",
|
||||
from === "user" ? "is-user ml-auto justify-end" : "is-assistant",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const MessageContent = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: MessageContentProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"is-user:dark flex w-fit max-w-full flex-col gap-2 overflow-hidden text-sm break-words",
|
||||
"group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground",
|
||||
"group-[.is-assistant]:text-foreground",
|
||||
className
|
||||
)}
|
||||
style={{ overflowWrap: "anywhere" }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type MessageActionsProps = ComponentProps<"div">;
|
||||
|
||||
export const MessageActions = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: MessageActionsProps) => (
|
||||
<div className={cn("flex items-center gap-1", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type MessageActionProps = ComponentProps<typeof Button> & {
|
||||
tooltip?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export const MessageAction = ({
|
||||
tooltip,
|
||||
children,
|
||||
label,
|
||||
variant = "ghost",
|
||||
size = "icon-sm",
|
||||
...props
|
||||
}: MessageActionProps) => {
|
||||
const button = (
|
||||
<Button size={size} type="button" variant={variant} {...props}>
|
||||
{children}
|
||||
<span className="sr-only">{label || tooltip}</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (tooltip) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
};
|
||||
|
||||
type MessageBranchContextType = {
|
||||
currentBranch: number;
|
||||
totalBranches: number;
|
||||
goToPrevious: () => void;
|
||||
goToNext: () => void;
|
||||
branches: ReactElement[];
|
||||
setBranches: (branches: ReactElement[]) => void;
|
||||
};
|
||||
|
||||
const MessageBranchContext = createContext<MessageBranchContextType | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const useMessageBranch = () => {
|
||||
const context = useContext(MessageBranchContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"MessageBranch components must be used within MessageBranch"
|
||||
);
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export type MessageBranchProps = HTMLAttributes<HTMLDivElement> & {
|
||||
defaultBranch?: number;
|
||||
onBranchChange?: (branchIndex: number) => void;
|
||||
};
|
||||
|
||||
export const MessageBranch = ({
|
||||
defaultBranch = 0,
|
||||
onBranchChange,
|
||||
className,
|
||||
...props
|
||||
}: MessageBranchProps) => {
|
||||
const [currentBranch, setCurrentBranch] = useState(defaultBranch);
|
||||
const [branches, setBranches] = useState<ReactElement[]>([]);
|
||||
|
||||
const handleBranchChange = (newBranch: number) => {
|
||||
setCurrentBranch(newBranch);
|
||||
onBranchChange?.(newBranch);
|
||||
};
|
||||
|
||||
const goToPrevious = () => {
|
||||
const newBranch =
|
||||
currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
|
||||
handleBranchChange(newBranch);
|
||||
};
|
||||
|
||||
const goToNext = () => {
|
||||
const newBranch =
|
||||
currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
|
||||
handleBranchChange(newBranch);
|
||||
};
|
||||
|
||||
const contextValue: MessageBranchContextType = {
|
||||
currentBranch,
|
||||
totalBranches: branches.length,
|
||||
goToPrevious,
|
||||
goToNext,
|
||||
branches,
|
||||
setBranches,
|
||||
};
|
||||
|
||||
return (
|
||||
<MessageBranchContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn("grid w-full gap-2 [&>div]:pb-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
</MessageBranchContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const MessageBranchContent = ({
|
||||
children,
|
||||
...props
|
||||
}: MessageBranchContentProps) => {
|
||||
const { currentBranch, setBranches, branches } = useMessageBranch();
|
||||
const childrenArray = Array.isArray(children) ? children : [children];
|
||||
|
||||
// Use useEffect to update branches when they change
|
||||
useEffect(() => {
|
||||
if (branches.length !== childrenArray.length) {
|
||||
setBranches(childrenArray);
|
||||
}
|
||||
}, [childrenArray, branches, setBranches]);
|
||||
|
||||
return childrenArray.map((branch, index) => (
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-2 overflow-hidden [&>div]:pb-0",
|
||||
index === currentBranch ? "block" : "hidden"
|
||||
)}
|
||||
key={branch.key}
|
||||
{...props}
|
||||
>
|
||||
{branch}
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
export type MessageBranchSelectorProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: UIMessage["role"];
|
||||
};
|
||||
|
||||
export const MessageBranchSelector = ({
|
||||
className,
|
||||
from,
|
||||
...props
|
||||
}: MessageBranchSelectorProps) => {
|
||||
const { totalBranches } = useMessageBranch();
|
||||
|
||||
// Don't render if there's only one branch
|
||||
if (totalBranches <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ButtonGroup
|
||||
className="[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md"
|
||||
orientation="horizontal"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchPreviousProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const MessageBranchPrevious = ({
|
||||
children,
|
||||
...props
|
||||
}: MessageBranchPreviousProps) => {
|
||||
const { goToPrevious, totalBranches } = useMessageBranch();
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label="Previous branch"
|
||||
disabled={totalBranches <= 1}
|
||||
onClick={goToPrevious}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronLeftIcon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchNextProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const MessageBranchNext = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: MessageBranchNextProps) => {
|
||||
const { goToNext, totalBranches } = useMessageBranch();
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label="Next branch"
|
||||
disabled={totalBranches <= 1}
|
||||
onClick={goToNext}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRightIcon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const MessageBranchPage = ({
|
||||
className,
|
||||
...props
|
||||
}: MessageBranchPageProps) => {
|
||||
const { currentBranch, totalBranches } = useMessageBranch();
|
||||
|
||||
return (
|
||||
<ButtonGroupText
|
||||
className={cn(
|
||||
"border-none bg-transparent text-muted-foreground shadow-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{currentBranch + 1} of {totalBranches}
|
||||
</ButtonGroupText>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageResponseProps = {
|
||||
children?: string;
|
||||
className?: string;
|
||||
mode?: "static" | "streaming";
|
||||
};
|
||||
|
||||
export const MessageResponse = memo(
|
||||
({ className, children, ...props }: MessageResponseProps) => (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className={cn("whitespace-pre-wrap break-words", className)}>
|
||||
{children}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LazyStreamdown
|
||||
className={cn(
|
||||
"size-full break-words [overflow-wrap:anywhere] [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</LazyStreamdown>
|
||||
</Suspense>
|
||||
),
|
||||
(prevProps, nextProps) => prevProps.children === nextProps.children
|
||||
);
|
||||
|
||||
MessageResponse.displayName = "MessageResponse";
|
||||
|
||||
export type MessageAttachmentProps = HTMLAttributes<HTMLDivElement> & {
|
||||
data: FileUIPart;
|
||||
className?: string;
|
||||
onRemove?: () => void;
|
||||
};
|
||||
|
||||
export function MessageAttachment({
|
||||
data,
|
||||
className,
|
||||
onRemove,
|
||||
...props
|
||||
}: MessageAttachmentProps) {
|
||||
const filename = data.filename || "";
|
||||
const mediaType =
|
||||
data.mediaType?.startsWith("image/") && data.url ? "image" : "file";
|
||||
const isImage = mediaType === "image";
|
||||
const attachmentLabel = filename || (isImage ? "Image" : "Attachment");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative size-24 overflow-hidden rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{isImage ? (
|
||||
<>
|
||||
<img
|
||||
alt={filename || "attachment"}
|
||||
className="size-full object-cover"
|
||||
height={100}
|
||||
src={data.url}
|
||||
width={100}
|
||||
/>
|
||||
{onRemove && (
|
||||
<Button
|
||||
aria-label="Remove attachment"
|
||||
className="absolute top-2 right-2 size-6 rounded-full bg-background/80 p-0 opacity-0 backdrop-blur-sm transition-opacity hover:bg-background group-hover:opacity-100 [&>svg]:size-3"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Remove</span>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex size-full shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
|
||||
<PaperclipIcon className="size-4" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{attachmentLabel}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{onRemove && (
|
||||
<Button
|
||||
aria-label="Remove attachment"
|
||||
className="size-6 shrink-0 rounded-full p-0 opacity-0 transition-opacity hover:bg-accent group-hover:opacity-100 [&>svg]:size-3"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Remove</span>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type MessageAttachmentsProps = ComponentProps<"div">;
|
||||
|
||||
export function MessageAttachments({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: MessageAttachmentsProps) {
|
||||
if (!children) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"ml-auto flex w-fit flex-wrap items-start gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type MessageToolbarProps = ComponentProps<"div">;
|
||||
|
||||
export const MessageToolbar = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: MessageToolbarProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-4 flex w-full items-center justify-between gap-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "../../src/lib/utils";
|
||||
import { Button } from "../ui/button";
|
||||
import { ShieldAlertIcon, CheckIcon, XIcon } from "lucide-react";
|
||||
import type { PermissionOption } from "../../src/acp/types";
|
||||
|
||||
// Get button variant based on option kind
|
||||
function getButtonVariant(kind: PermissionOption["kind"]): "default" | "destructive" | "outline" | "secondary" {
|
||||
switch (kind) {
|
||||
case "allow_once":
|
||||
case "allow_always":
|
||||
return "default";
|
||||
case "reject_once":
|
||||
case "reject_always":
|
||||
return "destructive";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
}
|
||||
|
||||
// Get button icon based on option kind
|
||||
function getButtonIcon(kind: PermissionOption["kind"]) {
|
||||
switch (kind) {
|
||||
case "allow_once":
|
||||
case "allow_always":
|
||||
return <CheckIcon className="size-4" />;
|
||||
case "reject_once":
|
||||
case "reject_always":
|
||||
return <XIcon className="size-4" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Permission buttons component - used inside Tool component
|
||||
export interface ToolPermissionButtonsProps {
|
||||
requestId: string;
|
||||
options: PermissionOption[];
|
||||
onRespond: (requestId: string, optionId: string | null, optionKind: PermissionOption["kind"] | null) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ToolPermissionButtons({ requestId, options, onRespond, className }: ToolPermissionButtonsProps) {
|
||||
const handleOptionClick = (option: PermissionOption) => {
|
||||
onRespond(requestId, option.optionId, option.kind);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("p-3 border-t border-warning-border/30 bg-warning-bg/50", className)}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<ShieldAlertIcon className="size-4 text-warning-text" />
|
||||
<span className="text-xs font-medium text-warning-text">
|
||||
Permission Required
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{options.map((option) => (
|
||||
<Button
|
||||
key={option.optionId}
|
||||
variant={getButtonVariant(option.kind)}
|
||||
size="sm"
|
||||
onClick={() => handleOptionClick(option)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{getButtonIcon(option.kind)}
|
||||
{option.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,190 @@
|
||||
"use client";
|
||||
|
||||
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "../ui/collapsible";
|
||||
import { cn } from "../../src/lib/utils";
|
||||
import { BrainIcon, ChevronDownIcon } from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { createContext, memo, useCallback, useContext, useEffect, useState } from "react";
|
||||
import { Shimmer } from "./shimmer";
|
||||
|
||||
interface ReasoningContextValue {
|
||||
isStreaming: boolean;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
duration: number | undefined;
|
||||
}
|
||||
|
||||
const ReasoningContext = createContext<ReasoningContextValue | null>(null);
|
||||
|
||||
export const useReasoning = () => {
|
||||
const context = useContext(ReasoningContext);
|
||||
if (!context) {
|
||||
throw new Error("Reasoning components must be used within Reasoning");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export type ReasoningProps = ComponentProps<typeof Collapsible> & {
|
||||
isStreaming?: boolean;
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
duration?: number;
|
||||
};
|
||||
|
||||
const AUTO_CLOSE_DELAY = 1000;
|
||||
const MS_IN_S = 1000;
|
||||
|
||||
export const Reasoning = memo(
|
||||
({
|
||||
className,
|
||||
isStreaming = false,
|
||||
open,
|
||||
defaultOpen = true,
|
||||
onOpenChange,
|
||||
duration: durationProp,
|
||||
children,
|
||||
...props
|
||||
}: ReasoningProps) => {
|
||||
const [isOpen, setIsOpen] = useControllableState({
|
||||
prop: open,
|
||||
defaultProp: defaultOpen,
|
||||
onChange: onOpenChange,
|
||||
});
|
||||
const [duration, setDuration] = useControllableState({
|
||||
prop: durationProp,
|
||||
defaultProp: undefined,
|
||||
});
|
||||
|
||||
const [hasAutoClosed, setHasAutoClosed] = useState(false);
|
||||
const [startTime, setStartTime] = useState<number | null>(null);
|
||||
|
||||
// Track duration when streaming starts and ends
|
||||
useEffect(() => {
|
||||
if (isStreaming) {
|
||||
if (startTime === null) {
|
||||
setStartTime(Date.now());
|
||||
}
|
||||
} else if (startTime !== null) {
|
||||
setDuration(Math.ceil((Date.now() - startTime) / MS_IN_S));
|
||||
setStartTime(null);
|
||||
}
|
||||
}, [isStreaming, startTime, setDuration]);
|
||||
|
||||
// Auto-open when streaming starts, auto-close when streaming ends (once only)
|
||||
// Respect prefers-reduced-motion: skip animation auto-close
|
||||
const prefersReducedMotion = typeof window !== "undefined"
|
||||
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
useEffect(() => {
|
||||
if (!prefersReducedMotion && defaultOpen && !isStreaming && isOpen && !hasAutoClosed) {
|
||||
const timer = setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
setHasAutoClosed(true);
|
||||
}, AUTO_CLOSE_DELAY);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isStreaming, isOpen, defaultOpen, setIsOpen, hasAutoClosed, prefersReducedMotion]);
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
setIsOpen(newOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<ReasoningContext.Provider
|
||||
value={{ isStreaming, isOpen: isOpen ?? false, setIsOpen, duration }}
|
||||
>
|
||||
<Collapsible
|
||||
className={cn("not-prose mb-4", className)}
|
||||
onOpenChange={handleOpenChange}
|
||||
open={isOpen}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Collapsible>
|
||||
</ReasoningContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type ReasoningTriggerProps = ComponentProps<
|
||||
typeof CollapsibleTrigger
|
||||
> & {
|
||||
getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode;
|
||||
};
|
||||
|
||||
const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
|
||||
if (isStreaming || duration === 0) {
|
||||
return <Shimmer duration={1}>Thinking...</Shimmer>;
|
||||
}
|
||||
if (duration === undefined) {
|
||||
return <p>Thought for a few seconds</p>;
|
||||
}
|
||||
return <p>Thought for {duration} seconds</p>;
|
||||
};
|
||||
|
||||
export const ReasoningTrigger = memo(
|
||||
({
|
||||
className,
|
||||
children,
|
||||
getThinkingMessage = defaultGetThinkingMessage,
|
||||
...props
|
||||
}: ReasoningTriggerProps) => {
|
||||
const { isStreaming, isOpen, duration } = useReasoning();
|
||||
|
||||
return (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<BrainIcon className="size-4" />
|
||||
{getThinkingMessage(isStreaming, duration)}
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-4 transition-transform",
|
||||
isOpen ? "rotate-180" : "rotate-0"
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type ReasoningContentProps = ComponentProps<
|
||||
typeof CollapsibleContent
|
||||
> & {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const ReasoningContent = memo(
|
||||
({ className, children, ...props }: ReasoningContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"mt-4 text-sm",
|
||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</CollapsibleContent>
|
||||
)
|
||||
);
|
||||
|
||||
Reasoning.displayName = "Reasoning";
|
||||
ReasoningTrigger.displayName = "ReasoningTrigger";
|
||||
ReasoningContent.displayName = "ReasoningContent";
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "../../src/lib/utils";
|
||||
import { motion } from "motion/react";
|
||||
import {
|
||||
type ElementType,
|
||||
type JSX,
|
||||
memo,
|
||||
} from "react";
|
||||
|
||||
export interface TextShimmerProps {
|
||||
children: string;
|
||||
as?: ElementType;
|
||||
className?: string;
|
||||
duration?: number;
|
||||
spread?: number;
|
||||
}
|
||||
|
||||
const ShimmerComponent = ({
|
||||
children,
|
||||
as: Component = "p",
|
||||
className,
|
||||
duration = 2,
|
||||
}: TextShimmerProps) => {
|
||||
const MotionComponent = motion.create(
|
||||
Component as keyof JSX.IntrinsicElements
|
||||
);
|
||||
|
||||
return (
|
||||
<MotionComponent
|
||||
animate={{ opacity: [0.5, 1, 0.5] }}
|
||||
className={cn(
|
||||
"relative inline-block text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
transition={{
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
duration,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MotionComponent>
|
||||
);
|
||||
};
|
||||
|
||||
export const Shimmer = memo(ShimmerComponent);
|
||||
@@ -0,0 +1,171 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "../ui/badge";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "../ui/collapsible";
|
||||
import { cn } from "../../src/lib/utils";
|
||||
import type { ToolUIPart } from "ai";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ChevronDownIcon,
|
||||
CircleIcon,
|
||||
ClockIcon,
|
||||
WrenchIcon,
|
||||
XCircleIcon,
|
||||
} from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { isValidElement } from "react";
|
||||
import { CodeBlock } from "./code-block";
|
||||
|
||||
export type ToolProps = ComponentProps<typeof Collapsible>;
|
||||
|
||||
export const Tool = ({ className, ...props }: ToolProps) => (
|
||||
<Collapsible
|
||||
className={cn("not-prose mb-4 w-full max-w-full overflow-hidden rounded-md border", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
// Extended state type to include our custom states
|
||||
export type ExtendedToolState = ToolUIPart["state"] | "waiting-for-confirmation" | "rejected";
|
||||
|
||||
export type ToolHeaderProps = {
|
||||
title?: string;
|
||||
type: ToolUIPart["type"];
|
||||
state: ExtendedToolState;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: ExtendedToolState) => {
|
||||
const labels: Record<ExtendedToolState, string> = {
|
||||
"input-streaming": "Pending",
|
||||
"input-available": "Running",
|
||||
"approval-requested": "Awaiting Approval",
|
||||
"approval-responded": "Responded",
|
||||
"output-available": "Completed",
|
||||
"output-error": "Error",
|
||||
"output-denied": "Denied",
|
||||
"waiting-for-confirmation": "Awaiting Approval",
|
||||
"rejected": "Rejected",
|
||||
};
|
||||
|
||||
const icons: Record<ExtendedToolState, ReactNode> = {
|
||||
"input-streaming": <CircleIcon className="size-4" />,
|
||||
"input-available": <ClockIcon className="size-4 animate-pulse" />,
|
||||
"approval-requested": <ClockIcon className="size-4 text-yellow-600" />,
|
||||
"approval-responded": <CheckCircleIcon className="size-4 text-blue-600" />,
|
||||
"output-available": <CheckCircleIcon className="size-4 text-green-600" />,
|
||||
"output-error": <XCircleIcon className="size-4 text-red-600" />,
|
||||
"output-denied": <XCircleIcon className="size-4 text-orange-600" />,
|
||||
"waiting-for-confirmation": <ClockIcon className="size-4 text-yellow-600" />,
|
||||
"rejected": <XCircleIcon className="size-4 text-orange-600" />,
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge className="gap-1.5 rounded-full text-xs" variant="secondary">
|
||||
{icons[status]}
|
||||
{labels[status]}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
export const ToolHeader = ({
|
||||
className,
|
||||
title,
|
||||
type,
|
||||
state,
|
||||
...props
|
||||
}: ToolHeaderProps) => (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-4 p-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<WrenchIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate font-medium text-sm">
|
||||
{title ?? type.split("-").slice(1).join("-")}
|
||||
</span>
|
||||
{getStatusBadge(state)}
|
||||
</div>
|
||||
<ChevronDownIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
|
||||
export type ToolContentProps = ComponentProps<typeof CollapsibleContent>;
|
||||
|
||||
export const ToolContent = ({ className, ...props }: ToolContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ToolInputProps = ComponentProps<"div"> & {
|
||||
input: ToolUIPart["input"];
|
||||
};
|
||||
|
||||
export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
|
||||
<div className={cn("space-y-2 overflow-hidden p-4 max-w-full", className)} {...props}>
|
||||
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Parameters
|
||||
</h4>
|
||||
<div className="rounded-md bg-muted/50 overflow-hidden">
|
||||
<CodeBlock code={JSON.stringify(input, null, 2)} language="json" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export type ToolOutputProps = ComponentProps<"div"> & {
|
||||
output: ToolUIPart["output"];
|
||||
errorText: ToolUIPart["errorText"];
|
||||
};
|
||||
|
||||
export const ToolOutput = ({
|
||||
className,
|
||||
output,
|
||||
errorText,
|
||||
...props
|
||||
}: ToolOutputProps) => {
|
||||
if (!(output || errorText)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let Output = <div>{output as ReactNode}</div>;
|
||||
|
||||
if (typeof output === "object" && !isValidElement(output)) {
|
||||
Output = (
|
||||
<CodeBlock code={JSON.stringify(output, null, 2)} language="json" />
|
||||
);
|
||||
} else if (typeof output === "string") {
|
||||
Output = <CodeBlock code={output} language="json" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2 p-4 max-w-full overflow-hidden", className)} {...props}>
|
||||
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||
{errorText ? "Error" : "Result"}
|
||||
</h4>
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-md text-xs [&_table]:w-full",
|
||||
errorText
|
||||
? "bg-destructive/10 text-destructive"
|
||||
: "bg-muted/50 text-foreground"
|
||||
)}
|
||||
>
|
||||
{errorText && <div className="p-2">{errorText}</div>}
|
||||
{Output}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
340
packages/remote-control-server/web/components/chat/ChatInput.tsx
Normal file
340
packages/remote-control-server/web/components/chat/ChatInput.tsx
Normal file
@@ -0,0 +1,340 @@
|
||||
import { useState, useRef, useCallback, type KeyboardEvent, type ClipboardEvent } from "react";
|
||||
import { cn } from "../../src/lib/utils";
|
||||
import { Send, Square, Paperclip, Slash } from "lucide-react";
|
||||
import type { ChatInputMessage, UserMessageImage } from "../../src/lib/types";
|
||||
import type { AvailableCommand } from "../../src/acp/types";
|
||||
import { CommandMenu } from "./CommandMenu";
|
||||
import imageCompression from "browser-image-compression";
|
||||
|
||||
// 图片压缩配置
|
||||
const IMAGE_COMPRESSION_OPTIONS = {
|
||||
maxSizeMB: 2,
|
||||
maxWidthOrHeight: 2048,
|
||||
useWebWorker: true,
|
||||
fileType: "image/jpeg" as const,
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Anthropic 风格聊天输入框 — 底部居中浮动卡片,橙色焦点环
|
||||
// =============================================================================
|
||||
|
||||
interface ChatInputProps {
|
||||
onSubmit: (message: ChatInputMessage) => void;
|
||||
isLoading?: boolean;
|
||||
onInterrupt?: () => void;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
/** 是否支持图片上传 */
|
||||
supportsImages?: boolean;
|
||||
/** Agent 提供的可用 slash 命令 */
|
||||
commands?: AvailableCommand[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ChatInput({
|
||||
onSubmit,
|
||||
isLoading = false,
|
||||
onInterrupt,
|
||||
disabled = false,
|
||||
placeholder = "给 Claude 发送消息…",
|
||||
supportsImages = false,
|
||||
commands,
|
||||
className,
|
||||
}: ChatInputProps) {
|
||||
const [text, setText] = useState("");
|
||||
const [images, setImages] = useState<UserMessageImage[]>([]);
|
||||
const [showCommandMenu, setShowCommandMenu] = useState(false);
|
||||
const [commandFilter, setCommandFilter] = useState("");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
const trimmed = text.trim();
|
||||
if ((!trimmed && images.length === 0) || disabled) return;
|
||||
|
||||
onSubmit({ text: trimmed, images: images.length > 0 ? images : undefined });
|
||||
setText("");
|
||||
setImages([]);
|
||||
setShowCommandMenu(false);
|
||||
setCommandFilter("");
|
||||
// 重置 textarea 高度
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = "auto";
|
||||
}
|
||||
}, [text, images, disabled, onSubmit]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (showCommandMenu) {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setShowCommandMenu(false);
|
||||
return;
|
||||
}
|
||||
// Arrow keys and Enter are handled by CommandMenu via document-level listener
|
||||
// Don't submit or move cursor when menu is open
|
||||
if (e.key === "ArrowUp" || e.key === "ArrowDown" || e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
setShowCommandMenu(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault();
|
||||
if (isLoading) {
|
||||
onInterrupt?.();
|
||||
} else {
|
||||
handleSubmit();
|
||||
}
|
||||
}
|
||||
},
|
||||
[handleSubmit, isLoading, onInterrupt, showCommandMenu],
|
||||
);
|
||||
|
||||
const handleInput = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const value = e.target.value;
|
||||
setText(value);
|
||||
|
||||
// 检测 slash 命令模式:仅在输入开头输入 / 时触发
|
||||
if (value.startsWith("/") && commands && commands.length > 0) {
|
||||
setShowCommandMenu(true);
|
||||
setCommandFilter(value.slice(1).split(/\s/)[0] || "");
|
||||
} else if (showCommandMenu) {
|
||||
setShowCommandMenu(false);
|
||||
setCommandFilter("");
|
||||
}
|
||||
|
||||
// 自动调整高度
|
||||
const el = e.target;
|
||||
el.style.height = "auto";
|
||||
el.style.height = Math.min(el.scrollHeight, 200) + "px";
|
||||
}, [commands, showCommandMenu]);
|
||||
|
||||
// 粘贴图片
|
||||
const handlePaste = useCallback(async (e: ClipboardEvent) => {
|
||||
if (!supportsImages) return;
|
||||
const files = Array.from(e.clipboardData.files).filter((f) => f.type.startsWith("image/"));
|
||||
if (files.length === 0) return;
|
||||
|
||||
e.preventDefault();
|
||||
const newImages = await processImageFiles(files);
|
||||
setImages((prev) => [...prev, ...newImages]);
|
||||
}, [supportsImages]);
|
||||
|
||||
// 选择文件
|
||||
const handleFileSelect = useCallback(async () => {
|
||||
if (!fileInputRef.current) return;
|
||||
const files = fileInputRef.current.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const newImages = await processImageFiles(Array.from(files));
|
||||
setImages((prev) => [...prev, ...newImages]);
|
||||
// 清空 input 以便重复选择
|
||||
fileInputRef.current.value = "";
|
||||
}, []);
|
||||
|
||||
const removeImage = useCallback((index: number) => {
|
||||
setImages((prev) => prev.filter((_, i) => i !== index));
|
||||
}, []);
|
||||
|
||||
const handleCommandSelect = useCallback((command: AvailableCommand) => {
|
||||
setText(`/${command.name} `);
|
||||
setShowCommandMenu(false);
|
||||
setCommandFilter("");
|
||||
textareaRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const toggleCommandMenu = useCallback(() => {
|
||||
if (showCommandMenu) {
|
||||
setShowCommandMenu(false);
|
||||
setCommandFilter("");
|
||||
} else {
|
||||
if (!text.startsWith("/")) {
|
||||
setText("/" + text);
|
||||
}
|
||||
setShowCommandMenu(true);
|
||||
setCommandFilter(text.startsWith("/") ? text.slice(1).split(/\s/)[0] || "" : "");
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
}, [showCommandMenu, text]);
|
||||
|
||||
const canSend = (text.trim() || images.length > 0) && !disabled;
|
||||
|
||||
return (
|
||||
<div className={cn("w-full max-w-3xl mx-auto px-4 sm:px-8 pb-4 pt-2", className)}>
|
||||
<div className="relative">
|
||||
{/* Slash command menu — floating above input */}
|
||||
{showCommandMenu && commands && commands.length > 0 && (
|
||||
<CommandMenu
|
||||
commands={commands}
|
||||
filter={commandFilter}
|
||||
onSelect={handleCommandSelect}
|
||||
onClose={() => {
|
||||
setShowCommandMenu(false);
|
||||
setCommandFilter("");
|
||||
}}
|
||||
className="absolute bottom-full left-0 right-0 mb-1 z-50"
|
||||
/>
|
||||
)}
|
||||
<div className={cn(
|
||||
"rounded-xl border border-border bg-surface-2 overflow-hidden",
|
||||
"focus-within:border-brand/50 focus-within:shadow-[0_0_0_3px_rgba(217,119,87,0.15)] transition-all",
|
||||
)}>
|
||||
{/* 图片预览 */}
|
||||
{images.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 px-3 pt-3">
|
||||
{images.map((img, i) => (
|
||||
<div key={i} className="relative group">
|
||||
<img
|
||||
src={`data:${img.mimeType};base64,${img.data}`}
|
||||
alt={`Attached image ${i + 1}`}
|
||||
className="h-14 w-14 object-cover rounded-lg border border-border"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeImage(i)}
|
||||
className="absolute -top-1.5 -right-1.5 min-h-[32px] min-w-[32px] h-5 w-5 rounded-full bg-surface-2 border border-border flex items-center justify-center text-text-muted hover:text-text-primary text-xs opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
aria-label={`Remove image ${i + 1}`}
|
||||
>
|
||||
{"\u00D7"}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 输入区域 — Anthropic 单行紧凑布局 */}
|
||||
<div className="flex items-end gap-2 px-3 py-2.5">
|
||||
{/* 左侧附件按钮 */}
|
||||
{supportsImages && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex-shrink-0 h-8 w-8 flex items-center justify-center rounded-lg text-text-muted hover:text-text-secondary hover:bg-surface-1/50 transition-colors"
|
||||
disabled={disabled}
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
<span className="sr-only">Attach file</span>
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Slash 命令按钮 */}
|
||||
{commands && commands.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleCommandMenu}
|
||||
className={cn(
|
||||
"flex-shrink-0 h-8 w-8 flex items-center justify-center rounded-lg transition-colors",
|
||||
showCommandMenu
|
||||
? "bg-brand/15 text-brand"
|
||||
: "text-text-muted hover:text-text-secondary hover:bg-surface-1/50",
|
||||
)}
|
||||
disabled={disabled}
|
||||
title="命令列表"
|
||||
>
|
||||
<Slash className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Textarea — Poppins font */}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={text}
|
||||
onChange={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
rows={1}
|
||||
className={cn(
|
||||
"flex-1 resize-none border-none bg-transparent outline-none",
|
||||
"text-sm text-text-primary placeholder:text-text-muted font-display",
|
||||
"max-h-[200px] min-h-[24px] leading-normal",
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* 右侧发送/取消按钮 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={isLoading ? onInterrupt : handleSubmit}
|
||||
disabled={!isLoading && !canSend}
|
||||
className={cn(
|
||||
"flex-shrink-0 h-8 w-8 flex items-center justify-center rounded-lg transition-all",
|
||||
isLoading
|
||||
? "bg-text-primary text-surface-2 hover:bg-text-secondary"
|
||||
: canSend
|
||||
? "bg-brand text-white hover:bg-brand-light hover:scale-[1.05] active:scale-[0.97]"
|
||||
: "bg-surface-1 text-text-muted",
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Square className="h-3.5 w-3.5" fill="currentColor" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>{/* end relative */}
|
||||
|
||||
{/* 提示文本 */}
|
||||
<div className="text-center mt-1.5">
|
||||
<span className="text-[11px] text-text-muted font-display">
|
||||
Enter 发送,Shift+Enter 换行
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 图片处理工具
|
||||
// =============================================================================
|
||||
|
||||
async function processImageFiles(files: File[]): Promise<UserMessageImage[]> {
|
||||
const results: UserMessageImage[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
let blob: Blob = file;
|
||||
let mimeType = file.type;
|
||||
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
const compressed = await imageCompression(file, IMAGE_COMPRESSION_OPTIONS);
|
||||
blob = compressed;
|
||||
mimeType = "image/jpeg";
|
||||
}
|
||||
|
||||
const base64 = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const result = reader.result as string;
|
||||
const commaIdx = result.indexOf(",");
|
||||
resolve(commaIdx >= 0 ? result.slice(commaIdx + 1) : result);
|
||||
};
|
||||
reader.onerror = () => reject(new Error("FileReader error"));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
|
||||
results.push({ mimeType, data: base64 });
|
||||
} catch (err) {
|
||||
console.error("Failed to process image:", err);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
173
packages/remote-control-server/web/components/chat/ChatView.tsx
Normal file
173
packages/remote-control-server/web/components/chat/ChatView.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import type { ThreadEntry, ToolCallEntry, PlanDisplayEntry } from "../../src/lib/types";
|
||||
import { cn } from "../../src/lib/utils";
|
||||
import { UserBubble, AssistantBubble } from "./MessageBubble";
|
||||
import { ToolCallGroup } from "./ToolCallGroup";
|
||||
import { PlanDisplay } from "./PlanView";
|
||||
import { Conversation, ConversationContent, ConversationEmptyState, ConversationScrollButtons } from "../ai-elements/conversation";
|
||||
|
||||
// =============================================================================
|
||||
// 统一聊天视图 — Anthropic 编辑式排版
|
||||
// 无气泡间距,用垂直 rhythm 区分消息块
|
||||
// =============================================================================
|
||||
|
||||
interface ChatViewProps {
|
||||
entries: ThreadEntry[];
|
||||
isLoading?: boolean;
|
||||
onPermissionRespond?: (requestId: string, optionId: string | null, optionKind: string | null) => void;
|
||||
emptyTitle?: string;
|
||||
emptyDescription?: string;
|
||||
}
|
||||
|
||||
export function ChatView({
|
||||
entries,
|
||||
isLoading = false,
|
||||
onPermissionRespond,
|
||||
emptyTitle = "开始对话",
|
||||
emptyDescription = "输入消息开始聊天",
|
||||
}: ChatViewProps) {
|
||||
// 将相邻的 ToolCallEntry 合并为一组
|
||||
const grouped = groupToolCalls(entries);
|
||||
const hasMessages = entries.length > 0;
|
||||
|
||||
// 检查是否正在加载(最后一个条目是用户消息)
|
||||
const showThinking = isLoading && entries.length > 0 && entries[entries.length - 1]?.type === "user_message";
|
||||
|
||||
return (
|
||||
<Conversation className="flex-1">
|
||||
<ConversationContent>
|
||||
{!hasMessages ? (
|
||||
<ConversationEmptyState
|
||||
title={emptyTitle}
|
||||
description={emptyDescription}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{grouped.map((item, i) => {
|
||||
if (item.type === "single") {
|
||||
return (
|
||||
<div key={`entry-${i}`} className={cn(entrySpacing(entries, i))}>
|
||||
<EntryRenderer entry={item.entry} isLoading={isLoading} onPermissionRespond={onPermissionRespond} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 工具调用组 — 紧贴在助手消息下方
|
||||
return (
|
||||
<div key={`group-${i}`} className="-mt-2">
|
||||
<ToolCallGroup entries={item.entries} onPermissionRespond={onPermissionRespond} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 思考指示器 — Anthropic 打字动画 */}
|
||||
{showThinking && (
|
||||
<div className="flex gap-4 items-start">
|
||||
<div className="w-8 h-8 rounded-lg bg-brand/8 flex items-center justify-center flex-shrink-0">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="var(--color-brand)" fillRule="nonzero" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 pt-2">
|
||||
<span className="chat-typing-indicator" aria-hidden="true">
|
||||
<span></span><span></span><span></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<ConversationScrollButtons hasUserMessages={entries.some((e) => e.type === "user_message")} />
|
||||
</ConversationContent>
|
||||
</Conversation>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 间距逻辑 — 用户消息前后间距大,工具调用紧贴
|
||||
// =============================================================================
|
||||
|
||||
function entrySpacing(entries: ThreadEntry[], index: number): string {
|
||||
const entry = entries[index];
|
||||
// 用户消息前后大留白 — Claude.ai 式宽松间距
|
||||
if (entry?.type === "user_message") {
|
||||
return "pt-10 pb-3";
|
||||
}
|
||||
// 助手消息 — 工具调用紧贴,否则多留白
|
||||
if (entry?.type === "assistant_message") {
|
||||
const next = entries[index + 1];
|
||||
if (next?.type === "tool_call") {
|
||||
return "pt-3 pb-1";
|
||||
}
|
||||
return "pt-3 pb-8";
|
||||
}
|
||||
// Plan 条目
|
||||
if (entry?.type === "plan") {
|
||||
return "pt-3 pb-3";
|
||||
}
|
||||
return "py-2";
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 单条目渲染器
|
||||
// =============================================================================
|
||||
|
||||
function EntryRenderer({
|
||||
entry,
|
||||
isLoading,
|
||||
onPermissionRespond,
|
||||
}: {
|
||||
entry: ThreadEntry;
|
||||
isLoading: boolean;
|
||||
onPermissionRespond?: (requestId: string, optionId: string | null, optionKind: string | null) => void;
|
||||
}) {
|
||||
switch (entry.type) {
|
||||
case "user_message":
|
||||
return <UserBubble entry={entry} />;
|
||||
case "assistant_message":
|
||||
return <AssistantBubble entry={entry} isStreaming={isLoading} />;
|
||||
case "tool_call":
|
||||
return (
|
||||
<ToolCallGroup
|
||||
entries={[entry as ToolCallEntry]}
|
||||
onPermissionRespond={onPermissionRespond}
|
||||
/>
|
||||
);
|
||||
case "plan":
|
||||
return <PlanDisplay entry={entry as PlanDisplayEntry} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 工具调用分组逻辑
|
||||
// =============================================================================
|
||||
|
||||
type GroupedItem =
|
||||
| { type: "single"; entry: ThreadEntry }
|
||||
| { type: "tool_group"; entries: ToolCallEntry[] };
|
||||
|
||||
function groupToolCalls(entries: ThreadEntry[]): GroupedItem[] {
|
||||
const result: GroupedItem[] = [];
|
||||
let currentToolGroup: ToolCallEntry[] = [];
|
||||
|
||||
const flushToolGroup = () => {
|
||||
if (currentToolGroup.length === 1) {
|
||||
result.push({ type: "single", entry: currentToolGroup[0] });
|
||||
} else if (currentToolGroup.length > 1) {
|
||||
result.push({ type: "tool_group", entries: currentToolGroup });
|
||||
}
|
||||
currentToolGroup = [];
|
||||
};
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.type === "tool_call") {
|
||||
currentToolGroup.push(entry);
|
||||
} else {
|
||||
flushToolGroup();
|
||||
result.push({ type: "single", entry });
|
||||
}
|
||||
}
|
||||
flushToolGroup();
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useMemo, useRef, useEffect, useState } from "react";
|
||||
import { cn } from "../../src/lib/utils";
|
||||
import type { AvailableCommand } from "../../src/acp/types";
|
||||
|
||||
// =============================================================================
|
||||
// Slash command picker — floating above ChatInput
|
||||
// =============================================================================
|
||||
|
||||
interface CommandMenuProps {
|
||||
commands: AvailableCommand[];
|
||||
/** Text after "/" used for filtering */
|
||||
filter: string;
|
||||
onSelect: (command: AvailableCommand) => void;
|
||||
onClose: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefix match — checks if the text starts with the query.
|
||||
*/
|
||||
function prefixMatch(query: string, text: string): boolean {
|
||||
if (!query) return true;
|
||||
return text.toLowerCase().startsWith(query.toLowerCase());
|
||||
}
|
||||
|
||||
export function CommandMenu({
|
||||
commands,
|
||||
filter,
|
||||
onSelect,
|
||||
onClose,
|
||||
className,
|
||||
}: CommandMenuProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
|
||||
// Filter commands by current input
|
||||
const filtered = useMemo(() => {
|
||||
if (!filter) return commands;
|
||||
return commands.filter(
|
||||
(cmd) => prefixMatch(filter, cmd.name),
|
||||
);
|
||||
}, [commands, filter]);
|
||||
|
||||
// Reset active index when filter changes
|
||||
useEffect(() => {
|
||||
setActiveIndex(0);
|
||||
}, [filter]);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [onClose]);
|
||||
|
||||
// Handle keyboard navigation (ArrowUp/ArrowDown/Enter) via document-level listener
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (filtered.length === 0) return;
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((prev) => (prev + 1) % filtered.length);
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((prev) => (prev - 1 + filtered.length) % filtered.length);
|
||||
} else if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
const cmd = filtered[activeIndex];
|
||||
if (cmd) onSelect(cmd);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown, true); // capture phase
|
||||
return () => document.removeEventListener("keydown", handleKeyDown, true);
|
||||
}, [filtered, activeIndex, onSelect]);
|
||||
|
||||
// Scroll active item into view
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const active = container.querySelector("[data-active='true']");
|
||||
active?.scrollIntoView({ block: "nearest" });
|
||||
}, [activeIndex]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"rounded-xl border border-border bg-surface-2 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="max-h-[320px] overflow-y-auto py-1">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="text-xs text-text-muted font-display py-3 text-center">
|
||||
没有匹配的命令
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((cmd, index) => (
|
||||
<button
|
||||
key={cmd.name}
|
||||
type="button"
|
||||
data-active={index === activeIndex}
|
||||
onClick={() => onSelect(cmd)}
|
||||
onMouseEnter={() => setActiveIndex(index)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 px-3 py-2 cursor-pointer rounded-lg mx-1 text-left",
|
||||
"transition-colors",
|
||||
index === activeIndex
|
||||
? "bg-brand/10 text-text-primary"
|
||||
: "text-text-secondary hover:bg-surface-1/50",
|
||||
)}
|
||||
style={{ width: "calc(100% - 8px)" }}
|
||||
>
|
||||
<span className="text-sm font-display font-medium text-brand">
|
||||
/{cmd.name}
|
||||
</span>
|
||||
<span className="text-xs text-text-muted truncate flex-1">
|
||||
{cmd.description}
|
||||
</span>
|
||||
{cmd.input?.hint && (
|
||||
<span className="text-[10px] text-text-muted italic">
|
||||
{cmd.input.hint}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import type { UserMessageEntry, AssistantMessageEntry, UserMessageImage } from "../../src/lib/types";
|
||||
import { cn, esc } from "../../src/lib/utils";
|
||||
import { MessageResponse } from "../ai-elements/message";
|
||||
import { Reasoning, ReasoningTrigger, ReasoningContent } from "../ai-elements/reasoning";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
// 用户消息折叠最大高度(px)
|
||||
const COLLAPSED_MAX_HEIGHT = 200;
|
||||
|
||||
// =============================================================================
|
||||
// 用户消息 — 右对齐,品牌色淡底,可折叠
|
||||
// =============================================================================
|
||||
|
||||
interface UserBubbleProps {
|
||||
entry: UserMessageEntry;
|
||||
}
|
||||
|
||||
export function UserBubble({ entry }: UserBubbleProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [overflowing, setOverflowing] = useState(false);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const checkOverflow = useCallback(() => {
|
||||
const el = contentRef.current;
|
||||
if (!el) return;
|
||||
setOverflowing(el.scrollHeight > COLLAPSED_MAX_HEIGHT + 4);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
checkOverflow();
|
||||
}, [checkOverflow, entry.content]);
|
||||
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<div className="max-w-[85%] sm:max-w-[70%]">
|
||||
{/* 图片附件 */}
|
||||
{entry.images && entry.images.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-2 justify-end">
|
||||
{entry.images.map((img, i) => (
|
||||
<ImageThumbnail key={i} image={img} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* 文本内容 — 品牌色淡底 + 折叠 */}
|
||||
{entry.content && (
|
||||
<div className="relative rounded-2xl rounded-br-md bg-user-bubble border border-user-bubble-border overflow-hidden">
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={cn(
|
||||
"px-5 py-3 text-sm text-white whitespace-pre-wrap font-display leading-relaxed",
|
||||
!expanded && overflowing && `max-h-[${COLLAPSED_MAX_HEIGHT}px]`,
|
||||
)}
|
||||
style={!expanded && overflowing ? { maxHeight: `${COLLAPSED_MAX_HEIGHT}px` } : undefined}
|
||||
>
|
||||
{esc(entry.content)}
|
||||
</div>
|
||||
{/* 折叠渐变遮罩 + 展开按钮 */}
|
||||
{!expanded && overflowing && (
|
||||
<div className="absolute bottom-0 inset-x-0 flex flex-col items-center pt-8 bg-gradient-to-t from-user-bubble via-user-bubble/80 to-transparent">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(true)}
|
||||
className="flex items-center gap-1 px-3 py-1 rounded-full text-xs font-display font-medium text-white/90 hover:bg-white/15 transition-colors"
|
||||
>
|
||||
<span>展开</span>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 助手消息 — 左对齐,无背景卡片,编辑式排版
|
||||
// =============================================================================
|
||||
|
||||
interface AssistantBubbleProps {
|
||||
entry: AssistantMessageEntry;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
export function AssistantBubble({ entry, isStreaming }: AssistantBubbleProps) {
|
||||
return (
|
||||
<div className="flex gap-4 items-start">
|
||||
{/* Orange triangle avatar */}
|
||||
<div className="w-8 h-8 rounded-lg bg-brand/8 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="var(--color-brand)" fillRule="nonzero" />
|
||||
</svg>
|
||||
</div>
|
||||
{/* 内容 — 无卡片背景,直接排版 */}
|
||||
<div className="flex-1 min-w-0 space-y-4">
|
||||
{/* Sender label */}
|
||||
<span className="text-sm font-semibold text-text-primary font-display">Claude</span>
|
||||
{entry.chunks.map((chunk, i) => {
|
||||
if (chunk.type === "thought") {
|
||||
const isLastChunk = i === entry.chunks.length - 1;
|
||||
const isThoughtStreaming = isStreaming && isLastChunk;
|
||||
return (
|
||||
<Reasoning key={i} isStreaming={isThoughtStreaming}>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>
|
||||
<div className="text-sm text-text-secondary leading-relaxed">
|
||||
{chunk.text}
|
||||
</div>
|
||||
</ReasoningContent>
|
||||
</Reasoning>
|
||||
);
|
||||
}
|
||||
// 普通消息块 — 直接输出,无包裹卡片
|
||||
return (
|
||||
<div key={i} className="message-content text-text-primary leading-[1.75]">
|
||||
<MessageResponse>{chunk.text}</MessageResponse>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 图片缩略图 — 点击放大
|
||||
// =============================================================================
|
||||
|
||||
function ImageThumbnail({ image }: { image: UserMessageImage }) {
|
||||
const dataUrl = `data:${image.mimeType};base64,${image.data}`;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg overflow-hidden border border-border hover:border-brand/40 transition-colors cursor-pointer"
|
||||
onClick={() => {
|
||||
const w = window.open("");
|
||||
if (w) {
|
||||
w.document.write(`<img src="${dataUrl}" style="max-width:100%;max-height:100%" />`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={dataUrl}
|
||||
alt="Uploaded image"
|
||||
className="h-20 w-20 object-cover"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { PendingPermission } from "../../src/lib/types";
|
||||
import { cn } from "../../src/lib/utils";
|
||||
import { ShieldAlert, Check, X } from "lucide-react";
|
||||
|
||||
// =============================================================================
|
||||
// 权限请求面板 — 固定在输入框上方(Anthropic warm token style)
|
||||
// =============================================================================
|
||||
|
||||
interface PermissionPanelProps {
|
||||
requests: PendingPermission[];
|
||||
onRespond?: (requestId: string, approved: boolean) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PermissionPanel({ requests, onRespond, className }: PermissionPanelProps) {
|
||||
if (requests.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={cn("w-full max-w-3xl mx-auto px-4", className)}>
|
||||
<div className="space-y-2">
|
||||
{requests.map((req) => (
|
||||
<PermissionCard
|
||||
key={req.requestId}
|
||||
request={req}
|
||||
onRespond={onRespond}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 单个权限卡片 — warm warning tokens + left-border accent
|
||||
// =============================================================================
|
||||
|
||||
interface PermissionCardProps {
|
||||
request: PendingPermission;
|
||||
onRespond?: (requestId: string, approved: boolean) => void;
|
||||
}
|
||||
|
||||
function PermissionCard({ request, onRespond }: PermissionCardProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-xl border border-warning-border/30 bg-warning-bg/50 px-4 py-3">
|
||||
<ShieldAlert className="h-5 w-5 text-warning-text flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-warning-text">
|
||||
{request.toolName}
|
||||
</div>
|
||||
{request.description && (
|
||||
<div className="text-xs text-warning-text/80 mt-0.5 truncate">
|
||||
{request.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRespond?.(request.requestId, true)}
|
||||
className="h-8 px-3 rounded-lg bg-brand text-white text-xs font-medium hover:bg-brand-light transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
允许
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRespond?.(request.requestId, false)}
|
||||
className="h-8 px-3 rounded-lg border border-warning-border/30 text-warning-text text-xs font-medium hover:bg-warning-bg transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
拒绝
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
143
packages/remote-control-server/web/components/chat/PlanView.tsx
Normal file
143
packages/remote-control-server/web/components/chat/PlanView.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { useState } from "react";
|
||||
import type { PlanDisplayEntry } from "../../src/lib/types";
|
||||
import type { PlanEntry, PlanEntryPriority, PlanEntryStatus } from "../../src/acp/types";
|
||||
import { cn } from "../../src/lib/utils";
|
||||
import { CheckCircle2, Loader2, Circle } from "lucide-react";
|
||||
|
||||
// =============================================================================
|
||||
// Plan 展示组件 — 执行计划可视化
|
||||
// =============================================================================
|
||||
|
||||
interface PlanDisplayProps {
|
||||
entry: PlanDisplayEntry;
|
||||
}
|
||||
|
||||
export function PlanDisplay({ entry }: PlanDisplayProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const { entries } = entry;
|
||||
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
const completed = entries.filter((e) => e.status === "completed").length;
|
||||
const total = entries.length;
|
||||
const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="pl-10">
|
||||
<div className="rounded-xl border border-border bg-brand/5 overflow-hidden">
|
||||
{/* Header */}
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-2 px-3 py-2.5 text-sm hover:bg-surface-1/50 transition-colors"
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
className={cn("transition-transform text-text-muted flex-shrink-0", collapsed && "rotate-90")}
|
||||
>
|
||||
<path d="M4 2L8 6L4 10" stroke="currentColor" strokeWidth="1.5" fill="none" />
|
||||
</svg>
|
||||
|
||||
<span className="text-xs font-display font-medium text-text-secondary">
|
||||
执行计划
|
||||
</span>
|
||||
|
||||
<span className="text-[10px] text-text-muted font-mono">
|
||||
{completed}/{total}
|
||||
</span>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="flex-1 h-1 rounded-full bg-surface-1 overflow-hidden ml-1 mr-2">
|
||||
<div
|
||||
className="h-full rounded-full bg-brand/70 transition-all duration-500"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className="text-[10px] text-text-muted font-mono">
|
||||
{percentage}%
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Entry list */}
|
||||
{!collapsed && (
|
||||
<div className={cn(
|
||||
"border-t border-border px-3 py-1.5 space-y-0.5",
|
||||
total > 5 && "max-h-64 overflow-y-auto",
|
||||
)}>
|
||||
{entries.map((planEntry, i) => (
|
||||
<PlanEntryRow key={i} entry={planEntry} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 单条 Plan 条目
|
||||
// =============================================================================
|
||||
|
||||
function PlanEntryRow({ entry }: { entry: PlanEntry }) {
|
||||
return (
|
||||
<div className="flex items-start gap-2 py-1.5 px-1">
|
||||
<span className="flex-shrink-0 mt-0.5">
|
||||
<StatusIcon status={entry.status} />
|
||||
</span>
|
||||
<span className={cn(
|
||||
"text-xs leading-relaxed flex-1",
|
||||
entry.status === "completed" ? "text-text-muted line-through" : "text-text-secondary",
|
||||
entry.status === "in_progress" && "text-text-primary font-medium",
|
||||
)}>
|
||||
{entry.content}
|
||||
</span>
|
||||
<PriorityBadge priority={entry.priority} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 状态图标
|
||||
// =============================================================================
|
||||
|
||||
function StatusIcon({ status }: { status: PlanEntryStatus }) {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return <CheckCircle2 className="h-3.5 w-3.5 text-status-active" />;
|
||||
case "in_progress":
|
||||
return <Loader2 className="h-3.5 w-3.5 text-brand animate-spin" style={{ animationDuration: "2s" }} />;
|
||||
case "pending":
|
||||
return <Circle className="h-3.5 w-3.5 text-text-muted" />;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 优先级标签
|
||||
// =============================================================================
|
||||
|
||||
function PriorityBadge({ priority }: { priority: PlanEntryPriority }) {
|
||||
const styles: Record<PlanEntryPriority, string> = {
|
||||
high: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300",
|
||||
medium: "bg-brand/10 text-brand dark:bg-brand/20",
|
||||
low: "bg-surface-1 text-text-muted",
|
||||
};
|
||||
|
||||
const labels: Record<PlanEntryPriority, string> = {
|
||||
high: "高",
|
||||
medium: "中",
|
||||
low: "低",
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={cn(
|
||||
"text-[9px] font-display rounded-full px-1.5 py-0.5 flex-shrink-0 leading-none",
|
||||
styles[priority],
|
||||
)}>
|
||||
{labels[priority]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { cn } from "../../src/lib/utils";
|
||||
import { Plus, MessageSquare, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import type { SessionListItem } from "../../src/lib/types";
|
||||
|
||||
// =============================================================================
|
||||
// 会话侧边栏 — Anthropic 分段式:今天/昨天/更早 + 橙色活跃态
|
||||
// =============================================================================
|
||||
|
||||
interface SessionSidebarProps {
|
||||
sessions: SessionListItem[];
|
||||
activeId?: string | null;
|
||||
onSelect?: (id: string) => void;
|
||||
onNew?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SessionSidebar({
|
||||
sessions,
|
||||
activeId,
|
||||
onSelect,
|
||||
onNew,
|
||||
className,
|
||||
}: SessionSidebarProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
// 按日期分组
|
||||
const groups = groupByRecency(sessions);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"hidden md:flex flex-col border-r border-border bg-surface-1 transition-all duration-200",
|
||||
collapsed ? "w-12" : "w-64",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between px-3 py-3 border-b border-border">
|
||||
{!collapsed && (
|
||||
<span className="text-xs font-display font-medium text-text-muted uppercase tracking-wider">会话</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
{!collapsed && onNew && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNew}
|
||||
className="h-7 w-7 flex items-center justify-center rounded-lg text-text-muted hover:text-brand hover:bg-brand/10 transition-colors"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="h-7 w-7 flex items-center justify-center rounded-lg text-text-muted hover:text-text-primary hover:bg-surface-2 transition-colors"
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 会话列表 — 分段 */}
|
||||
{!collapsed && (
|
||||
<nav className="flex-1 overflow-y-auto py-2" aria-label="历史会话">
|
||||
{groups.map((group) => (
|
||||
<div key={group.label}>
|
||||
<div className="px-3 py-1.5">
|
||||
<span className="text-[10px] font-display font-medium uppercase tracking-widest text-text-muted">
|
||||
{group.label}
|
||||
</span>
|
||||
</div>
|
||||
{group.sessions.map((session) => (
|
||||
<button
|
||||
key={session.id}
|
||||
type="button"
|
||||
onClick={() => onSelect?.(session.id)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-3 py-2 text-left transition-colors",
|
||||
session.id === activeId
|
||||
? "bg-brand/10 text-text-primary"
|
||||
: "text-text-secondary hover:bg-surface-1/50 hover:text-text-primary",
|
||||
)}
|
||||
title={session.title || session.id}
|
||||
>
|
||||
<MessageSquare className="h-3.5 w-3.5 shrink-0 text-text-muted" />
|
||||
<span className="text-sm font-display truncate">
|
||||
{session.title || session.id.slice(0, 8)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{sessions.length === 0 && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<span className="text-xs text-text-muted font-display">暂无会话</span>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 按日期分组
|
||||
// =============================================================================
|
||||
|
||||
interface SessionGroup {
|
||||
label: string;
|
||||
sessions: SessionListItem[];
|
||||
}
|
||||
|
||||
function groupByRecency(sessions: SessionListItem[]): SessionGroup[] {
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const yesterday = new Date(today.getTime() - 86400000);
|
||||
|
||||
const groups: SessionGroup[] = [
|
||||
{ label: "今天", sessions: [] },
|
||||
{ label: "昨天", sessions: [] },
|
||||
{ label: "更早", sessions: [] },
|
||||
];
|
||||
|
||||
for (const session of sessions) {
|
||||
const date = session.updatedAt ? new Date(session.updatedAt) : new Date(0);
|
||||
if (date >= today) {
|
||||
groups[0].sessions.push(session);
|
||||
} else if (date >= yesterday) {
|
||||
groups[1].sessions.push(session);
|
||||
} else {
|
||||
groups[2].sessions.push(session);
|
||||
}
|
||||
}
|
||||
|
||||
return groups.filter((g) => g.sessions.length > 0);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { useState } from "react";
|
||||
import type { ToolCallEntry, ToolCallData } from "../../src/lib/types";
|
||||
import { cn } from "../../src/lib/utils";
|
||||
import { ToolPermissionButtons } from "../ai-elements/permission-request";
|
||||
|
||||
// =============================================================================
|
||||
// 工具调用折叠组 — Anthropic: subtle card, left-border accent, compact layout
|
||||
// =============================================================================
|
||||
|
||||
interface ToolCallGroupProps {
|
||||
entries: ToolCallEntry[];
|
||||
onPermissionRespond?: (requestId: string, optionId: string | null, optionKind: string | null) => void;
|
||||
}
|
||||
|
||||
export function ToolCallGroup({ entries, onPermissionRespond }: ToolCallGroupProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
// 单个工具调用 — 默认折叠,不展开内容详情
|
||||
if (entries.length === 1) {
|
||||
return (
|
||||
<div className="pl-10">
|
||||
<SingleToolCard
|
||||
tool={entries[0].toolCall}
|
||||
compact
|
||||
onPermissionRespond={onPermissionRespond}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 多个工具调用 — 折叠组
|
||||
const summary = buildSummary(entries);
|
||||
|
||||
return (
|
||||
<div className="pl-10">
|
||||
<div className="rounded-lg border border-border bg-surface-2/50 overflow-hidden">
|
||||
{/* 折叠头 */}
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-text-secondary hover:bg-surface-1/50 transition-colors"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
className={cn("transition-transform text-text-muted", expanded && "rotate-90")}
|
||||
>
|
||||
<path d="M4 2L8 6L4 10" stroke="currentColor" strokeWidth="1.5" fill="none" />
|
||||
</svg>
|
||||
<span className="text-xs text-text-muted font-display">{summary}</span>
|
||||
</button>
|
||||
|
||||
{/* 展开内容 */}
|
||||
{expanded && (
|
||||
<div className="border-t border-border divide-y divide-border">
|
||||
{entries.map((entry, i) => (
|
||||
<SingleToolCard
|
||||
key={entry.toolCall.id || i}
|
||||
tool={entry.toolCall}
|
||||
compact
|
||||
onPermissionRespond={onPermissionRespond}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 单个工具卡片 — compact, left-accent, inline status
|
||||
// =============================================================================
|
||||
|
||||
interface SingleToolCardProps {
|
||||
tool: ToolCallData;
|
||||
compact?: boolean;
|
||||
onPermissionRespond?: (requestId: string, optionId: string | null, optionKind: string | null) => void;
|
||||
}
|
||||
|
||||
function SingleToolCard({ tool, compact, onPermissionRespond }: SingleToolCardProps) {
|
||||
const [expanded, setExpanded] = useState(!compact);
|
||||
|
||||
const statusIcon = (() => {
|
||||
switch (tool.status) {
|
||||
case "running":
|
||||
return <span className="text-status-running text-[10px]">▶</span>;
|
||||
case "complete":
|
||||
return <span className="text-status-active text-[10px]">✓</span>;
|
||||
case "error":
|
||||
return <span className="text-status-error text-[10px]">✕</span>;
|
||||
case "waiting_for_confirmation":
|
||||
return <span className="text-brand text-[10px]">⍻</span>;
|
||||
case "canceled":
|
||||
return <span className="text-text-muted text-[10px]">—</span>;
|
||||
case "rejected":
|
||||
return <span className="text-status-error text-[10px]">✕</span>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
const hasOutput = tool.status !== "running" && tool.status !== "waiting_for_confirmation" && (tool.rawOutput || tool.content);
|
||||
|
||||
return (
|
||||
<div className={cn("px-3 py-2", compact && "py-1.5")}>
|
||||
{/* 标题行 — 单行紧凑 */}
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-1.5 text-left group"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{statusIcon}
|
||||
<span className="text-xs font-display font-medium text-text-secondary group-hover:text-text-primary transition-colors truncate">
|
||||
{tool.title}
|
||||
</span>
|
||||
{tool.status === "running" && (
|
||||
<span className="text-[10px] text-status-running animate-pulse">running</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 权限请求按钮 */}
|
||||
{tool.status === "waiting_for_confirmation" && tool.permissionRequest && (
|
||||
<div className="mt-1.5 ml-4">
|
||||
<ToolPermissionButtons
|
||||
requestId={tool.permissionRequest.requestId}
|
||||
options={tool.permissionRequest.options}
|
||||
onRespond={onPermissionRespond || (() => {})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 展开详情 */}
|
||||
{expanded && (
|
||||
<div className="mt-1.5 ml-4 space-y-1.5">
|
||||
{tool.rawInput && Object.keys(tool.rawInput).length > 0 && (
|
||||
<div>
|
||||
<pre className="text-[11px] bg-surface-1 rounded-md p-2 overflow-x-auto font-mono max-h-36 text-text-secondary">
|
||||
{truncate(JSON.stringify(tool.rawInput, null, 2), 2000)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{hasOutput && (
|
||||
<div>
|
||||
<pre className={cn(
|
||||
"text-[11px] rounded-md p-2 overflow-x-auto font-mono max-h-36",
|
||||
tool.status === "error" ? "bg-status-error/10 text-status-error" : "bg-surface-1 text-text-secondary",
|
||||
)}>
|
||||
{formatOutput(tool)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 工具函数
|
||||
// =============================================================================
|
||||
|
||||
/** 构建统计摘要 */
|
||||
function buildSummary(entries: ToolCallEntry[]): string {
|
||||
const toolCounts = new Map<string, number>();
|
||||
for (const entry of entries) {
|
||||
const name = simplifyToolName(entry.toolCall.title);
|
||||
toolCounts.set(name, (toolCounts.get(name) || 0) + 1);
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
for (const [name, count] of toolCounts) {
|
||||
parts.push(count === 1 ? name : `${count} 次${name}`);
|
||||
}
|
||||
|
||||
if (parts.length === 0) return `${entries.length} 个工具调用`;
|
||||
if (parts.length === 1) return parts[0];
|
||||
return `${entries.length} 个工具: ${parts.join("、")}`;
|
||||
}
|
||||
|
||||
/** 简化工具名称 */
|
||||
function simplifyToolName(title: string): string {
|
||||
const match = title.match(/^(\w+)/);
|
||||
return match ? match[1] : title;
|
||||
}
|
||||
|
||||
/** 格式化工具输出 */
|
||||
function formatOutput(tool: ToolCallData): string {
|
||||
if (tool.content && tool.content.length > 0) {
|
||||
const texts = tool.content
|
||||
.filter((c): c is Extract<typeof c, { type: "content" }> => c.type === "content")
|
||||
.filter((c) => c.content.type === "text" && "text" in c.content)
|
||||
.map((c) => (c.content as { text: string }).text);
|
||||
if (texts.length > 0) return truncate(texts.join("\n"), 2000);
|
||||
}
|
||||
if (tool.rawOutput && Object.keys(tool.rawOutput).length > 0) {
|
||||
return truncate(JSON.stringify(tool.rawOutput, null, 2), 2000);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function truncate(str: string, max: number): string {
|
||||
return str.length > max ? str.slice(0, max) + "..." : str;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { ChatView } from "./ChatView";
|
||||
export { UserBubble, AssistantBubble } from "./MessageBubble";
|
||||
export { ToolCallGroup } from "./ToolCallGroup";
|
||||
export { PlanDisplay } from "./PlanView";
|
||||
export { ChatInput } from "./ChatInput";
|
||||
export { PermissionPanel } from "./PermissionPanel";
|
||||
export { SessionSidebar } from "./SessionSidebar";
|
||||
export { CommandMenu } from "./CommandMenu";
|
||||
6
packages/remote-control-server/web/components/index.ts
Normal file
6
packages/remote-control-server/web/components/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from "./ACPConnect";
|
||||
export * from "./ACPMain";
|
||||
export * from "./ChatInterface";
|
||||
export * from "./ChatMessage";
|
||||
export * from "./ThreadHistory";
|
||||
export * from "./model-selector";
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { Check } from "lucide-react";
|
||||
import {
|
||||
Command,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
} from "../ui/command";
|
||||
import type { ModelInfo } from "../../src/acp/types";
|
||||
import { cn } from "../../src/lib/utils";
|
||||
|
||||
interface ModelSelectorPickerProps {
|
||||
models: ModelInfo[];
|
||||
currentModelId: string | null;
|
||||
onSelect: (model: ModelInfo) => void;
|
||||
/** Whether to show the search input (default: true) */
|
||||
showSearch?: boolean;
|
||||
/** Whether we're on a mobile device (disables auto-selection) */
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fuzzy search implementation for model filtering.
|
||||
* Reference: Zed's fuzzy_search() in model_selector.rs
|
||||
*/
|
||||
function fuzzyMatch(query: string, text: string): boolean {
|
||||
if (!query) return true;
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const lowerText = text.toLowerCase();
|
||||
|
||||
// Simple fuzzy match - check if all query chars appear in order
|
||||
let queryIdx = 0;
|
||||
for (let i = 0; i < lowerText.length && queryIdx < lowerQuery.length; i++) {
|
||||
if (lowerText[i] === lowerQuery[queryIdx]) {
|
||||
queryIdx++;
|
||||
}
|
||||
}
|
||||
return queryIdx === lowerQuery.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Model picker using cmdk Command component.
|
||||
* Reference: Zed's AcpModelPickerDelegate with fuzzy search support.
|
||||
*/
|
||||
export function ModelSelectorPicker({
|
||||
models,
|
||||
currentModelId,
|
||||
onSelect,
|
||||
showSearch = true,
|
||||
isMobile = false,
|
||||
}: ModelSelectorPickerProps) {
|
||||
const [search, setSearch] = useState("");
|
||||
// On mobile, don't auto-select first item (no keyboard navigation needed)
|
||||
// Use a non-existent value to prevent any item from being selected
|
||||
const [selectedValue, setSelectedValue] = useState(isMobile ? "__none__" : undefined);
|
||||
|
||||
// Filter models using fuzzy search
|
||||
const filteredModels = useMemo(() => {
|
||||
if (!search) return models;
|
||||
return models.filter((model) =>
|
||||
fuzzyMatch(search, model.name) ||
|
||||
fuzzyMatch(search, model.modelId)
|
||||
);
|
||||
}, [models, search]);
|
||||
|
||||
return (
|
||||
<Command shouldFilter={false} value={selectedValue} onValueChange={setSelectedValue}>
|
||||
{showSearch && (
|
||||
<CommandInput
|
||||
placeholder="Select a model…"
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
/>
|
||||
)}
|
||||
<CommandList>
|
||||
<CommandEmpty>No models found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{filteredModels.map((model) => (
|
||||
<CommandItem
|
||||
key={model.modelId}
|
||||
value={model.modelId}
|
||||
onSelect={() => onSelect(model)}
|
||||
className="flex items-center justify-between gap-2"
|
||||
>
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<span className="truncate font-medium">{model.name}</span>
|
||||
{model.description && (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{model.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Check
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0",
|
||||
currentModelId === model.modelId ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useState } from "react";
|
||||
import { ChevronDown, ChevronUp, Loader2 } from "lucide-react";
|
||||
import { Button } from "../ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||
import { ModelSelectorPicker } from "./ModelSelectorPicker";
|
||||
import type { ACPClient } from "../../src/acp/client";
|
||||
import type { ModelInfo } from "../../src/acp/types";
|
||||
import { useModels } from "../../src/hooks/useModels";
|
||||
|
||||
interface ModelSelectorPopoverProps {
|
||||
/** ACPClient instance for model state management */
|
||||
client: ACPClient;
|
||||
/** Callback when a model is selected */
|
||||
onModelSelect?: (modelId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Model selector popover component.
|
||||
* Reference: Zed's AcpModelSelectorPopover that shows current model and allows switching.
|
||||
*/
|
||||
export function ModelSelectorPopover({
|
||||
client,
|
||||
onModelSelect,
|
||||
}: ModelSelectorPopoverProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const {
|
||||
supportsModelSelection,
|
||||
availableModels,
|
||||
currentModel,
|
||||
setModel,
|
||||
isLoading,
|
||||
} = useModels(client);
|
||||
|
||||
// Always show the button — disable dropdown when no models available
|
||||
const hasModels = supportsModelSelection && availableModels.length > 0;
|
||||
|
||||
// Check if we're on a mobile device (touch-only)
|
||||
const isMobile = typeof window !== "undefined" &&
|
||||
window.matchMedia("(hover: none) and (pointer: coarse)").matches;
|
||||
|
||||
const handleSelect = async (model: ModelInfo) => {
|
||||
try {
|
||||
await setModel(model.modelId);
|
||||
onModelSelect?.(model.modelId);
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
console.error("[ModelSelector] Failed to set model:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={hasModels ? setOpen : undefined}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1.5 text-muted-foreground hover:text-foreground h-7 px-2"
|
||||
disabled={!hasModels || isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : null}
|
||||
<span className="max-w-32 truncate">
|
||||
{currentModel?.name ?? "Select Model"}
|
||||
</span>
|
||||
{open ? (
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72 p-0" align="end">
|
||||
<ModelSelectorPicker
|
||||
models={availableModels}
|
||||
currentModelId={currentModel?.modelId ?? null}
|
||||
onSelect={handleSelect}
|
||||
showSearch={!isMobile}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export { ModelSelectorPopover } from "./ModelSelectorPopover";
|
||||
export { ModelSelectorPicker } from "./ModelSelectorPicker";
|
||||
|
||||
47
packages/remote-control-server/web/components/ui/badge.tsx
Normal file
47
packages/remote-control-server/web/components/ui/badge.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "../../src/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "../../src/lib/utils"
|
||||
import { Separator } from "./separator"
|
||||
|
||||
const buttonGroupVariants = cva(
|
||||
"flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
horizontal:
|
||||
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
|
||||
vertical:
|
||||
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "horizontal",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function ButtonGroup({
|
||||
className,
|
||||
orientation,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="button-group"
|
||||
data-orientation={orientation}
|
||||
className={cn(buttonGroupVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ButtonGroupText({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "div"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(
|
||||
"bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ButtonGroupSeparator({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="button-group-separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
ButtonGroup,
|
||||
ButtonGroupSeparator,
|
||||
ButtonGroupText,
|
||||
buttonGroupVariants,
|
||||
}
|
||||
|
||||
61
packages/remote-control-server/web/components/ui/button.tsx
Normal file
61
packages/remote-control-server/web/components/ui/button.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "../../src/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
|
||||
93
packages/remote-control-server/web/components/ui/card.tsx
Normal file
93
packages/remote-control-server/web/components/ui/card.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "../../src/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client"
|
||||
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleTrigger
|
||||
data-slot="collapsible-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
|
||||
183
packages/remote-control-server/web/components/ui/command.tsx
Normal file
183
packages/remote-control-server/web/components/ui/command.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import * as React from "react"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "../../src/lib/utils"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "./dialog"
|
||||
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
children,
|
||||
className,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string
|
||||
description?: string
|
||||
className?: string
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent
|
||||
className={cn("overflow-hidden p-0", className)}
|
||||
showCloseButton={showCloseButton}
|
||||
>
|
||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="command-input-wrapper"
|
||||
className="flex h-9 items-center gap-2 border-b px-3"
|
||||
>
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("bg-border -mx-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user