mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-04-14 17:41:32 +00:00
Compare commits
8 Commits
v1.28.1
...
feature/re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d7d12742f | ||
|
|
13e97e2c71 | ||
|
|
1a0f10217d | ||
|
|
f8d1a0dd2c | ||
|
|
78c1d35a5d | ||
|
|
906997391d | ||
|
|
c7a5baf147 | ||
|
|
e2459cb0f8 |
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
@@ -20,14 +20,14 @@ jobs:
|
||||
contents: write
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.RELEASE_PAT }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- name: git config
|
||||
|
||||
11
README.de.md
11
README.de.md
@@ -76,6 +76,8 @@ Der schnellste Einstieg – keine lokale Einrichtung erforderlich. Erhalte eine
|
||||
|
||||
### Self-Hosted (Open Source)
|
||||
|
||||
#### npm
|
||||
|
||||
CloudCLI UI sofort mit **npx** ausprobieren (erfordert **Node.js** v22+):
|
||||
|
||||
```bash
|
||||
@@ -93,6 +95,15 @@ cloudcli
|
||||
|
||||
Die **[Dokumentation →](https://cloudcli.ai/docs)** enthält weitere Konfigurationsoptionen, PM2, Remote-Server-Einrichtung und mehr.
|
||||
|
||||
#### Docker Sandboxes (Experimentell)
|
||||
|
||||
Agents in isolierten Sandboxes mit Hypervisor-Isolation ausführen. Standardmäßig wird Claude Code gestartet. Erfordert die [`sbx` CLI](https://docs.docker.com/ai/sandboxes/get-started/).
|
||||
|
||||
```
|
||||
npx @cloudcli-ai/cloudcli sandbox ~/my-project
|
||||
```
|
||||
|
||||
Unterstützt Claude Code, Codex und Gemini CLI. Weitere Details in der [Sandbox-Dokumentation](docker/).
|
||||
|
||||
---
|
||||
|
||||
|
||||
11
README.ja.md
11
README.ja.md
@@ -72,6 +72,8 @@
|
||||
|
||||
### セルフホスト(オープンソース)
|
||||
|
||||
#### npm
|
||||
|
||||
**npx** で今すぐ CloudCLI UI を試せます(**Node.js** v22+ が必要):
|
||||
|
||||
```bash
|
||||
@@ -89,6 +91,15 @@ cloudcli
|
||||
|
||||
より詳細な設定オプション、PM2、リモートサーバー設定などについては **[ドキュメントはこちら →](https://cloudcli.ai/docs)** を参照してください。
|
||||
|
||||
#### Docker Sandboxes(実験的)
|
||||
|
||||
ハイパーバイザーレベルの分離でエージェントをサンドボックスで実行します。デフォルトでは Claude Code が起動します。[`sbx` CLI](https://docs.docker.com/ai/sandboxes/get-started/) が必要です。
|
||||
|
||||
```
|
||||
npx @cloudcli-ai/cloudcli sandbox ~/my-project
|
||||
```
|
||||
|
||||
Claude Code、Codex、Gemini CLI に対応。詳細は[サンドボックスのドキュメント](docker/)をご覧ください。
|
||||
|
||||
---
|
||||
|
||||
|
||||
14
README.ko.md
14
README.ko.md
@@ -72,6 +72,8 @@
|
||||
|
||||
### 셀프 호스트 (오픈 소스)
|
||||
|
||||
#### npm
|
||||
|
||||
**npx**로 즉시 CloudCLI UI를 실행하세요 (Node.js v22+ 필요):
|
||||
|
||||
```bash
|
||||
@@ -87,7 +89,17 @@ cloudcli
|
||||
|
||||
`http://localhost:3001`을 열면 기존 세션이 자동으로 발견됩니다.
|
||||
|
||||
자세한 구성 옵션, PM2, 원격 서버 설정 등은 **[문서 →](https://cloudcli.ai/docs)**를 참고하세요
|
||||
자세한 구성 옵션, PM2, 원격 서버 설정 등은 **[문서 →](https://cloudcli.ai/docs)**를 참고하세요.
|
||||
|
||||
#### Docker Sandboxes (실험적)
|
||||
|
||||
하이퍼바이저 수준 격리로 에이전트를 샌드박스에서 실행합니다. 기본 에이전트는 Claude Code입니다. [`sbx` CLI](https://docs.docker.com/ai/sandboxes/get-started/)가 필요합니다.
|
||||
|
||||
```
|
||||
npx @cloudcli-ai/cloudcli sandbox ~/my-project
|
||||
```
|
||||
|
||||
Claude Code, Codex, Gemini CLI를 지원합니다. 자세한 내용은 [샌드박스 문서](docker/)를 참고하세요.
|
||||
|
||||
---
|
||||
|
||||
|
||||
48
README.md
48
README.md
@@ -76,6 +76,8 @@ The fastest way to get started — no local setup required. Get a fully managed,
|
||||
|
||||
### Self-Hosted (Open source)
|
||||
|
||||
#### npm
|
||||
|
||||
Try CloudCLI UI instantly with **npx** (requires **Node.js** v22+):
|
||||
|
||||
```
|
||||
@@ -91,33 +93,41 @@ cloudcli
|
||||
|
||||
Open `http://localhost:3001` — all your existing sessions are discovered automatically.
|
||||
|
||||
Visit the **[documentation →](https://cloudcli.ai/docs)** for more full configuration options, PM2, remote server setup and more
|
||||
Visit the **[documentation →](https://cloudcli.ai/docs)** for full configuration options, PM2, remote server setup and more.
|
||||
|
||||
#### Docker Sandboxes (Experimental)
|
||||
|
||||
Run agents in isolated sandboxes with hypervisor-level isolation. Starts Claude Code by default. Requires the [`sbx` CLI](https://docs.docker.com/ai/sandboxes/get-started/).
|
||||
|
||||
```
|
||||
npx @cloudcli-ai/cloudcli sandbox ~/my-project
|
||||
```
|
||||
|
||||
Supports Claude Code, Codex, and Gemini CLI. See the [sandbox docs](docker/) for setup and advanced options.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Which option is right for you?
|
||||
|
||||
CloudCLI UI is the open source UI layer that powers CloudCLI Cloud. You can self-host it on your own machine, or use CloudCLI Cloud which builds on top of it with a full managed cloud environment, team features, and deeper integrations.
|
||||
CloudCLI UI is the open source UI layer that powers CloudCLI Cloud. You can self-host it on your own machine, run it in a Docker sandbox for isolation, or use CloudCLI Cloud for a fully managed environment.
|
||||
|
||||
| | CloudCLI UI (Self-hosted) | CloudCLI Cloud |
|
||||
|---|---|---|
|
||||
| **Best for** | Developers who want a full UI for local agent sessions on their own machine | Teams and developers who want agents running in the cloud, accessible from anywhere |
|
||||
| **How you access it** | Browser via `[yourip]:port` | Browser, any IDE, REST API, n8n |
|
||||
| **Setup** | `npx @cloudcli-ai/cloudcli` | No setup required |
|
||||
| **Machine needs to stay on** | Yes | No |
|
||||
| **Mobile access** | Any browser on your network | Any device, native app coming |
|
||||
| **Sessions available** | All sessions auto-discovered from `~/.claude` | All sessions within your cloud environment |
|
||||
| **Agents supported** | Claude Code, Cursor CLI, Codex, Gemini CLI | Claude Code, Cursor CLI, Codex, Gemini CLI |
|
||||
| **File explorer and Git** | Yes, built into the UI | Yes, built into the UI |
|
||||
| **MCP configuration** | Managed via UI, synced with your local `~/.claude` config | Managed via UI |
|
||||
| **IDE access** | Your local IDE | Any IDE connected to your cloud environment |
|
||||
| **REST API** | Yes | Yes |
|
||||
| **n8n node** | No | Yes |
|
||||
| **Team sharing** | No | Yes |
|
||||
| **Platform cost** | Free, open source | Starts at $7/month |
|
||||
| | Self-Hosted (npm) | Self-Hosted (Docker Sandbox) *(Experimental)* | CloudCLI Cloud |
|
||||
|---|---|---|---|
|
||||
| **Best for** | Local agent sessions on your own machine | Isolated agents with web/mobile IDE | Teams who want agents in the cloud |
|
||||
| **How you access it** | Browser via `[yourip]:port` | Browser via `localhost:port` | Browser, any IDE, REST API, n8n |
|
||||
| **Setup** | `npx @cloudcli-ai/cloudcli` | `npx @cloudcli-ai/cloudcli sandbox ~/project` | No setup required |
|
||||
| **Isolation** | Runs on your host | Hypervisor-level sandbox (microVM) | Full cloud isolation |
|
||||
| **Machine needs to stay on** | Yes | Yes | No |
|
||||
| **Mobile access** | Any browser on your network | Any browser on your network | Any device, native app coming |
|
||||
| **Agents supported** | Claude Code, Cursor CLI, Codex, Gemini CLI | Claude Code, Codex, Gemini CLI | Claude Code, Cursor CLI, Codex, Gemini CLI |
|
||||
| **File explorer and Git** | Yes | Yes | Yes |
|
||||
| **MCP configuration** | Synced with `~/.claude` | Managed via UI | Managed via UI |
|
||||
| **REST API** | Yes | Yes | Yes |
|
||||
| **Team sharing** | No | No | Yes |
|
||||
| **Platform cost** | Free, open source | Free, open source | Starts at $7/month |
|
||||
|
||||
> Both options use your own AI subscriptions (Claude, Cursor, etc.) — CloudCLI provides the environment, not the AI.
|
||||
> All options use your own AI subscriptions (Claude, Cursor, etc.) — CloudCLI provides the environment, not the AI.
|
||||
|
||||
---
|
||||
|
||||
|
||||
13
README.ru.md
13
README.ru.md
@@ -76,6 +76,8 @@
|
||||
|
||||
### Self-Hosted (Open source)
|
||||
|
||||
#### npm
|
||||
|
||||
Попробовать CloudCLI UI можно сразу через **npx** (требуется **Node.js** v22+):
|
||||
|
||||
```bash
|
||||
@@ -91,8 +93,17 @@ cloudcli
|
||||
|
||||
Откройте `http://localhost:3001` — все ваши существующие сессии будут обнаружены автоматически.
|
||||
|
||||
Посетите **[документацию →](https://cloudcli.ai/docs)**, чтобы узнать про дополнительные варианты конфигурации, PM2, настройку удалённого сервера и многое другое
|
||||
Посетите **[документацию →](https://cloudcli.ai/docs)**, чтобы узнать про дополнительные варианты конфигурации, PM2, настройку удалённого сервера и многое другое.
|
||||
|
||||
#### Docker Sandboxes (Экспериментально)
|
||||
|
||||
Запускайте агентов в изолированных песочницах с гипервизорной изоляцией. По умолчанию запускается Claude Code. Требуется [`sbx` CLI](https://docs.docker.com/ai/sandboxes/get-started/).
|
||||
|
||||
```
|
||||
npx @cloudcli-ai/cloudcli sandbox ~/my-project
|
||||
```
|
||||
|
||||
Поддерживаются Claude Code, Codex и Gemini CLI. Подробнее в [документации sandbox](docker/).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -72,6 +72,8 @@
|
||||
|
||||
### 自托管(开源)
|
||||
|
||||
#### npm
|
||||
|
||||
启动 CloudCLI UI,只需一行 `npx`(需要 Node.js v22+):
|
||||
|
||||
```bash
|
||||
@@ -87,7 +89,17 @@ cloudcli
|
||||
|
||||
打开 `http://localhost:3001`,系统会自动发现所有现有会话。
|
||||
|
||||
更多配置选项、PM2、远程服务器设置等,请参阅 **[文档 →](https://cloudcli.ai/docs)**
|
||||
更多配置选项、PM2、远程服务器设置等,请参阅 **[文档 →](https://cloudcli.ai/docs)**。
|
||||
|
||||
#### Docker Sandboxes(实验性)
|
||||
|
||||
在隔离的沙箱中运行代理,具有虚拟机管理程序级别的隔离。默认启动 Claude Code。需要 [`sbx` CLI](https://docs.docker.com/ai/sandboxes/get-started/)。
|
||||
|
||||
```
|
||||
npx @cloudcli-ai/cloudcli sandbox ~/my-project
|
||||
```
|
||||
|
||||
支持 Claude Code、Codex 和 Gemini CLI。详情请参阅 [沙箱文档](docker/)。
|
||||
|
||||
---
|
||||
|
||||
|
||||
173
docker/README.md
173
docker/README.md
@@ -1,89 +1,146 @@
|
||||
# CloudCLI — Docker Sandbox Templates
|
||||
<!-- Docker Hub short description (100 chars max): -->
|
||||
<!-- Sandbox templates for running AI coding agents with a web & mobile IDE (Claude Code, Codex, Gemini) -->
|
||||
|
||||
Run AI coding agents with a full web IDE inside [Docker Sandboxes](https://docs.docker.com/ai/sandboxes/).
|
||||
# Sandboxed coding agents with a web & mobile IDE (CloudCLI)
|
||||
|
||||
Instead of a terminal-only experience, get a browser-based interface with chat, file explorer, git panel, shell, and MCP configuration — all running safely inside an isolated sandbox.
|
||||
[Docker Sandbox](https://docs.docker.com/ai/sandboxes/) templates that add [CloudCLI](https://cloudcli.ai) on top of Claude Code, Codex, and Gemini CLI. You get a full web and mobile IDE accessible from any browser on any device.
|
||||
|
||||
## Available Templates
|
||||
## Get started
|
||||
|
||||
| Template | Base Image | Agent |
|
||||
|----------|-----------|-------|
|
||||
| `cloudcli-ai/sandbox:claude-code` | `docker/sandbox-templates:claude-code` | Claude Code |
|
||||
| `cloudcli-ai/sandbox:codex` | `docker/sandbox-templates:codex` | OpenAI Codex |
|
||||
| `cloudcli-ai/sandbox:gemini` | `docker/sandbox-templates:gemini` | Gemini CLI |
|
||||
### 1. Install the sbx CLI
|
||||
|
||||
## Quick Start
|
||||
Docker Sandboxes run agents in isolated microVMs. Install the `sbx` CLI:
|
||||
|
||||
### 1. Start a sandbox with the template
|
||||
- **macOS**: `brew install docker/tap/sbx`
|
||||
- **Windows**: `winget install -h Docker.sbx`
|
||||
- **Linux**: `sudo apt-get install docker-sbx`
|
||||
|
||||
Full instructions: [docs.docker.com/ai/sandboxes/get-started](https://docs.docker.com/ai/sandboxes/get-started/)
|
||||
|
||||
### 2. Store your API key
|
||||
|
||||
`sbx` manages credentials securely — your API key never enters the sandbox. Store it once:
|
||||
|
||||
```bash
|
||||
sbx run --template docker.io/cloudcli-ai/sandbox:claude-code claude ~/my-project
|
||||
sbx login
|
||||
sbx secret set -g anthropic
|
||||
```
|
||||
|
||||
### 2. Forward the UI port
|
||||
### 3. Launch Claude Code
|
||||
|
||||
```bash
|
||||
sbx ports <sandbox-name> --publish 3001:3001
|
||||
npx @cloudcli-ai/cloudcli sandbox ~/my-project
|
||||
```
|
||||
|
||||
### 3. Open the browser
|
||||
Open **http://localhost:3001**. Set a password on first visit. Start building.
|
||||
|
||||
```
|
||||
http://localhost:3001
|
||||
### Using a different agent
|
||||
|
||||
Store the matching API key and pass `--agent`:
|
||||
|
||||
```bash
|
||||
# OpenAI Codex
|
||||
sbx secret set -g openai
|
||||
npx @cloudcli-ai/cloudcli sandbox ~/my-project --agent codex
|
||||
|
||||
# Gemini CLI
|
||||
sbx secret set -g google
|
||||
npx @cloudcli-ai/cloudcli sandbox ~/my-project --agent gemini
|
||||
```
|
||||
|
||||
On first visit you'll set a password — this protects the UI if the port is ever exposed beyond localhost.
|
||||
### Available templates
|
||||
|
||||
## What You Get
|
||||
| Agent | Template |
|
||||
|-------|----------|
|
||||
| **Claude Code** (default) | `docker.io/cloudcliai/sandbox:claude-code` |
|
||||
| OpenAI Codex | `docker.io/cloudcliai/sandbox:codex` |
|
||||
| Gemini CLI | `docker.io/cloudcliai/sandbox:gemini` |
|
||||
|
||||
- **Chat** — Rich conversation UI with markdown rendering, code blocks, and message history
|
||||
- **Files** — Visual file tree with syntax-highlighted editor
|
||||
- **Git** — Diff viewer, staging, branch switching, and commit — all visual
|
||||
These are used with `--template` when running `sbx` directly (see [Advanced usage](#advanced-usage)).
|
||||
|
||||
## Managing sandboxes
|
||||
|
||||
```bash
|
||||
cloudcli sandbox ls # List all sandboxes
|
||||
cloudcli sandbox stop my-project # Stop (preserves state)
|
||||
cloudcli sandbox start my-project # Restart and re-launch web UI
|
||||
cloudcli sandbox logs my-project # View server logs
|
||||
cloudcli sandbox rm my-project # Remove everything
|
||||
```
|
||||
|
||||
## What you get
|
||||
|
||||
- **Chat** — Markdown rendering, code blocks, message history
|
||||
- **Files** — File tree with syntax-highlighted editor
|
||||
- **Git** — Diff viewer, staging, branch switching, commits
|
||||
- **Shell** — Built-in terminal emulator
|
||||
- **MCP** — Configure Model Context Protocol servers through the UI
|
||||
- **MCP** — Configure Model Context Protocol servers visually
|
||||
- **Mobile** — Works on tablet and phone browsers
|
||||
|
||||
## Building Locally
|
||||
|
||||
All Dockerfiles share scripts from `shared/`. Build with the `docker/` directory as context:
|
||||
|
||||
```bash
|
||||
# Claude Code variant
|
||||
docker build -f docker/claude-code/Dockerfile -t cloudcli-sandbox:claude-code docker/
|
||||
|
||||
# Codex variant
|
||||
docker build -f docker/codex/Dockerfile -t cloudcli-sandbox:codex docker/
|
||||
|
||||
# Gemini variant
|
||||
docker build -f docker/gemini/Dockerfile -t cloudcli-sandbox:gemini docker/
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
Each template extends Docker's official sandbox base image and adds:
|
||||
|
||||
1. **Node.js 22** — Runtime for CloudCLI
|
||||
2. **CloudCLI** — Installed globally via `npm install -g @cloudcli-ai/cloudcli`
|
||||
3. **Auto-start** — The UI server starts in the background when the sandbox shell opens (port 3001)
|
||||
|
||||
The agent (Claude Code, Codex, or Gemini) comes from the base image. CloudCLI connects to it and provides the web interface on top.
|
||||
Your project directory is mounted bidirectionally — edits propagate in real time, both ways.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Environment Variable | Default | Description |
|
||||
|---------------------|---------|-------------|
|
||||
| `SERVER_PORT` | `3001` | Port for the web UI |
|
||||
| `HOST` | `0.0.0.0` | Bind address |
|
||||
| `DATABASE_PATH` | `~/.cloudcli/auth.db` | SQLite database location |
|
||||
|
||||
## Network Policies
|
||||
|
||||
If your sandbox uses restricted network policies, allow the UI port:
|
||||
Set variables at creation time with `--env`:
|
||||
|
||||
```bash
|
||||
sbx policy allow network "localhost:3001"
|
||||
npx @cloudcli-ai/cloudcli sandbox ~/my-project --env SERVER_PORT=8080
|
||||
```
|
||||
|
||||
Or inside a running sandbox:
|
||||
|
||||
```bash
|
||||
sbx exec my-project bash -c 'echo "export SERVER_PORT=8080" >> /etc/sandbox-persistent.sh'
|
||||
sbx exec my-project bash -c 'pkill -f "server/index.js"; . ~/.cloudcli-start.sh'
|
||||
```
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `SERVER_PORT` | `3001` | Web UI port |
|
||||
| `HOST` | `0.0.0.0` | Bind address (must be `0.0.0.0` for `sbx ports`) |
|
||||
| `DATABASE_PATH` | `~/.cloudcli/auth.db` | SQLite database location |
|
||||
|
||||
## Advanced usage
|
||||
|
||||
For branch mode, multiple workspaces, memory limits, or the terminal agent experience, use `sbx` with the template:
|
||||
|
||||
```bash
|
||||
# Terminal agent + web UI
|
||||
sbx run --template docker.io/cloudcliai/sandbox:claude-code claude ~/my-project --name my-project
|
||||
sbx ports my-project --publish 3001:3001
|
||||
|
||||
# Branch mode (Git worktree isolation)
|
||||
sbx run --template docker.io/cloudcliai/sandbox:claude-code claude ~/my-project --branch my-feature
|
||||
|
||||
# Multiple workspaces
|
||||
sbx run --template docker.io/cloudcliai/sandbox:claude-code claude ~/project ~/shared-libs:ro
|
||||
|
||||
# Pass a prompt directly
|
||||
sbx run --template docker.io/cloudcliai/sandbox:claude-code claude ~/my-project -- "Fix the auth bug"
|
||||
```
|
||||
|
||||
CloudCLI auto-starts via `.bashrc` when using `sbx run`.
|
||||
|
||||
Full options in the [Docker Sandboxes usage guide](https://docs.docker.com/ai/sandboxes/usage/).
|
||||
|
||||
## Network policies
|
||||
|
||||
Sandboxes restrict outbound access by default. To reach host services from inside the sandbox:
|
||||
|
||||
```bash
|
||||
sbx policy allow network localhost:11434
|
||||
# Inside the sandbox: curl http://host.docker.internal:11434
|
||||
```
|
||||
|
||||
The web UI itself doesn't need a policy — access it via `sbx ports`.
|
||||
|
||||
## Links
|
||||
|
||||
- [CloudCLI Cloud](https://cloudcli.ai) — fully managed, no setup required
|
||||
- [Documentation](https://cloudcli.ai/docs) — full configuration guide
|
||||
- [Discord](https://discord.gg/buxwujPNRE) — community support
|
||||
- [GitHub](https://github.com/siteboon/claudecodeui) — source code and issues
|
||||
|
||||
## License
|
||||
|
||||
These templates are free and open-source under the same license as CloudCLI (AGPL-3.0-or-later).
|
||||
AGPL-3.0-or-later
|
||||
|
||||
@@ -5,7 +5,7 @@ COPY shared/install-cloudcli.sh /tmp/install-cloudcli.sh
|
||||
RUN chmod +x /tmp/install-cloudcli.sh && /tmp/install-cloudcli.sh
|
||||
|
||||
USER agent
|
||||
RUN npm install -g @cloudcli-ai/cloudcli
|
||||
RUN npm install -g @cloudcli-ai/cloudcli && cloudcli --version
|
||||
|
||||
COPY shared/start-cloudcli.sh /home/agent/.cloudcli-start.sh
|
||||
COPY --chown=agent:agent shared/start-cloudcli.sh /home/agent/.cloudcli-start.sh
|
||||
RUN echo '. ~/.cloudcli-start.sh' >> /home/agent/.bashrc
|
||||
|
||||
@@ -5,7 +5,7 @@ COPY shared/install-cloudcli.sh /tmp/install-cloudcli.sh
|
||||
RUN chmod +x /tmp/install-cloudcli.sh && /tmp/install-cloudcli.sh
|
||||
|
||||
USER agent
|
||||
RUN npm install -g @cloudcli-ai/cloudcli
|
||||
RUN npm install -g @cloudcli-ai/cloudcli && cloudcli --version
|
||||
|
||||
COPY shared/start-cloudcli.sh /home/agent/.cloudcli-start.sh
|
||||
COPY --chown=agent:agent shared/start-cloudcli.sh /home/agent/.cloudcli-start.sh
|
||||
RUN echo '. ~/.cloudcli-start.sh' >> /home/agent/.bashrc
|
||||
|
||||
@@ -5,7 +5,7 @@ COPY shared/install-cloudcli.sh /tmp/install-cloudcli.sh
|
||||
RUN chmod +x /tmp/install-cloudcli.sh && /tmp/install-cloudcli.sh
|
||||
|
||||
USER agent
|
||||
RUN npm install -g @cloudcli-ai/cloudcli
|
||||
RUN npm install -g @cloudcli-ai/cloudcli && cloudcli --version
|
||||
|
||||
COPY shared/start-cloudcli.sh /home/agent/.cloudcli-start.sh
|
||||
COPY --chown=agent:agent shared/start-cloudcli.sh /home/agent/.cloudcli-start.sh
|
||||
RUN echo '. ~/.cloudcli-start.sh' >> /home/agent/.bashrc
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Install Node.js 22 LTS
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
|
||||
|
||||
# Install Node.js + build tools needed for native modules (node-pty, better-sqlite3, bcrypt)
|
||||
# Node.js + build tools for native modules + common dev tools
|
||||
apt-get install -y --no-install-recommends \
|
||||
nodejs build-essential python3 python3-setuptools \
|
||||
# Install build tools needed for native modules (node-pty, better-sqlite3, bcrypt)
|
||||
# Node.js is already provided by the sandbox base image
|
||||
apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential python3 python3-setuptools \
|
||||
jq ripgrep sqlite3 zip unzip tree vim-tiny
|
||||
|
||||
# Clean up apt cache to reduce image size
|
||||
|
||||
@@ -4,19 +4,14 @@
|
||||
# This script is sourced from ~/.bashrc on sandbox shell open.
|
||||
|
||||
if ! pgrep -f "server/index.js" > /dev/null 2>&1; then
|
||||
# Start the pre-installed version immediately
|
||||
nohup cloudcli start --port 3001 > /tmp/cloudcli-ui.log 2>&1 &
|
||||
disown
|
||||
|
||||
# Check for updates in the background (non-blocking)
|
||||
nohup npm update -g @cloudcli-ai/cloudcli > /tmp/cloudcli-update.log 2>&1 &
|
||||
disown
|
||||
|
||||
echo ""
|
||||
echo " CloudCLI is starting on port 3001..."
|
||||
echo ""
|
||||
echo " To access the web UI, forward the port:"
|
||||
echo " sbx ports \$(hostname) --publish 3001:3001"
|
||||
echo " Forward the port from another terminal:"
|
||||
echo " sbx ports <sandbox-name> --publish 3001:3001"
|
||||
echo ""
|
||||
echo " Then open: http://localhost:3001"
|
||||
echo ""
|
||||
|
||||
@@ -35,7 +35,8 @@
|
||||
"release": "./release.sh",
|
||||
"prepublishOnly": "npm run build",
|
||||
"postinstall": "node scripts/fix-node-pty.js",
|
||||
"prepare": "husky"
|
||||
"prepare": "husky",
|
||||
"update:platform": "./update-platform.sh"
|
||||
},
|
||||
"keywords": [
|
||||
"claude code",
|
||||
|
||||
365
server/cli.js
365
server/cli.js
@@ -7,6 +7,7 @@
|
||||
* Commands:
|
||||
* (no args) - Start the server (default)
|
||||
* start - Start the server
|
||||
* sandbox - Manage Docker sandbox environments
|
||||
* status - Show configuration and data locations
|
||||
* help - Show help information
|
||||
* version - Show version information
|
||||
@@ -150,6 +151,7 @@ Usage:
|
||||
|
||||
Commands:
|
||||
start Start the CloudCLI server (default)
|
||||
sandbox Manage Docker sandbox environments
|
||||
status Show configuration and data locations
|
||||
update Update to the latest version
|
||||
help Show this help information
|
||||
@@ -164,8 +166,7 @@ Options:
|
||||
Examples:
|
||||
$ cloudcli # Start with defaults
|
||||
$ cloudcli --port 8080 # Start on port 8080
|
||||
$ cloudcli -p 3000 # Short form for port
|
||||
$ cloudcli start --port 4000 # Explicit start command
|
||||
$ cloudcli sandbox ~/my-project # Run in a Docker sandbox
|
||||
$ cloudcli status # Show configuration
|
||||
|
||||
Environment Variables:
|
||||
@@ -244,6 +245,357 @@ async function updatePackage() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sandbox command ─────────────────────────────────────────
|
||||
|
||||
const SANDBOX_TEMPLATES = {
|
||||
claude: 'docker.io/cloudcliai/sandbox:claude-code',
|
||||
codex: 'docker.io/cloudcliai/sandbox:codex',
|
||||
gemini: 'docker.io/cloudcliai/sandbox:gemini',
|
||||
};
|
||||
|
||||
const SANDBOX_SECRETS = {
|
||||
claude: 'anthropic',
|
||||
codex: 'openai',
|
||||
gemini: 'google',
|
||||
};
|
||||
|
||||
function parseSandboxArgs(args) {
|
||||
const result = {
|
||||
subcommand: null,
|
||||
workspace: null,
|
||||
agent: 'claude',
|
||||
name: null,
|
||||
port: 3001,
|
||||
template: null,
|
||||
env: [],
|
||||
};
|
||||
|
||||
const subcommands = ['ls', 'stop', 'start', 'rm', 'logs', 'help'];
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
|
||||
if (i === 0 && subcommands.includes(arg)) {
|
||||
result.subcommand = arg;
|
||||
} else if (arg === '--agent' || arg === '-a') {
|
||||
result.agent = args[++i];
|
||||
} else if (arg === '--name' || arg === '-n') {
|
||||
result.name = args[++i];
|
||||
} else if (arg === '--port') {
|
||||
result.port = parseInt(args[++i], 10);
|
||||
} else if (arg === '--template' || arg === '-t') {
|
||||
result.template = args[++i];
|
||||
} else if (arg === '--env' || arg === '-e') {
|
||||
result.env.push(args[++i]);
|
||||
} else if (!arg.startsWith('-')) {
|
||||
if (!result.subcommand) {
|
||||
result.workspace = arg;
|
||||
} else {
|
||||
result.name = arg; // for stop/start/rm/logs <name>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default subcommand based on what we got
|
||||
if (!result.subcommand) {
|
||||
result.subcommand = 'create';
|
||||
}
|
||||
|
||||
// Derive name from workspace path if not set
|
||||
if (!result.name && result.workspace) {
|
||||
result.name = path.basename(path.resolve(result.workspace.replace(/^~/, os.homedir())));
|
||||
}
|
||||
|
||||
// Default template from agent
|
||||
if (!result.template) {
|
||||
result.template = SANDBOX_TEMPLATES[result.agent] || SANDBOX_TEMPLATES.claude;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function showSandboxHelp() {
|
||||
console.log(`
|
||||
${c.bright('CloudCLI Sandbox')} — Run CloudCLI inside Docker Sandboxes
|
||||
|
||||
Usage:
|
||||
cloudcli sandbox <workspace> Create and start a sandbox
|
||||
cloudcli sandbox <subcommand> [name] Manage sandboxes
|
||||
|
||||
Subcommands:
|
||||
${c.bright('(default)')} Create a sandbox and start the web UI
|
||||
${c.bright('ls')} List all sandboxes
|
||||
${c.bright('start')} Restart a stopped sandbox and re-launch the web UI
|
||||
${c.bright('stop')} Stop a sandbox (preserves state)
|
||||
${c.bright('rm')} Remove a sandbox
|
||||
${c.bright('logs')} Show CloudCLI server logs
|
||||
${c.bright('help')} Show this help
|
||||
|
||||
Options:
|
||||
-a, --agent <agent> Agent to use: claude, codex, gemini (default: claude)
|
||||
-n, --name <name> Sandbox name (default: derived from workspace folder)
|
||||
-t, --template <image> Custom template image
|
||||
-e, --env <KEY=VALUE> Set environment variable (repeatable)
|
||||
--port <port> Host port for the web UI (default: 3001)
|
||||
|
||||
Examples:
|
||||
$ cloudcli sandbox ~/my-project
|
||||
$ cloudcli sandbox ~/my-project --agent codex --port 8080
|
||||
$ cloudcli sandbox ~/my-project --env SERVER_PORT=8080 --env HOST=0.0.0.0
|
||||
$ cloudcli sandbox ls
|
||||
$ cloudcli sandbox stop my-project
|
||||
$ cloudcli sandbox start my-project
|
||||
$ cloudcli sandbox rm my-project
|
||||
|
||||
Prerequisites:
|
||||
1. Install sbx CLI: https://docs.docker.com/ai/sandboxes/get-started/
|
||||
2. Authenticate and store your API key:
|
||||
sbx login
|
||||
sbx secret set -g anthropic # for Claude
|
||||
sbx secret set -g openai # for Codex
|
||||
sbx secret set -g google # for Gemini
|
||||
|
||||
Advanced usage:
|
||||
For branch mode, multiple workspaces, memory limits, network policies,
|
||||
or passing prompts to the agent, use sbx directly with the template:
|
||||
|
||||
sbx run --template docker.io/cloudcliai/sandbox:claude-code claude ~/my-project --branch my-feature
|
||||
sbx run --template docker.io/cloudcliai/sandbox:claude-code claude ~/project ~/libs:ro --memory 8g
|
||||
|
||||
Full Docker Sandboxes docs: https://docs.docker.com/ai/sandboxes/usage/
|
||||
`);
|
||||
}
|
||||
|
||||
async function sandboxCommand(args) {
|
||||
const { execFileSync } = await import('child_process');
|
||||
|
||||
// Safe execution — uses execFileSync (no shell) to prevent injection
|
||||
const sbx = (subcmd, opts = {}) => {
|
||||
const result = execFileSync('sbx', subcmd, {
|
||||
encoding: 'utf8',
|
||||
stdio: opts.inherit ? 'inherit' : 'pipe',
|
||||
});
|
||||
return result || '';
|
||||
};
|
||||
|
||||
const opts = parseSandboxArgs(args);
|
||||
|
||||
if (opts.subcommand === 'help') {
|
||||
showSandboxHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate name (alphanumeric, hyphens, underscores only)
|
||||
if (opts.name && !/^[\w-]+$/.test(opts.name)) {
|
||||
console.error(`\n${c.error('❌')} Invalid sandbox name: ${opts.name}`);
|
||||
console.log(` Names may only contain letters, numbers, hyphens, and underscores.\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Check sbx is installed
|
||||
try {
|
||||
sbx(['version']);
|
||||
} catch {
|
||||
console.error(`\n${c.error('❌')} ${c.bright('sbx')} CLI not found.\n`);
|
||||
console.log(` Install it from: ${c.info('https://docs.docker.com/ai/sandboxes/get-started/')}`);
|
||||
console.log(` Then run: ${c.bright('sbx login')}`);
|
||||
console.log(` And store your API key: ${c.bright('sbx secret set -g anthropic')}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
switch (opts.subcommand) {
|
||||
|
||||
case 'ls':
|
||||
sbx(['ls'], { inherit: true });
|
||||
break;
|
||||
|
||||
case 'stop':
|
||||
if (!opts.name) {
|
||||
console.error(`\n${c.error('❌')} Sandbox name required: cloudcli sandbox stop <name>\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
sbx(['stop', opts.name], { inherit: true });
|
||||
break;
|
||||
|
||||
case 'rm':
|
||||
if (!opts.name) {
|
||||
console.error(`\n${c.error('❌')} Sandbox name required: cloudcli sandbox rm <name>\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
sbx(['rm', opts.name], { inherit: true });
|
||||
break;
|
||||
|
||||
case 'logs':
|
||||
if (!opts.name) {
|
||||
console.error(`\n${c.error('❌')} Sandbox name required: cloudcli sandbox logs <name>\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
sbx(['exec', opts.name, 'bash', '-c', 'cat /tmp/cloudcli-ui.log'], { inherit: true });
|
||||
} catch (e) {
|
||||
console.error(`\n${c.error('❌')} Could not read logs: ${e.message || 'Is the sandbox running?'}\n`);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'start': {
|
||||
if (!opts.name) {
|
||||
console.error(`\n${c.error('❌')} Sandbox name required: cloudcli sandbox start <name>\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`\n${c.info('▶')} Starting sandbox ${c.bright(opts.name)}...`);
|
||||
try {
|
||||
sbx(['start', opts.name], { inherit: true });
|
||||
} catch { /* might already be running */ }
|
||||
|
||||
console.log(`${c.info('▶')} Launching CloudCLI web server...`);
|
||||
sbx(['exec', '-d', opts.name, 'bash', '-c', '. ~/.cloudcli-start.sh']);
|
||||
|
||||
console.log(`${c.info('▶')} Forwarding port ${opts.port} → 3001...`);
|
||||
try {
|
||||
sbx(['ports', opts.name, '--publish', `${opts.port}:3001`]);
|
||||
} catch (e) {
|
||||
const msg = e.stdout || e.stderr || e.message || '';
|
||||
if (msg.includes('address already in use')) {
|
||||
const altPort = opts.port + 1;
|
||||
console.log(`${c.warn('⚠')} Port ${opts.port} in use, trying ${altPort}...`);
|
||||
try {
|
||||
sbx(['ports', opts.name, '--publish', `${altPort}:3001`]);
|
||||
opts.port = altPort;
|
||||
} catch {
|
||||
console.error(`${c.error('❌')} Ports ${opts.port} and ${altPort} both in use. Use --port to specify a free port.`);
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n${c.ok('✔')} ${c.bright('CloudCLI is ready!')}`);
|
||||
console.log(` ${c.info('→')} ${c.bright(`http://localhost:${opts.port}`)}\n`);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'create': {
|
||||
if (!opts.workspace) {
|
||||
console.error(`\n${c.error('❌')} Workspace path required: cloudcli sandbox <path>\n`);
|
||||
console.log(` Example: ${c.bright('cloudcli sandbox ~/my-project')}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const workspace = opts.workspace.startsWith('~')
|
||||
? opts.workspace.replace(/^~/, os.homedir())
|
||||
: path.resolve(opts.workspace);
|
||||
|
||||
if (!fs.existsSync(workspace)) {
|
||||
console.error(`\n${c.error('❌')} Workspace path not found: ${c.dim(workspace)}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const secret = SANDBOX_SECRETS[opts.agent] || 'anthropic';
|
||||
|
||||
// Check if the required secret is stored
|
||||
try {
|
||||
const secretList = sbx(['secret', 'ls']);
|
||||
if (!secretList.includes(secret)) {
|
||||
console.error(`\n${c.error('❌')} No ${c.bright(secret)} API key found.\n`);
|
||||
console.log(` Run: ${c.bright(`sbx secret set -g ${secret}`)}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch { /* sbx secret ls not available, skip check */ }
|
||||
|
||||
console.log(`\n${c.bright('CloudCLI Sandbox')}`);
|
||||
console.log(c.dim('─'.repeat(50)));
|
||||
console.log(` Agent: ${c.info(opts.agent)} ${c.dim(`(${secret} credentials)`)}`);
|
||||
console.log(` Workspace: ${c.dim(workspace)}`);
|
||||
console.log(` Name: ${c.dim(opts.name)}`);
|
||||
console.log(` Template: ${c.dim(opts.template)}`);
|
||||
console.log(` Port: ${c.dim(String(opts.port))}`);
|
||||
if (opts.env.length > 0) {
|
||||
console.log(` Env: ${c.dim(opts.env.join(', '))}`);
|
||||
}
|
||||
console.log(c.dim('─'.repeat(50)));
|
||||
|
||||
// Step 1: Create sandbox
|
||||
console.log(`\n${c.info('▶')} Creating sandbox ${c.bright(opts.name)}...`);
|
||||
try {
|
||||
sbx(
|
||||
['create', '--template', opts.template, '--name', opts.name, opts.agent, workspace],
|
||||
{ inherit: true }
|
||||
);
|
||||
} catch (e) {
|
||||
const msg = e.stdout || e.stderr || e.message || '';
|
||||
if (msg.includes('already exists')) {
|
||||
console.log(`${c.warn('⚠')} Sandbox ${c.bright(opts.name)} already exists. Starting it instead...\n`);
|
||||
try { sbx(['start', opts.name]); } catch { /* may already be running */ }
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Inject environment variables
|
||||
if (opts.env.length > 0) {
|
||||
console.log(`${c.info('▶')} Setting environment variables...`);
|
||||
const exports = opts.env
|
||||
.filter(e => /^\w+=.+$/.test(e))
|
||||
.map(e => `export ${e}`)
|
||||
.join('\n');
|
||||
if (exports) {
|
||||
sbx(['exec', opts.name, 'bash', '-c', `echo '${exports}' >> /etc/sandbox-persistent.sh`]);
|
||||
}
|
||||
const invalid = opts.env.filter(e => !/^\w+=.+$/.test(e));
|
||||
if (invalid.length > 0) {
|
||||
console.log(`${c.warn('⚠')} Skipped invalid env vars: ${invalid.join(', ')} (expected KEY=VALUE)`);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Start CloudCLI
|
||||
console.log(`${c.info('▶')} Launching CloudCLI web server...`);
|
||||
try {
|
||||
sbx(['exec', '-d', opts.name, 'bash', '-c', '. ~/.cloudcli-start.sh']);
|
||||
} catch (e) {
|
||||
console.error(`${c.error('❌')} Failed to start CloudCLI: ${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Step 4: Forward port
|
||||
console.log(`${c.info('▶')} Forwarding port ${opts.port} → 3001...`);
|
||||
try {
|
||||
sbx(['ports', opts.name, '--publish', `${opts.port}:3001`]);
|
||||
} catch (e) {
|
||||
const msg = e.stdout || e.stderr || e.message || '';
|
||||
if (msg.includes('address already in use')) {
|
||||
const altPort = opts.port + 1;
|
||||
console.log(`${c.warn('⚠')} Port ${opts.port} in use, trying ${altPort}...`);
|
||||
try {
|
||||
sbx(['ports', opts.name, '--publish', `${altPort}:3001`]);
|
||||
opts.port = altPort;
|
||||
} catch {
|
||||
console.error(`${c.error('❌')} Ports ${opts.port} and ${altPort} both in use. Use --port to specify a free port.`);
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Done
|
||||
console.log(`\n${c.ok('✔')} ${c.bright('CloudCLI is ready!')}`);
|
||||
console.log(` ${c.info('→')} Open ${c.bright(`http://localhost:${opts.port}`)}`);
|
||||
console.log(`\n${c.dim(' Manage with:')}`);
|
||||
console.log(` ${c.dim('$')} cloudcli sandbox ls`);
|
||||
console.log(` ${c.dim('$')} cloudcli sandbox stop ${opts.name}`);
|
||||
console.log(` ${c.dim('$')} cloudcli sandbox start ${opts.name}`);
|
||||
console.log(` ${c.dim('$')} cloudcli sandbox rm ${opts.name}\n`);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
showSandboxHelp();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Server ──────────────────────────────────────────────────
|
||||
|
||||
// Start the server
|
||||
async function startServer() {
|
||||
// Check for updates silently on startup
|
||||
@@ -274,6 +626,10 @@ function parseArgs(args) {
|
||||
parsed.command = 'version';
|
||||
} else if (!arg.startsWith('-')) {
|
||||
parsed.command = arg;
|
||||
if (arg === 'sandbox') {
|
||||
parsed.remainingArgs = args.slice(i + 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,7 +639,7 @@ function parseArgs(args) {
|
||||
// Main CLI handler
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const { command, options } = parseArgs(args);
|
||||
const { command, options, remainingArgs } = parseArgs(args);
|
||||
|
||||
// Apply CLI options to environment variables
|
||||
if (options.serverPort) {
|
||||
@@ -299,6 +655,9 @@ async function main() {
|
||||
case 'start':
|
||||
await startServer();
|
||||
break;
|
||||
case 'sandbox':
|
||||
await sandboxCommand(remainingArgs || []);
|
||||
break;
|
||||
case 'status':
|
||||
case 'info':
|
||||
showStatus();
|
||||
|
||||
@@ -435,13 +435,20 @@ app.post('/api/system/update', authenticateToken, async (req, res) => {
|
||||
|
||||
console.log('Starting system update from directory:', projectRoot);
|
||||
|
||||
// Run the update command based on install mode
|
||||
const updateCommand = installMode === 'git'
|
||||
? 'git checkout main && git pull && npm install'
|
||||
: 'npm install -g @cloudcli-ai/cloudcli@latest';
|
||||
// Platform deployments use their own update workflow from the project root.
|
||||
const updateCommand = IS_PLATFORM
|
||||
// In platform, husky and dev dependencies are not needed
|
||||
? 'npm run update:platform'
|
||||
: installMode === 'git'
|
||||
? 'git checkout main && git pull && npm install'
|
||||
: 'npm install -g @cloudcli-ai/cloudcli@latest';
|
||||
|
||||
const updateCwd = IS_PLATFORM || installMode === 'git'
|
||||
? projectRoot
|
||||
: os.homedir();
|
||||
|
||||
const child = spawn('sh', ['-c', updateCommand], {
|
||||
cwd: installMode === 'git' ? projectRoot : os.homedir(),
|
||||
cwd: updateCwd,
|
||||
env: process.env
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useState, useRef, useEffect, useCallback, type CSSProperties } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Brain, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { thinkingModes } from '../../constants/thinkingModes';
|
||||
@@ -12,6 +13,11 @@ type ThinkingModeSelectorProps = {
|
||||
|
||||
function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className = '' }: ThinkingModeSelectorProps) {
|
||||
const { t } = useTranslation('chat');
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [dropdownStyle, setDropdownStyle] = useState<CSSProperties | null>(null);
|
||||
|
||||
// Mapping from mode ID to translation key
|
||||
const modeKeyMap: Record<string, string> = {
|
||||
@@ -29,50 +35,143 @@ function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className =
|
||||
};
|
||||
});
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const closeDropdown = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
onClose?.();
|
||||
}, [onClose]);
|
||||
|
||||
const updateDropdownPosition = useCallback(() => {
|
||||
const trigger = triggerRef.current;
|
||||
const dropdown = dropdownRef.current;
|
||||
if (!trigger || !dropdown || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const triggerRect = trigger.getBoundingClientRect();
|
||||
const viewportPadding = window.innerWidth < 640 ? 12 : 16;
|
||||
const spacing = 8;
|
||||
const width = Math.min(window.innerWidth - viewportPadding * 2, window.innerWidth < 640 ? 320 : 256);
|
||||
let left = triggerRect.left + triggerRect.width / 2 - width / 2;
|
||||
left = Math.max(viewportPadding, Math.min(left, window.innerWidth - width - viewportPadding));
|
||||
|
||||
const measuredHeight = dropdown.offsetHeight || 0;
|
||||
const spaceBelow = window.innerHeight - triggerRect.bottom - spacing - viewportPadding;
|
||||
const spaceAbove = triggerRect.top - spacing - viewportPadding;
|
||||
const openBelow = spaceBelow >= Math.min(measuredHeight || 320, 320) || spaceBelow >= spaceAbove;
|
||||
const availableHeight = Math.min(
|
||||
window.innerHeight - viewportPadding * 2,
|
||||
Math.max(180, openBelow ? spaceBelow : spaceAbove),
|
||||
);
|
||||
const panelHeight = Math.min(measuredHeight || availableHeight, availableHeight);
|
||||
const top = openBelow
|
||||
? Math.min(triggerRect.bottom + spacing, window.innerHeight - viewportPadding - panelHeight)
|
||||
: Math.max(viewportPadding, triggerRect.top - spacing - panelHeight);
|
||||
|
||||
setDropdownStyle({
|
||||
position: 'fixed',
|
||||
top,
|
||||
left,
|
||||
width,
|
||||
maxHeight: availableHeight,
|
||||
zIndex: 80,
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
if (onClose) onClose();
|
||||
if (!isOpen) {
|
||||
setDropdownStyle(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const rafId = window.requestAnimationFrame(updateDropdownPosition);
|
||||
const handleViewportChange = () => updateDropdownPosition();
|
||||
|
||||
window.addEventListener('resize', handleViewportChange);
|
||||
window.addEventListener('scroll', handleViewportChange, true);
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
window.removeEventListener('resize', handleViewportChange);
|
||||
window.removeEventListener('scroll', handleViewportChange, true);
|
||||
};
|
||||
}, [isOpen, updateDropdownPosition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (containerRef.current?.contains(target) || dropdownRef.current?.contains(target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
closeDropdown();
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
closeDropdown();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [onClose]);
|
||||
document.addEventListener('pointerdown', handlePointerDown, true);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', handlePointerDown, true);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isOpen, closeDropdown]);
|
||||
|
||||
const currentMode = translatedModes.find(mode => mode.id === selectedMode) || translatedModes[0];
|
||||
const IconComponent = currentMode.icon || Brain;
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`} ref={dropdownRef}>
|
||||
<div className={`relative ${className}`} ref={containerRef}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
onClick={() => {
|
||||
if (isOpen) {
|
||||
closeDropdown();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsOpen(true);
|
||||
}}
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-full transition-all duration-200 sm:h-10 sm:w-10 ${selectedMode === 'none'
|
||||
? 'bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600'
|
||||
: 'bg-blue-100 hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800'
|
||||
}`}
|
||||
title={t('thinkingMode.buttonTitle', { mode: currentMode.name })}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<IconComponent className={`h-5 w-5 ${currentMode.color}`} />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute bottom-full right-0 mb-2 w-64 overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-800">
|
||||
{isOpen && typeof document !== 'undefined' && createPortal(
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
style={dropdownStyle || { position: 'fixed', top: 0, left: 0, visibility: 'hidden' }}
|
||||
className="flex flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-800"
|
||||
role="dialog"
|
||||
aria-modal="false"
|
||||
>
|
||||
<div className="border-b border-gray-200 p-3 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{t('thinkingMode.selector.title')}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
if (onClose) onClose();
|
||||
}}
|
||||
type="button"
|
||||
onClick={closeDropdown}
|
||||
className="rounded p-1 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<X className="h-4 w-4 text-gray-500" />
|
||||
@@ -83,7 +182,7 @@ function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className =
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="py-1">
|
||||
<div className="min-h-0 overflow-y-auto py-1">
|
||||
{translatedModes.map((mode) => {
|
||||
const ModeIcon = mode.icon;
|
||||
const isSelected = mode.id === selectedMode;
|
||||
@@ -91,10 +190,10 @@ function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className =
|
||||
return (
|
||||
<button
|
||||
key={mode.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onModeChange(mode.id);
|
||||
setIsOpen(false);
|
||||
if (onClose) onClose();
|
||||
closeDropdown();
|
||||
}}
|
||||
className={`w-full px-4 py-3 text-left transition-colors hover:bg-gray-50 dark:hover:bg-gray-700 ${isSelected ? 'bg-gray-50 dark:bg-gray-700' : ''
|
||||
}`}
|
||||
@@ -135,10 +234,11 @@ function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className =
|
||||
<strong>Tip:</strong> {t('thinkingMode.selector.tip')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ThinkingModeSelector;
|
||||
export default ThinkingModeSelector;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { authenticatedFetch } from "../../../utils/api";
|
||||
import { ReleaseInfo } from "../../../types/sharedTypes";
|
||||
import { copyTextToClipboard } from "../../../utils/clipboard";
|
||||
import type { InstallMode } from "../../../hooks/useVersionCheck";
|
||||
import { IS_PLATFORM } from "../../../constants/config";
|
||||
|
||||
interface VersionUpgradeModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -25,7 +26,9 @@ export function VersionUpgradeModal({
|
||||
const { t } = useTranslation('common');
|
||||
const upgradeCommand = installMode === 'npm'
|
||||
? t('versionUpdate.npmUpgradeCommand')
|
||||
: 'git checkout main && git pull && npm install';
|
||||
: IS_PLATFORM
|
||||
? 'npm run update:platform'
|
||||
: 'git checkout main && git pull && npm install';
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [updateOutput, setUpdateOutput] = useState('');
|
||||
const [updateError, setUpdateError] = useState('');
|
||||
@@ -46,7 +49,8 @@ export function VersionUpgradeModal({
|
||||
if (response.ok) {
|
||||
setUpdateOutput(prev => prev + data.output + '\n');
|
||||
setUpdateOutput(prev => prev + '\n✅ Update completed successfully!\n');
|
||||
setUpdateOutput(prev => prev + 'Please restart the server to apply changes.\n');
|
||||
const text = IS_PLATFORM ? 'Please refresh the page after 5 seconds to load the new version. If that doesn\'t work, RESTART the environment.' : 'Please restart the server to apply changes.';
|
||||
setUpdateOutput(prev => prev + text + '\n');
|
||||
} else {
|
||||
setUpdateError(data.error || 'Update failed');
|
||||
setUpdateOutput(prev => prev + '\n❌ Update failed: ' + (data.error || 'Unknown error') + '\n');
|
||||
|
||||
Reference in New Issue
Block a user