feat(web): migrate launcher to modular web frontend/backend and improve management UX (#1275)
* refactor: remove the legacy picoclaw-launcher * feat: create initial web frontend and backend structure * feat(packaging): add desktop entry for PicoClaw Launcher (#1062) - Add .desktop file with Terminal=true, named "PicoClaw Launcher" - Install to /usr/share/applications/ for app menu visibility - Add 512x512 PNG icon to /usr/share/icons/hicolor/ Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * `make dev`: If you haven't built it before, you need to run `build` first. * feat(web): comprehensive web UI and backend refactoring This commit introduces a major overhaul of both the frontend web UI and the Go backend API, transitioning to a highly modular architecture and integrating new core features. Backend: - Refactored monolithic API endpoints into domain-specific modules (config, gateway, log, models, pico, session). - Cleaned up obsolete files (`server.go`, `status.go`, WebSocket handlers) and outdated tests. - Implemented Gateway process lifecycle management (start/stop/restart) and real-time log streaming. Frontend: - Integrated Shadcn UI components to establish a modern, consistent design system. - Introduced a new application layout featuring a responsive sidebar (`app-sidebar`) and header. - Implemented internationalization (i18n) with initial support for English and Chinese. - Restructured API clients, hooks, and Zustand stores into logical domains. - Added new management pages for Settings, Logs, Models, Providers, and Credentials. - Upgraded the Pico chat interface with session history management and dynamic model selection. Build & Config: - Updated frontend dependencies, Vite configuration, and lockfiles. - Refined routing setup and overarching application stylesheets. * feat(web): enhance model management, sorting, and deletion logic - Implement model sorting in UI (default > configured > unconfigured) - Prevent deletion of default models in the frontend - Update backend to clear default settings when a model is deleted - Add existence validation when setting a default model via API - Group models in chat UI by type (API Key, OAuth, Local) - Conditionally display model selector in chat based on configuration status * refactor(web): refactor chat page into modular components/hooks and update i18n - split chat route into dedicated chat components (page, composer, empty state, messages, history, model selector) - extract model/session logic into use-chat-models and use-session-history hooks - update chat locale keys in en/zh and add empty-state/history-related translations * refactor(models): refactor models page into modular components and improve UX - split /models route into dedicated components (page, provider section, card, add/edit sheets, delete dialog) - add provider grouping/sorting, provider labels/icons, and a no-default hint in the models page - add "Set as default model" toggle to add/edit flows with safer defaults - introduce shared form helpers and new UI primitives (field, label, switch) - update i18n strings (en/zh) for models and gateway header text usage - apply minor UI polish (models nav icon, separator client directive) * fix(web): add SPA index fallback for embedded frontend routes Serve existing static assets as-is, keep /api/* and missing asset paths returning 404, and add tests for SPA fallback behavior on refresh. * fix(frontend/chat): normalize message timestamp units to prevent invalid far-future dates * chore: delete TestSPARouteFallsBackToIndex * feat: update build for web-based launcher (#1186) - Makefile: add build-launcher target (builds frontend + Go backend) - GoReleaser: point picoclaw-launcher build to web/backend, add frontend build hook, restore winres hook with updated paths - Restore icon.ico and winres config from main for Windows builds Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * feat(credentials): add multi-provider OAuth credential management - add backend `/api/oauth/*` endpoints for provider status, browser/device-code/token login, flow query/polling, and logout - extend API handler with OAuth flow/state tracking and route registration, plus OAuth unit tests - implement frontend credentials page/components for OpenAI, Anthropic, and Google Antigravity login/logout - add OAuth API client and `useCredentialsPage` hook, with new EN/ZH i18n strings * chore: remove placeholder index.html from dist (#1188) The .gitkeep is sufficient for go:embed to find the dist directory. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * fix(frontend): polish model and credential UX; remove Providers nav - remove the Providers item from sidebar navigation and locale keys - simplify chat composer by dropping attach/voice action buttons - support ReactNode titles in credential cards and add provider brand icons - refine sheet header/footer styling and device-code footer button hierarchy - disable “Set default” when a model is unconfigured or already default * feat(web): Update config page (#1173) * feat(web): Update config page * fix(web): useEffect resets editorValue whenever config changes * fix(web): react-hooks/set-state-in-effect error & pnpm lint #1173 * feat(web): add channel management page for web console (#1190) * feat(web): add channel management page for web console Add a complete channel management UI that allows users to configure messaging channels (Telegram, Discord, Slack, Feishu, etc.) directly from the web console instead of manually editing config.json. Backend: GET/PUT/PATCH API endpoints for listing, updating, and toggling channels with secret field masking. Frontend: Channel cards grid with enable/disable toggles, per-channel configuration sheets with dedicated forms for major platforms and a generic fallback for others. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(web/channels): move channels to own sidebar group and fix sheet padding - Channels now has its own navigation group instead of being under Services - Fix edit sheet form content padding (px-1 -> px-4) to match header/footer - Fix naked return lint error in extractChannelInfo Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * fix(web): harden channel config updates and resolve frontend lint issues - validate channel PUT/PATCH updates before saving and return structured validation errors - require `enabled` in toggle requests to avoid silent false defaults - support editing `allow_origins` in the generic channel form and parse string/array inputs on backend - replace channel form `any` usage with `ChannelConfig` (`Record<string, unknown>`) and add safe value helpers - add i18n strings for allow-origins fields and apply related frontend formatting cleanups * fix(frontend): prevent false "Invalid JSON" errors in config editor * feat: add startup readiness checks and propagate start availability to UI - add gateway precondition validation for default model and credentials - auto-start gateway on backend boot when conditions are met - include gateway_start_allowed and gateway_start_reason in status updates - prevent frontend start actions when gateway cannot be started * feat(web): revamp channel config UX with catalog-based routing - replace legacy channel management endpoints with a backend channel catalog API - switch frontend channel updates to PATCH /api/config and per-channel config pages - add dynamic channel items in the sidebar with support for expand/collapse - migrate /channels to nested routes (/channels/$name) and remove old card/sheet flow - improve channel forms with clearer hints, required/error states, and reusable switch cards - fix Discord mention-only toggle to read/write group_trigger.mention_only * refactor(frontend): move shared-form to components and unify default-model switch with SwitchCardField * fix(frontend): improve model form validation and unify secret placeholder handling - block duplicate model aliases when adding a model (with localized error messages) - share masked secret placeholder logic across model and channel forms - refresh gateway state after setting the default model - apply minor UI cleanup to provider icon rendering * feat(web): add visual system config and launcher/autostart controls - add launcher config model and persistence (`launcher-config.json`) for port/public/CIDR settings - add system APIs for launch-at-login and launcher parameters - apply CIDR-based access-control middleware to backend HTTP routes - split config routing into visual config and raw JSON config pages - add frontend system API client and visual config sections for runtime/devices/launcher - expand i18n strings (en/zh) for new config UI - improve sidebar active matching and session ID generation fallback * refactor(frontend): remove i18n fallback strings and drop providers route - Replace `t(key, defaultValue)` calls with key-only translations across UI pages - Clean up locale files by pruning unused keys and adding missing shared keys - Remove the obsolete `/providers` page and update generated route tree * fix(backend): correct gateway status detection on Windows * fix(repo): keep web backend dist placeholder tracked --------- Co-authored-by: Guoguo <16666742+imguoguo@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Dihubopen <dihubcn@gmail.com> Co-authored-by: Dihubopen <130813726+Dihubopen@users.noreply.github.com>
@@ -65,6 +65,14 @@ jobs:
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Setup pnpm
|
||||
run: corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
|
||||
@@ -47,6 +47,12 @@ docs/plans/
|
||||
|
||||
# Added by goreleaser init:
|
||||
dist/
|
||||
*.vite/
|
||||
|
||||
# Windows Application Icon/Resource
|
||||
*.syso
|
||||
|
||||
# Keep embedded backend dist directory placeholder in VCS
|
||||
!web/backend/dist/
|
||||
web/backend/dist/*
|
||||
!web/backend/dist/.gitkeep
|
||||
|
||||
@@ -6,8 +6,9 @@ before:
|
||||
hooks:
|
||||
- go mod tidy
|
||||
- go generate ./...
|
||||
- sh -c 'cd web/frontend && pnpm install && pnpm build:backend'
|
||||
- go install github.com/tc-hib/go-winres@latest
|
||||
- go-winres make --in cmd/picoclaw-launcher/winres/winres.json --out cmd/picoclaw-launcher/rsrc --product-version={{ .Version }} --file-version={{ .Version }}
|
||||
- go-winres make --in web/backend/winres/winres.json --out web/backend/rsrc --product-version={{ .Version }} --file-version={{ .Version }}
|
||||
|
||||
builds:
|
||||
- id: picoclaw
|
||||
@@ -70,7 +71,7 @@ builds:
|
||||
- "7"
|
||||
gomips:
|
||||
- softfloat
|
||||
main: ./cmd/picoclaw-launcher
|
||||
main: ./web/backend
|
||||
ignore:
|
||||
- goos: windows
|
||||
goarch: arm
|
||||
@@ -178,6 +179,11 @@ nfpms:
|
||||
- rpm
|
||||
- deb
|
||||
bindir: /usr/bin
|
||||
contents:
|
||||
- src: web/picoclaw-launcher.desktop
|
||||
dst: /usr/share/applications/picoclaw-launcher.desktop
|
||||
- src: web/picoclaw-launcher.png
|
||||
dst: /usr/share/icons/hicolor/512x512/apps/picoclaw-launcher.png
|
||||
|
||||
changelog:
|
||||
sort: asc
|
||||
|
||||
@@ -111,6 +111,18 @@ build: generate
|
||||
@echo "Build complete: $(BINARY_PATH)"
|
||||
@ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
|
||||
|
||||
## build-launcher: Build the picoclaw-launcher (web console) binary
|
||||
build-launcher:
|
||||
@echo "Building picoclaw-launcher for $(PLATFORM)/$(ARCH)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
@if [ ! -f web/backend/dist/index.html ]; then \
|
||||
echo "Building frontend..."; \
|
||||
cd web/frontend && pnpm install && pnpm build:backend; \
|
||||
fi
|
||||
@$(GO) build $(GOFLAGS) -o $(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH) ./web/backend
|
||||
@ln -sf picoclaw-launcher-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/picoclaw-launcher
|
||||
@echo "Build complete: $(BUILD_DIR)/picoclaw-launcher"
|
||||
|
||||
## build-whatsapp-native: Build with WhatsApp native (whatsmeow) support; larger binary
|
||||
build-whatsapp-native: generate
|
||||
## @echo "Building $(BINARY_NAME) with WhatsApp native for $(PLATFORM)/$(ARCH)..."
|
||||
|
||||
@@ -1,290 +0,0 @@
|
||||
# PicoClaw Launcher
|
||||
|
||||
> [!WARNING]
|
||||
> This project is a temporary solution and will be refactored in the future to provide a complete web service. Therefore, the APIs in this directory are not stable.
|
||||
|
||||
A standalone launcher for PicoClaw, providing visual JSON editing and OAuth provider authentication management.
|
||||
|
||||
## Features
|
||||
|
||||
- 📝 **Config Editor** — Sidebar-based settings UI with model management, channel configuration forms, and a raw JSON editor
|
||||
- 🤖 **Model Management** — Model card grid with availability status (grayed out without API key), primary model selection, add/edit/delete with required/optional field separation
|
||||
- 📡 **Channel Configuration** — Form-based settings for 13 channel types (Telegram, Discord, Slack, Matrix, WeCom, DingTalk, Feishu, LINE, WhatsApp, QQ, OneBot, MaixCAM, etc.) with documentation links
|
||||
- 🔐 **Provider Auth** — Login to OpenAI (Device Code), Anthropic (API Token), Google Antigravity (Browser OAuth)
|
||||
- 🌐 **Embedded Frontend** — Compiles to a single binary with no external dependencies
|
||||
- 🌍 **i18n** — Chinese/English language switching with browser auto-detection
|
||||
- 🎨 **Theme** — Light / Dark / System theme toggle with localStorage persistence
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Build
|
||||
go build -o picoclaw-launcher ./cmd/picoclaw-launcher/
|
||||
|
||||
# Run with default config path (~/.picoclaw/config.json)
|
||||
./picoclaw-launcher
|
||||
|
||||
# Specify a config file
|
||||
./picoclaw-launcher ./config.json
|
||||
|
||||
# Allow LAN access
|
||||
./picoclaw-launcher -public
|
||||
```
|
||||
|
||||
Open `http://localhost:18800` in your browser.
|
||||
|
||||
## CLI Options
|
||||
|
||||
```
|
||||
Usage: picoclaw-config [options] [config.json]
|
||||
|
||||
Arguments:
|
||||
config.json Path to the configuration file (default: ~/.picoclaw/config.json)
|
||||
|
||||
Options:
|
||||
-public Listen on all interfaces (0.0.0.0), allowing access from other devices
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
Base URL: `http://localhost:18800`
|
||||
|
||||
---
|
||||
|
||||
### Static Files
|
||||
|
||||
#### GET /
|
||||
|
||||
Serves the embedded frontend (`index.html`).
|
||||
|
||||
---
|
||||
|
||||
### Config API
|
||||
|
||||
#### GET /api/config
|
||||
|
||||
Reads the current configuration file.
|
||||
|
||||
**Response** `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"config": { ... },
|
||||
"path": "/Users/xiao/.picoclaw/config.json"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### PUT /api/config
|
||||
|
||||
Saves the configuration. The request body must be a complete Config JSON object.
|
||||
|
||||
**Request Body** — `application/json`
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": { "defaults": { "model_name": "gpt-5.2" } },
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.2",
|
||||
"model": "openai/gpt-5.2",
|
||||
"auth_method": "oauth"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Response** `200 OK`
|
||||
|
||||
```json
|
||||
{ "status": "ok" }
|
||||
```
|
||||
|
||||
**Error** `400 Bad Request` — Invalid JSON
|
||||
|
||||
---
|
||||
|
||||
### Auth API
|
||||
|
||||
#### GET /api/auth/status
|
||||
|
||||
Returns the authentication status of all providers and any in-progress device code login.
|
||||
|
||||
**Response** `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": [
|
||||
{
|
||||
"provider": "openai",
|
||||
"auth_method": "oauth",
|
||||
"status": "active",
|
||||
"account_id": "user-xxx",
|
||||
"expires_at": "2026-03-01T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"pending_device": {
|
||||
"provider": "openai",
|
||||
"status": "pending",
|
||||
"device_url": "https://auth.openai.com/activate",
|
||||
"user_code": "ABCD-1234"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`status` values: `active` | `expired` | `needs_refresh`
|
||||
|
||||
`pending_device` is only present when a device code login is in progress.
|
||||
|
||||
---
|
||||
|
||||
#### POST /api/auth/login
|
||||
|
||||
Initiates a provider login.
|
||||
|
||||
**Request Body** — `application/json`
|
||||
|
||||
```json
|
||||
{ "provider": "openai" }
|
||||
```
|
||||
|
||||
Supported `provider` values: `openai` | `anthropic` | `google-antigravity`
|
||||
|
||||
##### OpenAI (Device Code Flow)
|
||||
|
||||
Returns device code info. The server polls for completion in the background.
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "pending",
|
||||
"device_url": "https://auth.openai.com/activate",
|
||||
"user_code": "ABCD-1234",
|
||||
"message": "Open the URL and enter the code to authenticate."
|
||||
}
|
||||
```
|
||||
|
||||
The user opens `device_url` in a browser and enters `user_code`. Once authenticated, `GET /api/auth/status` will show `pending_device.status` as `success`.
|
||||
|
||||
##### Anthropic (API Token)
|
||||
|
||||
Requires a `token` field in the request:
|
||||
|
||||
```json
|
||||
{ "provider": "anthropic", "token": "sk-ant-xxx" }
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{ "status": "success", "message": "Anthropic token saved" }
|
||||
```
|
||||
|
||||
##### Google Antigravity (Browser OAuth)
|
||||
|
||||
Returns an authorization URL for the frontend to open in a new tab:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "redirect",
|
||||
"auth_url": "https://accounts.google.com/o/oauth2/auth?...",
|
||||
"message": "Open the URL to authenticate with Google."
|
||||
}
|
||||
```
|
||||
|
||||
After authentication, Google redirects to `GET /auth/callback`, which saves the credentials and redirects back to the picoclaw-config UI.
|
||||
|
||||
---
|
||||
|
||||
#### POST /api/auth/logout
|
||||
|
||||
Logs out from a provider.
|
||||
|
||||
**Request Body** — `application/json`
|
||||
|
||||
```json
|
||||
{ "provider": "openai" }
|
||||
```
|
||||
|
||||
Omit or leave `provider` empty to log out from all providers.
|
||||
|
||||
**Response** `200 OK`
|
||||
|
||||
```json
|
||||
{ "status": "ok" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### GET /auth/callback
|
||||
|
||||
OAuth browser callback endpoint (used by Google Antigravity). Called by the OAuth provider's redirect — **not invoked directly by the frontend**.
|
||||
|
||||
**Query Parameters:**
|
||||
- `state` — OAuth state for CSRF validation
|
||||
- `code` — Authorization code
|
||||
|
||||
On success, redirects to `/#auth`.
|
||||
|
||||
|
||||
### Process API
|
||||
|
||||
#### GET /api/process/status
|
||||
|
||||
Gets the running status of the `picoclaw gateway` process.
|
||||
|
||||
**Response** `200 OK` (Running)
|
||||
|
||||
```json
|
||||
{
|
||||
"process_status": "running",
|
||||
"status": "ok",
|
||||
"uptime": "1.010814s"
|
||||
}
|
||||
```
|
||||
|
||||
**Response** `200 OK` (Stopped)
|
||||
|
||||
```json
|
||||
{
|
||||
"process_status": "stopped",
|
||||
"error": "Get \"http://localhost:18790/health\": dial tcp [::1]:18790: connect: connection refused"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### POST /api/process/start
|
||||
|
||||
Starts the `picoclaw gateway` process in the background.
|
||||
|
||||
**Response** `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"pid": 12345
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### POST /api/process/stop
|
||||
|
||||
Stops the running `picoclaw gateway` process.
|
||||
|
||||
**Response** `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
go test -v ./cmd/picoclaw-launcher/
|
||||
```
|
||||
@@ -1,287 +0,0 @@
|
||||
# PicoClaw Launcher
|
||||
|
||||
> [!WARNING]
|
||||
> 该项目属于临时解决方案,后续会重构并提供完整的 Web 服务,因此该目录下的接口并不稳定。
|
||||
|
||||
PicoClaw 的独立启动器,提供可视化 JSON 配置编辑和 OAuth Provider 认证管理。
|
||||
|
||||
## 功能
|
||||
|
||||
- 📝 **配置编辑** — 侧边栏式设置 UI,支持模型管理、通道配置表单和原始 JSON 编辑器
|
||||
- 🤖 **模型管理** — 模型卡片网格,可用性状态显示(无 API Key 时灰色),主模型选择,增删改查,必填/选填字段分离
|
||||
- 📡 **通道配置** — 12 种通道类型(Telegram、Discord、Slack、企业微信、钉钉、飞书、LINE、WhatsApp、QQ、OneBot、MaixCAM 等)的表单化配置,附带文档链接
|
||||
- 🔐 **Provider 认证** — 支持 OpenAI (Device Code)、Anthropic (API Token)、Google Antigravity (Browser OAuth) 登录
|
||||
- 🌐 **嵌入式前端** — 编译为单一二进制文件,无需额外依赖
|
||||
- 🌍 **国际化** — 中英文切换,首次访问自动检测浏览器语言
|
||||
- 🎨 **主题** — 亮色 / 暗色 / 跟随系统,偏好保存在 localStorage
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 编译
|
||||
go build -o picoclaw-launcher ./cmd/picoclaw-launcher/
|
||||
|
||||
# 运行(使用默认配置路径 ~/.picoclaw/config.json)
|
||||
./picoclaw-launcher
|
||||
|
||||
# 指定配置文件
|
||||
./picoclaw-launcher ./config.json
|
||||
|
||||
# 允许局域网访问
|
||||
./picoclaw-launcher -public
|
||||
```
|
||||
|
||||
启动后在浏览器中打开 `http://localhost:18800`。
|
||||
|
||||
## 命令行参数
|
||||
|
||||
```
|
||||
Usage: picoclaw-launcher [options] [config.json]
|
||||
|
||||
Arguments:
|
||||
config.json 配置文件路径(默认: ~/.picoclaw/config.json)
|
||||
|
||||
Options:
|
||||
-public 监听所有网络接口(0.0.0.0),允许局域网设备访问
|
||||
```
|
||||
|
||||
## API 文档
|
||||
|
||||
Base URL: `http://localhost:18800`
|
||||
|
||||
### 静态文件
|
||||
|
||||
#### GET /
|
||||
|
||||
提供嵌入式前端页面(`index.html`)。
|
||||
|
||||
---
|
||||
|
||||
### Config API
|
||||
|
||||
#### GET /api/config
|
||||
|
||||
读取当前配置文件内容。
|
||||
|
||||
**Response** `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"config": { ... },
|
||||
"path": "/Users/xiao/.picoclaw/config.json"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### PUT /api/config
|
||||
|
||||
保存配置。请求体为完整的 Config JSON。
|
||||
|
||||
**Request Body** — `application/json`
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": { "defaults": { "model_name": "gpt-5.2" } },
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.2",
|
||||
"model": "openai/gpt-5.2",
|
||||
"auth_method": "oauth"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Response** `200 OK`
|
||||
|
||||
```json
|
||||
{ "status": "ok" }
|
||||
```
|
||||
|
||||
**Error** `400 Bad Request` — 无效 JSON
|
||||
|
||||
---
|
||||
|
||||
### Auth API
|
||||
|
||||
#### GET /api/auth/status
|
||||
|
||||
获取所有 Provider 的认证状态和进行中的 Device Code 登录信息。
|
||||
|
||||
**Response** `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": [
|
||||
{
|
||||
"provider": "openai",
|
||||
"auth_method": "oauth",
|
||||
"status": "active",
|
||||
"account_id": "user-xxx",
|
||||
"expires_at": "2026-03-01T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"pending_device": {
|
||||
"provider": "openai",
|
||||
"status": "pending",
|
||||
"device_url": "https://auth.openai.com/activate",
|
||||
"user_code": "ABCD-1234"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`status` 可选值: `active` | `expired` | `needs_refresh`
|
||||
|
||||
`pending_device` 仅在有进行中的 Device Code 登录时返回。
|
||||
|
||||
---
|
||||
|
||||
#### POST /api/auth/login
|
||||
|
||||
发起 Provider 登录。
|
||||
|
||||
**Request Body** — `application/json`
|
||||
|
||||
```json
|
||||
{ "provider": "openai" }
|
||||
```
|
||||
|
||||
支持的 `provider` 值: `openai` | `anthropic` | `google-antigravity`
|
||||
|
||||
##### OpenAI (Device Code Flow)
|
||||
|
||||
返回 Device Code 信息,后台自动轮询认证结果:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "pending",
|
||||
"device_url": "https://auth.openai.com/activate",
|
||||
"user_code": "ABCD-1234",
|
||||
"message": "Open the URL and enter the code to authenticate."
|
||||
}
|
||||
```
|
||||
|
||||
用户在浏览器中打开 `device_url` 并输入 `user_code`。认证完成后通过 `GET /api/auth/status` 的 `pending_device.status` 变为 `success` 通知前端。
|
||||
|
||||
##### Anthropic (API Token)
|
||||
|
||||
需在请求中附带 token:
|
||||
|
||||
```json
|
||||
{ "provider": "anthropic", "token": "sk-ant-xxx" }
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{ "status": "success", "message": "Anthropic token saved" }
|
||||
```
|
||||
|
||||
##### Google Antigravity (Browser OAuth)
|
||||
|
||||
返回授权 URL,前端打开新标签页:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "redirect",
|
||||
"auth_url": "https://accounts.google.com/o/oauth2/auth?...",
|
||||
"message": "Open the URL to authenticate with Google."
|
||||
}
|
||||
```
|
||||
|
||||
认证完成后 Google 回调至 `GET /auth/callback`,自动保存凭据并重定向回 picoclaw-config 页面。
|
||||
|
||||
---
|
||||
|
||||
#### POST /api/auth/logout
|
||||
|
||||
登出 Provider。
|
||||
|
||||
**Request Body** — `application/json`
|
||||
|
||||
```json
|
||||
{ "provider": "openai" }
|
||||
```
|
||||
|
||||
传空字符串或省略 `provider` 则登出所有 Provider。
|
||||
|
||||
**Response** `200 OK`
|
||||
|
||||
```json
|
||||
{ "status": "ok" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### GET /auth/callback
|
||||
|
||||
OAuth Browser 回调端点(Google Antigravity 专用),由 OAuth Provider 重定向调用,**非前端直接使用**。
|
||||
|
||||
**Query Parameters:**
|
||||
- `state` — OAuth state 校验
|
||||
- `code` — 授权码
|
||||
|
||||
认证成功后重定向到 `/#auth`。
|
||||
|
||||
### Process API
|
||||
|
||||
#### GET /api/process/status
|
||||
|
||||
获取 `picoclaw gateway` 进程的运行状态。
|
||||
|
||||
**Response** `200 OK` (运行中)
|
||||
|
||||
```json
|
||||
{
|
||||
"process_status": "running",
|
||||
"status": "ok",
|
||||
"uptime": "1.010814s"
|
||||
}
|
||||
```
|
||||
|
||||
**Response** `200 OK` (未运行)
|
||||
|
||||
```json
|
||||
{
|
||||
"process_status": "stopped",
|
||||
"error": "Get \"http://localhost:18790/health\": dial tcp [::1]:18790: connect: connection refused"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### POST /api/process/start
|
||||
|
||||
在后台启动 `picoclaw gateway` 进程。
|
||||
|
||||
**Response** `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"pid": 12345
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### POST /api/process/stop
|
||||
|
||||
停止正在运行的 `picoclaw gateway` 进程。
|
||||
|
||||
**Response** `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
go test -v ./cmd/picoclaw-launcher/
|
||||
```
|
||||
@@ -1,147 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/auth"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
// updateConfigAfterLogin updates config.json after a successful provider login.
|
||||
func updateConfigAfterLogin(configPath, provider string, cred *auth.AuthCredential) {
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
log.Printf("Warning: could not load config to update auth_method: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
switch provider {
|
||||
case "openai":
|
||||
cfg.Providers.OpenAI.AuthMethod = "oauth"
|
||||
found := false
|
||||
for i := range cfg.ModelList {
|
||||
if isOpenAIModel(cfg.ModelList[i].Model) {
|
||||
cfg.ModelList[i].AuthMethod = "oauth"
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
cfg.ModelList = append(cfg.ModelList, config.ModelConfig{
|
||||
ModelName: "gpt-5.2",
|
||||
Model: "openai/gpt-5.2",
|
||||
AuthMethod: "oauth",
|
||||
})
|
||||
}
|
||||
cfg.Agents.Defaults.ModelName = "gpt-5.2"
|
||||
|
||||
case "anthropic":
|
||||
cfg.Providers.Anthropic.AuthMethod = "token"
|
||||
found := false
|
||||
for i := range cfg.ModelList {
|
||||
if isAnthropicModel(cfg.ModelList[i].Model) {
|
||||
cfg.ModelList[i].AuthMethod = "token"
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
cfg.ModelList = append(cfg.ModelList, config.ModelConfig{
|
||||
ModelName: "claude-sonnet-4.6",
|
||||
Model: "anthropic/claude-sonnet-4.6",
|
||||
AuthMethod: "token",
|
||||
})
|
||||
}
|
||||
cfg.Agents.Defaults.ModelName = "claude-sonnet-4.6"
|
||||
|
||||
case "google-antigravity":
|
||||
cfg.Providers.Antigravity.AuthMethod = "oauth"
|
||||
found := false
|
||||
for i := range cfg.ModelList {
|
||||
if isAntigravityModel(cfg.ModelList[i].Model) {
|
||||
cfg.ModelList[i].AuthMethod = "oauth"
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
cfg.ModelList = append(cfg.ModelList, config.ModelConfig{
|
||||
ModelName: "gemini-flash",
|
||||
Model: "antigravity/gemini-3-flash",
|
||||
AuthMethod: "oauth",
|
||||
})
|
||||
}
|
||||
cfg.Agents.Defaults.ModelName = "gemini-flash"
|
||||
}
|
||||
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
log.Printf("Warning: could not update config: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// clearAuthMethodInConfig clears auth_method for a specific provider in config.json.
|
||||
func clearAuthMethodInConfig(configPath, provider string) {
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for i := range cfg.ModelList {
|
||||
switch provider {
|
||||
case "openai":
|
||||
if isOpenAIModel(cfg.ModelList[i].Model) {
|
||||
cfg.ModelList[i].AuthMethod = ""
|
||||
}
|
||||
case "anthropic":
|
||||
if isAnthropicModel(cfg.ModelList[i].Model) {
|
||||
cfg.ModelList[i].AuthMethod = ""
|
||||
}
|
||||
case "google-antigravity", "antigravity":
|
||||
if isAntigravityModel(cfg.ModelList[i].Model) {
|
||||
cfg.ModelList[i].AuthMethod = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch provider {
|
||||
case "openai":
|
||||
cfg.Providers.OpenAI.AuthMethod = ""
|
||||
case "anthropic":
|
||||
cfg.Providers.Anthropic.AuthMethod = ""
|
||||
case "google-antigravity", "antigravity":
|
||||
cfg.Providers.Antigravity.AuthMethod = ""
|
||||
}
|
||||
|
||||
config.SaveConfig(configPath, cfg)
|
||||
}
|
||||
|
||||
// clearAllAuthMethodsInConfig clears auth_method for all providers in config.json.
|
||||
func clearAllAuthMethodsInConfig(configPath string) {
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for i := range cfg.ModelList {
|
||||
cfg.ModelList[i].AuthMethod = ""
|
||||
}
|
||||
cfg.Providers.OpenAI.AuthMethod = ""
|
||||
cfg.Providers.Anthropic.AuthMethod = ""
|
||||
cfg.Providers.Antigravity.AuthMethod = ""
|
||||
config.SaveConfig(configPath, cfg)
|
||||
}
|
||||
|
||||
// ── Model identification helpers ─────────────────────────────────
|
||||
|
||||
func isOpenAIModel(model string) bool {
|
||||
return model == "openai" || strings.HasPrefix(model, "openai/")
|
||||
}
|
||||
|
||||
func isAnthropicModel(model string) bool {
|
||||
return model == "anthropic" || strings.HasPrefix(model, "anthropic/")
|
||||
}
|
||||
|
||||
func isAntigravityModel(model string) bool {
|
||||
return model == "antigravity" || model == "google-antigravity" ||
|
||||
strings.HasPrefix(model, "antigravity/") || strings.HasPrefix(model, "google-antigravity/")
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/auth"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
// ── Model identification helpers ─────────────────────────────────
|
||||
|
||||
func TestIsOpenAIModel(t *testing.T) {
|
||||
tests := []struct {
|
||||
model string
|
||||
want bool
|
||||
}{
|
||||
{"openai", true},
|
||||
{"openai/gpt-4o", true},
|
||||
{"openai/gpt-5.2", true},
|
||||
{"anthropic", false},
|
||||
{"anthropic/claude-sonnet-4.6", false},
|
||||
{"openai-compatible", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := isOpenAIModel(tt.model); got != tt.want {
|
||||
t.Errorf("isOpenAIModel(%q) = %v, want %v", tt.model, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAnthropicModel(t *testing.T) {
|
||||
tests := []struct {
|
||||
model string
|
||||
want bool
|
||||
}{
|
||||
{"anthropic", true},
|
||||
{"anthropic/claude-sonnet-4.6", true},
|
||||
{"openai", false},
|
||||
{"openai/gpt-4o", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := isAnthropicModel(tt.model); got != tt.want {
|
||||
t.Errorf("isAnthropicModel(%q) = %v, want %v", tt.model, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAntigravityModel(t *testing.T) {
|
||||
tests := []struct {
|
||||
model string
|
||||
want bool
|
||||
}{
|
||||
{"antigravity", true},
|
||||
{"google-antigravity", true},
|
||||
{"antigravity/gemini-3-flash", true},
|
||||
{"google-antigravity/gemini-3-flash", true},
|
||||
{"openai", false},
|
||||
{"antigravity-custom", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := isAntigravityModel(tt.model); got != tt.want {
|
||||
t.Errorf("isAntigravityModel(%q) = %v, want %v", tt.model, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Config update helpers ────────────────────────────────────────
|
||||
|
||||
func writeTempConfigViaSave(t *testing.T, cfg *config.Config) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.json")
|
||||
if err := config.SaveConfig(path, cfg); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func loadTempConfig(t *testing.T, path string) *config.Config {
|
||||
t.Helper()
|
||||
cfg, err := config.LoadConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("load config: %v", err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func TestUpdateConfigAfterLogin_OpenAI_ExistingModel(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
ModelList: []config.ModelConfig{
|
||||
{ModelName: "gpt-4o", Model: "openai/gpt-4o"},
|
||||
},
|
||||
}
|
||||
path := writeTempConfigViaSave(t, cfg)
|
||||
|
||||
cred := &auth.AuthCredential{AuthMethod: "oauth"}
|
||||
updateConfigAfterLogin(path, "openai", cred)
|
||||
|
||||
result := loadTempConfig(t, path)
|
||||
|
||||
// Model-level auth_method persists through serialization
|
||||
if len(result.ModelList) != 1 {
|
||||
t.Fatalf("expected 1 model, got %d", len(result.ModelList))
|
||||
}
|
||||
if result.ModelList[0].AuthMethod != "oauth" {
|
||||
t.Errorf("expected model auth_method=oauth, got %q", result.ModelList[0].AuthMethod)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateConfigAfterLogin_OpenAI_NoExistingModel(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
ModelList: []config.ModelConfig{
|
||||
{ModelName: "claude", Model: "anthropic/claude-sonnet-4.6"},
|
||||
},
|
||||
}
|
||||
path := writeTempConfigViaSave(t, cfg)
|
||||
|
||||
cred := &auth.AuthCredential{AuthMethod: "oauth"}
|
||||
updateConfigAfterLogin(path, "openai", cred)
|
||||
|
||||
result := loadTempConfig(t, path)
|
||||
|
||||
if len(result.ModelList) != 2 {
|
||||
t.Fatalf("expected 2 models (original + added), got %d", len(result.ModelList))
|
||||
}
|
||||
if result.ModelList[1].Model != "openai/gpt-5.2" {
|
||||
t.Errorf("expected added model openai/gpt-5.2, got %q", result.ModelList[1].Model)
|
||||
}
|
||||
if result.Agents.Defaults.ModelName != "gpt-5.2" {
|
||||
t.Errorf("expected default model_name=gpt-5.2, got %q", result.Agents.Defaults.ModelName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateConfigAfterLogin_Anthropic(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
path := writeTempConfigViaSave(t, cfg)
|
||||
|
||||
cred := &auth.AuthCredential{AuthMethod: "token"}
|
||||
updateConfigAfterLogin(path, "anthropic", cred)
|
||||
|
||||
result := loadTempConfig(t, path)
|
||||
|
||||
// Model should be added with correct auth_method
|
||||
if len(result.ModelList) != 1 {
|
||||
t.Fatalf("expected 1 model added, got %d", len(result.ModelList))
|
||||
}
|
||||
if result.ModelList[0].Model != "anthropic/claude-sonnet-4.6" {
|
||||
t.Errorf("expected model anthropic/claude-sonnet-4.6, got %q", result.ModelList[0].Model)
|
||||
}
|
||||
if result.ModelList[0].AuthMethod != "token" {
|
||||
t.Errorf("expected model auth_method=token, got %q", result.ModelList[0].AuthMethod)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateConfigAfterLogin_GoogleAntigravity(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
path := writeTempConfigViaSave(t, cfg)
|
||||
|
||||
cred := &auth.AuthCredential{AuthMethod: "oauth"}
|
||||
updateConfigAfterLogin(path, "google-antigravity", cred)
|
||||
|
||||
result := loadTempConfig(t, path)
|
||||
|
||||
// Model should be added with correct auth_method
|
||||
if len(result.ModelList) != 1 {
|
||||
t.Fatalf("expected 1 model added, got %d", len(result.ModelList))
|
||||
}
|
||||
if result.ModelList[0].Model != "antigravity/gemini-3-flash" {
|
||||
t.Errorf("expected model antigravity/gemini-3-flash, got %q", result.ModelList[0].Model)
|
||||
}
|
||||
if result.ModelList[0].AuthMethod != "oauth" {
|
||||
t.Errorf("expected model auth_method=oauth, got %q", result.ModelList[0].AuthMethod)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearAuthMethodInConfig(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
ModelList: []config.ModelConfig{
|
||||
{ModelName: "gpt-4o", Model: "openai/gpt-4o", AuthMethod: "oauth"},
|
||||
{ModelName: "claude", Model: "anthropic/claude-sonnet-4.6", AuthMethod: "token"},
|
||||
},
|
||||
}
|
||||
path := writeTempConfigViaSave(t, cfg)
|
||||
|
||||
clearAuthMethodInConfig(path, "openai")
|
||||
|
||||
result := loadTempConfig(t, path)
|
||||
|
||||
// Openai model auth_method should be cleared
|
||||
if result.ModelList[0].AuthMethod != "" {
|
||||
t.Errorf("expected openai model auth_method cleared, got %q", result.ModelList[0].AuthMethod)
|
||||
}
|
||||
// Anthropic model should be unchanged
|
||||
if result.ModelList[1].AuthMethod != "token" {
|
||||
t.Errorf("expected anthropic model auth_method unchanged, got %q", result.ModelList[1].AuthMethod)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearAllAuthMethodsInConfig(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
ModelList: []config.ModelConfig{
|
||||
{ModelName: "gpt-4o", Model: "openai/gpt-4o", AuthMethod: "oauth"},
|
||||
{ModelName: "claude", Model: "anthropic/claude-sonnet-4.6", AuthMethod: "token"},
|
||||
{ModelName: "gemini", Model: "antigravity/gemini-3-flash", AuthMethod: "oauth"},
|
||||
},
|
||||
}
|
||||
path := writeTempConfigViaSave(t, cfg)
|
||||
|
||||
clearAllAuthMethodsInConfig(path)
|
||||
|
||||
result := loadTempConfig(t, path)
|
||||
|
||||
for i, m := range result.ModelList {
|
||||
if m.AuthMethod != "" {
|
||||
t.Errorf("model[%d] auth_method not cleared, got %q", i, m.AuthMethod)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,315 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/auth"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
// oauthSession stores in-flight OAuth state for browser-based flows.
|
||||
type oauthSession struct {
|
||||
Provider string
|
||||
PKCE auth.PKCECodes
|
||||
State string
|
||||
RedirectURI string
|
||||
OAuthCfg auth.OAuthProviderConfig
|
||||
ConfigPath string
|
||||
}
|
||||
|
||||
// deviceCodeSession stores in-flight device code flow state.
|
||||
type deviceCodeSession struct {
|
||||
mu sync.Mutex
|
||||
Provider string
|
||||
Info *auth.DeviceCodeInfo
|
||||
OAuthCfg auth.OAuthProviderConfig
|
||||
ConfigPath string
|
||||
Status string // "pending", "success", "error"
|
||||
Error string
|
||||
Done bool
|
||||
}
|
||||
|
||||
var (
|
||||
oauthSessions = map[string]*oauthSession{} // keyed by state
|
||||
oauthSessionsMu sync.Mutex
|
||||
|
||||
activeDeviceSession *deviceCodeSession
|
||||
activeDeviceSessionMu sync.Mutex
|
||||
)
|
||||
|
||||
// handleOpenAILogin starts the OpenAI device code flow and returns device code info to the frontend.
|
||||
func handleOpenAILogin(w http.ResponseWriter, configPath string) {
|
||||
// Check if there's already a pending device code session
|
||||
activeDeviceSessionMu.Lock()
|
||||
if activeDeviceSession != nil {
|
||||
activeDeviceSession.mu.Lock()
|
||||
if !activeDeviceSession.Done {
|
||||
resp := map[string]any{
|
||||
"status": "pending",
|
||||
"device_url": activeDeviceSession.Info.VerifyURL,
|
||||
"user_code": activeDeviceSession.Info.UserCode,
|
||||
"message": "Device code flow already in progress. Enter the code in your browser.",
|
||||
}
|
||||
activeDeviceSession.mu.Unlock()
|
||||
activeDeviceSessionMu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
return
|
||||
}
|
||||
activeDeviceSession.mu.Unlock()
|
||||
}
|
||||
activeDeviceSessionMu.Unlock()
|
||||
|
||||
// Request a device code
|
||||
oauthCfg := auth.OpenAIOAuthConfig()
|
||||
info, err := auth.RequestDeviceCode(oauthCfg)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to request device code: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
session := &deviceCodeSession{
|
||||
Provider: "openai",
|
||||
Info: info,
|
||||
OAuthCfg: oauthCfg,
|
||||
ConfigPath: configPath,
|
||||
Status: "pending",
|
||||
}
|
||||
|
||||
activeDeviceSessionMu.Lock()
|
||||
activeDeviceSession = session
|
||||
activeDeviceSessionMu.Unlock()
|
||||
|
||||
// Start background polling
|
||||
go func() {
|
||||
deadline := time.After(15 * time.Minute)
|
||||
ticker := time.NewTicker(time.Duration(info.Interval) * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-deadline:
|
||||
session.mu.Lock()
|
||||
session.Status = "error"
|
||||
session.Error = "Authentication timed out after 15 minutes"
|
||||
session.Done = true
|
||||
session.mu.Unlock()
|
||||
return
|
||||
case <-ticker.C:
|
||||
cred, err := auth.PollDeviceCodeOnce(oauthCfg, info.DeviceAuthID, info.UserCode)
|
||||
if err != nil {
|
||||
continue // Still pending
|
||||
}
|
||||
if cred != nil {
|
||||
if saveErr := auth.SetCredential("openai", cred); saveErr != nil {
|
||||
session.mu.Lock()
|
||||
session.Status = "error"
|
||||
session.Error = saveErr.Error()
|
||||
session.Done = true
|
||||
session.mu.Unlock()
|
||||
return
|
||||
}
|
||||
updateConfigAfterLogin(configPath, "openai", cred)
|
||||
session.mu.Lock()
|
||||
session.Status = "success"
|
||||
session.Done = true
|
||||
session.mu.Unlock()
|
||||
log.Printf("OpenAI device code login successful (account: %s)", cred.AccountID)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Return device code info to frontend
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"status": "pending",
|
||||
"device_url": info.VerifyURL,
|
||||
"user_code": info.UserCode,
|
||||
"message": "Open the URL and enter the code to authenticate.",
|
||||
})
|
||||
}
|
||||
|
||||
// handleAnthropicLogin saves a pasted API token for Anthropic.
|
||||
func handleAnthropicLogin(w http.ResponseWriter, token, configPath string) {
|
||||
if token == "" {
|
||||
http.Error(w, "Token is required for Anthropic login", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
cred := &auth.AuthCredential{
|
||||
AccessToken: token,
|
||||
Provider: "anthropic",
|
||||
AuthMethod: "token",
|
||||
}
|
||||
|
||||
if err := auth.SetCredential("anthropic", cred); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to save credentials: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
updateConfigAfterLogin(configPath, "anthropic", cred)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "success",
|
||||
"message": "Anthropic token saved",
|
||||
})
|
||||
}
|
||||
|
||||
// handleGoogleAntigravityLogin generates a PKCE + auth URL and returns it to the frontend.
|
||||
func handleGoogleAntigravityLogin(w http.ResponseWriter, r *http.Request, configPath string) {
|
||||
oauthCfg := auth.GoogleAntigravityOAuthConfig()
|
||||
|
||||
pkce, err := auth.GeneratePKCE()
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to generate PKCE: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
state, err := auth.GenerateState()
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to generate state: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Build redirect URI pointing to picoclaw-launcher's own callback
|
||||
scheme := "http"
|
||||
redirectURI := fmt.Sprintf("%s://%s/auth/callback", scheme, r.Host)
|
||||
|
||||
authURL := auth.BuildAuthorizeURL(oauthCfg, pkce, state, redirectURI)
|
||||
|
||||
// Store session for callback
|
||||
oauthSessionsMu.Lock()
|
||||
oauthSessions[state] = &oauthSession{
|
||||
Provider: "google-antigravity",
|
||||
PKCE: pkce,
|
||||
State: state,
|
||||
RedirectURI: redirectURI,
|
||||
OAuthCfg: oauthCfg,
|
||||
ConfigPath: configPath,
|
||||
}
|
||||
oauthSessionsMu.Unlock()
|
||||
|
||||
// Clean up stale sessions after 10 minutes
|
||||
go func() {
|
||||
time.Sleep(10 * time.Minute)
|
||||
oauthSessionsMu.Lock()
|
||||
delete(oauthSessions, state)
|
||||
oauthSessionsMu.Unlock()
|
||||
}()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "redirect",
|
||||
"auth_url": authURL,
|
||||
"message": "Open the URL to authenticate with Google.",
|
||||
})
|
||||
}
|
||||
|
||||
// handleOAuthCallback processes the OAuth callback from Google Antigravity.
|
||||
func handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
|
||||
state := r.URL.Query().Get("state")
|
||||
code := r.URL.Query().Get("code")
|
||||
|
||||
oauthSessionsMu.Lock()
|
||||
session, ok := oauthSessions[state]
|
||||
if ok {
|
||||
delete(oauthSessions, state)
|
||||
}
|
||||
oauthSessionsMu.Unlock()
|
||||
|
||||
if !ok {
|
||||
http.Error(w, "Invalid or expired OAuth state", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if code == "" {
|
||||
errMsg := r.URL.Query().Get("error")
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
fmt.Fprintf(
|
||||
w,
|
||||
`<html><body><h2>Authentication failed</h2><p>%s</p><p>You can close this window.</p></body></html>`,
|
||||
errMsg,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
cred, err := auth.ExchangeCodeForTokens(session.OAuthCfg, code, session.PKCE.CodeVerifier, session.RedirectURI)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
fmt.Fprintf(
|
||||
w,
|
||||
`<html><body><h2>Authentication failed</h2><p>%s</p><p>You can close this window.</p></body></html>`,
|
||||
err.Error(),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
cred.Provider = session.Provider
|
||||
|
||||
// Fetch user info for Google Antigravity
|
||||
if session.Provider == "google-antigravity" {
|
||||
if email, err := fetchGoogleUserEmail(cred.AccessToken); err == nil {
|
||||
cred.Email = email
|
||||
}
|
||||
if projectID, err := providers.FetchAntigravityProjectID(cred.AccessToken); err == nil {
|
||||
cred.ProjectID = projectID
|
||||
}
|
||||
}
|
||||
|
||||
if err := auth.SetCredential(session.Provider, cred); err != nil {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
fmt.Fprintf(w, `<html><body><h2>Failed to save credentials</h2><p>%s</p></body></html>`, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
updateConfigAfterLogin(session.ConfigPath, session.Provider, cred)
|
||||
|
||||
// Redirect back to picoclaw-launcher UI
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
fmt.Fprintf(w, `<html><body>
|
||||
<h2>Authentication successful!</h2>
|
||||
<p>Redirecting back to Config Editor...</p>
|
||||
<script>setTimeout(function(){ window.location.href = '/#auth'; }, 1000);</script>
|
||||
</body></html>`)
|
||||
}
|
||||
|
||||
// fetchGoogleUserEmail retrieves the user's email from Google's userinfo endpoint.
|
||||
func fetchGoogleUserEmail(accessToken string) (string, error) {
|
||||
req, err := http.NewRequest("GET", "https://www.googleapis.com/oauth2/v2/userinfo", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reading userinfo response: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("userinfo request failed: %s", string(body))
|
||||
}
|
||||
|
||||
var userInfo struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &userInfo); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return userInfo.Email, nil
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestLogBuffer_Basic(t *testing.T) {
|
||||
buf := NewLogBuffer(5)
|
||||
|
||||
// Empty buffer
|
||||
lines, total, runID := buf.LinesSince(0)
|
||||
assert.Nil(t, lines)
|
||||
assert.Equal(t, 0, total)
|
||||
assert.Equal(t, 0, runID)
|
||||
|
||||
// Append some lines
|
||||
buf.Append("line1")
|
||||
buf.Append("line2")
|
||||
buf.Append("line3")
|
||||
|
||||
lines, total, runID = buf.LinesSince(0)
|
||||
assert.Equal(t, []string{"line1", "line2", "line3"}, lines)
|
||||
assert.Equal(t, 3, total)
|
||||
assert.Equal(t, 0, runID)
|
||||
|
||||
// Incremental read
|
||||
lines, total, _ = buf.LinesSince(2)
|
||||
assert.Equal(t, []string{"line3"}, lines)
|
||||
assert.Equal(t, 3, total)
|
||||
|
||||
// No new lines
|
||||
lines, total, _ = buf.LinesSince(3)
|
||||
assert.Nil(t, lines)
|
||||
assert.Equal(t, 3, total)
|
||||
}
|
||||
|
||||
func TestLogBuffer_Wrap(t *testing.T) {
|
||||
buf := NewLogBuffer(3)
|
||||
|
||||
buf.Append("a")
|
||||
buf.Append("b")
|
||||
buf.Append("c")
|
||||
buf.Append("d") // evicts "a"
|
||||
buf.Append("e") // evicts "b"
|
||||
|
||||
lines, total, _ := buf.LinesSince(0)
|
||||
assert.Equal(t, []string{"c", "d", "e"}, lines)
|
||||
assert.Equal(t, 5, total)
|
||||
|
||||
// Incremental after wrap
|
||||
lines, total, _ = buf.LinesSince(3)
|
||||
assert.Equal(t, []string{"d", "e"}, lines)
|
||||
assert.Equal(t, 5, total)
|
||||
|
||||
// Offset too old (before buffer start), get all buffered
|
||||
lines, total, _ = buf.LinesSince(1)
|
||||
assert.Equal(t, []string{"c", "d", "e"}, lines)
|
||||
assert.Equal(t, 5, total)
|
||||
}
|
||||
|
||||
func TestLogBuffer_Reset(t *testing.T) {
|
||||
buf := NewLogBuffer(5)
|
||||
|
||||
buf.Append("before")
|
||||
assert.Equal(t, 0, buf.RunID())
|
||||
|
||||
buf.Reset()
|
||||
assert.Equal(t, 1, buf.RunID())
|
||||
assert.Equal(t, 0, buf.Total())
|
||||
|
||||
lines, total, runID := buf.LinesSince(0)
|
||||
assert.Nil(t, lines)
|
||||
assert.Equal(t, 0, total)
|
||||
assert.Equal(t, 1, runID)
|
||||
|
||||
buf.Append("after")
|
||||
lines, total, runID = buf.LinesSince(0)
|
||||
assert.Equal(t, []string{"after"}, lines)
|
||||
assert.Equal(t, 1, total)
|
||||
assert.Equal(t, 1, runID)
|
||||
}
|
||||
|
||||
func TestLogBuffer_Concurrent(t *testing.T) {
|
||||
buf := NewLogBuffer(100)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// 10 writers
|
||||
for i := range 10 {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
for j := range 50 {
|
||||
buf.Append(fmt.Sprintf("writer-%d-line-%d", id, j))
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
// 5 readers
|
||||
for range 5 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for range 100 {
|
||||
buf.LinesSince(0)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
assert.Equal(t, 500, buf.Total())
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
// gatewayLogs stores captured stdout/stderr from the gateway process launched by the launcher.
|
||||
var gatewayLogs = NewLogBuffer(200)
|
||||
|
||||
// RegisterProcessAPI registers endpoints to start, stop and check status of the picoclaw gateway.
|
||||
func RegisterProcessAPI(mux *http.ServeMux, absPath string) {
|
||||
mux.HandleFunc("GET /api/process/status", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleStatusGateway(w, r, absPath)
|
||||
})
|
||||
mux.HandleFunc("POST /api/process/start", handleStartGateway)
|
||||
mux.HandleFunc("POST /api/process/stop", handleStopGateway)
|
||||
}
|
||||
|
||||
func handleStartGateway(w http.ResponseWriter, r *http.Request) {
|
||||
// Locate picoclaw executable:
|
||||
// 1. Try same directory as current executable
|
||||
// 2. Fallback to just "picoclaw" (relies on $PATH)
|
||||
execPath := "picoclaw"
|
||||
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
dir := filepath.Dir(exe)
|
||||
candidate := filepath.Join(dir, "picoclaw")
|
||||
if runtime.GOOS == "windows" {
|
||||
candidate += ".exe"
|
||||
}
|
||||
|
||||
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
|
||||
execPath = candidate
|
||||
}
|
||||
}
|
||||
|
||||
cmd := exec.Command(execPath, "gateway")
|
||||
|
||||
stdoutPipe, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
log.Printf("Failed to create stdout pipe: %v\n", err)
|
||||
http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
stderrPipe, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
log.Printf("Failed to create stderr pipe: %v\n", err)
|
||||
http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Clear old logs and increment runID before starting
|
||||
gatewayLogs.Reset()
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
log.Printf("Failed to start picoclaw gateway: %v\n", err)
|
||||
http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Read stdout and stderr into the log buffer
|
||||
go scanPipe(stdoutPipe, gatewayLogs)
|
||||
go scanPipe(stderrPipe, gatewayLogs)
|
||||
|
||||
// Wait for the process to exit in the background to avoid zombies
|
||||
go func() {
|
||||
if err := cmd.Wait(); err != nil {
|
||||
log.Printf("Gateway process exited: %v\n", err)
|
||||
}
|
||||
}()
|
||||
|
||||
log.Printf("Started picoclaw gateway (PID: %d) from %s\n", cmd.Process.Pid, execPath)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"status": "ok",
|
||||
"pid": cmd.Process.Pid,
|
||||
})
|
||||
}
|
||||
|
||||
// scanPipe reads lines from r and appends them to buf. It returns when r reaches EOF.
|
||||
func scanPipe(r io.Reader, buf *LogBuffer) {
|
||||
scanner := bufio.NewScanner(r)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) // up to 1MB per line
|
||||
|
||||
for scanner.Scan() {
|
||||
buf.Append(scanner.Text())
|
||||
}
|
||||
}
|
||||
|
||||
func handleStopGateway(w http.ResponseWriter, r *http.Request) {
|
||||
var err error
|
||||
if runtime.GOOS == "windows" {
|
||||
// Kill via taskkill finding picoclaw.exe (though it might kill this config tool if it's named picoclaw-launcher.exe...? No, /IM does exact match usually, but just to be safe let's stop exactly picoclaw.exe)
|
||||
// Alternatively, we use powershell to kill processes with commandline containing 'gateway'
|
||||
psCmd := `Get-WmiObject Win32_Process | Where-Object { $_.CommandLine -match 'picoclaw.*gateway' } | ForEach-Object { Stop-Process $_.ProcessId -Force }`
|
||||
err = exec.Command("powershell", "-Command", psCmd).Run()
|
||||
} else {
|
||||
// Linux/macOS
|
||||
err = exec.Command("pkill", "-f", "picoclaw gateway").Run()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to stop gateway (perhaps not running?): %v\n", err)
|
||||
// We still return 200 OK because pkill returns an error if no process was found
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"status": "ok", // or "not_found"
|
||||
"msg": "Stop command executed, but returned error (process might not be running).",
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Stopped picoclaw gateway processes.\n")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "ok",
|
||||
})
|
||||
}
|
||||
|
||||
func handleStatusGateway(w http.ResponseWriter, r *http.Request, absPath string) {
|
||||
cfg, cfgErr := config.LoadConfig(absPath)
|
||||
host := "127.0.0.1"
|
||||
port := 18790
|
||||
if cfgErr == nil && cfg != nil {
|
||||
if cfg.Gateway.Host != "" && cfg.Gateway.Host != "0.0.0.0" {
|
||||
host = cfg.Gateway.Host
|
||||
}
|
||||
if cfg.Gateway.Port != 0 {
|
||||
port = cfg.Gateway.Port
|
||||
}
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("http://%s/health", net.JoinHostPort(host, strconv.Itoa(port)))
|
||||
client := http.Client{Timeout: 2 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
|
||||
// Build the response data map
|
||||
data := map[string]any{}
|
||||
|
||||
if err != nil {
|
||||
data["process_status"] = "stopped"
|
||||
data["error"] = err.Error()
|
||||
} else {
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
data["process_status"] = "error"
|
||||
data["status_code"] = resp.StatusCode
|
||||
} else {
|
||||
var healthData map[string]any
|
||||
if decErr := json.NewDecoder(resp.Body).Decode(&healthData); decErr != nil {
|
||||
data["process_status"] = "error"
|
||||
data["error"] = "invalid response from gateway"
|
||||
} else {
|
||||
// Gateway is running and responded properly — merge health data
|
||||
for k, v := range healthData {
|
||||
data[k] = v
|
||||
}
|
||||
data["process_status"] = "running"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Append log data from the buffer
|
||||
appendLogData(r, data)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
// appendLogData reads log_offset and log_run_id query params from the request and
|
||||
// populates the response data map with incremental log lines.
|
||||
func appendLogData(r *http.Request, data map[string]any) {
|
||||
clientOffset := 0
|
||||
clientRunID := -1
|
||||
|
||||
if v := r.URL.Query().Get("log_offset"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
clientOffset = n
|
||||
}
|
||||
}
|
||||
|
||||
if v := r.URL.Query().Get("log_run_id"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
clientRunID = n
|
||||
}
|
||||
}
|
||||
|
||||
runID := gatewayLogs.RunID()
|
||||
|
||||
// If runID is 0 (never reset = never launched from this launcher), report no source
|
||||
if runID == 0 {
|
||||
data["logs"] = []string{}
|
||||
data["log_total"] = 0
|
||||
data["log_run_id"] = 0
|
||||
data["log_source"] = "none"
|
||||
return
|
||||
}
|
||||
|
||||
// If the client's runID doesn't match, send all buffered lines (gateway restarted)
|
||||
offset := clientOffset
|
||||
if clientRunID != runID {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
lines, total, runID := gatewayLogs.LinesSince(offset)
|
||||
if lines == nil {
|
||||
lines = []string{}
|
||||
}
|
||||
|
||||
data["logs"] = lines
|
||||
data["log_total"] = total
|
||||
data["log_run_id"] = runID
|
||||
data["log_source"] = "launcher"
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/auth"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
const DefaultPort = "18800"
|
||||
|
||||
// providerStatus represents the auth status of a single provider in API responses.
|
||||
type providerStatus struct {
|
||||
Provider string `json:"provider"`
|
||||
AuthMethod string `json:"auth_method"`
|
||||
Status string `json:"status"`
|
||||
AccountID string `json:"account_id,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
// ── Route registration ───────────────────────────────────────────
|
||||
|
||||
func RegisterConfigAPI(mux *http.ServeMux, absPath string) {
|
||||
// GET /api/config — read config
|
||||
mux.HandleFunc("GET /api/config", func(w http.ResponseWriter, r *http.Request) {
|
||||
cfg, err := config.LoadConfig(absPath)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
resp := map[string]any{
|
||||
"config": cfg,
|
||||
"path": absPath,
|
||||
}
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(resp); err != nil {
|
||||
log.Printf("Failed to encode response: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// PUT /api/config — save config
|
||||
mux.HandleFunc("PUT /api/config", func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to read request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
var cfg config.Config
|
||||
if err := json.Unmarshal(body, &cfg); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := config.SaveConfig(absPath, &cfg); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
})
|
||||
}
|
||||
|
||||
func RegisterAuthAPI(mux *http.ServeMux, absPath string) {
|
||||
// GET /api/auth/status — all authenticated providers + pending login state
|
||||
mux.HandleFunc("GET /api/auth/status", func(w http.ResponseWriter, r *http.Request) {
|
||||
store, err := auth.LoadStore()
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to load auth store: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
result := []providerStatus{}
|
||||
for name, cred := range store.Credentials {
|
||||
status := "active"
|
||||
if cred.IsExpired() {
|
||||
status = "expired"
|
||||
} else if cred.NeedsRefresh() {
|
||||
status = "needs_refresh"
|
||||
}
|
||||
ps := providerStatus{
|
||||
Provider: name,
|
||||
AuthMethod: cred.AuthMethod,
|
||||
Status: status,
|
||||
AccountID: cred.AccountID,
|
||||
Email: cred.Email,
|
||||
ProjectID: cred.ProjectID,
|
||||
}
|
||||
if !cred.ExpiresAt.IsZero() {
|
||||
ps.ExpiresAt = cred.ExpiresAt.Format(time.RFC3339)
|
||||
}
|
||||
result = append(result, ps)
|
||||
}
|
||||
|
||||
// Include pending device code state
|
||||
var pendingDevice map[string]any
|
||||
activeDeviceSessionMu.Lock()
|
||||
if activeDeviceSession != nil {
|
||||
activeDeviceSession.mu.Lock()
|
||||
pendingDevice = map[string]any{
|
||||
"provider": activeDeviceSession.Provider,
|
||||
"status": activeDeviceSession.Status,
|
||||
"device_url": activeDeviceSession.Info.VerifyURL,
|
||||
"user_code": activeDeviceSession.Info.UserCode,
|
||||
}
|
||||
if activeDeviceSession.Error != "" {
|
||||
pendingDevice["error"] = activeDeviceSession.Error
|
||||
}
|
||||
if activeDeviceSession.Done {
|
||||
activeDeviceSession.mu.Unlock()
|
||||
activeDeviceSession = nil
|
||||
} else {
|
||||
activeDeviceSession.mu.Unlock()
|
||||
}
|
||||
}
|
||||
activeDeviceSessionMu.Unlock()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"providers": result,
|
||||
"pending_device": pendingDevice,
|
||||
})
|
||||
})
|
||||
|
||||
// POST /api/auth/login — initiate provider login
|
||||
mux.HandleFunc("POST /api/auth/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Provider string `json:"provider"`
|
||||
Token string `json:"token,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
switch req.Provider {
|
||||
case "openai":
|
||||
handleOpenAILogin(w, absPath)
|
||||
case "anthropic":
|
||||
handleAnthropicLogin(w, req.Token, absPath)
|
||||
case "google-antigravity", "antigravity":
|
||||
handleGoogleAntigravityLogin(w, r, absPath)
|
||||
default:
|
||||
http.Error(
|
||||
w,
|
||||
fmt.Sprintf(
|
||||
"Unsupported provider: %s (supported: openai, anthropic, google-antigravity)",
|
||||
req.Provider,
|
||||
),
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/auth/logout — logout a provider
|
||||
mux.HandleFunc("POST /api/auth/logout", func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Provider string `json:"provider"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Provider == "" {
|
||||
if err := auth.DeleteAllCredentials(); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to logout: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
clearAllAuthMethodsInConfig(absPath)
|
||||
} else {
|
||||
if err := auth.DeleteCredential(req.Provider); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to logout: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
clearAuthMethodInConfig(absPath, req.Provider)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
})
|
||||
|
||||
// GET /auth/callback — OAuth browser callback for Google Antigravity
|
||||
mux.HandleFunc("GET /auth/callback", handleOAuthCallback)
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
// ── Config API tests ─────────────────────────────────────────────
|
||||
|
||||
func setupConfigMux(t *testing.T, cfg *config.Config) (*http.ServeMux, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.json")
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal config: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
RegisterConfigAPI(mux, path)
|
||||
RegisterAuthAPI(mux, path)
|
||||
return mux, path
|
||||
}
|
||||
|
||||
func TestGetConfig(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
ModelList: []config.ModelConfig{
|
||||
{ModelName: "gpt-4o", Model: "openai/gpt-4o"},
|
||||
},
|
||||
}
|
||||
mux, path := setupConfigMux(t, cfg)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/config", nil)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET /api/config: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Config config.Config `json:"config"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
|
||||
if resp.Path != path {
|
||||
t.Errorf("expected path %q, got %q", path, resp.Path)
|
||||
}
|
||||
if len(resp.Config.ModelList) != 1 {
|
||||
t.Errorf("expected 1 model, got %d", len(resp.Config.ModelList))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetConfig_MissingFile_ReturnsDefault(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
RegisterConfigAPI(mux, "/tmp/nonexistent-picoclaw-launcher-test/config.json")
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/config", nil)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
// LoadConfig returns a default empty config when file is missing
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200 for missing file (default config), got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutConfig(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
mux, path := setupConfigMux(t, cfg)
|
||||
|
||||
newCfg := config.Config{
|
||||
ModelList: []config.ModelConfig{
|
||||
{ModelName: "claude", Model: "anthropic/claude-sonnet-4.6", AuthMethod: "token"},
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(newCfg)
|
||||
|
||||
req := httptest.NewRequest("PUT", "/api/config", strings.NewReader(string(body)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("PUT /api/config: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
saved, err := config.LoadConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("load saved config: %v", err)
|
||||
}
|
||||
if len(saved.ModelList) != 1 {
|
||||
t.Fatalf("expected 1 model saved, got %d", len(saved.ModelList))
|
||||
}
|
||||
if saved.ModelList[0].Model != "anthropic/claude-sonnet-4.6" {
|
||||
t.Errorf("expected model anthropic/claude-sonnet-4.6, got %q", saved.ModelList[0].Model)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutConfig_InvalidJSON(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
mux, _ := setupConfigMux(t, cfg)
|
||||
|
||||
req := httptest.NewRequest("PUT", "/api/config", strings.NewReader("{invalid"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for invalid JSON, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auth API tests ───────────────────────────────────────────────
|
||||
|
||||
func TestAuthStatus(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
mux, _ := setupConfigMux(t, cfg)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/auth/status", nil)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET /api/auth/status: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Providers []providerStatus `json:"providers"`
|
||||
PendingDevice map[string]any `json:"pending_device"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
|
||||
// providers should be a non-nil list (could be empty)
|
||||
if resp.Providers == nil {
|
||||
t.Error("providers should not be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogin_UnsupportedProvider(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
mux, _ := setupConfigMux(t, cfg)
|
||||
|
||||
body := `{"provider": "unsupported"}`
|
||||
req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for unsupported provider, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogin_AnthropicNoToken(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
mux, _ := setupConfigMux(t, cfg)
|
||||
|
||||
body := `{"provider": "anthropic"}`
|
||||
req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for anthropic without token, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogin_InvalidBody(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
mux, _ := setupConfigMux(t, cfg)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader("{bad"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for invalid JSON body, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogout_InvalidBody(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
mux, _ := setupConfigMux(t, cfg)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/auth/logout", strings.NewReader("{bad"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for invalid body, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthCallback_InvalidState(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
mux, _ := setupConfigMux(t, cfg)
|
||||
|
||||
req := httptest.NewRequest("GET", "/auth/callback?state=invalid&code=test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for invalid state, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Utility tests ────────────────────────────────────────────────
|
||||
|
||||
func TestDefaultConfigPath(t *testing.T) {
|
||||
path := DefaultConfigPath()
|
||||
if path == "" {
|
||||
t.Error("defaultConfigPath should not return empty")
|
||||
}
|
||||
if !strings.HasSuffix(path, filepath.Join(".picoclaw", "config.json")) {
|
||||
t.Errorf("expected path ending with .picoclaw/config.json, got %q", path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLocalIP(t *testing.T) {
|
||||
// Just ensure it doesn't panic; IP may or may not be available
|
||||
ip := GetLocalIP()
|
||||
if ip != "" {
|
||||
// If returned, should look like an IP
|
||||
if !strings.Contains(ip, ".") {
|
||||
t.Errorf("getLocalIP returned non-IPv4 looking string: %q", ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func DefaultConfigPath() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "config.json"
|
||||
}
|
||||
return filepath.Join(home, ".picoclaw", "config.json")
|
||||
}
|
||||
|
||||
func GetLocalIP() string {
|
||||
addrs, err := net.InterfaceAddrs()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, a := range addrs {
|
||||
if ipnet, ok := a.(*net.IPNet); ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil {
|
||||
return ipnet.IP.String()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
// PicoClaw Launcher - Standalone HTTP service
|
||||
//
|
||||
// Provides a web-based JSON editor for picoclaw config files,
|
||||
// with OAuth provider authentication support.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// go build -o picoclaw-launcher ./cmd/picoclaw-launcher/
|
||||
// ./picoclaw-launcher [config.json]
|
||||
// ./picoclaw-launcher -public config.json
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw-launcher/internal/server"
|
||||
)
|
||||
|
||||
//go:embed internal/ui/index.html
|
||||
var staticFiles embed.FS
|
||||
|
||||
func main() {
|
||||
public := flag.Bool("public", false, "Listen on all interfaces (0.0.0.0) instead of localhost only")
|
||||
flag.Usage = func() {
|
||||
fmt.Fprintf(os.Stderr, "PicoClaw Launcher - A web-based configuration editor\n\n")
|
||||
fmt.Fprintf(os.Stderr, "Usage: %s [options] [config.json]\n\n", os.Args[0])
|
||||
fmt.Fprintf(os.Stderr, "Arguments:\n")
|
||||
fmt.Fprintf(os.Stderr, " config.json Path to the configuration file (default: ~/.picoclaw/config.json)\n\n")
|
||||
fmt.Fprintf(os.Stderr, "Options:\n")
|
||||
flag.PrintDefaults()
|
||||
fmt.Fprintf(os.Stderr, "\nExamples:\n")
|
||||
fmt.Fprintf(os.Stderr, " %s Use default config path\n", os.Args[0])
|
||||
fmt.Fprintf(os.Stderr, " %s ./config.json Specify a config file\n", os.Args[0])
|
||||
fmt.Fprintf(
|
||||
os.Stderr,
|
||||
" %s -public ./config.json Allow access from other devices on the network\n",
|
||||
os.Args[0],
|
||||
)
|
||||
}
|
||||
flag.Parse()
|
||||
|
||||
configPath := server.DefaultConfigPath()
|
||||
if flag.NArg() > 0 {
|
||||
configPath = flag.Arg(0)
|
||||
}
|
||||
|
||||
absPath, err := filepath.Abs(configPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to resolve config path: %v", err)
|
||||
}
|
||||
|
||||
var addr string
|
||||
if *public {
|
||||
addr = "0.0.0.0:" + server.DefaultPort
|
||||
} else {
|
||||
addr = "127.0.0.1:" + server.DefaultPort
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
server.RegisterConfigAPI(mux, absPath)
|
||||
server.RegisterAuthAPI(mux, absPath)
|
||||
server.RegisterProcessAPI(mux, absPath)
|
||||
|
||||
staticFS, err := fs.Sub(staticFiles, "internal/ui")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create sub filesystem: %v", err)
|
||||
}
|
||||
mux.Handle("/", http.FileServer(http.FS(staticFS)))
|
||||
|
||||
// Print startup banner
|
||||
fmt.Println("=============================================")
|
||||
fmt.Println(" PicoClaw Launcher")
|
||||
fmt.Println("=============================================")
|
||||
fmt.Printf(" Config file : %s\n", absPath)
|
||||
fmt.Printf(" Listen addr : %s\n\n", addr)
|
||||
fmt.Println(" Open the following URL in your browser")
|
||||
fmt.Println(" to view and edit the configuration:")
|
||||
fmt.Println()
|
||||
fmt.Printf(" >> http://localhost:%s <<\n", server.DefaultPort)
|
||||
if *public {
|
||||
if ip := server.GetLocalIP(); ip != "" {
|
||||
fmt.Printf(" >> http://%s:%s <<\n", ip, server.DefaultPort)
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
// fmt.Println("=============================================")
|
||||
|
||||
go func() {
|
||||
// Wait briefly to ensure the server is ready before opening the browser
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
url := "http://localhost:" + server.DefaultPort
|
||||
if err := openBrowser(url); err != nil {
|
||||
log.Printf("Warning: Failed to auto-open browser: %v\n", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||
log.Fatalf("Server failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// openBrowser automatically opens the given URL in the default browser.
|
||||
func openBrowser(url string) error {
|
||||
var err error
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
err = exec.Command("xdg-open", url).Start()
|
||||
case "windows":
|
||||
err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
|
||||
case "darwin":
|
||||
err = exec.Command("open", url).Start()
|
||||
default:
|
||||
err = fmt.Errorf("unsupported platform")
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
.PHONY: dev dev-frontend dev-backend build test lint clean
|
||||
|
||||
# Run both frontend and backend dev servers
|
||||
dev:
|
||||
@if [ ! -f backend/picoclaw-web ] || [ ! -d backend/dist ]; then \
|
||||
echo "Build artifacts not found, building..."; \
|
||||
$(MAKE) build; \
|
||||
fi
|
||||
@echo "Starting backend and frontend dev servers..."
|
||||
@$(MAKE) dev-backend & $(MAKE) dev-frontend
|
||||
|
||||
# Start frontend dev server (Vite, with proxy to backend)
|
||||
dev-frontend:
|
||||
cd frontend && pnpm dev
|
||||
|
||||
# Start backend dev server
|
||||
dev-backend:
|
||||
cd backend && go run .
|
||||
|
||||
# Build frontend and embed into Go binary
|
||||
build:
|
||||
cd frontend && pnpm build:backend
|
||||
cd backend && go build -o picoclaw-web .
|
||||
|
||||
# Run all tests
|
||||
test:
|
||||
cd backend && go test ./...
|
||||
cd frontend && pnpm lint
|
||||
|
||||
# Lint and format
|
||||
lint:
|
||||
cd backend && go vet ./...
|
||||
cd frontend && pnpm check
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
rm -rf frontend/dist backend/dist backend/picoclaw-web
|
||||
mkdir -p backend/dist && touch backend/dist/.gitkeep
|
||||
@@ -0,0 +1,51 @@
|
||||
# Picoclaw Web
|
||||
|
||||
This directory contains the standalone web service for `picoclaw`.
|
||||
It provides a complete unified web interface, acting as a dashboard, configuration center, and interactive console (channel client) for the core `picoclaw` engine.
|
||||
|
||||
## Architecture
|
||||
|
||||
The service is structured as a monorepo containing both the backend and frontend code to ensure high cohesion and simplify deployment.
|
||||
|
||||
* **`backend/`**: The Go-based web server. It provides RESTful APIs, manages WebSocket connections for chat, and handles the lifecycle of the `picoclaw` process. It eventually embeds the compiled frontend assets into a single executable.
|
||||
* **`frontend/`**: The Vite + React + TanStack Router single-page application (SPA). It provides the interactive user interface.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
* Go 1.25+
|
||||
* Node.js 20+ with pnpm
|
||||
|
||||
### Development
|
||||
|
||||
Run both the frontend dev server and the Go backend simultaneously:
|
||||
|
||||
```bash
|
||||
make dev
|
||||
```
|
||||
|
||||
Or run them separately:
|
||||
|
||||
```bash
|
||||
make dev-frontend # Vite dev server
|
||||
make dev-backend # Go backend
|
||||
```
|
||||
|
||||
### Build
|
||||
|
||||
Build the frontend and embed it into a single Go binary:
|
||||
|
||||
```bash
|
||||
make build
|
||||
```
|
||||
|
||||
The output binary is `backend/picoclaw-web`.
|
||||
|
||||
### Other Commands
|
||||
|
||||
```bash
|
||||
make test # Run backend tests and frontend lint
|
||||
make lint # Run go vet and prettier/eslint
|
||||
make clean # Remove all build artifacts
|
||||
```
|
||||
@@ -0,0 +1,19 @@
|
||||
# Go build output
|
||||
*.exe
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
*.test
|
||||
*.out
|
||||
picoclaw-web
|
||||
|
||||
# Frontend build artifacts (embedded by Go)
|
||||
dist/*
|
||||
!dist/.gitkeep
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Editors
|
||||
.vscode/
|
||||
.idea/
|
||||
@@ -0,0 +1,47 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type channelCatalogItem struct {
|
||||
Name string `json:"name"`
|
||||
ConfigKey string `json:"config_key"`
|
||||
Variant string `json:"variant,omitempty"`
|
||||
}
|
||||
|
||||
var channelCatalog = []channelCatalogItem{
|
||||
{Name: "telegram", ConfigKey: "telegram"},
|
||||
{Name: "discord", ConfigKey: "discord"},
|
||||
{Name: "slack", ConfigKey: "slack"},
|
||||
{Name: "feishu", ConfigKey: "feishu"},
|
||||
{Name: "dingtalk", ConfigKey: "dingtalk"},
|
||||
{Name: "line", ConfigKey: "line"},
|
||||
{Name: "qq", ConfigKey: "qq"},
|
||||
{Name: "onebot", ConfigKey: "onebot"},
|
||||
{Name: "wecom", ConfigKey: "wecom"},
|
||||
{Name: "wecom_app", ConfigKey: "wecom_app"},
|
||||
{Name: "wecom_aibot", ConfigKey: "wecom_aibot"},
|
||||
{Name: "whatsapp", ConfigKey: "whatsapp", Variant: "bridge"},
|
||||
{Name: "whatsapp_native", ConfigKey: "whatsapp", Variant: "native"},
|
||||
{Name: "pico", ConfigKey: "pico"},
|
||||
{Name: "maixcam", ConfigKey: "maixcam"},
|
||||
{Name: "matrix", ConfigKey: "matrix"},
|
||||
{Name: "irc", ConfigKey: "irc"},
|
||||
}
|
||||
|
||||
// registerChannelRoutes binds read-only channel catalog endpoints to the ServeMux.
|
||||
func (h *Handler) registerChannelRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/channels/catalog", h.handleListChannelCatalog)
|
||||
}
|
||||
|
||||
// handleListChannelCatalog returns the channels supported by backend.
|
||||
//
|
||||
// GET /api/channels/catalog
|
||||
func (h *Handler) handleListChannelCatalog(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"channels": channelCatalog,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
// registerConfigRoutes binds configuration management endpoints to the ServeMux.
|
||||
func (h *Handler) registerConfigRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/config", h.handleGetConfig)
|
||||
mux.HandleFunc("PUT /api/config", h.handleUpdateConfig)
|
||||
mux.HandleFunc("PATCH /api/config", h.handlePatchConfig)
|
||||
}
|
||||
|
||||
// loadFilteredConfig loads the configuration and filters out default placeholder credentials
|
||||
// (like API limits/keys) if the configuration file has not been created yet by the user.
|
||||
func (h *Handler) loadFilteredConfig() (*config.Config, error) {
|
||||
cfg, err := config.LoadConfig(h.configPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
configExists := false
|
||||
if h.configPath != "" {
|
||||
if _, err := os.Stat(h.configPath); err == nil {
|
||||
configExists = true
|
||||
}
|
||||
}
|
||||
|
||||
if !configExists {
|
||||
for i := range cfg.ModelList {
|
||||
cfg.ModelList[i].APIKey = ""
|
||||
cfg.ModelList[i].AuthMethod = ""
|
||||
}
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// handleGetConfig returns the complete system configuration.
|
||||
//
|
||||
// GET /api/config
|
||||
func (h *Handler) handleGetConfig(w http.ResponseWriter, r *http.Request) {
|
||||
cfg, err := h.loadFilteredConfig()
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(cfg); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// handleUpdateConfig updates the complete system configuration.
|
||||
//
|
||||
// PUT /api/config
|
||||
func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to read request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
var cfg config.Config
|
||||
if err := json.Unmarshal(body, &cfg); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if errs := validateConfig(&cfg); len(errs) > 0 {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"status": "validation_error",
|
||||
"errors": errs,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := config.SaveConfig(h.configPath, &cfg); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// handlePatchConfig partially updates the system configuration using JSON Merge Patch (RFC 7396).
|
||||
// Only the fields present in the request body will be updated; all other fields remain unchanged.
|
||||
//
|
||||
// PATCH /api/config
|
||||
func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) {
|
||||
patchBody, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to read request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
// Validate the patch is valid JSON
|
||||
var patch map[string]any
|
||||
if err = json.Unmarshal(patchBody, &patch); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Load existing config and marshal to a map for merging
|
||||
cfg, err := config.LoadConfig(h.configPath)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
existing, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to serialize current config", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var base map[string]any
|
||||
if err = json.Unmarshal(existing, &base); err != nil {
|
||||
http.Error(w, "Failed to parse current config", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Recursively merge patch into base
|
||||
mergeMap(base, patch)
|
||||
|
||||
// Convert merged map back to Config struct
|
||||
merged, err := json.Marshal(base)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to serialize merged config", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var newCfg config.Config
|
||||
if err := json.Unmarshal(merged, &newCfg); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Merged config is invalid: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if errs := validateConfig(&newCfg); len(errs) > 0 {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"status": "validation_error",
|
||||
"errors": errs,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := config.SaveConfig(h.configPath, &newCfg); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// validateConfig checks the config for common errors before saving.
|
||||
// Returns a list of human-readable error strings; empty means valid.
|
||||
func validateConfig(cfg *config.Config) []string {
|
||||
var errs []string
|
||||
|
||||
// Validate model_list entries
|
||||
if err := cfg.ValidateModelList(); err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
}
|
||||
|
||||
// Gateway port range
|
||||
if cfg.Gateway.Port != 0 && (cfg.Gateway.Port < 1 || cfg.Gateway.Port > 65535) {
|
||||
errs = append(errs, fmt.Sprintf("gateway.port %d is out of valid range (1-65535)", cfg.Gateway.Port))
|
||||
}
|
||||
|
||||
// Pico channel: token required when enabled
|
||||
if cfg.Channels.Pico.Enabled && cfg.Channels.Pico.Token == "" {
|
||||
errs = append(errs, "channels.pico.token is required when pico channel is enabled")
|
||||
}
|
||||
|
||||
// Telegram: token required when enabled
|
||||
if cfg.Channels.Telegram.Enabled && cfg.Channels.Telegram.Token == "" {
|
||||
errs = append(errs, "channels.telegram.token is required when telegram channel is enabled")
|
||||
}
|
||||
|
||||
// Discord: token required when enabled
|
||||
if cfg.Channels.Discord.Enabled && cfg.Channels.Discord.Token == "" {
|
||||
errs = append(errs, "channels.discord.token is required when discord channel is enabled")
|
||||
}
|
||||
|
||||
return errs
|
||||
}
|
||||
|
||||
// mergeMap recursively merges src into dst (JSON Merge Patch semantics).
|
||||
// - If a key in src has a null value, it is deleted from dst.
|
||||
// - If both dst and src have a nested object for the same key, merge recursively.
|
||||
// - Otherwise the value from src overwrites dst.
|
||||
func mergeMap(dst, src map[string]any) {
|
||||
for key, srcVal := range src {
|
||||
if srcVal == nil {
|
||||
delete(dst, key)
|
||||
continue
|
||||
}
|
||||
srcMap, srcIsMap := srcVal.(map[string]any)
|
||||
dstMap, dstIsMap := dst[key].(map[string]any)
|
||||
if srcIsMap && dstIsMap {
|
||||
mergeMap(dstMap, srcMap)
|
||||
} else {
|
||||
dst[key] = srcVal
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// GatewayEvent represents a state change event for the gateway process.
|
||||
type GatewayEvent struct {
|
||||
Status string `json:"gateway_status"` // "running", "starting", "stopped", "error"
|
||||
PID int `json:"pid,omitempty"`
|
||||
}
|
||||
|
||||
// EventBroadcaster manages SSE client subscriptions and broadcasts events.
|
||||
type EventBroadcaster struct {
|
||||
mu sync.RWMutex
|
||||
clients map[chan string]struct{}
|
||||
}
|
||||
|
||||
// NewEventBroadcaster creates a new broadcaster.
|
||||
func NewEventBroadcaster() *EventBroadcaster {
|
||||
return &EventBroadcaster{
|
||||
clients: make(map[chan string]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe adds a new listener channel and returns it.
|
||||
// The caller must call Unsubscribe when done.
|
||||
func (b *EventBroadcaster) Subscribe() chan string {
|
||||
ch := make(chan string, 8)
|
||||
b.mu.Lock()
|
||||
b.clients[ch] = struct{}{}
|
||||
b.mu.Unlock()
|
||||
return ch
|
||||
}
|
||||
|
||||
// Unsubscribe removes a listener channel and closes it.
|
||||
func (b *EventBroadcaster) Unsubscribe(ch chan string) {
|
||||
b.mu.Lock()
|
||||
delete(b.clients, ch)
|
||||
b.mu.Unlock()
|
||||
close(ch)
|
||||
}
|
||||
|
||||
// Broadcast sends a GatewayEvent to all connected SSE clients.
|
||||
func (b *EventBroadcaster) Broadcast(event GatewayEvent) {
|
||||
data, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
|
||||
for ch := range b.clients {
|
||||
// Non-blocking send; drop event if client is slow
|
||||
select {
|
||||
case ch <- string(data):
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
// gateway holds the state for the managed gateway process.
|
||||
var gateway = struct {
|
||||
mu sync.Mutex
|
||||
cmd *exec.Cmd
|
||||
logs *LogBuffer
|
||||
events *EventBroadcaster
|
||||
}{
|
||||
logs: NewLogBuffer(200),
|
||||
events: NewEventBroadcaster(),
|
||||
}
|
||||
|
||||
// registerGatewayRoutes binds gateway lifecycle endpoints to the ServeMux.
|
||||
func (h *Handler) registerGatewayRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/gateway/status", h.handleGatewayStatus)
|
||||
mux.HandleFunc("GET /api/gateway/events", h.handleGatewayEvents)
|
||||
mux.HandleFunc("POST /api/gateway/start", h.handleGatewayStart)
|
||||
mux.HandleFunc("POST /api/gateway/stop", h.handleGatewayStop)
|
||||
mux.HandleFunc("POST /api/gateway/restart", h.handleGatewayRestart)
|
||||
}
|
||||
|
||||
// TryAutoStartGateway checks whether gateway start preconditions are met and
|
||||
// starts it when possible. Intended to be called by the backend at startup.
|
||||
func (h *Handler) TryAutoStartGateway() {
|
||||
gateway.mu.Lock()
|
||||
defer gateway.mu.Unlock()
|
||||
|
||||
if isGatewayProcessAliveLocked() {
|
||||
return
|
||||
}
|
||||
if gateway.cmd != nil && gateway.cmd.Process != nil {
|
||||
gateway.cmd = nil
|
||||
}
|
||||
|
||||
ready, reason, err := h.gatewayStartReady()
|
||||
if err != nil {
|
||||
log.Printf("Skip auto-starting gateway: %v", err)
|
||||
return
|
||||
}
|
||||
if !ready {
|
||||
log.Printf("Skip auto-starting gateway: %s", reason)
|
||||
return
|
||||
}
|
||||
|
||||
pid, err := h.startGatewayLocked()
|
||||
if err != nil {
|
||||
log.Printf("Failed to auto-start gateway: %v", err)
|
||||
return
|
||||
}
|
||||
log.Printf("Gateway auto-started (PID: %d)", pid)
|
||||
}
|
||||
|
||||
// gatewayStartReady validates whether current config can start the gateway.
|
||||
func (h *Handler) gatewayStartReady() (bool, string, error) {
|
||||
cfg, err := config.LoadConfig(h.configPath)
|
||||
if err != nil {
|
||||
return false, "", fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
modelName := strings.TrimSpace(cfg.Agents.Defaults.GetModelName())
|
||||
if modelName == "" {
|
||||
return false, "no default model configured", nil
|
||||
}
|
||||
|
||||
modelCfg := lookupModelConfig(cfg, modelName)
|
||||
if modelCfg == nil {
|
||||
return false, fmt.Sprintf("default model %q is invalid", modelName), nil
|
||||
}
|
||||
|
||||
hasCredential := strings.TrimSpace(modelCfg.APIKey) != "" ||
|
||||
strings.TrimSpace(modelCfg.AuthMethod) != ""
|
||||
if !hasCredential {
|
||||
return false, fmt.Sprintf("default model %q has no credentials configured", modelName), nil
|
||||
}
|
||||
|
||||
return true, "", nil
|
||||
}
|
||||
|
||||
func lookupModelConfig(cfg *config.Config, modelName string) *config.ModelConfig {
|
||||
modelCfg, err := cfg.GetModelConfig(modelName)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return modelCfg
|
||||
}
|
||||
|
||||
func isGatewayProcessAliveLocked() bool {
|
||||
return isCmdProcessAliveLocked(gateway.cmd)
|
||||
}
|
||||
|
||||
func isCmdProcessAliveLocked(cmd *exec.Cmd) bool {
|
||||
if cmd == nil || cmd.Process == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Wait() sets ProcessState when the process exits; use it when available.
|
||||
if cmd.ProcessState != nil && cmd.ProcessState.Exited() {
|
||||
return false
|
||||
}
|
||||
|
||||
// Windows does not support Signal(0) probing. If we still own cmd and it
|
||||
// has not reported exit, treat it as alive.
|
||||
if runtime.GOOS == "windows" {
|
||||
return true
|
||||
}
|
||||
|
||||
return cmd.Process.Signal(syscall.Signal(0)) == nil
|
||||
}
|
||||
|
||||
func (h *Handler) startGatewayLocked() (int, error) {
|
||||
// Locate the picoclaw executable
|
||||
execPath := findPicoclawBinary()
|
||||
|
||||
cmd := exec.Command(execPath, "gateway")
|
||||
|
||||
stdoutPipe, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to create stdout pipe: %w", err)
|
||||
}
|
||||
|
||||
stderrPipe, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to create stderr pipe: %w", err)
|
||||
}
|
||||
|
||||
// Clear old logs for this new run
|
||||
gateway.logs.Reset()
|
||||
|
||||
// Ensure Pico Channel is configured before starting gateway
|
||||
if _, err := h.ensurePicoChannel(); err != nil {
|
||||
log.Printf("Warning: failed to ensure pico channel: %v", err)
|
||||
// Non-fatal: gateway can still start without pico channel
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return 0, fmt.Errorf("failed to start gateway: %w", err)
|
||||
}
|
||||
|
||||
gateway.cmd = cmd
|
||||
pid := cmd.Process.Pid
|
||||
log.Printf("Started picoclaw gateway (PID: %d) from %s", pid, execPath)
|
||||
|
||||
// Broadcast starting event
|
||||
gateway.events.Broadcast(GatewayEvent{Status: "starting", PID: pid})
|
||||
|
||||
// Capture stdout/stderr in background
|
||||
go scanPipe(stdoutPipe, gateway.logs)
|
||||
go scanPipe(stderrPipe, gateway.logs)
|
||||
|
||||
// Wait for exit in background and clean up
|
||||
go func() {
|
||||
if err := cmd.Wait(); err != nil {
|
||||
log.Printf("Gateway process exited: %v", err)
|
||||
} else {
|
||||
log.Printf("Gateway process exited normally")
|
||||
}
|
||||
|
||||
gateway.mu.Lock()
|
||||
if gateway.cmd == cmd {
|
||||
gateway.cmd = nil
|
||||
}
|
||||
gateway.mu.Unlock()
|
||||
|
||||
// Broadcast stopped event
|
||||
gateway.events.Broadcast(GatewayEvent{Status: "stopped"})
|
||||
}()
|
||||
|
||||
// Start a goroutine to probe health and broadcast "running" once ready
|
||||
go func() {
|
||||
for i := 0; i < 30; i++ { // try for up to 15 seconds
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
gateway.mu.Lock()
|
||||
stillOurs := gateway.cmd == cmd
|
||||
gateway.mu.Unlock()
|
||||
if !stillOurs {
|
||||
return
|
||||
}
|
||||
cfg, err := config.LoadConfig(h.configPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
healthHost := "127.0.0.1"
|
||||
if cfg.Gateway.Host != "" && cfg.Gateway.Host != "0.0.0.0" {
|
||||
healthHost = cfg.Gateway.Host
|
||||
}
|
||||
healthPort := cfg.Gateway.Port
|
||||
if healthPort == 0 {
|
||||
healthPort = 18790
|
||||
}
|
||||
healthURL := fmt.Sprintf("http://%s/health", net.JoinHostPort(healthHost, strconv.Itoa(healthPort)))
|
||||
client := http.Client{Timeout: 1 * time.Second}
|
||||
resp, err := client.Get(healthURL)
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
gateway.events.Broadcast(GatewayEvent{Status: "running", PID: pid})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return pid, nil
|
||||
}
|
||||
|
||||
// handleGatewayStart starts the picoclaw gateway subprocess.
|
||||
//
|
||||
// POST /api/gateway/start
|
||||
func (h *Handler) handleGatewayStart(w http.ResponseWriter, r *http.Request) {
|
||||
gateway.mu.Lock()
|
||||
defer gateway.mu.Unlock()
|
||||
|
||||
// Prevent duplicate starts
|
||||
if isGatewayProcessAliveLocked() {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"status": "already_running",
|
||||
"pid": gateway.cmd.Process.Pid,
|
||||
})
|
||||
return
|
||||
}
|
||||
if gateway.cmd != nil && gateway.cmd.Process != nil {
|
||||
gateway.cmd = nil
|
||||
}
|
||||
|
||||
ready, reason, err := h.gatewayStartReady()
|
||||
if err != nil {
|
||||
http.Error(
|
||||
w,
|
||||
fmt.Sprintf("Failed to validate gateway start conditions: %v", err),
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
return
|
||||
}
|
||||
if !ready {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"status": "precondition_failed",
|
||||
"message": reason,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
pid, err := h.startGatewayLocked()
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"status": "ok",
|
||||
"pid": pid,
|
||||
})
|
||||
}
|
||||
|
||||
// handleGatewayStop stops the running gateway subprocess gracefully.
|
||||
//
|
||||
// POST /api/gateway/stop
|
||||
func (h *Handler) handleGatewayStop(w http.ResponseWriter, r *http.Request) {
|
||||
gateway.mu.Lock()
|
||||
defer gateway.mu.Unlock()
|
||||
|
||||
if gateway.cmd == nil || gateway.cmd.Process == nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"status": "not_running",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
pid := gateway.cmd.Process.Pid
|
||||
|
||||
// Send SIGTERM for graceful shutdown (SIGKILL on Windows)
|
||||
var sigErr error
|
||||
if runtime.GOOS == "windows" {
|
||||
sigErr = gateway.cmd.Process.Kill()
|
||||
} else {
|
||||
sigErr = gateway.cmd.Process.Signal(syscall.SIGTERM)
|
||||
}
|
||||
|
||||
if sigErr != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to stop gateway (PID %d): %v", pid, sigErr), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Sent stop signal to gateway (PID: %d)", pid)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"status": "ok",
|
||||
"pid": pid,
|
||||
})
|
||||
}
|
||||
|
||||
// handleGatewayRestart stops the gateway (if running) and starts a new instance.
|
||||
//
|
||||
// POST /api/gateway/restart
|
||||
func (h *Handler) handleGatewayRestart(w http.ResponseWriter, r *http.Request) {
|
||||
gateway.mu.Lock()
|
||||
|
||||
// Stop existing process if running
|
||||
if gateway.cmd != nil && gateway.cmd.Process != nil {
|
||||
if isCmdProcessAliveLocked(gateway.cmd) {
|
||||
// Process is alive, send SIGTERM
|
||||
if runtime.GOOS == "windows" {
|
||||
gateway.cmd.Process.Kill()
|
||||
} else {
|
||||
gateway.cmd.Process.Signal(syscall.SIGTERM)
|
||||
}
|
||||
|
||||
// Wait briefly for it to exit
|
||||
gateway.mu.Unlock()
|
||||
time.Sleep(2 * time.Second)
|
||||
gateway.mu.Lock()
|
||||
}
|
||||
gateway.cmd = nil
|
||||
}
|
||||
|
||||
gateway.mu.Unlock()
|
||||
|
||||
// Start fresh via the existing handler
|
||||
h.handleGatewayStart(w, r)
|
||||
}
|
||||
|
||||
// handleGatewayStatus returns the gateway run status, health info, and logs.
|
||||
//
|
||||
// GET /api/gateway/status
|
||||
func (h *Handler) handleGatewayStatus(w http.ResponseWriter, r *http.Request) {
|
||||
data := map[string]any{}
|
||||
|
||||
// Check process state
|
||||
gateway.mu.Lock()
|
||||
processAlive := isGatewayProcessAliveLocked()
|
||||
if processAlive {
|
||||
data["pid"] = gateway.cmd.Process.Pid
|
||||
}
|
||||
gateway.mu.Unlock()
|
||||
|
||||
if !processAlive {
|
||||
data["gateway_status"] = "stopped"
|
||||
} else {
|
||||
// Process is alive — probe its health endpoint
|
||||
cfg, err := config.LoadConfig(h.configPath)
|
||||
host := "127.0.0.1"
|
||||
port := 18790
|
||||
if err == nil && cfg != nil {
|
||||
if cfg.Gateway.Host != "" && cfg.Gateway.Host != "0.0.0.0" {
|
||||
host = cfg.Gateway.Host
|
||||
}
|
||||
if cfg.Gateway.Port != 0 {
|
||||
port = cfg.Gateway.Port
|
||||
}
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("http://%s/health", net.JoinHostPort(host, strconv.Itoa(port)))
|
||||
client := http.Client{Timeout: 2 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
|
||||
if err != nil {
|
||||
data["gateway_status"] = "starting"
|
||||
} else {
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
data["gateway_status"] = "error"
|
||||
data["status_code"] = resp.StatusCode
|
||||
} else {
|
||||
var healthData map[string]any
|
||||
if decErr := json.NewDecoder(resp.Body).Decode(&healthData); decErr != nil {
|
||||
data["gateway_status"] = "error"
|
||||
} else {
|
||||
for k, v := range healthData {
|
||||
data[k] = v
|
||||
}
|
||||
data["gateway_status"] = "running"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ready, reason, readyErr := h.gatewayStartReady()
|
||||
if readyErr != nil {
|
||||
data["gateway_start_allowed"] = false
|
||||
data["gateway_start_reason"] = readyErr.Error()
|
||||
} else {
|
||||
data["gateway_start_allowed"] = ready
|
||||
if !ready {
|
||||
data["gateway_start_reason"] = reason
|
||||
}
|
||||
}
|
||||
|
||||
// Append incremental log data
|
||||
appendGatewayLogs(r, data)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
// appendGatewayLogs reads log_offset and log_run_id query params from the request
|
||||
// and populates the response data map with incremental log lines.
|
||||
func appendGatewayLogs(r *http.Request, data map[string]any) {
|
||||
clientOffset := 0
|
||||
clientRunID := -1
|
||||
|
||||
if v := r.URL.Query().Get("log_offset"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
clientOffset = n
|
||||
}
|
||||
}
|
||||
|
||||
if v := r.URL.Query().Get("log_run_id"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
clientRunID = n
|
||||
}
|
||||
}
|
||||
|
||||
runID := gateway.logs.RunID()
|
||||
|
||||
if runID == 0 {
|
||||
data["logs"] = []string{}
|
||||
data["log_total"] = 0
|
||||
data["log_run_id"] = 0
|
||||
return
|
||||
}
|
||||
|
||||
// If runID changed, reset offset to get all logs from new run
|
||||
offset := clientOffset
|
||||
if clientRunID != runID {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
lines, total, runID := gateway.logs.LinesSince(offset)
|
||||
if lines == nil {
|
||||
lines = []string{}
|
||||
}
|
||||
|
||||
data["logs"] = lines
|
||||
data["log_total"] = total
|
||||
data["log_run_id"] = runID
|
||||
}
|
||||
|
||||
// handleGatewayEvents serves an SSE stream of gateway state change events.
|
||||
//
|
||||
// GET /api/gateway/events
|
||||
func (h *Handler) handleGatewayEvents(w http.ResponseWriter, r *http.Request) {
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "SSE not supported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
|
||||
// Subscribe to gateway events
|
||||
ch := gateway.events.Subscribe()
|
||||
defer gateway.events.Unsubscribe(ch)
|
||||
|
||||
// Send initial status so the client doesn't start blank
|
||||
initial := h.currentGatewayStatus()
|
||||
fmt.Fprintf(w, "data: %s\n\n", initial)
|
||||
flusher.Flush()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case data, ok := <-ch:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "data: %s\n\n", data)
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// currentGatewayStatus returns the current gateway status as a JSON string.
|
||||
func (h *Handler) currentGatewayStatus() string {
|
||||
gateway.mu.Lock()
|
||||
defer gateway.mu.Unlock()
|
||||
|
||||
data := map[string]any{
|
||||
"gateway_status": "stopped",
|
||||
}
|
||||
if isGatewayProcessAliveLocked() {
|
||||
data["gateway_status"] = "running"
|
||||
data["pid"] = gateway.cmd.Process.Pid
|
||||
}
|
||||
|
||||
ready, reason, readyErr := h.gatewayStartReady()
|
||||
if readyErr != nil {
|
||||
data["gateway_start_allowed"] = false
|
||||
data["gateway_start_reason"] = readyErr.Error()
|
||||
} else {
|
||||
data["gateway_start_allowed"] = ready
|
||||
if !ready {
|
||||
data["gateway_start_reason"] = reason
|
||||
}
|
||||
}
|
||||
|
||||
encoded, _ := json.Marshal(data)
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
// findPicoclawBinary locates the picoclaw executable.
|
||||
// Tries the same directory as the current executable first, then falls back to $PATH.
|
||||
func findPicoclawBinary() string {
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
dir := filepath.Dir(exe)
|
||||
candidate := filepath.Join(dir, "picoclaw")
|
||||
if runtime.GOOS == "windows" {
|
||||
candidate += ".exe"
|
||||
}
|
||||
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return "picoclaw"
|
||||
}
|
||||
|
||||
// scanPipe reads lines from r and appends them to buf. Returns when r reaches EOF.
|
||||
func scanPipe(r io.Reader, buf *LogBuffer) {
|
||||
scanner := bufio.NewScanner(r)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
buf.Append(scanner.Text())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func TestGatewayStartReady_NoDefaultModel(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
h := NewHandler(configPath)
|
||||
|
||||
ready, reason, err := h.gatewayStartReady()
|
||||
if err != nil {
|
||||
t.Fatalf("gatewayStartReady() error = %v", err)
|
||||
}
|
||||
if ready {
|
||||
t.Fatalf("gatewayStartReady() ready = true, want false")
|
||||
}
|
||||
if reason != "no default model configured" {
|
||||
t.Fatalf("gatewayStartReady() reason = %q, want %q", reason, "no default model configured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayStartReady_InvalidDefaultModel(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Agents.Defaults.Model = "missing-model"
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
|
||||
h := NewHandler(configPath)
|
||||
ready, reason, err := h.gatewayStartReady()
|
||||
if err != nil {
|
||||
t.Fatalf("gatewayStartReady() error = %v", err)
|
||||
}
|
||||
if ready {
|
||||
t.Fatalf("gatewayStartReady() ready = true, want false")
|
||||
}
|
||||
if reason == "" {
|
||||
t.Fatalf("gatewayStartReady() reason is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayStartReady_ValidDefaultModel(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
|
||||
cfg.ModelList[0].APIKey = "test-key"
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
|
||||
h := NewHandler(configPath)
|
||||
ready, reason, err := h.gatewayStartReady()
|
||||
if err != nil {
|
||||
t.Fatalf("gatewayStartReady() error = %v", err)
|
||||
}
|
||||
if !ready {
|
||||
t.Fatalf("gatewayStartReady() ready = false, want true (reason=%q)", reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayStartReady_DefaultModelWithoutCredential(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
|
||||
cfg.ModelList[0].APIKey = ""
|
||||
cfg.ModelList[0].AuthMethod = ""
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
|
||||
h := NewHandler(configPath)
|
||||
ready, reason, err := h.gatewayStartReady()
|
||||
if err != nil {
|
||||
t.Fatalf("gatewayStartReady() error = %v", err)
|
||||
}
|
||||
if ready {
|
||||
t.Fatalf("gatewayStartReady() ready = true, want false")
|
||||
}
|
||||
if !strings.Contains(reason, "no credentials configured") {
|
||||
t.Fatalf("gatewayStartReady() reason = %q, want contains %q", reason, "no credentials configured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayStatusIncludesStartConditionWhenNotReady(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
allowed, ok := body["gateway_start_allowed"].(bool)
|
||||
if !ok {
|
||||
t.Fatalf("gateway_start_allowed missing or not bool: %#v", body["gateway_start_allowed"])
|
||||
}
|
||||
if allowed {
|
||||
t.Fatalf("gateway_start_allowed = true, want false")
|
||||
}
|
||||
if _, ok := body["gateway_start_reason"].(string); !ok {
|
||||
t.Fatalf("gateway_start_reason missing or not string: %#v", body["gateway_start_reason"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
|
||||
)
|
||||
|
||||
type launcherConfigPayload struct {
|
||||
Port int `json:"port"`
|
||||
Public bool `json:"public"`
|
||||
AllowedCIDRs []string `json:"allowed_cidrs"`
|
||||
}
|
||||
|
||||
func (h *Handler) registerLauncherConfigRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/system/launcher-config", h.handleGetLauncherConfig)
|
||||
mux.HandleFunc("PUT /api/system/launcher-config", h.handleUpdateLauncherConfig)
|
||||
}
|
||||
|
||||
func (h *Handler) launcherConfigPath() string {
|
||||
return launcherconfig.PathForAppConfig(h.configPath)
|
||||
}
|
||||
|
||||
func (h *Handler) launcherFallbackConfig() launcherconfig.Config {
|
||||
port := h.serverPort
|
||||
if port <= 0 {
|
||||
port = launcherconfig.DefaultPort
|
||||
}
|
||||
return launcherconfig.Config{
|
||||
Port: port,
|
||||
Public: h.serverPublic,
|
||||
AllowedCIDRs: append([]string(nil), h.serverCIDRs...),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) loadLauncherConfig() (launcherconfig.Config, error) {
|
||||
return launcherconfig.Load(h.launcherConfigPath(), h.launcherFallbackConfig())
|
||||
}
|
||||
|
||||
func (h *Handler) handleGetLauncherConfig(w http.ResponseWriter, r *http.Request) {
|
||||
cfg, err := h.loadLauncherConfig()
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to load launcher config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(launcherConfigPayload{
|
||||
Port: cfg.Port,
|
||||
Public: cfg.Public,
|
||||
AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) handleUpdateLauncherConfig(w http.ResponseWriter, r *http.Request) {
|
||||
var payload launcherConfigPayload
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
cfg := launcherconfig.Config{
|
||||
Port: payload.Port,
|
||||
Public: payload.Public,
|
||||
AllowedCIDRs: append([]string(nil), payload.AllowedCIDRs...),
|
||||
}
|
||||
if err := launcherconfig.Validate(cfg); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := launcherconfig.Save(h.launcherConfigPath(), cfg); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to save launcher config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(launcherConfigPayload{
|
||||
Port: cfg.Port,
|
||||
Public: cfg.Public,
|
||||
AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
|
||||
)
|
||||
|
||||
func TestGetLauncherConfigUsesRuntimeFallback(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
h := NewHandler(configPath)
|
||||
h.SetServerOptions(19999, true, []string{"192.168.1.0/24"})
|
||||
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/system/launcher-config", nil)
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
var got launcherConfigPayload
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
if got.Port != 19999 || !got.Public {
|
||||
t.Fatalf("response = %+v, want port=19999 public=true", got)
|
||||
}
|
||||
if len(got.AllowedCIDRs) != 1 || got.AllowedCIDRs[0] != "192.168.1.0/24" {
|
||||
t.Fatalf("response allowed_cidrs = %v, want [192.168.1.0/24]", got.AllowedCIDRs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutLauncherConfigPersists(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
h := NewHandler(configPath)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPut,
|
||||
"/api/system/launcher-config",
|
||||
strings.NewReader(`{"port":18080,"public":true,"allowed_cidrs":["192.168.1.0/24"]}`),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
path := launcherconfig.PathForAppConfig(configPath)
|
||||
cfg, err := launcherconfig.Load(path, launcherconfig.Default())
|
||||
if err != nil {
|
||||
t.Fatalf("launcherconfig.Load() error = %v", err)
|
||||
}
|
||||
if cfg.Port != 18080 || !cfg.Public {
|
||||
t.Fatalf("saved config = %+v, want port=18080 public=true", cfg)
|
||||
}
|
||||
if len(cfg.AllowedCIDRs) != 1 || cfg.AllowedCIDRs[0] != "192.168.1.0/24" {
|
||||
t.Fatalf("saved config allowed_cidrs = %v, want [192.168.1.0/24]", cfg.AllowedCIDRs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutLauncherConfigRejectsInvalidPort(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
h := NewHandler(configPath)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPut,
|
||||
"/api/system/launcher-config",
|
||||
strings.NewReader(`{"port":70000,"public":false}`),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutLauncherConfigRejectsInvalidCIDR(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
h := NewHandler(configPath)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPut,
|
||||
"/api/system/launcher-config",
|
||||
strings.NewReader(`{"port":18080,"public":false,"allowed_cidrs":["bad-cidr"]}`),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package server
|
||||
package api
|
||||
|
||||
import "sync"
|
||||
|
||||
@@ -89,11 +89,3 @@ func (b *LogBuffer) RunID() int {
|
||||
|
||||
return b.runID
|
||||
}
|
||||
|
||||
// Total returns the total number of lines appended in the current run.
|
||||
func (b *LogBuffer) Total() int {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
|
||||
return b.total
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
// registerModelRoutes binds model list management endpoints to the ServeMux.
|
||||
func (h *Handler) registerModelRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/models", h.handleListModels)
|
||||
mux.HandleFunc("POST /api/models", h.handleAddModel)
|
||||
mux.HandleFunc("POST /api/models/default", h.handleSetDefaultModel)
|
||||
mux.HandleFunc("PUT /api/models/{index}", h.handleUpdateModel)
|
||||
mux.HandleFunc("DELETE /api/models/{index}", h.handleDeleteModel)
|
||||
}
|
||||
|
||||
// modelResponse is the JSON structure returned for each model in the list.
|
||||
// All ModelConfig fields are included so the frontend can display and edit them.
|
||||
type modelResponse struct {
|
||||
Index int `json:"index"`
|
||||
ModelName string `json:"model_name"`
|
||||
Model string `json:"model"`
|
||||
APIBase string `json:"api_base,omitempty"`
|
||||
APIKey string `json:"api_key"`
|
||||
Proxy string `json:"proxy,omitempty"`
|
||||
AuthMethod string `json:"auth_method,omitempty"`
|
||||
// Advanced fields
|
||||
ConnectMode string `json:"connect_mode,omitempty"`
|
||||
Workspace string `json:"workspace,omitempty"`
|
||||
RPM int `json:"rpm,omitempty"`
|
||||
MaxTokensField string `json:"max_tokens_field,omitempty"`
|
||||
RequestTimeout int `json:"request_timeout,omitempty"`
|
||||
ThinkingLevel string `json:"thinking_level,omitempty"`
|
||||
// Meta
|
||||
Configured bool `json:"configured"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
}
|
||||
|
||||
// handleListModels returns all model_list entries with masked API keys.
|
||||
//
|
||||
// GET /api/models
|
||||
func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) {
|
||||
cfg, err := h.loadFilteredConfig()
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
defaultModel := cfg.Agents.Defaults.GetModelName()
|
||||
|
||||
models := make([]modelResponse, 0, len(cfg.ModelList))
|
||||
for i, m := range cfg.ModelList {
|
||||
models = append(models, modelResponse{
|
||||
Index: i,
|
||||
ModelName: m.ModelName,
|
||||
Model: m.Model,
|
||||
APIBase: m.APIBase,
|
||||
APIKey: maskAPIKey(m.APIKey),
|
||||
Proxy: m.Proxy,
|
||||
AuthMethod: m.AuthMethod,
|
||||
ConnectMode: m.ConnectMode,
|
||||
Workspace: m.Workspace,
|
||||
RPM: m.RPM,
|
||||
MaxTokensField: m.MaxTokensField,
|
||||
RequestTimeout: m.RequestTimeout,
|
||||
ThinkingLevel: m.ThinkingLevel,
|
||||
Configured: m.APIKey != "" || m.AuthMethod != "",
|
||||
IsDefault: m.ModelName == defaultModel,
|
||||
})
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"models": models,
|
||||
"total": len(models),
|
||||
"default_model": defaultModel,
|
||||
})
|
||||
}
|
||||
|
||||
// handleAddModel appends a new model configuration entry.
|
||||
//
|
||||
// POST /api/models
|
||||
func (h *Handler) handleAddModel(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to read request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
var mc config.ModelConfig
|
||||
if err = json.Unmarshal(body, &mc); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err = mc.Validate(); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Validation error: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := config.LoadConfig(h.configPath)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
cfg.ModelList = append(cfg.ModelList, mc)
|
||||
|
||||
if err := config.SaveConfig(h.configPath, cfg); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"status": "ok",
|
||||
"index": len(cfg.ModelList) - 1,
|
||||
})
|
||||
}
|
||||
|
||||
// handleUpdateModel replaces a model configuration entry at the given index.
|
||||
// If the request body omits api_key (or sends an empty string), the existing
|
||||
// stored key is preserved so callers can update only api_base / proxy without
|
||||
// exposing or clearing the secret.
|
||||
//
|
||||
// PUT /api/models/{index}
|
||||
func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) {
|
||||
idx, err := strconv.Atoi(r.PathValue("index"))
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid index", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to read request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
var mc config.ModelConfig
|
||||
if err = json.Unmarshal(body, &mc); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err = mc.Validate(); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Validation error: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := config.LoadConfig(h.configPath)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if idx < 0 || idx >= len(cfg.ModelList) {
|
||||
http.Error(w, fmt.Sprintf("Index %d out of range (0-%d)", idx, len(cfg.ModelList)-1), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Preserve the existing API key when the caller omits it (empty string).
|
||||
// This lets the UI update api_base / proxy without clearing the stored secret.
|
||||
if mc.APIKey == "" {
|
||||
mc.APIKey = cfg.ModelList[idx].APIKey
|
||||
}
|
||||
|
||||
cfg.ModelList[idx] = mc
|
||||
|
||||
if err := config.SaveConfig(h.configPath, cfg); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// handleDeleteModel removes a model configuration entry at the given index.
|
||||
//
|
||||
// DELETE /api/models/{index}
|
||||
func (h *Handler) handleDeleteModel(w http.ResponseWriter, r *http.Request) {
|
||||
idx, err := strconv.Atoi(r.PathValue("index"))
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid index", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := config.LoadConfig(h.configPath)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if idx < 0 || idx >= len(cfg.ModelList) {
|
||||
http.Error(w, fmt.Sprintf("Index %d out of range (0-%d)", idx, len(cfg.ModelList)-1), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
deletedModelName := cfg.ModelList[idx].ModelName
|
||||
|
||||
cfg.ModelList = append(cfg.ModelList[:idx], cfg.ModelList[idx+1:]...)
|
||||
|
||||
// If the deleted model was the default, clear it.
|
||||
if cfg.Agents.Defaults.ModelName == deletedModelName {
|
||||
cfg.Agents.Defaults.ModelName = ""
|
||||
}
|
||||
if cfg.Agents.Defaults.Model == deletedModelName {
|
||||
cfg.Agents.Defaults.Model = ""
|
||||
}
|
||||
|
||||
if err := config.SaveConfig(h.configPath, cfg); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// handleSetDefaultModel sets the default model for all agents.
|
||||
//
|
||||
// POST /api/models/default
|
||||
func (h *Handler) handleSetDefaultModel(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to read request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
var req struct {
|
||||
ModelName string `json:"model_name"`
|
||||
}
|
||||
if err = json.Unmarshal(body, &req); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.ModelName == "" {
|
||||
http.Error(w, "model_name is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := config.LoadConfig(h.configPath)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the model_name exists in model_list
|
||||
found := false
|
||||
for _, m := range cfg.ModelList {
|
||||
if m.ModelName == req.ModelName {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
http.Error(w, fmt.Sprintf("Model %q not found in model_list", req.ModelName), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
cfg.Agents.Defaults.ModelName = req.ModelName
|
||||
|
||||
if err := config.SaveConfig(h.configPath, cfg); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "ok",
|
||||
"default_model": req.ModelName,
|
||||
})
|
||||
}
|
||||
|
||||
// maskAPIKey returns a masked version of an API key for safe display.
|
||||
// Keys longer than 8 chars show prefix + last 4 chars: "sk-****abcd"
|
||||
// Shorter keys are fully masked as "****".
|
||||
// Empty keys return empty string.
|
||||
func maskAPIKey(key string) string {
|
||||
if key == "" {
|
||||
return ""
|
||||
}
|
||||
if len(key) <= 8 {
|
||||
return "****"
|
||||
}
|
||||
// Show first 3 chars and last 4 chars
|
||||
return key[:3] + "****" + key[len(key)-4:]
|
||||
}
|
||||
@@ -0,0 +1,844 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/auth"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
const (
|
||||
oauthProviderOpenAI = "openai"
|
||||
oauthProviderAnthropic = "anthropic"
|
||||
oauthProviderGoogleAntigravity = "google-antigravity"
|
||||
|
||||
oauthMethodBrowser = "browser"
|
||||
oauthMethodDeviceCode = "device_code"
|
||||
oauthMethodToken = "token"
|
||||
|
||||
oauthFlowPending = "pending"
|
||||
oauthFlowSuccess = "success"
|
||||
oauthFlowError = "error"
|
||||
oauthFlowExpired = "expired"
|
||||
)
|
||||
|
||||
const (
|
||||
oauthBrowserFlowTTL = 10 * time.Minute
|
||||
oauthDeviceCodeFlowTTL = 15 * time.Minute
|
||||
oauthTerminalFlowGC = 30 * time.Minute
|
||||
)
|
||||
|
||||
var oauthProviderOrder = []string{
|
||||
oauthProviderOpenAI,
|
||||
oauthProviderAnthropic,
|
||||
oauthProviderGoogleAntigravity,
|
||||
}
|
||||
|
||||
var oauthProviderMethods = map[string][]string{
|
||||
oauthProviderOpenAI: {oauthMethodBrowser, oauthMethodDeviceCode, oauthMethodToken},
|
||||
oauthProviderAnthropic: {oauthMethodToken},
|
||||
oauthProviderGoogleAntigravity: {oauthMethodBrowser},
|
||||
}
|
||||
|
||||
var oauthProviderLabels = map[string]string{
|
||||
oauthProviderOpenAI: "OpenAI",
|
||||
oauthProviderAnthropic: "Anthropic",
|
||||
oauthProviderGoogleAntigravity: "Google Antigravity",
|
||||
}
|
||||
|
||||
var (
|
||||
oauthNow = time.Now
|
||||
oauthGeneratePKCE = auth.GeneratePKCE
|
||||
oauthGenerateState = auth.GenerateState
|
||||
oauthBuildAuthorizeURL = auth.BuildAuthorizeURL
|
||||
oauthRequestDeviceCode = auth.RequestDeviceCode
|
||||
oauthPollDeviceCodeOnce = auth.PollDeviceCodeOnce
|
||||
oauthExchangeCodeForTokens = auth.ExchangeCodeForTokens
|
||||
oauthGetCredential = auth.GetCredential
|
||||
oauthSetCredential = auth.SetCredential
|
||||
oauthDeleteCredential = auth.DeleteCredential
|
||||
oauthLoadConfig = config.LoadConfig
|
||||
oauthSaveConfig = config.SaveConfig
|
||||
oauthFetchAntigravityProject = providers.FetchAntigravityProjectID
|
||||
oauthFetchGoogleUserEmailFunc = fetchGoogleUserEmail
|
||||
)
|
||||
|
||||
type oauthFlow struct {
|
||||
ID string
|
||||
Provider string
|
||||
Method string
|
||||
Status string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
Error string
|
||||
CodeVerifier string
|
||||
OAuthState string
|
||||
RedirectURI string
|
||||
DeviceAuthID string
|
||||
UserCode string
|
||||
VerifyURL string
|
||||
Interval int
|
||||
}
|
||||
|
||||
type oauthProviderStatus struct {
|
||||
Provider string `json:"provider"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Methods []string `json:"methods"`
|
||||
LoggedIn bool `json:"logged_in"`
|
||||
Status string `json:"status"`
|
||||
AuthMethod string `json:"auth_method,omitempty"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
AccountID string `json:"account_id,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
}
|
||||
|
||||
type oauthFlowResponse struct {
|
||||
FlowID string `json:"flow_id"`
|
||||
Provider string `json:"provider"`
|
||||
Method string `json:"method"`
|
||||
Status string `json:"status"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
UserCode string `json:"user_code,omitempty"`
|
||||
VerifyURL string `json:"verify_url,omitempty"`
|
||||
Interval int `json:"interval,omitempty"`
|
||||
}
|
||||
|
||||
// registerOAuthRoutes binds OAuth login/logout endpoints to the ServeMux.
|
||||
func (h *Handler) registerOAuthRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/oauth/providers", h.handleListOAuthProviders)
|
||||
mux.HandleFunc("POST /api/oauth/login", h.handleOAuthLogin)
|
||||
mux.HandleFunc("GET /api/oauth/flows/{id}", h.handleGetOAuthFlow)
|
||||
mux.HandleFunc("POST /api/oauth/flows/{id}/poll", h.handlePollOAuthFlow)
|
||||
mux.HandleFunc("POST /api/oauth/logout", h.handleOAuthLogout)
|
||||
mux.HandleFunc("GET /oauth/callback", h.handleOAuthCallback)
|
||||
}
|
||||
|
||||
func (h *Handler) handleListOAuthProviders(w http.ResponseWriter, r *http.Request) {
|
||||
providersResp := make([]oauthProviderStatus, 0, len(oauthProviderOrder))
|
||||
|
||||
for _, provider := range oauthProviderOrder {
|
||||
cred, err := oauthGetCredential(provider)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to load credentials: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
item := oauthProviderStatus{
|
||||
Provider: provider,
|
||||
DisplayName: oauthProviderLabels[provider],
|
||||
Methods: oauthProviderMethods[provider],
|
||||
Status: "not_logged_in",
|
||||
}
|
||||
if cred != nil {
|
||||
item.LoggedIn = true
|
||||
item.AuthMethod = cred.AuthMethod
|
||||
item.AccountID = cred.AccountID
|
||||
item.Email = cred.Email
|
||||
item.ProjectID = cred.ProjectID
|
||||
if !cred.ExpiresAt.IsZero() {
|
||||
item.ExpiresAt = cred.ExpiresAt.Format(time.RFC3339)
|
||||
}
|
||||
switch {
|
||||
case cred.IsExpired():
|
||||
item.Status = "expired"
|
||||
case cred.NeedsRefresh():
|
||||
item.Status = "needs_refresh"
|
||||
default:
|
||||
item.Status = "connected"
|
||||
}
|
||||
}
|
||||
|
||||
providersResp = append(providersResp, item)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"providers": providersResp,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) handleOAuthLogin(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||||
if err != nil {
|
||||
http.Error(w, "failed to read request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
var req struct {
|
||||
Provider string `json:"provider"`
|
||||
Method string `json:"method"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err = json.Unmarshal(body, &req); err != nil {
|
||||
http.Error(w, fmt.Sprintf("invalid JSON: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
provider, err := normalizeOAuthProvider(req.Provider)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
method := strings.ToLower(strings.TrimSpace(req.Method))
|
||||
if !isOAuthMethodSupported(provider, method) {
|
||||
http.Error(
|
||||
w,
|
||||
fmt.Sprintf("unsupported login method %q for provider %q", method, provider),
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
switch method {
|
||||
case oauthMethodToken:
|
||||
token := strings.TrimSpace(req.Token)
|
||||
if token == "" {
|
||||
http.Error(w, "token is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
cred := &auth.AuthCredential{
|
||||
AccessToken: token,
|
||||
Provider: provider,
|
||||
AuthMethod: oauthMethodToken,
|
||||
}
|
||||
if err := h.persistCredentialAndConfig(provider, oauthMethodToken, cred); err != nil {
|
||||
http.Error(w, fmt.Sprintf("token login failed: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"status": "ok",
|
||||
"provider": provider,
|
||||
"method": method,
|
||||
})
|
||||
return
|
||||
|
||||
case oauthMethodDeviceCode:
|
||||
cfg := auth.OpenAIOAuthConfig()
|
||||
info, err := oauthRequestDeviceCode(cfg)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to request device code: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
now := oauthNow()
|
||||
flow := &oauthFlow{
|
||||
ID: newOAuthFlowID(),
|
||||
Provider: provider,
|
||||
Method: method,
|
||||
Status: oauthFlowPending,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
ExpiresAt: now.Add(oauthDeviceCodeFlowTTL),
|
||||
DeviceAuthID: info.DeviceAuthID,
|
||||
UserCode: info.UserCode,
|
||||
VerifyURL: info.VerifyURL,
|
||||
Interval: info.Interval,
|
||||
}
|
||||
h.storeOAuthFlow(flow)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"status": "ok",
|
||||
"provider": provider,
|
||||
"method": method,
|
||||
"flow_id": flow.ID,
|
||||
"user_code": flow.UserCode,
|
||||
"verify_url": flow.VerifyURL,
|
||||
"interval": flow.Interval,
|
||||
"expires_at": flow.ExpiresAt.Format(time.RFC3339),
|
||||
})
|
||||
return
|
||||
|
||||
case oauthMethodBrowser:
|
||||
cfg, err := oauthConfigForProvider(provider)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
pkce, err := oauthGeneratePKCE()
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to generate PKCE: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
state, err := oauthGenerateState()
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to generate state: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
redirectURI := buildOAuthRedirectURI(r)
|
||||
authURL := oauthBuildAuthorizeURL(cfg, pkce, state, redirectURI)
|
||||
|
||||
now := oauthNow()
|
||||
flow := &oauthFlow{
|
||||
ID: newOAuthFlowID(),
|
||||
Provider: provider,
|
||||
Method: method,
|
||||
Status: oauthFlowPending,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
ExpiresAt: now.Add(oauthBrowserFlowTTL),
|
||||
CodeVerifier: pkce.CodeVerifier,
|
||||
OAuthState: state,
|
||||
RedirectURI: redirectURI,
|
||||
}
|
||||
h.storeOAuthFlow(flow)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"status": "ok",
|
||||
"provider": provider,
|
||||
"method": method,
|
||||
"flow_id": flow.ID,
|
||||
"auth_url": authURL,
|
||||
"expires_at": flow.ExpiresAt.Format(time.RFC3339),
|
||||
})
|
||||
return
|
||||
default:
|
||||
http.Error(w, "unsupported login method", http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) handleGetOAuthFlow(w http.ResponseWriter, r *http.Request) {
|
||||
flowID := strings.TrimSpace(r.PathValue("id"))
|
||||
if flowID == "" {
|
||||
http.Error(w, "missing flow id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
flow, ok := h.getOAuthFlow(flowID)
|
||||
if !ok {
|
||||
http.Error(w, "flow not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(flowToResponse(flow))
|
||||
}
|
||||
|
||||
func (h *Handler) handlePollOAuthFlow(w http.ResponseWriter, r *http.Request) {
|
||||
flowID := strings.TrimSpace(r.PathValue("id"))
|
||||
if flowID == "" {
|
||||
http.Error(w, "missing flow id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
flow, ok := h.getOAuthFlow(flowID)
|
||||
if !ok {
|
||||
http.Error(w, "flow not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if flow.Method != oauthMethodDeviceCode {
|
||||
http.Error(w, "flow does not support polling", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if flow.Status != oauthFlowPending {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(flowToResponse(flow))
|
||||
return
|
||||
}
|
||||
|
||||
cfg := auth.OpenAIOAuthConfig()
|
||||
cred, err := oauthPollDeviceCodeOnce(cfg, flow.DeviceAuthID, flow.UserCode)
|
||||
if err != nil {
|
||||
if strings.Contains(strings.ToLower(err.Error()), "pending") {
|
||||
updated, _ := h.getOAuthFlow(flowID)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(flowToResponse(updated))
|
||||
return
|
||||
}
|
||||
h.setOAuthFlowError(flowID, fmt.Sprintf("device code poll failed: %v", err))
|
||||
updated, _ := h.getOAuthFlow(flowID)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(flowToResponse(updated))
|
||||
return
|
||||
}
|
||||
if cred == nil {
|
||||
updated, _ := h.getOAuthFlow(flowID)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(flowToResponse(updated))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.persistCredentialAndConfig(flow.Provider, oauthMethodTokenOrOAuth(flow.Method), cred); err != nil {
|
||||
h.setOAuthFlowError(flowID, fmt.Sprintf("failed to save credential: %v", err))
|
||||
updated, _ := h.getOAuthFlow(flowID)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(flowToResponse(updated))
|
||||
return
|
||||
}
|
||||
|
||||
h.setOAuthFlowSuccess(flowID)
|
||||
updated, _ := h.getOAuthFlow(flowID)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(flowToResponse(updated))
|
||||
}
|
||||
|
||||
func (h *Handler) handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
|
||||
state := strings.TrimSpace(r.URL.Query().Get("state"))
|
||||
if state == "" {
|
||||
renderOAuthCallbackPage(w, "", oauthFlowError, "Missing state", "missing_state")
|
||||
return
|
||||
}
|
||||
|
||||
flow, ok := h.getOAuthFlowByState(state)
|
||||
if !ok {
|
||||
renderOAuthCallbackPage(w, "", oauthFlowError, "OAuth flow not found", "flow_not_found")
|
||||
return
|
||||
}
|
||||
|
||||
if flow.Status != oauthFlowPending {
|
||||
renderOAuthCallbackPage(w, flow.ID, flow.Status, "Flow already completed", flow.Error)
|
||||
return
|
||||
}
|
||||
|
||||
if errMsg := strings.TrimSpace(r.URL.Query().Get("error")); errMsg != "" {
|
||||
if desc := strings.TrimSpace(r.URL.Query().Get("error_description")); desc != "" {
|
||||
errMsg += ": " + desc
|
||||
}
|
||||
h.setOAuthFlowError(flow.ID, errMsg)
|
||||
renderOAuthCallbackPage(w, flow.ID, oauthFlowError, "Authorization failed", errMsg)
|
||||
return
|
||||
}
|
||||
|
||||
code := strings.TrimSpace(r.URL.Query().Get("code"))
|
||||
if code == "" {
|
||||
h.setOAuthFlowError(flow.ID, "missing authorization code")
|
||||
renderOAuthCallbackPage(w, flow.ID, oauthFlowError, "Missing authorization code", "missing_code")
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := oauthConfigForProvider(flow.Provider)
|
||||
if err != nil {
|
||||
h.setOAuthFlowError(flow.ID, err.Error())
|
||||
renderOAuthCallbackPage(w, flow.ID, oauthFlowError, "Unsupported provider", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
cred, err := oauthExchangeCodeForTokens(cfg, code, flow.CodeVerifier, flow.RedirectURI)
|
||||
if err != nil {
|
||||
h.setOAuthFlowError(flow.ID, fmt.Sprintf("token exchange failed: %v", err))
|
||||
renderOAuthCallbackPage(w, flow.ID, oauthFlowError, "Token exchange failed", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.persistCredentialAndConfig(flow.Provider, oauthMethodTokenOrOAuth(flow.Method), cred); err != nil {
|
||||
h.setOAuthFlowError(flow.ID, fmt.Sprintf("failed to save credential: %v", err))
|
||||
renderOAuthCallbackPage(w, flow.ID, oauthFlowError, "Failed to save credential", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.setOAuthFlowSuccess(flow.ID)
|
||||
renderOAuthCallbackPage(w, flow.ID, oauthFlowSuccess, "Authentication successful", "")
|
||||
}
|
||||
|
||||
func (h *Handler) handleOAuthLogout(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||||
if err != nil {
|
||||
http.Error(w, "failed to read request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
var req struct {
|
||||
Provider string `json:"provider"`
|
||||
}
|
||||
if err = json.Unmarshal(body, &req); err != nil {
|
||||
http.Error(w, fmt.Sprintf("invalid JSON: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
provider, err := normalizeOAuthProvider(req.Provider)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := oauthDeleteCredential(provider); err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to delete credential: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := h.syncProviderAuthMethod(provider, ""); err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to update config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"status": "ok",
|
||||
"provider": provider,
|
||||
})
|
||||
}
|
||||
|
||||
func renderOAuthCallbackPage(w http.ResponseWriter, flowID, status, title, errMsg string) {
|
||||
payload := map[string]string{
|
||||
"type": "picoclaw-oauth-result",
|
||||
"flowId": flowID,
|
||||
"status": status,
|
||||
}
|
||||
if errMsg != "" {
|
||||
payload["error"] = errMsg
|
||||
}
|
||||
payloadJSON, _ := json.Marshal(payload)
|
||||
|
||||
message := title
|
||||
if errMsg != "" {
|
||||
message = fmt.Sprintf("%s: %s", title, errMsg)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if status == oauthFlowSuccess {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(
|
||||
w,
|
||||
"<!doctype html><html><head><meta charset=\"utf-8\"><title>PicoClaw OAuth</title></head><body><script>(function(){var payload=%s;var hasOpener=false;try{if(window.opener&&!window.opener.closed){window.opener.postMessage(payload,window.location.origin);hasOpener=true}}catch(e){}var target='/credentials?oauth_flow_id='+encodeURIComponent(payload.flowId||'')+'&oauth_status='+encodeURIComponent(payload.status||'');setTimeout(function(){if(hasOpener){window.close();return}window.location.replace(target)},800)})();</script><div style=\"font-family:Inter,system-ui,sans-serif;padding:24px\"><h2>%s</h2><p>%s</p><p>You can close this window.</p></div></body></html>",
|
||||
string(payloadJSON),
|
||||
html.EscapeString(title),
|
||||
html.EscapeString(message),
|
||||
)
|
||||
}
|
||||
|
||||
func normalizeOAuthProvider(raw string) (string, error) {
|
||||
provider := strings.ToLower(strings.TrimSpace(raw))
|
||||
switch provider {
|
||||
case "antigravity":
|
||||
return oauthProviderGoogleAntigravity, nil
|
||||
case oauthProviderOpenAI, oauthProviderAnthropic, oauthProviderGoogleAntigravity:
|
||||
return provider, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported provider %q", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func isOAuthMethodSupported(provider, method string) bool {
|
||||
methods := oauthProviderMethods[provider]
|
||||
for _, m := range methods {
|
||||
if m == method {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func oauthConfigForProvider(provider string) (auth.OAuthProviderConfig, error) {
|
||||
switch provider {
|
||||
case oauthProviderOpenAI:
|
||||
return auth.OpenAIOAuthConfig(), nil
|
||||
case oauthProviderGoogleAntigravity:
|
||||
return auth.GoogleAntigravityOAuthConfig(), nil
|
||||
default:
|
||||
return auth.OAuthProviderConfig{}, fmt.Errorf("provider %q does not support browser oauth", provider)
|
||||
}
|
||||
}
|
||||
|
||||
func oauthMethodTokenOrOAuth(method string) string {
|
||||
if method == oauthMethodToken {
|
||||
return oauthMethodToken
|
||||
}
|
||||
return "oauth"
|
||||
}
|
||||
|
||||
func buildOAuthRedirectURI(r *http.Request) string {
|
||||
scheme := "http"
|
||||
if r.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwarded != "" {
|
||||
scheme = strings.Split(forwarded, ",")[0]
|
||||
}
|
||||
return fmt.Sprintf("%s://%s/oauth/callback", scheme, r.Host)
|
||||
}
|
||||
|
||||
func flowToResponse(flow *oauthFlow) oauthFlowResponse {
|
||||
resp := oauthFlowResponse{
|
||||
FlowID: flow.ID,
|
||||
Provider: flow.Provider,
|
||||
Method: flow.Method,
|
||||
Status: flow.Status,
|
||||
Error: flow.Error,
|
||||
}
|
||||
if !flow.ExpiresAt.IsZero() {
|
||||
resp.ExpiresAt = flow.ExpiresAt.Format(time.RFC3339)
|
||||
}
|
||||
if flow.Method == oauthMethodDeviceCode {
|
||||
resp.UserCode = flow.UserCode
|
||||
resp.VerifyURL = flow.VerifyURL
|
||||
resp.Interval = flow.Interval
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func newOAuthFlowID() string {
|
||||
buf := make([]byte, 16)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return fmt.Sprintf("oauth_%d", time.Now().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(buf)
|
||||
}
|
||||
|
||||
func (h *Handler) storeOAuthFlow(flow *oauthFlow) {
|
||||
now := oauthNow()
|
||||
h.oauthMu.Lock()
|
||||
defer h.oauthMu.Unlock()
|
||||
|
||||
h.gcOAuthFlowsLocked(now)
|
||||
h.oauthFlows[flow.ID] = flow
|
||||
if flow.OAuthState != "" {
|
||||
h.oauthState[flow.OAuthState] = flow.ID
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) getOAuthFlow(flowID string) (*oauthFlow, bool) {
|
||||
now := oauthNow()
|
||||
h.oauthMu.Lock()
|
||||
defer h.oauthMu.Unlock()
|
||||
|
||||
h.gcOAuthFlowsLocked(now)
|
||||
flow, ok := h.oauthFlows[flowID]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
cp := *flow
|
||||
return &cp, true
|
||||
}
|
||||
|
||||
func (h *Handler) getOAuthFlowByState(state string) (*oauthFlow, bool) {
|
||||
now := oauthNow()
|
||||
h.oauthMu.Lock()
|
||||
defer h.oauthMu.Unlock()
|
||||
|
||||
h.gcOAuthFlowsLocked(now)
|
||||
flowID, ok := h.oauthState[state]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
flow, ok := h.oauthFlows[flowID]
|
||||
if !ok {
|
||||
delete(h.oauthState, state)
|
||||
return nil, false
|
||||
}
|
||||
cp := *flow
|
||||
return &cp, true
|
||||
}
|
||||
|
||||
func (h *Handler) setOAuthFlowSuccess(flowID string) {
|
||||
now := oauthNow()
|
||||
h.oauthMu.Lock()
|
||||
defer h.oauthMu.Unlock()
|
||||
|
||||
flow, ok := h.oauthFlows[flowID]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
flow.Status = oauthFlowSuccess
|
||||
flow.Error = ""
|
||||
flow.UpdatedAt = now
|
||||
if flow.OAuthState != "" {
|
||||
delete(h.oauthState, flow.OAuthState)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) setOAuthFlowError(flowID, errMsg string) {
|
||||
now := oauthNow()
|
||||
h.oauthMu.Lock()
|
||||
defer h.oauthMu.Unlock()
|
||||
|
||||
flow, ok := h.oauthFlows[flowID]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
flow.Status = oauthFlowError
|
||||
flow.Error = errMsg
|
||||
flow.UpdatedAt = now
|
||||
if flow.OAuthState != "" {
|
||||
delete(h.oauthState, flow.OAuthState)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) gcOAuthFlowsLocked(now time.Time) {
|
||||
for id, flow := range h.oauthFlows {
|
||||
if flow.Status == oauthFlowPending && !flow.ExpiresAt.IsZero() && now.After(flow.ExpiresAt) {
|
||||
flow.Status = oauthFlowExpired
|
||||
flow.Error = "flow expired"
|
||||
flow.UpdatedAt = now
|
||||
if flow.OAuthState != "" {
|
||||
delete(h.oauthState, flow.OAuthState)
|
||||
}
|
||||
}
|
||||
|
||||
if flow.Status != oauthFlowPending && now.Sub(flow.UpdatedAt) > oauthTerminalFlowGC {
|
||||
if flow.OAuthState != "" {
|
||||
delete(h.oauthState, flow.OAuthState)
|
||||
}
|
||||
delete(h.oauthFlows, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) persistCredentialAndConfig(provider, authMethod string, cred *auth.AuthCredential) error {
|
||||
if cred == nil {
|
||||
return fmt.Errorf("empty credential")
|
||||
}
|
||||
|
||||
cp := *cred
|
||||
cp.Provider = provider
|
||||
if cp.AuthMethod == "" {
|
||||
cp.AuthMethod = authMethod
|
||||
}
|
||||
|
||||
if provider == oauthProviderGoogleAntigravity {
|
||||
if cp.Email == "" {
|
||||
email, err := oauthFetchGoogleUserEmailFunc(cp.AccessToken)
|
||||
if err != nil {
|
||||
log.Printf("oauth warning: could not fetch google email: %v", err)
|
||||
} else {
|
||||
cp.Email = email
|
||||
}
|
||||
}
|
||||
if cp.ProjectID == "" {
|
||||
projectID, err := oauthFetchAntigravityProject(cp.AccessToken)
|
||||
if err != nil {
|
||||
log.Printf("oauth warning: could not fetch antigravity project id: %v", err)
|
||||
} else {
|
||||
cp.ProjectID = projectID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := oauthSetCredential(provider, &cp); err != nil {
|
||||
return fmt.Errorf("saving credential: %w", err)
|
||||
}
|
||||
if err := h.syncProviderAuthMethod(provider, authMethod); err != nil {
|
||||
return fmt.Errorf("syncing provider auth config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) syncProviderAuthMethod(provider, authMethod string) error {
|
||||
cfg, err := oauthLoadConfig(h.configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch provider {
|
||||
case oauthProviderOpenAI:
|
||||
cfg.Providers.OpenAI.AuthMethod = authMethod
|
||||
case oauthProviderAnthropic:
|
||||
cfg.Providers.Anthropic.AuthMethod = authMethod
|
||||
case oauthProviderGoogleAntigravity:
|
||||
cfg.Providers.Antigravity.AuthMethod = authMethod
|
||||
default:
|
||||
return fmt.Errorf("unsupported provider %q", provider)
|
||||
}
|
||||
|
||||
found := false
|
||||
for i := range cfg.ModelList {
|
||||
if modelBelongsToProvider(provider, cfg.ModelList[i].Model) {
|
||||
cfg.ModelList[i].AuthMethod = authMethod
|
||||
found = true
|
||||
}
|
||||
}
|
||||
|
||||
if !found && authMethod != "" {
|
||||
cfg.ModelList = append(cfg.ModelList, defaultModelConfigForProvider(provider, authMethod))
|
||||
}
|
||||
|
||||
return oauthSaveConfig(h.configPath, cfg)
|
||||
}
|
||||
|
||||
func modelBelongsToProvider(provider, model string) bool {
|
||||
lower := strings.ToLower(strings.TrimSpace(model))
|
||||
switch provider {
|
||||
case oauthProviderOpenAI:
|
||||
return lower == "openai" || strings.HasPrefix(lower, "openai/")
|
||||
case oauthProviderAnthropic:
|
||||
return lower == "anthropic" || strings.HasPrefix(lower, "anthropic/")
|
||||
case oauthProviderGoogleAntigravity:
|
||||
return lower == "antigravity" ||
|
||||
lower == "google-antigravity" ||
|
||||
strings.HasPrefix(lower, "antigravity/") ||
|
||||
strings.HasPrefix(lower, "google-antigravity/")
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func defaultModelConfigForProvider(provider, authMethod string) config.ModelConfig {
|
||||
switch provider {
|
||||
case oauthProviderOpenAI:
|
||||
return config.ModelConfig{
|
||||
ModelName: "gpt-5.2",
|
||||
Model: "openai/gpt-5.2",
|
||||
AuthMethod: authMethod,
|
||||
}
|
||||
case oauthProviderAnthropic:
|
||||
return config.ModelConfig{
|
||||
ModelName: "claude-sonnet-4.6",
|
||||
Model: "anthropic/claude-sonnet-4.6",
|
||||
AuthMethod: authMethod,
|
||||
}
|
||||
case oauthProviderGoogleAntigravity:
|
||||
return config.ModelConfig{
|
||||
ModelName: "gemini-flash",
|
||||
Model: "antigravity/gemini-3-flash",
|
||||
AuthMethod: authMethod,
|
||||
}
|
||||
default:
|
||||
return config.ModelConfig{}
|
||||
}
|
||||
}
|
||||
|
||||
func fetchGoogleUserEmail(accessToken string) (string, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, "https://www.googleapis.com/oauth2/v2/userinfo", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("userinfo request failed: %s", string(body))
|
||||
}
|
||||
|
||||
var userInfo struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &userInfo); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if userInfo.Email == "" {
|
||||
return "", fmt.Errorf("empty email in userinfo response")
|
||||
}
|
||||
return userInfo.Email, nil
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/auth"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func TestOAuthLoginRejectsUnsupportedMethod(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
resetOAuthHooks(t)
|
||||
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/oauth/login",
|
||||
strings.NewReader(`{"provider":"anthropic","method":"browser"}`),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthBrowserFlowCreatedAndQueried(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
resetOAuthHooks(t)
|
||||
|
||||
oauthGeneratePKCE = func() (auth.PKCECodes, error) {
|
||||
return auth.PKCECodes{CodeVerifier: "verifier-1", CodeChallenge: "challenge-1"}, nil
|
||||
}
|
||||
oauthGenerateState = func() (string, error) { return "state-1", nil }
|
||||
oauthBuildAuthorizeURL = func(cfg auth.OAuthProviderConfig, pkce auth.PKCECodes, state, redirectURI string) string {
|
||||
return "https://example.com/authorize?state=" + state
|
||||
}
|
||||
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/oauth/login",
|
||||
strings.NewReader(`{"provider":"openai","method":"browser"}`),
|
||||
)
|
||||
req.Host = "localhost:18800"
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
var loginResp map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &loginResp); err != nil {
|
||||
t.Fatalf("unmarshal login response: %v", err)
|
||||
}
|
||||
flowID, _ := loginResp["flow_id"].(string)
|
||||
if flowID == "" {
|
||||
t.Fatalf("flow_id is empty: %v", loginResp)
|
||||
}
|
||||
if loginResp["auth_url"] != "https://example.com/authorize?state=state-1" {
|
||||
t.Fatalf("unexpected auth_url: %v", loginResp["auth_url"])
|
||||
}
|
||||
|
||||
rec2 := httptest.NewRecorder()
|
||||
req2 := httptest.NewRequest(http.MethodGet, "/api/oauth/flows/"+flowID, nil)
|
||||
mux.ServeHTTP(rec2, req2)
|
||||
if rec2.Code != http.StatusOK {
|
||||
t.Fatalf("flow status code = %d, want %d, body=%s", rec2.Code, http.StatusOK, rec2.Body.String())
|
||||
}
|
||||
var flowResp oauthFlowResponse
|
||||
if err := json.Unmarshal(rec2.Body.Bytes(), &flowResp); err != nil {
|
||||
t.Fatalf("unmarshal flow response: %v", err)
|
||||
}
|
||||
if flowResp.Status != oauthFlowPending {
|
||||
t.Fatalf("flow status = %q, want %q", flowResp.Status, oauthFlowPending)
|
||||
}
|
||||
if flowResp.Method != oauthMethodBrowser {
|
||||
t.Fatalf("flow method = %q, want %q", flowResp.Method, oauthMethodBrowser)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthFlowExpiresWhenQueried(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
resetOAuthHooks(t)
|
||||
|
||||
now := time.Date(2026, 3, 6, 12, 0, 0, 0, time.UTC)
|
||||
oauthNow = func() time.Time { return now }
|
||||
|
||||
h := NewHandler(configPath)
|
||||
h.storeOAuthFlow(&oauthFlow{
|
||||
ID: "expired-flow",
|
||||
Provider: oauthProviderOpenAI,
|
||||
Method: oauthMethodBrowser,
|
||||
Status: oauthFlowPending,
|
||||
CreatedAt: now.Add(-20 * time.Minute),
|
||||
UpdatedAt: now.Add(-20 * time.Minute),
|
||||
ExpiresAt: now.Add(-1 * time.Minute),
|
||||
})
|
||||
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/oauth/flows/expired-flow", nil)
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
var flowResp oauthFlowResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &flowResp); err != nil {
|
||||
t.Fatalf("unmarshal flow response: %v", err)
|
||||
}
|
||||
if flowResp.Status != oauthFlowExpired {
|
||||
t.Fatalf("flow status = %q, want %q", flowResp.Status, oauthFlowExpired)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthCallbackUnknownState(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
resetOAuthHooks(t)
|
||||
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/oauth/callback?state=unknown&code=abc", nil)
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "OAuth flow not found") {
|
||||
t.Fatalf("unexpected body: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthLogoutClearsCredentialAndConfig(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
resetOAuthHooks(t)
|
||||
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig error: %v", err)
|
||||
}
|
||||
cfg.Providers.OpenAI.AuthMethod = "oauth"
|
||||
cfg.ModelList = append(cfg.ModelList, config.ModelConfig{
|
||||
ModelName: "gpt-5.2",
|
||||
Model: "openai/gpt-5.2",
|
||||
AuthMethod: "oauth",
|
||||
})
|
||||
if err = config.SaveConfig(configPath, cfg); err != nil {
|
||||
t.Fatalf("SaveConfig error: %v", err)
|
||||
}
|
||||
if err = auth.SetCredential(oauthProviderOpenAI, &auth.AuthCredential{
|
||||
AccessToken: "token-before-logout",
|
||||
Provider: oauthProviderOpenAI,
|
||||
AuthMethod: "oauth",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetCredential error: %v", err)
|
||||
}
|
||||
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/oauth/logout", bytes.NewBufferString(`{"provider":"openai"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
cred, err := auth.GetCredential(oauthProviderOpenAI)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCredential error: %v", err)
|
||||
}
|
||||
if cred != nil {
|
||||
t.Fatalf("expected credential deleted, got %#v", cred)
|
||||
}
|
||||
|
||||
updated, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig error: %v", err)
|
||||
}
|
||||
if updated.Providers.OpenAI.AuthMethod != "" {
|
||||
t.Fatalf("providers.openai.auth_method = %q, want empty", updated.Providers.OpenAI.AuthMethod)
|
||||
}
|
||||
for _, m := range updated.ModelList {
|
||||
if strings.HasPrefix(m.Model, "openai/") && m.AuthMethod != "" {
|
||||
t.Fatalf("openai model auth_method = %q, want empty", m.AuthMethod)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setupOAuthTestEnv(t *testing.T) (string, func()) {
|
||||
t.Helper()
|
||||
|
||||
tmp := t.TempDir()
|
||||
oldHome := os.Getenv("HOME")
|
||||
oldPicoHome := os.Getenv("PICOCLAW_HOME")
|
||||
|
||||
if err := os.Setenv("HOME", tmp); err != nil {
|
||||
t.Fatalf("set HOME: %v", err)
|
||||
}
|
||||
if err := os.Setenv("PICOCLAW_HOME", filepath.Join(tmp, ".picoclaw")); err != nil {
|
||||
t.Fatalf("set PICOCLAW_HOME: %v", err)
|
||||
}
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.ModelList = []config.ModelConfig{{
|
||||
ModelName: "custom-default",
|
||||
Model: "openai/gpt-4o",
|
||||
APIKey: "sk-default",
|
||||
}}
|
||||
cfg.Agents.Defaults.ModelName = "custom-default"
|
||||
|
||||
configPath := filepath.Join(tmp, "config.json")
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
t.Fatalf("SaveConfig error: %v", err)
|
||||
}
|
||||
|
||||
cleanup := func() {
|
||||
_ = os.Setenv("HOME", oldHome)
|
||||
if oldPicoHome == "" {
|
||||
_ = os.Unsetenv("PICOCLAW_HOME")
|
||||
} else {
|
||||
_ = os.Setenv("PICOCLAW_HOME", oldPicoHome)
|
||||
}
|
||||
}
|
||||
return configPath, cleanup
|
||||
}
|
||||
|
||||
func resetOAuthHooks(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
origNow := oauthNow
|
||||
origGeneratePKCE := oauthGeneratePKCE
|
||||
origGenerateState := oauthGenerateState
|
||||
origBuildAuthorizeURL := oauthBuildAuthorizeURL
|
||||
origRequestDeviceCode := oauthRequestDeviceCode
|
||||
origPollDeviceCodeOnce := oauthPollDeviceCodeOnce
|
||||
origExchangeCodeForTokens := oauthExchangeCodeForTokens
|
||||
origGetCredential := oauthGetCredential
|
||||
origSetCredential := oauthSetCredential
|
||||
origDeleteCredential := oauthDeleteCredential
|
||||
origLoadConfig := oauthLoadConfig
|
||||
origSaveConfig := oauthSaveConfig
|
||||
origFetchProject := oauthFetchAntigravityProject
|
||||
origFetchGoogleEmail := oauthFetchGoogleUserEmailFunc
|
||||
|
||||
t.Cleanup(func() {
|
||||
oauthNow = origNow
|
||||
oauthGeneratePKCE = origGeneratePKCE
|
||||
oauthGenerateState = origGenerateState
|
||||
oauthBuildAuthorizeURL = origBuildAuthorizeURL
|
||||
oauthRequestDeviceCode = origRequestDeviceCode
|
||||
oauthPollDeviceCodeOnce = origPollDeviceCodeOnce
|
||||
oauthExchangeCodeForTokens = origExchangeCodeForTokens
|
||||
oauthGetCredential = origGetCredential
|
||||
oauthSetCredential = origSetCredential
|
||||
oauthDeleteCredential = origDeleteCredential
|
||||
oauthLoadConfig = origLoadConfig
|
||||
oauthSaveConfig = origSaveConfig
|
||||
oauthFetchAntigravityProject = origFetchProject
|
||||
oauthFetchGoogleUserEmailFunc = origFetchGoogleEmail
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
// registerPicoRoutes binds Pico Channel management endpoints to the ServeMux.
|
||||
func (h *Handler) registerPicoRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/pico/token", h.handleGetPicoToken)
|
||||
mux.HandleFunc("POST /api/pico/token", h.handleRegenPicoToken)
|
||||
mux.HandleFunc("POST /api/pico/setup", h.handlePicoSetup)
|
||||
}
|
||||
|
||||
// handleGetPicoToken returns the current WS token and URL for the frontend.
|
||||
//
|
||||
// GET /api/pico/token
|
||||
func (h *Handler) handleGetPicoToken(w http.ResponseWriter, r *http.Request) {
|
||||
cfg, err := config.LoadConfig(h.configPath)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
wsURL := buildWsURL(r, cfg)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"token": cfg.Channels.Pico.Token,
|
||||
"ws_url": wsURL,
|
||||
"enabled": cfg.Channels.Pico.Enabled,
|
||||
})
|
||||
}
|
||||
|
||||
// handleRegenPicoToken generates a new Pico WebSocket token and saves it.
|
||||
//
|
||||
// POST /api/pico/token
|
||||
func (h *Handler) handleRegenPicoToken(w http.ResponseWriter, r *http.Request) {
|
||||
cfg, err := config.LoadConfig(h.configPath)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
token := generateSecureToken()
|
||||
cfg.Channels.Pico.Token = token
|
||||
|
||||
if err := config.SaveConfig(h.configPath, cfg); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
wsURL := fmt.Sprintf("ws://%s/pico/ws", net.JoinHostPort(cfg.Gateway.Host, strconv.Itoa(cfg.Gateway.Port)))
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"token": token,
|
||||
"ws_url": wsURL,
|
||||
})
|
||||
}
|
||||
|
||||
// ensurePicoChannel checks if the Pico Channel is properly configured and
|
||||
// enables it with sensible defaults if not. Returns true if config was changed.
|
||||
func (h *Handler) ensurePicoChannel() (bool, error) {
|
||||
cfg, err := config.LoadConfig(h.configPath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
changed := false
|
||||
|
||||
if !cfg.Channels.Pico.Enabled {
|
||||
cfg.Channels.Pico.Enabled = true
|
||||
changed = true
|
||||
}
|
||||
|
||||
if cfg.Channels.Pico.Token == "" {
|
||||
cfg.Channels.Pico.Token = generateSecureToken()
|
||||
changed = true
|
||||
}
|
||||
|
||||
if !cfg.Channels.Pico.AllowTokenQuery {
|
||||
cfg.Channels.Pico.AllowTokenQuery = true
|
||||
changed = true
|
||||
}
|
||||
|
||||
// Make sure origins are allowed (frontend might be running on a different port like 5173 during dev)
|
||||
if len(cfg.Channels.Pico.AllowOrigins) == 0 {
|
||||
cfg.Channels.Pico.AllowOrigins = []string{"*"}
|
||||
changed = true
|
||||
}
|
||||
|
||||
if changed {
|
||||
if err := config.SaveConfig(h.configPath, cfg); err != nil {
|
||||
return false, fmt.Errorf("failed to save config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
// handlePicoSetup automatically configures everything needed for the Pico Channel to work.
|
||||
//
|
||||
// POST /api/pico/setup
|
||||
func (h *Handler) handlePicoSetup(w http.ResponseWriter, r *http.Request) {
|
||||
changed, err := h.ensurePicoChannel()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := config.LoadConfig(h.configPath)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
wsURL := buildWsURL(r, cfg)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"token": cfg.Channels.Pico.Token,
|
||||
"ws_url": wsURL,
|
||||
"enabled": true,
|
||||
"changed": changed,
|
||||
})
|
||||
}
|
||||
|
||||
// buildWsURL creates a WebSocket URL for the Pico Channel.
|
||||
// When the gateway host is "0.0.0.0" or empty, it uses the hostname from the
|
||||
// incoming HTTP request so the browser gets a connectable address.
|
||||
func buildWsURL(r *http.Request, cfg *config.Config) string {
|
||||
host := cfg.Gateway.Host
|
||||
if host == "" || host == "0.0.0.0" {
|
||||
// Use the hostname the browser used to reach this backend
|
||||
reqHost, _, err := net.SplitHostPort(r.Host)
|
||||
if err != nil {
|
||||
reqHost = r.Host // r.Host might not have a port
|
||||
}
|
||||
host = reqHost
|
||||
}
|
||||
return "ws://" + net.JoinHostPort(host, strconv.Itoa(cfg.Gateway.Port)) + "/pico/ws"
|
||||
}
|
||||
|
||||
// generateSecureToken creates a random 32-character hex string.
|
||||
func generateSecureToken() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
// Fallback to something pseudo-random if crypto/rand fails
|
||||
return fmt.Sprintf("pico_%x", time.Now().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
|
||||
)
|
||||
|
||||
// Handler serves HTTP API requests.
|
||||
type Handler struct {
|
||||
configPath string
|
||||
serverPort int
|
||||
serverPublic bool
|
||||
serverCIDRs []string
|
||||
oauthMu sync.Mutex
|
||||
oauthFlows map[string]*oauthFlow
|
||||
oauthState map[string]string
|
||||
}
|
||||
|
||||
// NewHandler creates an instance of the API handler.
|
||||
func NewHandler(configPath string) *Handler {
|
||||
return &Handler{
|
||||
configPath: configPath,
|
||||
serverPort: launcherconfig.DefaultPort,
|
||||
oauthFlows: make(map[string]*oauthFlow),
|
||||
oauthState: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// SetServerOptions stores current backend listen options for fallback behavior.
|
||||
func (h *Handler) SetServerOptions(port int, public bool, allowedCIDRs []string) {
|
||||
h.serverPort = port
|
||||
h.serverPublic = public
|
||||
h.serverCIDRs = append([]string(nil), allowedCIDRs...)
|
||||
}
|
||||
|
||||
// RegisterRoutes binds all API endpoint handlers to the ServeMux.
|
||||
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||
// Config CRUD
|
||||
h.registerConfigRoutes(mux)
|
||||
|
||||
// Pico Channel (WebSocket chat)
|
||||
h.registerPicoRoutes(mux)
|
||||
|
||||
// Gateway process lifecycle
|
||||
h.registerGatewayRoutes(mux)
|
||||
|
||||
// Session history
|
||||
h.registerSessionRoutes(mux)
|
||||
|
||||
// OAuth login and credential management
|
||||
h.registerOAuthRoutes(mux)
|
||||
|
||||
// Model list management
|
||||
h.registerModelRoutes(mux)
|
||||
|
||||
// Channel catalog (for frontend navigation/config pages)
|
||||
h.registerChannelRoutes(mux)
|
||||
|
||||
// OS startup / launch-at-login
|
||||
h.registerStartupRoutes(mux)
|
||||
|
||||
// Launcher service parameters (port/public)
|
||||
h.registerLauncherConfigRoutes(mux)
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
// registerSessionRoutes binds session list and detail endpoints to the ServeMux.
|
||||
func (h *Handler) registerSessionRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/sessions", h.handleListSessions)
|
||||
mux.HandleFunc("GET /api/sessions/{id}", h.handleGetSession)
|
||||
mux.HandleFunc("DELETE /api/sessions/{id}", h.handleDeleteSession)
|
||||
}
|
||||
|
||||
// sessionFile mirrors the on-disk session JSON structure from pkg/session.
|
||||
type sessionFile struct {
|
||||
Key string `json:"key"`
|
||||
Messages []providers.Message `json:"messages"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Created time.Time `json:"created"`
|
||||
Updated time.Time `json:"updated"`
|
||||
}
|
||||
|
||||
// sessionListItem is a lightweight summary returned by GET /api/sessions.
|
||||
type sessionListItem struct {
|
||||
ID string `json:"id"`
|
||||
Preview string `json:"preview"`
|
||||
MessageCount int `json:"message_count"`
|
||||
Created string `json:"created"`
|
||||
Updated string `json:"updated"`
|
||||
}
|
||||
|
||||
// picoSessionPrefix is the key prefix used by the gateway's routing for Pico
|
||||
// channel sessions. The full key format is:
|
||||
//
|
||||
// agent:main:pico:direct:pico:<session-uuid>
|
||||
//
|
||||
// The sanitized filename replaces ':' with '_', so on disk it becomes:
|
||||
//
|
||||
// agent_main_pico_direct_pico_<session-uuid>.json
|
||||
const picoSessionPrefix = "agent:main:pico:direct:pico:"
|
||||
|
||||
// extractPicoSessionID extracts the session UUID from a full session key.
|
||||
// Returns the UUID and true if the key matches the Pico session pattern.
|
||||
func extractPicoSessionID(key string) (string, bool) {
|
||||
if strings.HasPrefix(key, picoSessionPrefix) {
|
||||
return strings.TrimPrefix(key, picoSessionPrefix), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// sessionsDir resolves the path to the gateway's session storage directory.
|
||||
// It reads the workspace from config, falling back to ~/.picoclaw/workspace.
|
||||
func (h *Handler) sessionsDir() (string, error) {
|
||||
cfg, err := config.LoadConfig(h.configPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
workspace := cfg.Agents.Defaults.Workspace
|
||||
if workspace == "" {
|
||||
home, _ := os.UserHomeDir()
|
||||
workspace = filepath.Join(home, ".picoclaw", "workspace")
|
||||
}
|
||||
|
||||
// Expand ~ prefix
|
||||
if len(workspace) > 0 && workspace[0] == '~' {
|
||||
home, _ := os.UserHomeDir()
|
||||
if len(workspace) > 1 && workspace[1] == '/' {
|
||||
workspace = home + workspace[1:]
|
||||
} else {
|
||||
workspace = home
|
||||
}
|
||||
}
|
||||
|
||||
return filepath.Join(workspace, "sessions"), nil
|
||||
}
|
||||
|
||||
// handleListSessions returns a list of Pico session summaries.
|
||||
//
|
||||
// GET /api/sessions
|
||||
func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) {
|
||||
dir, err := h.sessionsDir()
|
||||
if err != nil {
|
||||
http.Error(w, "failed to resolve sessions directory", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
// Directory doesn't exist yet = no sessions
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode([]sessionListItem{})
|
||||
return
|
||||
}
|
||||
|
||||
items := []sessionListItem{}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
|
||||
continue
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(dir, entry.Name()))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var sess sessionFile
|
||||
if err := json.Unmarshal(data, &sess); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Only include Pico channel sessions
|
||||
sessionID, ok := extractPicoSessionID(sess.Key)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Build a preview from the first user message
|
||||
preview := ""
|
||||
for _, msg := range sess.Messages {
|
||||
if msg.Role == "user" && strings.TrimSpace(msg.Content) != "" {
|
||||
preview = msg.Content
|
||||
break
|
||||
}
|
||||
}
|
||||
if len([]rune(preview)) > 60 {
|
||||
preview = string([]rune(preview)[:60]) + "..."
|
||||
}
|
||||
if preview == "" {
|
||||
preview = "(empty)"
|
||||
}
|
||||
|
||||
// Only count non-empty user and assistant messages
|
||||
validMessageCount := 0
|
||||
for _, msg := range sess.Messages {
|
||||
if (msg.Role == "user" || msg.Role == "assistant") && strings.TrimSpace(msg.Content) != "" {
|
||||
validMessageCount++
|
||||
}
|
||||
}
|
||||
|
||||
items = append(items, sessionListItem{
|
||||
ID: sessionID,
|
||||
Preview: preview,
|
||||
MessageCount: validMessageCount,
|
||||
Created: sess.Created.Format(time.RFC3339),
|
||||
Updated: sess.Updated.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
// Sort by updated descending (most recent first)
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
return items[i].Updated > items[j].Updated
|
||||
})
|
||||
|
||||
// Pagination parameters
|
||||
offsetStr := r.URL.Query().Get("offset")
|
||||
limitStr := r.URL.Query().Get("limit")
|
||||
|
||||
offset := 0
|
||||
limit := 20 // Default limit
|
||||
|
||||
if val, err := strconv.Atoi(offsetStr); err == nil && val >= 0 {
|
||||
offset = val
|
||||
}
|
||||
if val, err := strconv.Atoi(limitStr); err == nil && val > 0 {
|
||||
limit = val
|
||||
}
|
||||
|
||||
totalItems := len(items)
|
||||
|
||||
end := offset + limit
|
||||
if offset >= totalItems {
|
||||
items = []sessionListItem{} // Out of bounds, return empty
|
||||
} else {
|
||||
if end > totalItems {
|
||||
end = totalItems
|
||||
}
|
||||
items = items[offset:end]
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(items)
|
||||
}
|
||||
|
||||
// handleGetSession returns the full message history for a specific session.
|
||||
//
|
||||
// GET /api/sessions/{id}
|
||||
func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID := r.PathValue("id")
|
||||
if sessionID == "" {
|
||||
http.Error(w, "missing session id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
dir, err := h.sessionsDir()
|
||||
if err != nil {
|
||||
http.Error(w, "failed to resolve sessions directory", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// The sanitized filename replaces ':' with '_':
|
||||
// agent:main:pico:direct:pico:<uuid> -> agent_main_pico_direct_pico_<uuid>.json
|
||||
filename := strings.ReplaceAll(picoSessionPrefix+sessionID, ":", "_") + ".json"
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(dir, filename))
|
||||
if err != nil {
|
||||
http.Error(w, "session not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var sess sessionFile
|
||||
if err := json.Unmarshal(data, &sess); err != nil {
|
||||
http.Error(w, "failed to parse session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to a simpler format for the frontend
|
||||
type chatMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
messages := make([]chatMessage, 0, len(sess.Messages))
|
||||
for _, msg := range sess.Messages {
|
||||
// Only include user and assistant messages that have actual content
|
||||
if (msg.Role == "user" || msg.Role == "assistant") && strings.TrimSpace(msg.Content) != "" {
|
||||
messages = append(messages, chatMessage{
|
||||
Role: msg.Role,
|
||||
Content: msg.Content,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": sessionID,
|
||||
"messages": messages,
|
||||
"summary": sess.Summary,
|
||||
"created": sess.Created.Format(time.RFC3339),
|
||||
"updated": sess.Updated.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
// handleDeleteSession deletes a specific session.
|
||||
//
|
||||
// DELETE /api/sessions/{id}
|
||||
func (h *Handler) handleDeleteSession(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID := r.PathValue("id")
|
||||
if sessionID == "" {
|
||||
http.Error(w, "missing session id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
dir, err := h.sessionsDir()
|
||||
if err != nil {
|
||||
http.Error(w, "failed to resolve sessions directory", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// The sanitized filename replaces ':' with '_':
|
||||
// agent:main:pico:direct:pico:<uuid> -> agent_main_pico_direct_pico_<uuid>.json
|
||||
filename := strings.ReplaceAll(picoSessionPrefix+sessionID, ":", "_") + ".json"
|
||||
filePath := filepath.Join(dir, filename)
|
||||
|
||||
if err := os.Remove(filePath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
http.Error(w, "session not found", http.StatusNotFound)
|
||||
} else {
|
||||
http.Error(w, "failed to delete session", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
autoStartEntryName = "PicoClawLauncher"
|
||||
launchAgentLabel = "io.picoclaw.launcher"
|
||||
)
|
||||
|
||||
type autoStartRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type autoStartResponse struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Supported bool `json:"supported"`
|
||||
Platform string `json:"platform"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
var errAutoStartUnsupported = errors.New("autostart is not supported on this platform")
|
||||
|
||||
func (h *Handler) registerStartupRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/system/autostart", h.handleGetAutoStart)
|
||||
mux.HandleFunc("PUT /api/system/autostart", h.handleSetAutoStart)
|
||||
}
|
||||
|
||||
func (h *Handler) handleGetAutoStart(w http.ResponseWriter, r *http.Request) {
|
||||
enabled, supported, message, err := h.getAutoStartStatus()
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to read startup setting: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(autoStartResponse{
|
||||
Enabled: enabled,
|
||||
Supported: supported,
|
||||
Platform: runtime.GOOS,
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) handleSetAutoStart(w http.ResponseWriter, r *http.Request) {
|
||||
var req autoStartRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.setAutoStart(req.Enabled); err != nil {
|
||||
if errors.Is(err, errAutoStartUnsupported) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
http.Error(w, fmt.Sprintf("Failed to update startup setting: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
enabled, supported, message, err := h.getAutoStartStatus()
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to verify startup setting: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(autoStartResponse{
|
||||
Enabled: enabled,
|
||||
Supported: supported,
|
||||
Platform: runtime.GOOS,
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) resolveLaunchCommand() (string, []string, error) {
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
args := []string{"-no-browser"}
|
||||
if h.configPath != "" {
|
||||
args = append(args, h.configPath)
|
||||
}
|
||||
|
||||
return exePath, args, nil
|
||||
}
|
||||
|
||||
func (h *Handler) getAutoStartStatus() (enabled bool, supported bool, message string, err error) {
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
exists, err := fileExists(macLaunchAgentPath())
|
||||
return exists, true, "Changes apply on next login.", err
|
||||
case "linux":
|
||||
exists, err := fileExists(linuxAutoStartPath())
|
||||
return exists, true, "Changes apply on next login.", err
|
||||
case "windows":
|
||||
exists, err := windowsRunKeyExists()
|
||||
return exists, true, "Changes apply on next login.", err
|
||||
default:
|
||||
return false, false, "Current platform does not support launch at login.", nil
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) setAutoStart(enabled bool) error {
|
||||
exePath, args, err := h.resolveLaunchCommand()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
return setDarwinAutoStart(enabled, exePath, args)
|
||||
case "linux":
|
||||
return setLinuxAutoStart(enabled, exePath, args)
|
||||
case "windows":
|
||||
return setWindowsAutoStart(enabled, exePath, args)
|
||||
default:
|
||||
return errAutoStartUnsupported
|
||||
}
|
||||
}
|
||||
|
||||
func fileExists(path string) (bool, error) {
|
||||
_, err := os.Stat(path)
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
func macLaunchAgentPath() string {
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, "Library", "LaunchAgents", launchAgentLabel+".plist")
|
||||
}
|
||||
|
||||
func setDarwinAutoStart(enabled bool, exePath string, args []string) error {
|
||||
plistPath := macLaunchAgentPath()
|
||||
if enabled {
|
||||
if err := os.MkdirAll(filepath.Dir(plistPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
content := buildDarwinPlist(exePath, args)
|
||||
return os.WriteFile(plistPath, []byte(content), 0o644)
|
||||
}
|
||||
|
||||
if err := os.Remove(plistPath); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func xmlEscape(s string) string {
|
||||
var b bytes.Buffer
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '&':
|
||||
b.WriteString("&")
|
||||
case '<':
|
||||
b.WriteString("<")
|
||||
case '>':
|
||||
b.WriteString(">")
|
||||
case '"':
|
||||
b.WriteString(""")
|
||||
case '\'':
|
||||
b.WriteString("'")
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func buildDarwinPlist(exePath string, args []string) string {
|
||||
programArgs := make([]string, 0, len(args)+1)
|
||||
programArgs = append(programArgs, exePath)
|
||||
programArgs = append(programArgs, args...)
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(`<?xml version="1.0" encoding="UTF-8"?>` + "\n")
|
||||
b.WriteString(
|
||||
`<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">` + "\n",
|
||||
)
|
||||
b.WriteString(`<plist version="1.0">` + "\n")
|
||||
b.WriteString(`<dict>` + "\n")
|
||||
b.WriteString(` <key>Label</key>` + "\n")
|
||||
b.WriteString(` <string>` + launchAgentLabel + `</string>` + "\n")
|
||||
b.WriteString(` <key>ProgramArguments</key>` + "\n")
|
||||
b.WriteString(` <array>` + "\n")
|
||||
for _, arg := range programArgs {
|
||||
b.WriteString(` <string>` + xmlEscape(arg) + `</string>` + "\n")
|
||||
}
|
||||
b.WriteString(` </array>` + "\n")
|
||||
b.WriteString(` <key>RunAtLoad</key>` + "\n")
|
||||
b.WriteString(` <true/>` + "\n")
|
||||
b.WriteString(` <key>ProcessType</key>` + "\n")
|
||||
b.WriteString(` <string>Background</string>` + "\n")
|
||||
b.WriteString(`</dict>` + "\n")
|
||||
b.WriteString(`</plist>` + "\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func linuxAutoStartPath() string {
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".config", "autostart", "picoclaw-web.desktop")
|
||||
}
|
||||
|
||||
func shellQuote(s string) string {
|
||||
if s == "" {
|
||||
return "''"
|
||||
}
|
||||
if !strings.ContainsAny(s, " \t\n'\"\\$`") {
|
||||
return s
|
||||
}
|
||||
return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'"
|
||||
}
|
||||
|
||||
func buildLinuxExecLine(exePath string, args []string) string {
|
||||
parts := make([]string, 0, len(args)+1)
|
||||
parts = append(parts, shellQuote(exePath))
|
||||
for _, arg := range args {
|
||||
parts = append(parts, shellQuote(arg))
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func setLinuxAutoStart(enabled bool, exePath string, args []string) error {
|
||||
desktopPath := linuxAutoStartPath()
|
||||
if enabled {
|
||||
if err := os.MkdirAll(filepath.Dir(desktopPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
content := strings.Join([]string{
|
||||
"[Desktop Entry]",
|
||||
"Type=Application",
|
||||
"Version=1.0",
|
||||
"Name=PicoClaw Web",
|
||||
"Comment=Start PicoClaw Web on login",
|
||||
"Exec=" + buildLinuxExecLine(exePath, args),
|
||||
"Terminal=false",
|
||||
"X-GNOME-Autostart-enabled=true",
|
||||
"NoDisplay=true",
|
||||
"",
|
||||
}, "\n")
|
||||
return os.WriteFile(desktopPath, []byte(content), 0o644)
|
||||
}
|
||||
|
||||
if err := os.Remove(desktopPath); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func windowsCommandLine(exePath string, args []string) string {
|
||||
parts := make([]string, 0, len(args)+1)
|
||||
parts = append(parts, fmt.Sprintf("%q", exePath))
|
||||
for _, arg := range args {
|
||||
parts = append(parts, fmt.Sprintf("%q", arg))
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func windowsRunKeyExists() (bool, error) {
|
||||
cmd := exec.Command("reg", "query", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", autoStartEntryName)
|
||||
if err := cmd.Run(); err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func setWindowsAutoStart(enabled bool, exePath string, args []string) error {
|
||||
key := `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`
|
||||
if enabled {
|
||||
commandLine := windowsCommandLine(exePath, args)
|
||||
cmd := exec.Command("reg", "add", key, "/v", autoStartEntryName, "/t", "REG_SZ", "/d", commandLine, "/f")
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
cmd := exec.Command("reg", "delete", key, "/v", autoStartEntryName, "/f")
|
||||
if err := cmd.Run(); err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
|
||||
)
|
||||
|
||||
func TestResolveLaunchCommandUsesConfigFileDefaults(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
h := NewHandler(configPath)
|
||||
|
||||
// Persist non-default launcher options to ensure resolveLaunchCommand does not
|
||||
// pin them into autostart args.
|
||||
launcherPath := launcherconfig.PathForAppConfig(configPath)
|
||||
if err := launcherconfig.Save(launcherPath, launcherconfig.Config{
|
||||
Port: 19999,
|
||||
Public: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("launcherconfig.Save() error = %v", err)
|
||||
}
|
||||
|
||||
exePath, args, err := h.resolveLaunchCommand()
|
||||
if err != nil {
|
||||
t.Fatalf("resolveLaunchCommand() error = %v", err)
|
||||
}
|
||||
if exePath == "" {
|
||||
t.Fatal("resolveLaunchCommand() returned empty executable path")
|
||||
}
|
||||
if len(args) != 2 {
|
||||
t.Fatalf("args len = %d, want 2 (got %v)", len(args), args)
|
||||
}
|
||||
if args[0] != "-no-browser" {
|
||||
t.Fatalf("args[0] = %q, want %q", args[0], "-no-browser")
|
||||
}
|
||||
if args[1] != configPath {
|
||||
t.Fatalf("args[1] = %q, want %q", args[1], configPath)
|
||||
}
|
||||
for _, arg := range args {
|
||||
if arg == "-port" || arg == "-public" {
|
||||
t.Fatalf("autostart args should not pin network flags, got %v", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDarwinPlistIncludesRunAtLoad(t *testing.T) {
|
||||
plist := buildDarwinPlist("/tmp/picoclaw-web", []string{"-no-browser", "/tmp/config.json"})
|
||||
if !strings.Contains(plist, "<key>RunAtLoad</key>") {
|
||||
t.Fatalf("plist missing RunAtLoad key:\n%s", plist)
|
||||
}
|
||||
if !strings.Contains(plist, "<true/>") {
|
||||
t.Fatalf("plist missing RunAtLoad true value:\n%s", plist)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed all:dist
|
||||
var frontendFS embed.FS
|
||||
|
||||
// registerEmbedRoutes sets up the HTTP handler to serve the embedded frontend files
|
||||
func registerEmbedRoutes(mux *http.ServeMux) {
|
||||
// Attempt to get the subdirectory 'dist' where Vite usually builds
|
||||
subFS, err := fs.Sub(frontendFS, "dist")
|
||||
if err != nil {
|
||||
// Log a warning if dist doesn't exist yet (e.g., during development before a frontend build)
|
||||
log.Printf(
|
||||
"Warning: no 'dist' folder found in embedded frontend. " +
|
||||
"Ensure you run `pnpm build:backend` in the frontend directory " +
|
||||
"before building the Go backend.",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
fileServer := http.FileServer(http.FS(subFS))
|
||||
|
||||
// Serve static assets and fallback to index.html for SPA routes.
|
||||
mux.Handle(
|
||||
"/",
|
||||
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Keep unknown API paths as 404 instead of falling back to SPA entry.
|
||||
if r.URL.Path == "/api" || strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
cleanPath := path.Clean(strings.TrimPrefix(r.URL.Path, "/"))
|
||||
if cleanPath == "." {
|
||||
cleanPath = ""
|
||||
}
|
||||
|
||||
// Existing static files/directories should be served directly.
|
||||
if cleanPath != "" {
|
||||
if _, statErr := fs.Stat(subFS, cleanPath); statErr == nil {
|
||||
fileServer.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
// Missing asset-like paths should remain 404.
|
||||
if strings.Contains(path.Base(cleanPath), ".") {
|
||||
fileServer.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
indexReq := r.Clone(r.Context())
|
||||
indexReq.URL.Path = "/"
|
||||
fileServer.ServeHTTP(w, indexReq)
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUnknownAPIPathStays404(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
registerEmbedRoutes(mux)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/not-found", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want %d", rr.Code, http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingAssetStays404(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
registerEmbedRoutes(mux)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/assets/not-found.js", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want %d", rr.Code, http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
@@ -0,0 +1,113 @@
|
||||
package launcherconfig
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// FileName is the launcher-specific settings file name.
|
||||
FileName = "launcher-config.json"
|
||||
// DefaultPort is the default port for the web launcher.
|
||||
DefaultPort = 18800
|
||||
)
|
||||
|
||||
// Config stores launch parameters for the web backend service.
|
||||
type Config struct {
|
||||
Port int `json:"port"`
|
||||
Public bool `json:"public"`
|
||||
AllowedCIDRs []string `json:"allowed_cidrs,omitempty"`
|
||||
}
|
||||
|
||||
// Default returns default launcher settings.
|
||||
func Default() Config {
|
||||
return Config{Port: DefaultPort, Public: false}
|
||||
}
|
||||
|
||||
// Validate checks if launcher settings are valid.
|
||||
func Validate(cfg Config) error {
|
||||
if cfg.Port < 1 || cfg.Port > 65535 {
|
||||
return fmt.Errorf("port %d is out of range (1-65535)", cfg.Port)
|
||||
}
|
||||
for _, cidr := range cfg.AllowedCIDRs {
|
||||
if _, _, err := net.ParseCIDR(cidr); err != nil {
|
||||
return fmt.Errorf("invalid CIDR %q", cidr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NormalizeCIDRs trims entries, removes empty values, and deduplicates CIDRs.
|
||||
func NormalizeCIDRs(cidrs []string) []string {
|
||||
if len(cidrs) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(cidrs))
|
||||
seen := make(map[string]struct{}, len(cidrs))
|
||||
for _, raw := range cidrs {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[trimmed]; ok {
|
||||
continue
|
||||
}
|
||||
seen[trimmed] = struct{}{}
|
||||
out = append(out, trimmed)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// PathForAppConfig returns launcher-config path near the app config file.
|
||||
func PathForAppConfig(appConfigPath string) string {
|
||||
dir := filepath.Dir(appConfigPath)
|
||||
if dir == "" || dir == "." {
|
||||
dir = "."
|
||||
}
|
||||
return filepath.Join(dir, FileName)
|
||||
}
|
||||
|
||||
// Load reads launcher settings; fallback is returned when file does not exist.
|
||||
func Load(path string, fallback Config) (Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fallback, nil
|
||||
}
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
cfg := fallback
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.AllowedCIDRs = NormalizeCIDRs(cfg.AllowedCIDRs)
|
||||
if err := Validate(cfg); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// Save writes launcher settings to disk.
|
||||
func Save(path string, cfg Config) error {
|
||||
cfg.AllowedCIDRs = NormalizeCIDRs(cfg.AllowedCIDRs)
|
||||
if err := Validate(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data = append(data, '\n')
|
||||
return os.WriteFile(path, data, 0o600)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package launcherconfig
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadReturnsFallbackWhenMissing(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "launcher-config.json")
|
||||
fallback := Config{Port: 19999, Public: true}
|
||||
|
||||
got, err := Load(path, fallback)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if got.Port != fallback.Port || got.Public != fallback.Public {
|
||||
t.Fatalf("Load() = %+v, want %+v", got, fallback)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAndLoadRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "launcher-config.json")
|
||||
want := Config{
|
||||
Port: 18080,
|
||||
Public: true,
|
||||
AllowedCIDRs: []string{"192.168.1.0/24", "10.0.0.0/8"},
|
||||
}
|
||||
|
||||
if err := Save(path, want); err != nil {
|
||||
t.Fatalf("Save() error = %v", err)
|
||||
}
|
||||
got, err := Load(path, Default())
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if got.Port != want.Port || got.Public != want.Public {
|
||||
t.Fatalf("Load() = %+v, want %+v", got, want)
|
||||
}
|
||||
if len(got.AllowedCIDRs) != len(want.AllowedCIDRs) {
|
||||
t.Fatalf("allowed_cidrs len = %d, want %d", len(got.AllowedCIDRs), len(want.AllowedCIDRs))
|
||||
}
|
||||
for i := range want.AllowedCIDRs {
|
||||
if got.AllowedCIDRs[i] != want.AllowedCIDRs[i] {
|
||||
t.Fatalf("allowed_cidrs[%d] = %q, want %q", i, got.AllowedCIDRs[i], want.AllowedCIDRs[i])
|
||||
}
|
||||
}
|
||||
|
||||
stat, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Stat() error = %v", err)
|
||||
}
|
||||
if perm := stat.Mode().Perm(); perm != 0o600 {
|
||||
t.Fatalf("file perm = %o, want 600", perm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsInvalidPort(t *testing.T) {
|
||||
if err := Validate(Config{Port: 0, Public: false}); err == nil {
|
||||
t.Fatal("Validate() expected error for port 0")
|
||||
}
|
||||
if err := Validate(Config{Port: 65536, Public: false}); err == nil {
|
||||
t.Fatal("Validate() expected error for port 65536")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsInvalidCIDR(t *testing.T) {
|
||||
err := Validate(Config{
|
||||
Port: 18800,
|
||||
AllowedCIDRs: []string{"192.168.1.0/24", "not-a-cidr"},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("Validate() expected error for invalid CIDR")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCIDRs(t *testing.T) {
|
||||
got := NormalizeCIDRs([]string{" 192.168.1.0/24 ", "", "10.0.0.0/8", "192.168.1.0/24"})
|
||||
want := []string{"192.168.1.0/24", "10.0.0.0/8"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("len(got) = %d, want %d", len(got), len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("got[%d] = %q, want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
// PicoClaw Web Console - Web-based chat and management interface
|
||||
//
|
||||
// Provides a web UI for chatting with PicoClaw via the Pico Channel WebSocket,
|
||||
// with configuration management and gateway process control.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// go build -o picoclaw-web ./web/backend/
|
||||
// ./picoclaw-web [config.json]
|
||||
// ./picoclaw-web -public config.json
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/web/backend/api"
|
||||
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
|
||||
"github.com/sipeed/picoclaw/web/backend/middleware"
|
||||
)
|
||||
|
||||
func main() {
|
||||
port := flag.String("port", "18800", "Port to listen on")
|
||||
public := flag.Bool("public", false, "Listen on all interfaces (0.0.0.0) instead of localhost only")
|
||||
noBrowser := flag.Bool("no-browser", false, "Do not auto-open browser on startup")
|
||||
|
||||
flag.Usage = func() {
|
||||
fmt.Fprintf(os.Stderr, "PicoClaw Launcher - A web-based configuration editor\n\n")
|
||||
fmt.Fprintf(os.Stderr, "Usage: %s [options] [config.json]\n\n", os.Args[0])
|
||||
fmt.Fprintf(os.Stderr, "Arguments:\n")
|
||||
fmt.Fprintf(os.Stderr, " config.json Path to the configuration file (default: ~/.picoclaw/config.json)\n\n")
|
||||
fmt.Fprintf(os.Stderr, "Options:\n")
|
||||
flag.PrintDefaults()
|
||||
fmt.Fprintf(os.Stderr, "\nExamples:\n")
|
||||
fmt.Fprintf(os.Stderr, " %s Use default config path\n", os.Args[0])
|
||||
fmt.Fprintf(os.Stderr, " %s ./config.json Specify a config file\n", os.Args[0])
|
||||
fmt.Fprintf(
|
||||
os.Stderr,
|
||||
" %s -public ./config.json Allow access from other devices on the network\n",
|
||||
os.Args[0],
|
||||
)
|
||||
}
|
||||
flag.Parse()
|
||||
|
||||
// Resolve config path
|
||||
configPath := getDefaultConfigPath()
|
||||
if flag.NArg() > 0 {
|
||||
configPath = flag.Arg(0)
|
||||
}
|
||||
|
||||
absPath, err := filepath.Abs(configPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to resolve config path: %v", err)
|
||||
}
|
||||
|
||||
var explicitPort bool
|
||||
var explicitPublic bool
|
||||
flag.Visit(func(f *flag.Flag) {
|
||||
switch f.Name {
|
||||
case "port":
|
||||
explicitPort = true
|
||||
case "public":
|
||||
explicitPublic = true
|
||||
}
|
||||
})
|
||||
|
||||
launcherPath := launcherconfig.PathForAppConfig(absPath)
|
||||
launcherCfg, err := launcherconfig.Load(launcherPath, launcherconfig.Default())
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to load %s: %v", launcherPath, err)
|
||||
launcherCfg = launcherconfig.Default()
|
||||
}
|
||||
|
||||
effectivePort := *port
|
||||
effectivePublic := *public
|
||||
if !explicitPort {
|
||||
effectivePort = strconv.Itoa(launcherCfg.Port)
|
||||
}
|
||||
if !explicitPublic {
|
||||
effectivePublic = launcherCfg.Public
|
||||
}
|
||||
|
||||
portNum, err := strconv.Atoi(effectivePort)
|
||||
if err != nil || portNum < 1 || portNum > 65535 {
|
||||
if err == nil {
|
||||
err = errors.New("must be in range 1-65535")
|
||||
}
|
||||
log.Fatalf("Invalid port %q: %v", effectivePort, err)
|
||||
}
|
||||
|
||||
// Determine listen address
|
||||
var addr string
|
||||
if effectivePublic {
|
||||
addr = "0.0.0.0:" + effectivePort
|
||||
} else {
|
||||
addr = "127.0.0.1:" + effectivePort
|
||||
}
|
||||
|
||||
// Initialize Server components
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// API Routes (e.g. /api/status)
|
||||
apiHandler := api.NewHandler(absPath)
|
||||
apiHandler.SetServerOptions(portNum, effectivePublic, launcherCfg.AllowedCIDRs)
|
||||
apiHandler.RegisterRoutes(mux)
|
||||
|
||||
// Frontend Embedded Assets
|
||||
registerEmbedRoutes(mux)
|
||||
|
||||
accessControlledMux, err := middleware.IPAllowlist(launcherCfg.AllowedCIDRs, mux)
|
||||
if err != nil {
|
||||
log.Fatalf("Invalid allowed CIDR configuration: %v", err)
|
||||
}
|
||||
|
||||
// Apply middleware stack
|
||||
handler := middleware.Recoverer(
|
||||
middleware.Logger(
|
||||
middleware.JSONContentType(accessControlledMux),
|
||||
),
|
||||
)
|
||||
|
||||
// Print startup banner
|
||||
fmt.Print(banner)
|
||||
fmt.Println()
|
||||
fmt.Println(" Open the following URL in your browser:")
|
||||
fmt.Println()
|
||||
fmt.Printf(" >> http://localhost:%s <<\n", effectivePort)
|
||||
if effectivePublic {
|
||||
if ip := getLocalIP(); ip != "" {
|
||||
fmt.Printf(" >> http://%s:%s <<\n", ip, effectivePort)
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// Auto-open browser
|
||||
if !*noBrowser {
|
||||
go func() {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
url := "http://localhost:" + effectivePort
|
||||
if err := openBrowser(url); err != nil {
|
||||
log.Printf("Warning: Failed to auto-open browser: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Auto-start gateway after backend starts listening.
|
||||
go func() {
|
||||
time.Sleep(1 * time.Second)
|
||||
apiHandler.TryAutoStartGateway()
|
||||
}()
|
||||
|
||||
// Start the Server
|
||||
if err := http.ListenAndServe(addr, handler); err != nil {
|
||||
log.Fatalf("Server failed to start: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// IPAllowlist restricts access to requests from configured CIDR ranges.
|
||||
// Loopback addresses are always allowed for local administration.
|
||||
// Empty CIDR list means no restriction.
|
||||
func IPAllowlist(allowedCIDRs []string, next http.Handler) (http.Handler, error) {
|
||||
if len(allowedCIDRs) == 0 {
|
||||
return next, nil
|
||||
}
|
||||
|
||||
nets := make([]*net.IPNet, 0, len(allowedCIDRs))
|
||||
for _, cidr := range allowedCIDRs {
|
||||
_, ipNet, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid CIDR %q: %w", cidr, err)
|
||||
}
|
||||
nets = append(nets, ipNet)
|
||||
}
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := clientIPFromRemoteAddr(r.RemoteAddr)
|
||||
if ip == nil {
|
||||
rejectByPolicy(w, r)
|
||||
return
|
||||
}
|
||||
if ip.IsLoopback() {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
for _, ipNet := range nets {
|
||||
if ipNet.Contains(ip) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
rejectByPolicy(w, r)
|
||||
}), nil
|
||||
}
|
||||
|
||||
func clientIPFromRemoteAddr(remoteAddr string) net.IP {
|
||||
host := remoteAddr
|
||||
if h, _, err := net.SplitHostPort(remoteAddr); err == nil {
|
||||
host = h
|
||||
}
|
||||
return net.ParseIP(host)
|
||||
}
|
||||
|
||||
func rejectByPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = w.Write([]byte(`{"error":"access denied by network policy"}`))
|
||||
return
|
||||
}
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIPAllowlist_EmptyCIDRsAllowsAll(t *testing.T) {
|
||||
h, err := IPAllowlist(nil, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("IPAllowlist() error = %v", err)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.RemoteAddr = "203.0.113.5:1234"
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPAllowlist_RejectsOutsideCIDR(t *testing.T) {
|
||||
h, err := IPAllowlist([]string{"192.168.1.0/24"}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("IPAllowlist() error = %v", err)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/config", nil)
|
||||
req.RemoteAddr = "10.0.0.8:1234"
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPAllowlist_AllowsInsideCIDR(t *testing.T) {
|
||||
h, err := IPAllowlist([]string{"192.168.1.0/24"}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("IPAllowlist() error = %v", err)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.RemoteAddr = "192.168.1.88:1234"
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPAllowlist_AlwaysAllowsLoopback(t *testing.T) {
|
||||
h, err := IPAllowlist([]string{"192.168.1.0/24"}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("IPAllowlist() error = %v", err)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.RemoteAddr = "127.0.0.1:1234"
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPAllowlist_InvalidCIDR(t *testing.T) {
|
||||
_, err := IPAllowlist([]string{"bad-cidr"}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
if err == nil {
|
||||
t.Fatal("IPAllowlist() expected error for invalid CIDR")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// JSONContentType sets the Content-Type header to application/json for
|
||||
// API requests handled by the wrapped handler.
|
||||
// SSE endpoints (text/event-stream) are excluded.
|
||||
func JSONContentType(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") && !strings.HasSuffix(r.URL.Path, "/events") {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// responseRecorder wraps http.ResponseWriter to capture the status code.
|
||||
type responseRecorder struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
}
|
||||
|
||||
func (rr *responseRecorder) WriteHeader(code int) {
|
||||
rr.statusCode = code
|
||||
rr.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
// Flush delegates to the underlying ResponseWriter if it implements http.Flusher.
|
||||
// This is required for SSE (Server-Sent Events) to work through the middleware.
|
||||
func (rr *responseRecorder) Flush() {
|
||||
if f, ok := rr.ResponseWriter.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
// Unwrap returns the underlying ResponseWriter so that http.ResponseController
|
||||
// and interface checks (like http.Flusher) can see through the wrapper.
|
||||
func (rr *responseRecorder) Unwrap() http.ResponseWriter {
|
||||
return rr.ResponseWriter
|
||||
}
|
||||
|
||||
// Logger logs each HTTP request with method, path, status code, and duration.
|
||||
func Logger(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
rec := &responseRecorder{ResponseWriter: w, statusCode: http.StatusOK}
|
||||
next.ServeHTTP(rec, r)
|
||||
log.Printf("%s %s %d %s", r.Method, r.URL.Path, rec.statusCode, time.Since(start))
|
||||
})
|
||||
}
|
||||
|
||||
// Recoverer recovers from panics in downstream handlers and returns a 500
|
||||
// Internal Server Error response.
|
||||
func Recoverer(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
log.Printf("panic recovered: %v\n%s", err, debug.Stack())
|
||||
http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError)
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package model
|
||||
|
||||
// StatusResponse represents the response payload for the GET /api/status endpoint.
|
||||
type StatusResponse struct {
|
||||
Status string `json:"status"`
|
||||
Version string `json:"version"`
|
||||
Uptime string `json:"uptime"`
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
const (
|
||||
colorBlue = "\x1b[38;2;62;93;185m"
|
||||
colorRed = "\x1b[38;2;213;70;70m"
|
||||
colorReset = "\x1b[0m"
|
||||
banner = "\r\n" +
|
||||
colorBlue + "██████╗ ██╗ ██████╗ ██████╗ " + colorRed + " ██████╗██╗ █████╗ ██╗ ██╗\n" +
|
||||
colorBlue + "██╔══██╗██║██╔════╝██╔═══██╗" + colorRed + "██╔════╝██║ ██╔══██╗██║ ██║\n" +
|
||||
colorBlue + "██████╔╝██║██║ ██║ ██║" + colorRed + "██║ ██║ ███████║██║ █╗ ██║\n" +
|
||||
colorBlue + "██╔═══╝ ██║██║ ██║ ██║" + colorRed + "██║ ██║ ██╔══██║██║███╗██║\n" +
|
||||
colorBlue + "██║ ██║╚██████╗╚██████╔╝" + colorRed + "╚██████╗███████╗██║ ██║╚███╔███╔╝\n" +
|
||||
colorBlue + "╚═╝ ╚═╝ ╚═════╝ ╚═════╝ " + colorRed + " ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n" +
|
||||
colorReset
|
||||
)
|
||||
|
||||
// getDefaultConfigPath returns the default path to the picoclaw config file.
|
||||
func getDefaultConfigPath() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "config.json"
|
||||
}
|
||||
return filepath.Join(home, ".picoclaw", "config.json")
|
||||
}
|
||||
|
||||
// getLocalIP returns the local IP address of the machine.
|
||||
func getLocalIP() string {
|
||||
addrs, err := net.InterfaceAddrs()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, a := range addrs {
|
||||
if ipnet, ok := a.(*net.IPNet); ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil {
|
||||
return ipnet.IP.String()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// openBrowser automatically opens the given URL in the default browser.
|
||||
func openBrowser(url string) error {
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
return exec.Command("xdg-open", url).Start()
|
||||
case "windows":
|
||||
return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
|
||||
case "darwin":
|
||||
return exec.Command("open", url).Start()
|
||||
default:
|
||||
return fmt.Errorf("unsupported platform")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
@@ -0,0 +1,26 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
.tanstack
|
||||
@@ -0,0 +1,5 @@
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
routeTree.gen.ts
|
||||
src/components/ui
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "radix-vega",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "tabler",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import js from "@eslint/js"
|
||||
import eslintConfigPrettier from "eslint-config-prettier"
|
||||
import reactHooks from "eslint-plugin-react-hooks"
|
||||
import reactRefresh from "eslint-plugin-react-refresh"
|
||||
import { defineConfig, globalIgnores } from "eslint/config"
|
||||
import globals from "globals"
|
||||
import tseslint from "typescript-eslint"
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(["dist", "src/components/ui", "src/routeTree.gen.ts"]),
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
eslintConfigPrettier,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: "latest",
|
||||
globals: globals.browser,
|
||||
},
|
||||
rules: {
|
||||
"react-refresh/only-export-components": [
|
||||
"warn",
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PicoClaw</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"name": "picoclaw-web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"build:backend": "tsc -b && vite build --outDir ../backend/dist --emptyOutDir",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"format": "prettier --check .",
|
||||
"check": "prettier --write . && eslint --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@tabler/icons-react": "^3.38.0",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@tanstack/react-router": "^1.163.3",
|
||||
"@tanstack/react-router-devtools": "^1.163.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.19",
|
||||
"i18next": "^25.8.14",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"jotai": "^2.18.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-i18next": "^16.5.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-textarea-autosize": "^8.5.9",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"shadcn": "^3.8.5",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tanstack/router-plugin": "^1.164.0",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.56.1",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// @ts-check
|
||||
|
||||
/** @type {import('prettier').Config} */
|
||||
const config = {
|
||||
semi: false,
|
||||
printWidth: 80,
|
||||
tabWidth: 2,
|
||||
importOrder: ["<BUILTIN_MODULES>", "<THIRD_PARTY_MODULES>", "^@/", "^[./]"],
|
||||
importOrderSeparation: true,
|
||||
importOrderSortSpecifiers: true,
|
||||
plugins: [
|
||||
"@trivago/prettier-plugin-sort-imports",
|
||||
"prettier-plugin-tailwindcss",
|
||||
],
|
||||
}
|
||||
|
||||
export default config
|
||||
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 88 KiB |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><svg width="24" height="24" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M17 29C21 29 25 26.9339 28 23.4065C36 14 41.4242 16.8166 44 17.9998C38.5 20.9998 40.5 29.6233 33 35.9998C28.382 39.9259 23.4945 41.014 19 41C12.5231 40.9799 6.86226 37.7637 4 35.4063V16.9998" stroke="#000" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/><path d="M5.64808 15.8669C5.02231 14.9567 3.77715 14.7261 2.86694 15.3519C1.95673 15.9777 1.72615 17.2228 2.35192 18.1331L5.64808 15.8669ZM36.0021 35.7309C36.958 35.1774 37.2843 33.9539 36.7309 32.9979C36.1774 32.042 34.9539 31.7157 33.9979 32.2691L36.0021 35.7309ZM2.35192 18.1331C5.2435 22.339 10.7992 28.144 16.8865 32.2239C19.9345 34.2667 23.217 35.946 26.449 36.7324C29.6946 37.522 33.0451 37.4428 36.0021 35.7309L33.9979 32.2691C32.2049 33.3072 29.9929 33.478 27.3947 32.8458C24.783 32.2103 21.9405 30.7958 19.1135 28.9011C13.4508 25.106 8.2565 19.661 5.64808 15.8669L2.35192 18.1331Z" fill="#000"/><path d="M33.5947 17C32.84 14.7027 30.8551 9.94054 27.5947 7H11.5947C15.2174 10.6757 23.0002 16 27.0002 24" stroke="#000" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "MyWebSite",
|
||||
"short_name": "MySite",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
||||
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 102 KiB |
@@ -0,0 +1,65 @@
|
||||
// API client for channels navigation and channel-specific config flows.
|
||||
|
||||
export type ChannelConfig = Record<string, unknown>
|
||||
export type AppConfig = Record<string, unknown>
|
||||
|
||||
export interface SupportedChannel {
|
||||
name: string
|
||||
display_name?: string
|
||||
config_key: string
|
||||
variant?: string
|
||||
}
|
||||
|
||||
interface ChannelsCatalogResponse {
|
||||
channels: SupportedChannel[]
|
||||
}
|
||||
|
||||
interface ConfigActionResponse {
|
||||
status: string
|
||||
errors?: string[]
|
||||
}
|
||||
|
||||
const BASE_URL = ""
|
||||
|
||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE_URL}${path}`, options)
|
||||
if (!res.ok) {
|
||||
let message = `API error: ${res.status} ${res.statusText}`
|
||||
try {
|
||||
const body = (await res.json()) as {
|
||||
error?: string
|
||||
errors?: string[]
|
||||
status?: string
|
||||
}
|
||||
if (Array.isArray(body.errors) && body.errors.length > 0) {
|
||||
message = body.errors.join("; ")
|
||||
} else if (typeof body.error === "string" && body.error.trim() !== "") {
|
||||
message = body.error
|
||||
}
|
||||
} catch {
|
||||
// Keep default fallback message if response body is not JSON.
|
||||
}
|
||||
throw new Error(message)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
export async function getChannelsCatalog(): Promise<ChannelsCatalogResponse> {
|
||||
return request<ChannelsCatalogResponse>("/api/channels/catalog")
|
||||
}
|
||||
|
||||
export async function getAppConfig(): Promise<AppConfig> {
|
||||
return request<AppConfig>("/api/config")
|
||||
}
|
||||
|
||||
export async function patchAppConfig(
|
||||
patch: Record<string, unknown>,
|
||||
): Promise<ConfigActionResponse> {
|
||||
return request<ConfigActionResponse>("/api/config", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(patch),
|
||||
})
|
||||
}
|
||||
|
||||
export type { ChannelsCatalogResponse, ConfigActionResponse }
|
||||
@@ -0,0 +1,62 @@
|
||||
// API client for gateway process management.
|
||||
|
||||
interface GatewayStatusResponse {
|
||||
gateway_status: "running" | "starting" | "stopped" | "error"
|
||||
gateway_start_allowed?: boolean
|
||||
gateway_start_reason?: string
|
||||
pid?: number
|
||||
logs?: string[]
|
||||
log_total?: number
|
||||
log_run_id?: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface GatewayActionResponse {
|
||||
status: string
|
||||
pid?: number
|
||||
}
|
||||
|
||||
const BASE_URL = ""
|
||||
|
||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE_URL}${path}`, options)
|
||||
if (!res.ok) {
|
||||
throw new Error(`API error: ${res.status} ${res.statusText}`)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
export async function getGatewayStatus(options?: {
|
||||
log_offset?: number
|
||||
log_run_id?: number
|
||||
}): Promise<GatewayStatusResponse> {
|
||||
const params = new URLSearchParams()
|
||||
if (options?.log_offset !== undefined) {
|
||||
params.set("log_offset", options.log_offset.toString())
|
||||
}
|
||||
if (options?.log_run_id !== undefined) {
|
||||
params.set("log_run_id", options.log_run_id.toString())
|
||||
}
|
||||
const queryString = params.toString() ? `?${params.toString()}` : ""
|
||||
return request<GatewayStatusResponse>(`/api/gateway/status${queryString}`)
|
||||
}
|
||||
|
||||
export async function startGateway(): Promise<GatewayActionResponse> {
|
||||
return request<GatewayActionResponse>("/api/gateway/start", {
|
||||
method: "POST",
|
||||
})
|
||||
}
|
||||
|
||||
export async function stopGateway(): Promise<GatewayActionResponse> {
|
||||
return request<GatewayActionResponse>("/api/gateway/stop", {
|
||||
method: "POST",
|
||||
})
|
||||
}
|
||||
|
||||
export async function restartGateway(): Promise<GatewayActionResponse> {
|
||||
return request<GatewayActionResponse>("/api/gateway/restart", {
|
||||
method: "POST",
|
||||
})
|
||||
}
|
||||
|
||||
export type { GatewayStatusResponse, GatewayActionResponse }
|
||||
@@ -0,0 +1,91 @@
|
||||
import { refreshGatewayState } from "@/store/gateway"
|
||||
|
||||
// API client for model list management.
|
||||
|
||||
export interface ModelInfo {
|
||||
index: number
|
||||
model_name: string
|
||||
model: string
|
||||
api_base?: string
|
||||
api_key: string
|
||||
proxy?: string
|
||||
auth_method?: string
|
||||
// Advanced fields
|
||||
connect_mode?: string
|
||||
workspace?: string
|
||||
rpm?: number
|
||||
max_tokens_field?: string
|
||||
request_timeout?: number
|
||||
thinking_level?: string
|
||||
// Meta
|
||||
configured: boolean
|
||||
is_default: boolean
|
||||
}
|
||||
|
||||
interface ModelsListResponse {
|
||||
models: ModelInfo[]
|
||||
total: number
|
||||
default_model: string
|
||||
}
|
||||
|
||||
interface ModelActionResponse {
|
||||
status: string
|
||||
index?: number
|
||||
default_model?: string
|
||||
}
|
||||
|
||||
const BASE_URL = ""
|
||||
|
||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE_URL}${path}`, options)
|
||||
if (!res.ok) {
|
||||
throw new Error(`API error: ${res.status} ${res.statusText}`)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
export async function getModels(): Promise<ModelsListResponse> {
|
||||
return request<ModelsListResponse>("/api/models")
|
||||
}
|
||||
|
||||
export async function addModel(
|
||||
model: Partial<ModelInfo>,
|
||||
): Promise<ModelActionResponse> {
|
||||
return request<ModelActionResponse>("/api/models", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(model),
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateModel(
|
||||
index: number,
|
||||
model: Partial<ModelInfo>,
|
||||
): Promise<ModelActionResponse> {
|
||||
return request<ModelActionResponse>(`/api/models/${index}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(model),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteModel(index: number): Promise<ModelActionResponse> {
|
||||
return request<ModelActionResponse>(`/api/models/${index}`, {
|
||||
method: "DELETE",
|
||||
})
|
||||
}
|
||||
|
||||
export async function setDefaultModel(
|
||||
modelName: string,
|
||||
): Promise<ModelActionResponse> {
|
||||
const response = await request<ModelActionResponse>("/api/models/default", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model_name: modelName }),
|
||||
})
|
||||
|
||||
void refreshGatewayState()
|
||||
return response
|
||||
}
|
||||
|
||||
export type { ModelsListResponse, ModelActionResponse }
|
||||
@@ -0,0 +1,102 @@
|
||||
export type OAuthProvider = "openai" | "anthropic" | "google-antigravity"
|
||||
export type OAuthMethod = "browser" | "device_code" | "token"
|
||||
|
||||
export interface OAuthProviderStatus {
|
||||
provider: OAuthProvider
|
||||
display_name: string
|
||||
methods: OAuthMethod[]
|
||||
logged_in: boolean
|
||||
status: "connected" | "expired" | "needs_refresh" | "not_logged_in"
|
||||
auth_method?: string
|
||||
expires_at?: string
|
||||
account_id?: string
|
||||
email?: string
|
||||
project_id?: string
|
||||
}
|
||||
|
||||
export interface OAuthFlowState {
|
||||
flow_id: string
|
||||
provider: OAuthProvider
|
||||
method: OAuthMethod
|
||||
status: "pending" | "success" | "error" | "expired"
|
||||
expires_at?: string
|
||||
error?: string
|
||||
user_code?: string
|
||||
verify_url?: string
|
||||
interval?: number
|
||||
}
|
||||
|
||||
export interface OAuthLoginRequest {
|
||||
provider: OAuthProvider
|
||||
method: OAuthMethod
|
||||
token?: string
|
||||
}
|
||||
|
||||
export interface OAuthLoginResponse {
|
||||
status: string
|
||||
provider: OAuthProvider
|
||||
method: OAuthMethod
|
||||
flow_id?: string
|
||||
auth_url?: string
|
||||
user_code?: string
|
||||
verify_url?: string
|
||||
interval?: number
|
||||
expires_at?: string
|
||||
}
|
||||
|
||||
interface OAuthProvidersResponse {
|
||||
providers: OAuthProviderStatus[]
|
||||
}
|
||||
|
||||
const BASE_URL = ""
|
||||
|
||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE_URL}${path}`, options)
|
||||
if (!res.ok) {
|
||||
const message = await res.text()
|
||||
throw new Error(message || `API error: ${res.status} ${res.statusText}`)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
export async function getOAuthProviders(): Promise<OAuthProvidersResponse> {
|
||||
return request<OAuthProvidersResponse>("/api/oauth/providers")
|
||||
}
|
||||
|
||||
export async function loginOAuth(
|
||||
payload: OAuthLoginRequest,
|
||||
): Promise<OAuthLoginResponse> {
|
||||
return request<OAuthLoginResponse>("/api/oauth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export async function getOAuthFlow(flowID: string): Promise<OAuthFlowState> {
|
||||
return request<OAuthFlowState>(
|
||||
`/api/oauth/flows/${encodeURIComponent(flowID)}`,
|
||||
)
|
||||
}
|
||||
|
||||
export async function pollOAuthFlow(flowID: string): Promise<OAuthFlowState> {
|
||||
return request<OAuthFlowState>(
|
||||
`/api/oauth/flows/${encodeURIComponent(flowID)}/poll`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export async function logoutOAuth(
|
||||
provider: OAuthProvider,
|
||||
): Promise<{ status: string; provider: OAuthProvider }> {
|
||||
return request<{ status: string; provider: OAuthProvider }>(
|
||||
"/api/oauth/logout",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ provider }),
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// API client for Pico Channel configuration.
|
||||
|
||||
interface PicoTokenResponse {
|
||||
token: string
|
||||
ws_url: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
interface PicoSetupResponse {
|
||||
token: string
|
||||
ws_url: string
|
||||
enabled: boolean
|
||||
changed: boolean
|
||||
}
|
||||
|
||||
const BASE_URL = ""
|
||||
|
||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE_URL}${path}`, options)
|
||||
if (!res.ok) {
|
||||
throw new Error(`API error: ${res.status} ${res.statusText}`)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
export async function getPicoToken(): Promise<PicoTokenResponse> {
|
||||
return request<PicoTokenResponse>("/api/pico/token")
|
||||
}
|
||||
|
||||
export async function regenPicoToken(): Promise<PicoTokenResponse> {
|
||||
return request<PicoTokenResponse>("/api/pico/token", { method: "POST" })
|
||||
}
|
||||
|
||||
export async function setupPico(): Promise<PicoSetupResponse> {
|
||||
return request<PicoSetupResponse>("/api/pico/setup", { method: "POST" })
|
||||
}
|
||||
|
||||
export type { PicoTokenResponse, PicoSetupResponse }
|
||||
@@ -0,0 +1,50 @@
|
||||
// Sessions API — list and retrieve chat session history
|
||||
|
||||
export interface SessionSummary {
|
||||
id: string
|
||||
preview: string
|
||||
message_count: number
|
||||
created: string
|
||||
updated: string
|
||||
}
|
||||
|
||||
export interface SessionDetail {
|
||||
id: string
|
||||
messages: { role: "user" | "assistant"; content: string }[]
|
||||
summary: string
|
||||
created: string
|
||||
updated: string
|
||||
}
|
||||
|
||||
export async function getSessions(
|
||||
offset: number = 0,
|
||||
limit: number = 20,
|
||||
): Promise<SessionSummary[]> {
|
||||
const params = new URLSearchParams({
|
||||
offset: offset.toString(),
|
||||
limit: limit.toString(),
|
||||
})
|
||||
|
||||
const res = await fetch(`/api/sessions?${params.toString()}`)
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch sessions: ${res.status}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function getSessionHistory(id: string): Promise<SessionDetail> {
|
||||
const res = await fetch(`/api/sessions/${encodeURIComponent(id)}`)
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch session ${id}: ${res.status}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function deleteSession(id: string): Promise<void> {
|
||||
const res = await fetch(`/api/sessions/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to delete session ${id}: ${res.status}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
export interface AutoStartStatus {
|
||||
enabled: boolean
|
||||
supported: boolean
|
||||
platform: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface LauncherConfig {
|
||||
port: number
|
||||
public: boolean
|
||||
allowed_cidrs: string[]
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(path, options)
|
||||
if (!res.ok) {
|
||||
let message = `API error: ${res.status} ${res.statusText}`
|
||||
try {
|
||||
const body = (await res.json()) as {
|
||||
error?: string
|
||||
errors?: string[]
|
||||
}
|
||||
if (Array.isArray(body.errors) && body.errors.length > 0) {
|
||||
message = body.errors.join("; ")
|
||||
} else if (typeof body.error === "string" && body.error.trim() !== "") {
|
||||
message = body.error
|
||||
}
|
||||
} catch {
|
||||
// Keep fallback error message when response body is not JSON.
|
||||
}
|
||||
throw new Error(message)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
export async function getAutoStartStatus(): Promise<AutoStartStatus> {
|
||||
return request<AutoStartStatus>("/api/system/autostart")
|
||||
}
|
||||
|
||||
export async function setAutoStartEnabled(
|
||||
enabled: boolean,
|
||||
): Promise<AutoStartStatus> {
|
||||
return request<AutoStartStatus>("/api/system/autostart", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function getLauncherConfig(): Promise<LauncherConfig> {
|
||||
return request<LauncherConfig>("/api/system/launcher-config")
|
||||
}
|
||||
|
||||
export async function setLauncherConfig(
|
||||
payload: LauncherConfig,
|
||||
): Promise<LauncherConfig> {
|
||||
return request<LauncherConfig>("/api/system/launcher-config", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import {
|
||||
IconBook,
|
||||
IconLanguage,
|
||||
IconLoader2,
|
||||
IconMenu2,
|
||||
IconMoon,
|
||||
IconPlayerPlay,
|
||||
IconPower,
|
||||
IconSun,
|
||||
} from "@tabler/icons-react"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import * as React from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog.tsx"
|
||||
import { Button } from "@/components/ui/button.tsx"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu.tsx"
|
||||
import { Separator } from "@/components/ui/separator.tsx"
|
||||
import { SidebarTrigger } from "@/components/ui/sidebar"
|
||||
import { useGateway } from "@/hooks/use-gateway.ts"
|
||||
import { useTheme } from "@/hooks/use-theme.ts"
|
||||
|
||||
export function AppHeader() {
|
||||
const { i18n, t } = useTranslation()
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
const {
|
||||
state: gwState,
|
||||
loading: gwLoading,
|
||||
canStart,
|
||||
start,
|
||||
stop,
|
||||
} = useGateway()
|
||||
|
||||
const isRunning = gwState === "running"
|
||||
const isStarting = gwState === "starting"
|
||||
const isStopped = gwState === "stopped" || gwState === "unknown"
|
||||
const showNotConnectedHint =
|
||||
canStart && (gwState === "stopped" || gwState === "error")
|
||||
|
||||
const [showStopDialog, setShowStopDialog] = React.useState(false)
|
||||
|
||||
const handleGatewayToggle = () => {
|
||||
if (gwLoading || (!isRunning && !canStart)) return
|
||||
if (isRunning) {
|
||||
setShowStopDialog(true)
|
||||
} else {
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
const confirmStop = () => {
|
||||
setShowStopDialog(false)
|
||||
stop()
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="bg-background/95 supports-backdrop-filter:bg-background/60 border-b-border/50 sticky top-0 z-50 flex h-14 shrink-0 items-center justify-between border-b px-4 backdrop-blur">
|
||||
<div className="flex items-center gap-2">
|
||||
<SidebarTrigger className="text-muted-foreground hover:bg-accent hover:text-foreground flex h-9 w-9 items-center justify-center rounded-lg sm:hidden [&>svg]:size-5">
|
||||
<IconMenu2 />
|
||||
</SidebarTrigger>
|
||||
<div className="hidden w-36 shrink-0 items-center sm:flex">
|
||||
<Link to="/">
|
||||
<img className="w-full" src="/logo_with_text.png" alt="Logo" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center prominent connection status */}
|
||||
<div className="pointer-events-none absolute left-1/2 hidden h-full -translate-x-1/2 items-center justify-center lg:flex">
|
||||
{showNotConnectedHint && (
|
||||
<div className="text-muted-foreground flex items-center gap-2 rounded-full border border-dashed px-4 py-1.5 text-xs shadow-sm backdrop-blur-md">
|
||||
<span className="bg-destructive/50 relative flex size-2 shrink-0 items-center justify-center rounded-full">
|
||||
<span className="bg-destructive absolute inline-flex size-full animate-ping rounded-full opacity-75"></span>
|
||||
</span>
|
||||
{t("chat.notConnected")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AlertDialog open={showStopDialog} onOpenChange={setShowStopDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("header.gateway.stopDialog.title")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("header.gateway.stopDialog.description")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmStop}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{t("header.gateway.stopDialog.confirm")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<div className="text-muted-foreground flex items-center gap-1 text-sm font-medium md:gap-2">
|
||||
{/* Gateway Start/Stop */}
|
||||
<Button
|
||||
variant={isStarting ? "secondary" : "default"}
|
||||
size="sm"
|
||||
className={`h-8 gap-2 px-3 ${
|
||||
isRunning
|
||||
? "bg-destructive/10 text-destructive hover:bg-destructive/20"
|
||||
: isStopped
|
||||
? "bg-green-500 text-white hover:bg-green-600"
|
||||
: ""
|
||||
}`}
|
||||
onClick={handleGatewayToggle}
|
||||
disabled={gwLoading || isStarting || (!isRunning && !canStart)}
|
||||
>
|
||||
{gwLoading || isStarting ? (
|
||||
<IconLoader2 className="h-4 w-4 animate-spin opacity-70" />
|
||||
) : isRunning ? (
|
||||
<IconPower className="h-4 w-4 opacity-80" />
|
||||
) : (
|
||||
<IconPlayerPlay className="h-4 w-4 opacity-80" />
|
||||
)}
|
||||
<span className="text-xs font-semibold">
|
||||
{isRunning
|
||||
? t("header.gateway.action.stop")
|
||||
: isStarting
|
||||
? t("header.gateway.status.starting")
|
||||
: t("header.gateway.action.start")}
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<Separator
|
||||
className="mx-4 my-2 hidden md:block"
|
||||
orientation="vertical"
|
||||
/>
|
||||
|
||||
{/* Docs Link */}
|
||||
<Button variant="ghost" size="icon" className="size-8" asChild>
|
||||
<a href="https://docs.picoclaw.io" target="_blank" rel="noreferrer">
|
||||
<IconBook className="size-4.5" />
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
{/* Language Switcher */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="size-8">
|
||||
<IconLanguage className="size-4.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => i18n.changeLanguage("en")}>
|
||||
English
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => i18n.changeLanguage("zh")}>
|
||||
简体中文
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Theme Toggle */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
onClick={toggleTheme}
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<IconSun className="size-4.5" />
|
||||
) : (
|
||||
<IconMoon className="size-4.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ReactNode } from "react"
|
||||
import { Toaster } from "sonner"
|
||||
|
||||
import { AppHeader } from "@/components/app-header"
|
||||
import { AppSidebar } from "@/components/app-sidebar"
|
||||
import { SidebarProvider } from "@/components/ui/sidebar"
|
||||
import { TooltipProvider } from "@/components/ui/tooltip"
|
||||
|
||||
export function AppLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<SidebarProvider className="flex h-dvh flex-col overflow-hidden">
|
||||
<AppHeader />
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<AppSidebar />
|
||||
<div className="flex w-full flex-col overflow-hidden">
|
||||
<main className="flex min-h-0 w-full max-w-full flex-1 flex-col overflow-hidden">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<Toaster position="bottom-center" />
|
||||
</SidebarProvider>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { IconChevronRight } from "@tabler/icons-react"
|
||||
import {
|
||||
IconAtom,
|
||||
IconChevronsDown,
|
||||
IconChevronsUp,
|
||||
IconKey,
|
||||
IconListDetails,
|
||||
IconMessageCircle,
|
||||
IconSettings,
|
||||
} from "@tabler/icons-react"
|
||||
import { Link, useRouterState } from "@tanstack/react-router"
|
||||
import * as React from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible"
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarRail,
|
||||
} from "@/components/ui/sidebar"
|
||||
import { useSidebarChannels } from "@/hooks/use-sidebar-channels"
|
||||
|
||||
interface NavItem {
|
||||
title: string
|
||||
url: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
translateTitle?: boolean
|
||||
}
|
||||
|
||||
interface NavGroup {
|
||||
label: string
|
||||
defaultOpen: boolean
|
||||
items: NavItem[]
|
||||
isChannelsGroup?: boolean
|
||||
}
|
||||
|
||||
const baseNavGroups: Omit<NavGroup, "items">[] = [
|
||||
{
|
||||
label: "navigation.chat",
|
||||
defaultOpen: true,
|
||||
},
|
||||
{
|
||||
label: "navigation.model_group",
|
||||
defaultOpen: true,
|
||||
},
|
||||
{
|
||||
label: "navigation.services",
|
||||
defaultOpen: true,
|
||||
},
|
||||
]
|
||||
|
||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
const routerState = useRouterState()
|
||||
const { t } = useTranslation()
|
||||
const currentPath = routerState.location.pathname
|
||||
const {
|
||||
channelItems,
|
||||
hasMoreChannels,
|
||||
showAllChannels,
|
||||
toggleShowAllChannels,
|
||||
} = useSidebarChannels({ t })
|
||||
|
||||
const navGroups: NavGroup[] = React.useMemo(() => {
|
||||
return [
|
||||
{
|
||||
...baseNavGroups[0],
|
||||
items: [
|
||||
{
|
||||
title: "navigation.chat",
|
||||
url: "/",
|
||||
icon: IconMessageCircle,
|
||||
translateTitle: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
...baseNavGroups[1],
|
||||
items: [
|
||||
{
|
||||
title: "navigation.models",
|
||||
url: "/models",
|
||||
icon: IconAtom,
|
||||
translateTitle: true,
|
||||
},
|
||||
{
|
||||
title: "navigation.credentials",
|
||||
url: "/credentials",
|
||||
icon: IconKey,
|
||||
translateTitle: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "navigation.channels_group",
|
||||
defaultOpen: true,
|
||||
items: channelItems.map((item) => ({
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
icon: item.icon,
|
||||
translateTitle: false,
|
||||
})),
|
||||
isChannelsGroup: true,
|
||||
},
|
||||
{
|
||||
...baseNavGroups[2],
|
||||
items: [
|
||||
{
|
||||
title: "navigation.config",
|
||||
url: "/config",
|
||||
icon: IconSettings,
|
||||
translateTitle: true,
|
||||
},
|
||||
{
|
||||
title: "navigation.logs",
|
||||
url: "/logs",
|
||||
icon: IconListDetails,
|
||||
translateTitle: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
}, [channelItems])
|
||||
|
||||
return (
|
||||
<Sidebar
|
||||
{...props}
|
||||
className="bg-background border-r-border/20 border-r pt-3"
|
||||
>
|
||||
<SidebarContent className="bg-background">
|
||||
{navGroups.map((group) => (
|
||||
<Collapsible
|
||||
key={group.label}
|
||||
defaultOpen={group.defaultOpen}
|
||||
className="group/collapsible mb-1"
|
||||
>
|
||||
<SidebarGroup className="px-2 py-0">
|
||||
<SidebarGroupLabel asChild>
|
||||
<CollapsibleTrigger className="hover:bg-muted/60 flex w-full cursor-pointer items-center justify-between rounded-md px-2 py-1.5 transition-colors">
|
||||
<span>{t(group.label)}</span>
|
||||
<IconChevronRight className="size-3.5 opacity-50 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
|
||||
</CollapsibleTrigger>
|
||||
</SidebarGroupLabel>
|
||||
<CollapsibleContent>
|
||||
<SidebarGroupContent className="pt-1">
|
||||
<SidebarMenu>
|
||||
{group.items.map((item) => {
|
||||
const isActive =
|
||||
currentPath === item.url ||
|
||||
(item.url !== "/" &&
|
||||
currentPath.startsWith(`${item.url}/`))
|
||||
return (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={isActive}
|
||||
className={`h-9 px-3 ${isActive ? "bg-accent/80 text-foreground font-medium" : "text-muted-foreground hover:bg-muted/60"}`}
|
||||
>
|
||||
<Link to={item.url}>
|
||||
<item.icon
|
||||
className={`size-4 ${isActive ? "opacity-100" : "opacity-60"}`}
|
||||
/>
|
||||
<span
|
||||
className={
|
||||
isActive ? "opacity-100" : "opacity-80"
|
||||
}
|
||||
>
|
||||
{item.translateTitle === false
|
||||
? item.title
|
||||
: t(item.title)}
|
||||
</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
})}
|
||||
{group.isChannelsGroup && hasMoreChannels && (
|
||||
<SidebarMenuItem key="channels-more-toggle">
|
||||
<SidebarMenuButton
|
||||
onClick={toggleShowAllChannels}
|
||||
className="text-muted-foreground hover:bg-muted/60 h-9 px-3"
|
||||
>
|
||||
{showAllChannels ? (
|
||||
<IconChevronsUp className="size-4 opacity-60" />
|
||||
) : (
|
||||
<IconChevronsDown className="size-4 opacity-60" />
|
||||
)}
|
||||
<span className="opacity-80">
|
||||
{showAllChannels
|
||||
? t("navigation.show_less_channels")
|
||||
: t("navigation.show_more_channels")}
|
||||
</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</CollapsibleContent>
|
||||
</SidebarGroup>
|
||||
</Collapsible>
|
||||
))}
|
||||
</SidebarContent>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
import { IconLoader2 } from "@tabler/icons-react"
|
||||
import { useAtomValue } from "jotai"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import {
|
||||
type ChannelConfig,
|
||||
type SupportedChannel,
|
||||
getAppConfig,
|
||||
getChannelsCatalog,
|
||||
patchAppConfig,
|
||||
} from "@/api/channels"
|
||||
import { getChannelDisplayName } from "@/components/channels/channel-display-name"
|
||||
import { DiscordForm } from "@/components/channels/channel-forms/discord-form"
|
||||
import { FeishuForm } from "@/components/channels/channel-forms/feishu-form"
|
||||
import { GenericForm } from "@/components/channels/channel-forms/generic-form"
|
||||
import { SlackForm } from "@/components/channels/channel-forms/slack-form"
|
||||
import { TelegramForm } from "@/components/channels/channel-forms/telegram-form"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { gatewayAtom } from "@/store/gateway"
|
||||
|
||||
interface ChannelConfigPageProps {
|
||||
channelName: string
|
||||
}
|
||||
|
||||
const SECRET_FIELD_MAP: Record<string, string> = {
|
||||
token: "_token",
|
||||
app_secret: "_app_secret",
|
||||
client_secret: "_client_secret",
|
||||
corp_secret: "_corp_secret",
|
||||
channel_secret: "_channel_secret",
|
||||
channel_access_token: "_channel_access_token",
|
||||
access_token: "_access_token",
|
||||
bot_token: "_bot_token",
|
||||
app_token: "_app_token",
|
||||
encoding_aes_key: "_encoding_aes_key",
|
||||
encrypt_key: "_encrypt_key",
|
||||
verification_token: "_verification_token",
|
||||
password: "_password",
|
||||
nickserv_password: "_nickserv_password",
|
||||
sasl_password: "_sasl_password",
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
function asString(value: unknown): string {
|
||||
return typeof value === "string" ? value : ""
|
||||
}
|
||||
|
||||
function asBool(value: unknown): boolean {
|
||||
return value === true
|
||||
}
|
||||
|
||||
function buildEditConfig(config: ChannelConfig): ChannelConfig {
|
||||
const edit: ChannelConfig = { ...config }
|
||||
for (const secretKey of Object.keys(SECRET_FIELD_MAP)) {
|
||||
if (secretKey in config) {
|
||||
edit[SECRET_FIELD_MAP[secretKey]] = ""
|
||||
}
|
||||
}
|
||||
return edit
|
||||
}
|
||||
|
||||
function normalizeConfig(
|
||||
channel: SupportedChannel,
|
||||
rawConfig: ChannelConfig,
|
||||
): ChannelConfig {
|
||||
const config = { ...rawConfig }
|
||||
if (channel.name === "whatsapp_native") {
|
||||
config.use_native = true
|
||||
}
|
||||
if (channel.name === "whatsapp") {
|
||||
config.use_native = false
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
function buildSavePayload(
|
||||
channel: SupportedChannel,
|
||||
editConfig: ChannelConfig,
|
||||
enabled: boolean,
|
||||
): ChannelConfig {
|
||||
const payload: ChannelConfig = { enabled }
|
||||
|
||||
for (const [key, value] of Object.entries(editConfig)) {
|
||||
if (key.startsWith("_")) continue
|
||||
if (key === "enabled") continue
|
||||
|
||||
if (key in SECRET_FIELD_MAP) {
|
||||
const editKey = SECRET_FIELD_MAP[key]
|
||||
const incoming = asString(editConfig[editKey])
|
||||
payload[key] = incoming !== "" ? incoming : value
|
||||
continue
|
||||
}
|
||||
|
||||
payload[key] = value
|
||||
}
|
||||
|
||||
if (channel.name === "whatsapp_native") {
|
||||
payload.use_native = true
|
||||
}
|
||||
if (channel.name === "whatsapp") {
|
||||
payload.use_native = false
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
function isConfigured(
|
||||
channel: SupportedChannel,
|
||||
config: ChannelConfig,
|
||||
): boolean {
|
||||
switch (channel.name) {
|
||||
case "telegram":
|
||||
return asString(config.token) !== ""
|
||||
case "discord":
|
||||
return asString(config.token) !== ""
|
||||
case "slack":
|
||||
return asString(config.bot_token) !== ""
|
||||
case "feishu":
|
||||
return (
|
||||
asString(config.app_id) !== "" && asString(config.app_secret) !== ""
|
||||
)
|
||||
case "dingtalk":
|
||||
return (
|
||||
asString(config.client_id) !== "" &&
|
||||
asString(config.client_secret) !== ""
|
||||
)
|
||||
case "line":
|
||||
return asString(config.channel_access_token) !== ""
|
||||
case "qq":
|
||||
return (
|
||||
asString(config.app_id) !== "" && asString(config.app_secret) !== ""
|
||||
)
|
||||
case "onebot":
|
||||
return asString(config.ws_url) !== ""
|
||||
case "wecom":
|
||||
return asString(config.token) !== ""
|
||||
case "wecom_app":
|
||||
return (
|
||||
asString(config.corp_id) !== "" && asString(config.corp_secret) !== ""
|
||||
)
|
||||
case "wecom_aibot":
|
||||
return asString(config.token) !== ""
|
||||
case "whatsapp":
|
||||
return asString(config.bridge_url) !== ""
|
||||
case "whatsapp_native":
|
||||
return asBool(config.use_native)
|
||||
case "pico":
|
||||
return asString(config.token) !== ""
|
||||
case "maixcam":
|
||||
return asString(config.host) !== ""
|
||||
case "matrix":
|
||||
return (
|
||||
asString(config.homeserver) !== "" &&
|
||||
asString(config.user_id) !== "" &&
|
||||
asString(config.access_token) !== ""
|
||||
)
|
||||
case "irc":
|
||||
return asString(config.server) !== ""
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function getRequiredFieldKeys(channelName: string): string[] {
|
||||
switch (channelName) {
|
||||
case "telegram":
|
||||
return ["token"]
|
||||
case "discord":
|
||||
return ["token"]
|
||||
case "slack":
|
||||
return ["bot_token"]
|
||||
case "feishu":
|
||||
return ["app_id", "app_secret"]
|
||||
case "dingtalk":
|
||||
return ["client_id", "client_secret"]
|
||||
case "line":
|
||||
return ["channel_secret", "channel_access_token"]
|
||||
case "qq":
|
||||
return ["app_id", "app_secret"]
|
||||
case "onebot":
|
||||
return ["ws_url"]
|
||||
case "wecom":
|
||||
return ["token"]
|
||||
case "wecom_app":
|
||||
return ["corp_id", "corp_secret"]
|
||||
case "wecom_aibot":
|
||||
return ["token"]
|
||||
case "whatsapp":
|
||||
return ["bridge_url"]
|
||||
case "pico":
|
||||
return ["token"]
|
||||
case "maixcam":
|
||||
return ["host"]
|
||||
case "matrix":
|
||||
return ["homeserver", "user_id", "access_token"]
|
||||
case "irc":
|
||||
return ["server"]
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function isMissingRequiredValue(value: unknown): boolean {
|
||||
if (value === null || value === undefined) {
|
||||
return true
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value.trim() === ""
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.length === 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function getChannelDocSlug(channelName: string): string {
|
||||
return channelName.replaceAll("_", "-")
|
||||
}
|
||||
|
||||
const CHANNELS_WITHOUT_DOCS = new Set([
|
||||
"pico",
|
||||
"wecom",
|
||||
"matrix",
|
||||
"irc",
|
||||
"whatsapp",
|
||||
"whatsapp_native",
|
||||
])
|
||||
|
||||
export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const gateway = useAtomValue(gatewayAtom)
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [fetchError, setFetchError] = useState("")
|
||||
const [serverError, setServerError] = useState("")
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({})
|
||||
|
||||
const [channel, setChannel] = useState<SupportedChannel | null>(null)
|
||||
const [baseConfig, setBaseConfig] = useState<ChannelConfig>({})
|
||||
const [editConfig, setEditConfig] = useState<ChannelConfig>({})
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [catalog, appConfig] = await Promise.all([
|
||||
getChannelsCatalog(),
|
||||
getAppConfig(),
|
||||
])
|
||||
const matched =
|
||||
catalog.channels.find((item) => item.name === channelName) ?? null
|
||||
|
||||
if (!matched) {
|
||||
setChannel(null)
|
||||
setFetchError(
|
||||
t("channels.page.notFound", {
|
||||
name: channelName,
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const channelsConfig = asRecord(asRecord(appConfig).channels)
|
||||
const raw = asRecord(channelsConfig[matched.config_key])
|
||||
const normalized = normalizeConfig(matched, raw)
|
||||
|
||||
setChannel(matched)
|
||||
setBaseConfig(normalized)
|
||||
setEditConfig(buildEditConfig(normalized))
|
||||
setEnabled(asBool(normalized.enabled))
|
||||
setFetchError("")
|
||||
setServerError("")
|
||||
setFieldErrors({})
|
||||
} catch (e) {
|
||||
setFetchError(e instanceof Error ? e.message : t("channels.loadError"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [channelName, t])
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [loadData])
|
||||
|
||||
const previousGatewayStatusRef = useRef(gateway.status)
|
||||
useEffect(() => {
|
||||
const previousStatus = previousGatewayStatusRef.current
|
||||
if (previousStatus !== "running" && gateway.status === "running") {
|
||||
void loadData()
|
||||
}
|
||||
previousGatewayStatusRef.current = gateway.status
|
||||
}, [gateway.status, loadData])
|
||||
|
||||
const savePayload = useMemo(() => {
|
||||
if (!channel) return null
|
||||
return buildSavePayload(channel, editConfig, enabled)
|
||||
}, [channel, editConfig, enabled])
|
||||
|
||||
const configured = useMemo(() => {
|
||||
if (!channel || !savePayload) return false
|
||||
return isConfigured(channel, savePayload)
|
||||
}, [channel, savePayload])
|
||||
|
||||
const docsUrl = useMemo(() => {
|
||||
if (!channel) return ""
|
||||
if (CHANNELS_WITHOUT_DOCS.has(channel.name)) return ""
|
||||
const language = (
|
||||
i18n.resolvedLanguage ??
|
||||
i18n.language ??
|
||||
""
|
||||
).toLowerCase()
|
||||
const base = language.startsWith("zh")
|
||||
? "https://docs.picoclaw.io/zh-Hans/docs/channels"
|
||||
: "https://docs.picoclaw.io/docs/channels"
|
||||
return `${base}/${getChannelDocSlug(channel.name)}`
|
||||
}, [channel, i18n.language, i18n.resolvedLanguage])
|
||||
|
||||
const channelDisplayName = useMemo(() => {
|
||||
if (!channel) return channelName
|
||||
return getChannelDisplayName(channel, t)
|
||||
}, [channel, channelName, t])
|
||||
|
||||
const hiddenKeys = useMemo(() => {
|
||||
if (!channel) return []
|
||||
if (channel.name === "whatsapp") {
|
||||
return ["use_native"]
|
||||
}
|
||||
if (channel.name === "whatsapp_native") {
|
||||
return ["use_native", "bridge_url"]
|
||||
}
|
||||
return []
|
||||
}, [channel])
|
||||
const requiredKeys = useMemo(
|
||||
() => getRequiredFieldKeys(channelName),
|
||||
[channelName],
|
||||
)
|
||||
|
||||
const handleChange = useCallback((key: string, value: unknown) => {
|
||||
const normalizedKey = key.startsWith("_") ? key.slice(1) : key
|
||||
setEditConfig((prev) => ({ ...prev, [key]: value }))
|
||||
setFieldErrors((prev) => {
|
||||
if (!(key in prev) && !(normalizedKey in prev)) {
|
||||
return prev
|
||||
}
|
||||
const next = { ...prev }
|
||||
delete next[key]
|
||||
delete next[normalizedKey]
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleReset = () => {
|
||||
setEditConfig(buildEditConfig(baseConfig))
|
||||
setEnabled(asBool(baseConfig.enabled))
|
||||
setServerError("")
|
||||
setFieldErrors({})
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!channel || !savePayload) return
|
||||
|
||||
const missingRequiredFields = requiredKeys.filter((key) =>
|
||||
isMissingRequiredValue(savePayload[key]),
|
||||
)
|
||||
if (missingRequiredFields.length > 0) {
|
||||
const requiredFieldError = t("channels.validation.requiredField")
|
||||
const nextFieldErrors: Record<string, string> = {}
|
||||
for (const key of missingRequiredFields) {
|
||||
nextFieldErrors[key] = requiredFieldError
|
||||
}
|
||||
setFieldErrors(nextFieldErrors)
|
||||
setServerError("")
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
setServerError("")
|
||||
setFieldErrors({})
|
||||
try {
|
||||
await patchAppConfig({
|
||||
channels: {
|
||||
[channel.config_key]: savePayload,
|
||||
},
|
||||
})
|
||||
toast.success(t("channels.page.saveSuccess"))
|
||||
await loadData()
|
||||
} catch (e) {
|
||||
const message =
|
||||
e instanceof Error ? e.message : t("channels.page.saveError")
|
||||
setServerError(message)
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const renderForm = () => {
|
||||
if (!channel) return null
|
||||
const isEdit = configured
|
||||
|
||||
switch (channel.name) {
|
||||
case "telegram":
|
||||
return (
|
||||
<TelegramForm
|
||||
config={editConfig}
|
||||
onChange={handleChange}
|
||||
isEdit={isEdit}
|
||||
fieldErrors={fieldErrors}
|
||||
/>
|
||||
)
|
||||
case "discord":
|
||||
return (
|
||||
<DiscordForm
|
||||
config={editConfig}
|
||||
onChange={handleChange}
|
||||
isEdit={isEdit}
|
||||
fieldErrors={fieldErrors}
|
||||
/>
|
||||
)
|
||||
case "slack":
|
||||
return (
|
||||
<SlackForm
|
||||
config={editConfig}
|
||||
onChange={handleChange}
|
||||
isEdit={isEdit}
|
||||
fieldErrors={fieldErrors}
|
||||
/>
|
||||
)
|
||||
case "feishu":
|
||||
return (
|
||||
<FeishuForm
|
||||
config={editConfig}
|
||||
onChange={handleChange}
|
||||
isEdit={isEdit}
|
||||
fieldErrors={fieldErrors}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<GenericForm
|
||||
config={editConfig}
|
||||
onChange={handleChange}
|
||||
isEdit={isEdit}
|
||||
hiddenKeys={hiddenKeys}
|
||||
requiredKeys={requiredKeys}
|
||||
fieldErrors={fieldErrors}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader
|
||||
title={channelDisplayName}
|
||||
titleExtra={
|
||||
channel ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{enabled ? (
|
||||
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-600 dark:text-emerald-400">
|
||||
{t("channels.page.enabled")}
|
||||
</span>
|
||||
) : configured ? (
|
||||
<span className="rounded-full bg-amber-500/10 px-2 py-0.5 text-[10px] font-medium text-amber-600 dark:text-amber-400">
|
||||
{t("channels.status.configured")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex min-h-0 flex-1 justify-center overflow-y-auto px-4 pb-8 sm:px-6">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<IconLoader2 className="text-muted-foreground size-6 animate-spin" />
|
||||
</div>
|
||||
) : fetchError ? (
|
||||
<div className="text-destructive bg-destructive/10 rounded-lg px-4 py-3 text-sm">
|
||||
{fetchError}
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full max-w-250 space-y-5 pt-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<p className="font-medium">
|
||||
{t("channels.edit", {
|
||||
name: channelDisplayName,
|
||||
})}
|
||||
</p>
|
||||
{channel && docsUrl && (
|
||||
<a
|
||||
href={docsUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground text-xs underline underline-offset-2"
|
||||
>
|
||||
{t("channels.page.docLink")}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-border/60 bg-background flex items-center justify-between rounded-lg border px-4 py-3">
|
||||
<p className="text-sm font-medium">
|
||||
{t("channels.page.enableLabel")}
|
||||
</p>
|
||||
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||
</div>
|
||||
|
||||
{renderForm()}
|
||||
|
||||
{serverError && (
|
||||
<p className="text-destructive text-sm">{serverError}</p>
|
||||
)}
|
||||
|
||||
<div className="border-border/60 flex justify-end gap-2 border-t py-4">
|
||||
<Button variant="outline" onClick={handleReset} disabled={saving}>
|
||||
{t("common.reset")}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? t("common.saving") : t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { TFunction } from "i18next"
|
||||
|
||||
import type { SupportedChannel } from "@/api/channels"
|
||||
|
||||
export function getChannelDisplayName(
|
||||
channel: Pick<SupportedChannel, "name" | "display_name">,
|
||||
t: TFunction,
|
||||
): string {
|
||||
const key = `channels.name.${channel.name}`
|
||||
const translated = t(key)
|
||||
if (translated !== key) {
|
||||
return translated
|
||||
}
|
||||
|
||||
if (channel.display_name && channel.display_name.trim() !== "") {
|
||||
return channel.display_name
|
||||
}
|
||||
|
||||
return channel.name
|
||||
.split("_")
|
||||
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
|
||||
.join(" ")
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import type { ChannelConfig } from "@/api/channels"
|
||||
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
|
||||
import { Field, KeyInput, SwitchCardField } from "@/components/shared-form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
interface DiscordFormProps {
|
||||
config: ChannelConfig
|
||||
onChange: (key: string, value: unknown) => void
|
||||
isEdit: boolean
|
||||
fieldErrors?: Record<string, string>
|
||||
}
|
||||
|
||||
function asString(value: unknown): string {
|
||||
return typeof value === "string" ? value : ""
|
||||
}
|
||||
|
||||
function asStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.filter((item): item is string => typeof item === "string")
|
||||
}
|
||||
|
||||
function asBool(value: unknown): boolean {
|
||||
return value === true
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
export function DiscordForm({
|
||||
config,
|
||||
onChange,
|
||||
isEdit,
|
||||
fieldErrors = {},
|
||||
}: DiscordFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const groupTriggerConfig = asRecord(config.group_trigger)
|
||||
const tokenExtraHint =
|
||||
isEdit && asString(config.token)
|
||||
? ` ${t("channels.field.secretHintSet")}`
|
||||
: ""
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<Field
|
||||
label={t("channels.field.token")}
|
||||
required
|
||||
hint={`${t("channels.form.desc.token")}${tokenExtraHint}`}
|
||||
error={fieldErrors.token}
|
||||
>
|
||||
<KeyInput
|
||||
value={asString(config._token)}
|
||||
onChange={(v) => onChange("_token", v)}
|
||||
placeholder={maskedSecretPlaceholder(
|
||||
config.token,
|
||||
t("channels.field.tokenPlaceholder"),
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("channels.field.proxy")}
|
||||
hint={t("channels.form.desc.proxy")}
|
||||
>
|
||||
<Input
|
||||
value={asString(config.proxy)}
|
||||
onChange={(e) => onChange("proxy", e.target.value)}
|
||||
placeholder="http://127.0.0.1:7890"
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={t("channels.field.allowFrom")}
|
||||
hint={t("channels.form.desc.allowFrom")}
|
||||
>
|
||||
<Input
|
||||
value={asStringArray(config.allow_from).join(", ")}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
"allow_from",
|
||||
e.target.value
|
||||
.split(",")
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
placeholder={t("channels.field.allowFromPlaceholder")}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<SwitchCardField
|
||||
label={t("channels.field.mentionOnly")}
|
||||
hint={t("channels.form.desc.mentionOnly")}
|
||||
checked={asBool(groupTriggerConfig.mention_only)}
|
||||
onCheckedChange={(checked) => {
|
||||
onChange("group_trigger", {
|
||||
...groupTriggerConfig,
|
||||
mention_only: checked,
|
||||
})
|
||||
}}
|
||||
ariaLabel={t("channels.field.mentionOnly")}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import type { ChannelConfig } from "@/api/channels"
|
||||
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
|
||||
import { Field, KeyInput } from "@/components/shared-form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
interface FeishuFormProps {
|
||||
config: ChannelConfig
|
||||
onChange: (key: string, value: unknown) => void
|
||||
isEdit: boolean
|
||||
fieldErrors?: Record<string, string>
|
||||
}
|
||||
|
||||
function asString(value: unknown): string {
|
||||
return typeof value === "string" ? value : ""
|
||||
}
|
||||
|
||||
function asStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.filter((item): item is string => typeof item === "string")
|
||||
}
|
||||
|
||||
export function FeishuForm({
|
||||
config,
|
||||
onChange,
|
||||
isEdit,
|
||||
fieldErrors = {},
|
||||
}: FeishuFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const appSecretExtraHint =
|
||||
isEdit && asString(config.app_secret)
|
||||
? ` ${t("channels.field.secretHintSet")}`
|
||||
: ""
|
||||
const verificationExtraHint =
|
||||
isEdit && asString(config.verification_token)
|
||||
? ` ${t("channels.field.secretHintSet")}`
|
||||
: ""
|
||||
const encryptExtraHint =
|
||||
isEdit && asString(config.encrypt_key)
|
||||
? ` ${t("channels.field.secretHintSet")}`
|
||||
: ""
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<Field
|
||||
label={t("channels.field.appId")}
|
||||
required
|
||||
hint={t("channels.form.desc.appId")}
|
||||
error={fieldErrors.app_id}
|
||||
>
|
||||
<Input
|
||||
value={asString(config.app_id)}
|
||||
onChange={(e) => onChange("app_id", e.target.value)}
|
||||
placeholder="cli_xxxx"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("channels.field.appSecret")}
|
||||
required
|
||||
hint={`${t("channels.form.desc.appSecret")}${appSecretExtraHint}`}
|
||||
error={fieldErrors.app_secret}
|
||||
>
|
||||
<KeyInput
|
||||
value={asString(config._app_secret)}
|
||||
onChange={(v) => onChange("_app_secret", v)}
|
||||
placeholder={maskedSecretPlaceholder(
|
||||
config.app_secret,
|
||||
t("channels.field.secretPlaceholder"),
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("channels.field.verificationToken")}
|
||||
hint={`${t("channels.form.desc.verificationToken")}${verificationExtraHint}`}
|
||||
>
|
||||
<KeyInput
|
||||
value={asString(config._verification_token)}
|
||||
onChange={(v) => onChange("_verification_token", v)}
|
||||
placeholder={maskedSecretPlaceholder(
|
||||
config.verification_token,
|
||||
t("channels.field.secretPlaceholder"),
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={t("channels.field.encryptKey")}
|
||||
hint={`${t("channels.form.desc.encryptKey")}${encryptExtraHint}`}
|
||||
>
|
||||
<KeyInput
|
||||
value={asString(config._encrypt_key)}
|
||||
onChange={(v) => onChange("_encrypt_key", v)}
|
||||
placeholder={maskedSecretPlaceholder(
|
||||
config.encrypt_key,
|
||||
t("channels.field.secretPlaceholder"),
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={t("channels.field.allowFrom")}
|
||||
hint={t("channels.form.desc.allowFrom")}
|
||||
>
|
||||
<Input
|
||||
value={asStringArray(config.allow_from).join(", ")}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
"allow_from",
|
||||
e.target.value
|
||||
.split(",")
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
placeholder={t("channels.field.allowFromPlaceholder")}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import type { ChannelConfig } from "@/api/channels"
|
||||
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
|
||||
import { Field, KeyInput, SwitchCardField } from "@/components/shared-form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
interface GenericFormProps {
|
||||
config: ChannelConfig
|
||||
onChange: (key: string, value: unknown) => void
|
||||
isEdit: boolean
|
||||
hiddenKeys?: string[]
|
||||
requiredKeys?: string[]
|
||||
fieldErrors?: Record<string, string>
|
||||
}
|
||||
|
||||
// Secret field names that should use masked input.
|
||||
const SECRET_FIELDS = new Set([
|
||||
"token",
|
||||
"app_secret",
|
||||
"client_secret",
|
||||
"corp_secret",
|
||||
"channel_secret",
|
||||
"channel_access_token",
|
||||
"access_token",
|
||||
"bot_token",
|
||||
"app_token",
|
||||
"encoding_aes_key",
|
||||
"encrypt_key",
|
||||
"verification_token",
|
||||
"password",
|
||||
"nickserv_password",
|
||||
"sasl_password",
|
||||
])
|
||||
|
||||
// Fields to skip in the generic form (handled by enabled toggle or internal).
|
||||
const SKIP_FIELDS = new Set(["enabled", "reasoning_channel_id"])
|
||||
|
||||
// Fields that are objects/nested — show as JSON or skip.
|
||||
const OBJECT_FIELDS = new Set([
|
||||
"group_trigger",
|
||||
"typing",
|
||||
"placeholder",
|
||||
"allow_token_query",
|
||||
"allow_from",
|
||||
"allow_origins",
|
||||
])
|
||||
|
||||
function formatLabel(key: string): string {
|
||||
return key
|
||||
.split("_")
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
function formatSentenceFieldName(key: string): string {
|
||||
const label = formatLabel(key)
|
||||
return label.charAt(0).toLowerCase() + label.slice(1)
|
||||
}
|
||||
|
||||
function asString(value: unknown): string {
|
||||
return typeof value === "string" ? value : ""
|
||||
}
|
||||
|
||||
function asStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.filter((item): item is string => typeof item === "string")
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
function asBool(value: unknown): boolean {
|
||||
return value === true
|
||||
}
|
||||
|
||||
export function GenericForm({
|
||||
config,
|
||||
onChange,
|
||||
isEdit,
|
||||
hiddenKeys = [],
|
||||
requiredKeys = [],
|
||||
fieldErrors = {},
|
||||
}: GenericFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const hiddenFieldSet = new Set(hiddenKeys)
|
||||
const requiredFieldSet = new Set(requiredKeys)
|
||||
const groupTriggerConfig = asRecord(config.group_trigger)
|
||||
const typingConfig = asRecord(config.typing)
|
||||
const placeholderConfig = asRecord(config.placeholder)
|
||||
const placeholderEnabled = asBool(placeholderConfig.enabled)
|
||||
|
||||
const fields = Object.keys(config).filter(
|
||||
(k) =>
|
||||
!k.startsWith("_") &&
|
||||
!SKIP_FIELDS.has(k) &&
|
||||
!OBJECT_FIELDS.has(k) &&
|
||||
!hiddenFieldSet.has(k),
|
||||
)
|
||||
|
||||
const buildHint = (key: string): string => {
|
||||
const descriptions: Record<string, string> = {
|
||||
ws_url: t("channels.form.desc.wsUrl"),
|
||||
reconnect_interval: t("channels.form.desc.reconnectInterval"),
|
||||
bridge_url: t("channels.form.desc.bridgeUrl"),
|
||||
session_store_path: t("channels.form.desc.sessionStorePath"),
|
||||
use_native: t("channels.form.desc.useNative"),
|
||||
host: t("channels.form.desc.host"),
|
||||
port: t("channels.form.desc.port"),
|
||||
homeserver: t("channels.form.desc.homeserver"),
|
||||
user_id: t("channels.form.desc.userId"),
|
||||
device_id: t("channels.form.desc.deviceId"),
|
||||
join_on_invite: t("channels.form.desc.joinOnInvite"),
|
||||
app_id: t("channels.form.desc.appId"),
|
||||
client_id: t("channels.form.desc.clientId"),
|
||||
corp_id: t("channels.form.desc.corpId"),
|
||||
agent_id: t("channels.form.desc.agentId"),
|
||||
webhook_url: t("channels.form.desc.webhookUrl"),
|
||||
webhook_host: t("channels.form.desc.webhookHost"),
|
||||
webhook_port: t("channels.form.desc.webhookPort"),
|
||||
webhook_path: t("channels.form.desc.webhookPath"),
|
||||
reply_timeout: t("channels.form.desc.replyTimeout"),
|
||||
max_steps: t("channels.form.desc.maxSteps"),
|
||||
welcome_message: t("channels.form.desc.welcomeMessage"),
|
||||
allow_token_query: t("channels.form.desc.allowTokenQuery"),
|
||||
ping_interval: t("channels.form.desc.pingInterval"),
|
||||
read_timeout: t("channels.form.desc.readTimeout"),
|
||||
write_timeout: t("channels.form.desc.writeTimeout"),
|
||||
max_connections: t("channels.form.desc.maxConnections"),
|
||||
server: t("channels.form.desc.server"),
|
||||
tls: t("channels.form.desc.tls"),
|
||||
nick: t("channels.form.desc.nick"),
|
||||
user: t("channels.form.desc.user"),
|
||||
real_name: t("channels.form.desc.realName"),
|
||||
channels: t("channels.form.desc.channels"),
|
||||
request_caps: t("channels.form.desc.requestCaps"),
|
||||
}
|
||||
return (
|
||||
descriptions[key] ??
|
||||
t("channels.form.desc.genericField", {
|
||||
field: formatSentenceFieldName(key),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{fields.map((key) => {
|
||||
const isRequired = requiredFieldSet.has(key)
|
||||
if (SECRET_FIELDS.has(key)) {
|
||||
const editKey = `_${key}`
|
||||
const extraHint =
|
||||
isEdit && config[key] ? ` ${t("channels.field.secretHintSet")}` : ""
|
||||
return (
|
||||
<Field
|
||||
key={key}
|
||||
label={formatLabel(key)}
|
||||
required={isRequired}
|
||||
hint={`${buildHint(key)}${extraHint}`}
|
||||
error={fieldErrors[key]}
|
||||
>
|
||||
<KeyInput
|
||||
value={asString(config[editKey])}
|
||||
onChange={(v) => onChange(editKey, v)}
|
||||
placeholder={maskedSecretPlaceholder(config[key])}
|
||||
/>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
const value = config[key]
|
||||
if (typeof value === "boolean") {
|
||||
return (
|
||||
<SwitchCardField
|
||||
key={key}
|
||||
label={formatLabel(key)}
|
||||
hint={buildHint(key)}
|
||||
error={fieldErrors[key]}
|
||||
checked={value}
|
||||
onCheckedChange={(checked) => onChange(key, checked)}
|
||||
ariaLabel={formatLabel(key)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return (
|
||||
<Field
|
||||
key={key}
|
||||
label={formatLabel(key)}
|
||||
required={isRequired}
|
||||
hint={buildHint(key)}
|
||||
error={fieldErrors[key]}
|
||||
>
|
||||
<Input
|
||||
value={asStringArray(value).join(", ")}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
key,
|
||||
e.target.value
|
||||
.split(",")
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Field
|
||||
key={key}
|
||||
label={formatLabel(key)}
|
||||
required={isRequired}
|
||||
hint={buildHint(key)}
|
||||
error={fieldErrors[key]}
|
||||
>
|
||||
<Input
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) => {
|
||||
// Attempt to preserve number types
|
||||
const v = e.target.value
|
||||
if (typeof config[key] === "number") {
|
||||
onChange(key, v === "" ? 0 : Number(v))
|
||||
} else {
|
||||
onChange(key, v)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Allow From field */}
|
||||
{config.allow_from !== undefined && !hiddenFieldSet.has("allow_from") && (
|
||||
<Field
|
||||
label={t("channels.field.allowFrom")}
|
||||
hint={t("channels.form.desc.allowFrom")}
|
||||
>
|
||||
<Input
|
||||
value={asStringArray(config.allow_from).join(", ")}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
"allow_from",
|
||||
e.target.value
|
||||
.split(",")
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
placeholder={t("channels.field.allowFromPlaceholder")}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{config.allow_origins !== undefined &&
|
||||
!hiddenFieldSet.has("allow_origins") && (
|
||||
<Field
|
||||
label={t("channels.field.allowOrigins")}
|
||||
hint={t("channels.form.desc.allowOrigins")}
|
||||
>
|
||||
<Input
|
||||
value={asStringArray(config.allow_origins).join(", ")}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
"allow_origins",
|
||||
e.target.value
|
||||
.split(",")
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
placeholder={t("channels.field.allowOriginsPlaceholder")}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{config.allow_token_query !== undefined &&
|
||||
!hiddenFieldSet.has("allow_token_query") && (
|
||||
<SwitchCardField
|
||||
label={formatLabel("allow_token_query")}
|
||||
hint={buildHint("allow_token_query")}
|
||||
checked={asBool(config.allow_token_query)}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange("allow_token_query", checked)
|
||||
}
|
||||
ariaLabel={formatLabel("allow_token_query")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{config.group_trigger !== undefined &&
|
||||
!hiddenFieldSet.has("group_trigger") && (
|
||||
<>
|
||||
<SwitchCardField
|
||||
label={t("channels.field.groupTriggerMentionOnly")}
|
||||
hint={t("channels.form.desc.groupTriggerMentionOnly")}
|
||||
checked={asBool(groupTriggerConfig.mention_only)}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange("group_trigger", {
|
||||
...groupTriggerConfig,
|
||||
mention_only: checked,
|
||||
})
|
||||
}
|
||||
ariaLabel={t("channels.field.groupTriggerMentionOnly")}
|
||||
/>
|
||||
<Field
|
||||
label={t("channels.field.groupTriggerPrefixes")}
|
||||
hint={t("channels.form.desc.groupTriggerPrefixes")}
|
||||
>
|
||||
<Input
|
||||
value={asStringArray(groupTriggerConfig.prefixes).join(", ")}
|
||||
onChange={(e) =>
|
||||
onChange("group_trigger", {
|
||||
...groupTriggerConfig,
|
||||
prefixes: e.target.value
|
||||
.split(",")
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean),
|
||||
})
|
||||
}
|
||||
placeholder={t("channels.field.groupTriggerPrefixes")}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{config.typing !== undefined && !hiddenFieldSet.has("typing") && (
|
||||
<SwitchCardField
|
||||
label={t("channels.field.typingEnabled")}
|
||||
hint={t("channels.form.desc.typingEnabled")}
|
||||
checked={asBool(typingConfig.enabled)}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange("typing", { ...typingConfig, enabled: checked })
|
||||
}
|
||||
ariaLabel={t("channels.field.typingEnabled")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{config.placeholder !== undefined &&
|
||||
!hiddenFieldSet.has("placeholder") && (
|
||||
<SwitchCardField
|
||||
label={t("channels.field.placeholderEnabled")}
|
||||
hint={t("channels.form.desc.placeholderEnabled")}
|
||||
checked={placeholderEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange("placeholder", {
|
||||
...placeholderConfig,
|
||||
enabled: checked,
|
||||
})
|
||||
}
|
||||
ariaLabel={t("channels.field.placeholderEnabled")}
|
||||
>
|
||||
{placeholderEnabled && (
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
value={asString(placeholderConfig.text)}
|
||||
onChange={(e) =>
|
||||
onChange("placeholder", {
|
||||
...placeholderConfig,
|
||||
text: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder={t("channels.field.placeholderText")}
|
||||
aria-label={t("channels.field.placeholderText")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</SwitchCardField>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import type { ChannelConfig } from "@/api/channels"
|
||||
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
|
||||
import { Field, KeyInput } from "@/components/shared-form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
interface SlackFormProps {
|
||||
config: ChannelConfig
|
||||
onChange: (key: string, value: unknown) => void
|
||||
isEdit: boolean
|
||||
fieldErrors?: Record<string, string>
|
||||
}
|
||||
|
||||
function asString(value: unknown): string {
|
||||
return typeof value === "string" ? value : ""
|
||||
}
|
||||
|
||||
function asStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.filter((item): item is string => typeof item === "string")
|
||||
}
|
||||
|
||||
export function SlackForm({
|
||||
config,
|
||||
onChange,
|
||||
isEdit,
|
||||
fieldErrors = {},
|
||||
}: SlackFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const botTokenExtraHint =
|
||||
isEdit && asString(config.bot_token)
|
||||
? ` ${t("channels.field.secretHintSet")}`
|
||||
: ""
|
||||
const appTokenExtraHint =
|
||||
isEdit && asString(config.app_token)
|
||||
? ` ${t("channels.field.secretHintSet")}`
|
||||
: ""
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<Field
|
||||
label={t("channels.field.botToken")}
|
||||
required
|
||||
hint={`${t("channels.form.desc.botToken")}${botTokenExtraHint}`}
|
||||
error={fieldErrors.bot_token}
|
||||
>
|
||||
<KeyInput
|
||||
value={asString(config._bot_token)}
|
||||
onChange={(v) => onChange("_bot_token", v)}
|
||||
placeholder={maskedSecretPlaceholder(config.bot_token, "xoxb-xxxx")}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("channels.field.appToken")}
|
||||
hint={`${t("channels.form.desc.appToken")}${appTokenExtraHint}`}
|
||||
>
|
||||
<KeyInput
|
||||
value={asString(config._app_token)}
|
||||
onChange={(v) => onChange("_app_token", v)}
|
||||
placeholder={maskedSecretPlaceholder(config.app_token, "xapp-xxxx")}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("channels.field.allowFrom")}
|
||||
hint={t("channels.form.desc.allowFrom")}
|
||||
>
|
||||
<Input
|
||||
value={asStringArray(config.allow_from).join(", ")}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
"allow_from",
|
||||
e.target.value
|
||||
.split(",")
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
placeholder={t("channels.field.allowFromPlaceholder")}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import type { ChannelConfig } from "@/api/channels"
|
||||
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
|
||||
import { Field, KeyInput, SwitchCardField } from "@/components/shared-form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
interface TelegramFormProps {
|
||||
config: ChannelConfig
|
||||
onChange: (key: string, value: unknown) => void
|
||||
isEdit: boolean
|
||||
fieldErrors?: Record<string, string>
|
||||
}
|
||||
|
||||
function asString(value: unknown): string {
|
||||
return typeof value === "string" ? value : ""
|
||||
}
|
||||
|
||||
function asStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.filter((item): item is string => typeof item === "string")
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
function asBool(value: unknown): boolean {
|
||||
return value === true
|
||||
}
|
||||
|
||||
export function TelegramForm({
|
||||
config,
|
||||
onChange,
|
||||
isEdit,
|
||||
fieldErrors = {},
|
||||
}: TelegramFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const typingConfig = asRecord(config.typing)
|
||||
const placeholderConfig = asRecord(config.placeholder)
|
||||
const placeholderEnabled = asBool(placeholderConfig.enabled)
|
||||
const tokenExtraHint =
|
||||
isEdit && asString(config.token)
|
||||
? ` ${t("channels.field.secretHintSet")}`
|
||||
: ""
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<Field
|
||||
label={t("channels.field.token")}
|
||||
required
|
||||
hint={`${t("channels.form.desc.token")}${tokenExtraHint}`}
|
||||
error={fieldErrors.token}
|
||||
>
|
||||
<KeyInput
|
||||
value={asString(config._token)}
|
||||
onChange={(v) => onChange("_token", v)}
|
||||
placeholder={maskedSecretPlaceholder(
|
||||
config.token,
|
||||
t("channels.field.tokenPlaceholder"),
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("channels.field.baseUrl")}
|
||||
hint={t("channels.form.desc.baseUrl")}
|
||||
>
|
||||
<Input
|
||||
value={asString(config.base_url)}
|
||||
onChange={(e) => onChange("base_url", e.target.value)}
|
||||
placeholder="https://api.telegram.org"
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={t("channels.field.proxy")}
|
||||
hint={t("channels.form.desc.proxy")}
|
||||
>
|
||||
<Input
|
||||
value={asString(config.proxy)}
|
||||
onChange={(e) => onChange("proxy", e.target.value)}
|
||||
placeholder="http://127.0.0.1:7890"
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={t("channels.field.allowFrom")}
|
||||
hint={t("channels.form.desc.allowFrom")}
|
||||
>
|
||||
<Input
|
||||
value={asStringArray(config.allow_from).join(", ")}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
"allow_from",
|
||||
e.target.value
|
||||
.split(",")
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
placeholder={t("channels.field.allowFromPlaceholder")}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<SwitchCardField
|
||||
label={t("channels.field.typingEnabled")}
|
||||
hint={t("channels.form.desc.typingEnabled")}
|
||||
checked={asBool(typingConfig.enabled)}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange("typing", { ...typingConfig, enabled: checked })
|
||||
}
|
||||
ariaLabel={t("channels.field.typingEnabled")}
|
||||
/>
|
||||
|
||||
<SwitchCardField
|
||||
label={t("channels.field.placeholderEnabled")}
|
||||
hint={t("channels.form.desc.placeholderEnabled")}
|
||||
checked={placeholderEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange("placeholder", {
|
||||
...placeholderConfig,
|
||||
enabled: checked,
|
||||
})
|
||||
}
|
||||
ariaLabel={t("channels.field.placeholderEnabled")}
|
||||
>
|
||||
{placeholderEnabled && (
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
value={asString(placeholderConfig.text)}
|
||||
onChange={(e) =>
|
||||
onChange("placeholder", {
|
||||
...placeholderConfig,
|
||||
text: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder={t("channels.field.placeholderText")}
|
||||
aria-label={t("channels.field.placeholderText")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</SwitchCardField>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { IconCheck, IconCopy } from "@tabler/icons-react"
|
||||
import { useState } from "react"
|
||||
import ReactMarkdown from "react-markdown"
|
||||
import remarkGfm from "remark-gfm"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { formatMessageTime } from "@/hooks/use-pico-chat"
|
||||
|
||||
interface AssistantMessageProps {
|
||||
content: string
|
||||
timestamp?: string | number
|
||||
}
|
||||
|
||||
export function AssistantMessage({
|
||||
content,
|
||||
timestamp = "",
|
||||
}: AssistantMessageProps) {
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
const formattedTimestamp =
|
||||
timestamp !== "" ? formatMessageTime(timestamp) : ""
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(content).then(() => {
|
||||
setIsCopied(true)
|
||||
setTimeout(() => setIsCopied(false), 2000)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group flex w-full flex-col gap-1.5">
|
||||
<div className="text-muted-foreground flex items-center justify-between gap-2 px-1 text-xs opacity-70">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>PicoClaw</span>
|
||||
{formattedTimestamp && (
|
||||
<>
|
||||
<span className="opacity-50">•</span>
|
||||
<span>{formattedTimestamp}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-card text-card-foreground relative overflow-hidden rounded-xl border">
|
||||
<div className="prose dark:prose-invert prose-p:my-2 prose-pre:my-2 prose-pre:rounded-lg prose-pre:border prose-pre:bg-zinc-950 prose-pre:p-3 max-w-none p-4 text-[15px] leading-relaxed">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="bg-background/50 hover:bg-background/80 absolute top-2 right-2 h-7 w-7 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{isCopied ? (
|
||||
<IconCheck className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<IconCopy className="text-muted-foreground h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { IconArrowUp } from "@tabler/icons-react"
|
||||
import type { KeyboardEvent } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import TextareaAutosize from "react-textarea-autosize"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface ChatComposerProps {
|
||||
input: string
|
||||
onInputChange: (value: string) => void
|
||||
onSend: () => void
|
||||
isConnected: boolean
|
||||
hasDefaultModel: boolean
|
||||
}
|
||||
|
||||
export function ChatComposer({
|
||||
input,
|
||||
onInputChange,
|
||||
onSend,
|
||||
isConnected,
|
||||
hasDefaultModel,
|
||||
}: ChatComposerProps) {
|
||||
const { t } = useTranslation()
|
||||
const canInput = isConnected && hasDefaultModel
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.nativeEvent.isComposing) return
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
onSend()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-background shrink-0 px-4 pt-4 pb-[calc(1rem+env(safe-area-inset-bottom))] md:px-8 md:pb-8 lg:px-24 xl:px-48">
|
||||
<div className="bg-card border-border/80 mx-auto flex max-w-[1000px] flex-col rounded-2xl border p-3 shadow-md">
|
||||
<TextareaAutosize
|
||||
value={input}
|
||||
onChange={(e) => onInputChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t("chat.placeholder")}
|
||||
disabled={!canInput}
|
||||
className={cn(
|
||||
"max-h-[200px] min-h-[60px] resize-none border-0 bg-transparent px-2 py-1 text-[15px] shadow-none transition-colors focus-visible:ring-0 focus-visible:outline-none dark:bg-transparent",
|
||||
!canInput && "cursor-not-allowed",
|
||||
)}
|
||||
minRows={1}
|
||||
maxRows={8}
|
||||
/>
|
||||
|
||||
<div className="mt-2 flex items-center justify-between px-1">
|
||||
<div className="flex items-center gap-1">{/* action buttons */}</div>
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
className="size-8 rounded-full bg-violet-500 text-white transition-transform hover:bg-violet-600 active:scale-95"
|
||||
onClick={onSend}
|
||||
disabled={!input.trim() || !isConnected}
|
||||
>
|
||||
<IconArrowUp className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
IconPlugConnectedX,
|
||||
IconRobot,
|
||||
IconRobotOff,
|
||||
IconStar,
|
||||
} from "@tabler/icons-react"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
interface ChatEmptyStateProps {
|
||||
hasConfiguredModels: boolean
|
||||
defaultModelName: string
|
||||
isConnected: boolean
|
||||
}
|
||||
|
||||
export function ChatEmptyState({
|
||||
hasConfiguredModels,
|
||||
defaultModelName,
|
||||
isConnected,
|
||||
}: ChatEmptyStateProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!hasConfiguredModels) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 opacity-70">
|
||||
<div className="mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-amber-500/10 text-amber-500">
|
||||
<IconRobotOff className="h-8 w-8" />
|
||||
</div>
|
||||
<h3 className="mb-2 text-xl font-medium">
|
||||
{t("chat.empty.noConfiguredModel")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-4 text-center text-sm">
|
||||
{t("chat.empty.noConfiguredModelDescription")}
|
||||
</p>
|
||||
<Button asChild variant="secondary" size="sm" className="px-4">
|
||||
<Link to="/models">{t("chat.empty.goToModels")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!defaultModelName) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 opacity-70">
|
||||
<div className="mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-amber-500/10 text-amber-500">
|
||||
<IconStar className="h-8 w-8" />
|
||||
</div>
|
||||
<h3 className="mb-2 text-xl font-medium">
|
||||
{t("chat.empty.noSelectedModel")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-4 text-center text-sm">
|
||||
{t("chat.empty.noSelectedModelDescription")}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isConnected) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 opacity-70">
|
||||
<div className="mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-amber-500/10 text-amber-500">
|
||||
<IconPlugConnectedX className="h-8 w-8" />
|
||||
</div>
|
||||
<h3 className="mb-2 text-xl font-medium">
|
||||
{t("chat.empty.notRunning")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-4 text-center text-sm">
|
||||
{t("chat.empty.notRunningDescription")}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 opacity-70">
|
||||
<div className="mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-violet-500/10 text-violet-500">
|
||||
<IconRobot className="h-8 w-8" />
|
||||
</div>
|
||||
<h3 className="mb-2 text-xl font-medium">{t("chat.welcome")}</h3>
|
||||
<p className="text-muted-foreground text-center text-sm">
|
||||
{t("chat.welcomeDesc")}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { IconPlus } from "@tabler/icons-react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { AssistantMessage } from "@/components/chat/assistant-message"
|
||||
import { ChatComposer } from "@/components/chat/chat-composer"
|
||||
import { ChatEmptyState } from "@/components/chat/chat-empty-state"
|
||||
import { ModelSelector } from "@/components/chat/model-selector"
|
||||
import { SessionHistoryMenu } from "@/components/chat/session-history-menu"
|
||||
import { TypingIndicator } from "@/components/chat/typing-indicator"
|
||||
import { UserMessage } from "@/components/chat/user-message"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useChatModels } from "@/hooks/use-chat-models"
|
||||
import { useGateway } from "@/hooks/use-gateway"
|
||||
import { usePicoChat } from "@/hooks/use-pico-chat"
|
||||
import { useSessionHistory } from "@/hooks/use-session-history"
|
||||
|
||||
export function ChatPage() {
|
||||
const { t } = useTranslation()
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const [isAtBottom, setIsAtBottom] = useState(true)
|
||||
const [input, setInput] = useState("")
|
||||
|
||||
const {
|
||||
messages,
|
||||
isTyping,
|
||||
activeSessionId,
|
||||
sendMessage,
|
||||
switchSession,
|
||||
newChat,
|
||||
} = usePicoChat()
|
||||
|
||||
const { state: gwState } = useGateway()
|
||||
const isConnected = gwState === "running"
|
||||
|
||||
const {
|
||||
defaultModelName,
|
||||
hasConfiguredModels,
|
||||
apiKeyModels,
|
||||
oauthModels,
|
||||
localModels,
|
||||
handleSetDefault,
|
||||
} = useChatModels({ isConnected })
|
||||
|
||||
const { sessions, hasMore, observerRef, loadSessions, handleDeleteSession } =
|
||||
useSessionHistory({
|
||||
activeSessionId,
|
||||
onDeletedActiveSession: newChat,
|
||||
})
|
||||
|
||||
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget
|
||||
setIsAtBottom(scrollHeight - scrollTop <= clientHeight + 10)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isAtBottom && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||
}
|
||||
}, [messages, isTyping, isAtBottom])
|
||||
|
||||
const handleSend = () => {
|
||||
if (!input.trim() || !isConnected) return
|
||||
sendMessage(input.trim())
|
||||
setInput("")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-background/95 flex h-full flex-col">
|
||||
<PageHeader
|
||||
title={t("navigation.chat")}
|
||||
titleExtra={
|
||||
hasConfiguredModels && (
|
||||
<ModelSelector
|
||||
defaultModelName={defaultModelName}
|
||||
apiKeyModels={apiKeyModels}
|
||||
oauthModels={oauthModels}
|
||||
localModels={localModels}
|
||||
onValueChange={handleSetDefault}
|
||||
/>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={newChat}
|
||||
className="h-9 gap-2"
|
||||
>
|
||||
<IconPlus className="size-4" />
|
||||
<span className="hidden sm:inline">{t("chat.newChat")}</span>
|
||||
</Button>
|
||||
|
||||
<SessionHistoryMenu
|
||||
sessions={sessions}
|
||||
activeSessionId={activeSessionId}
|
||||
hasMore={hasMore}
|
||||
observerRef={observerRef}
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
void loadSessions(true)
|
||||
}
|
||||
}}
|
||||
onSwitchSession={switchSession}
|
||||
onDeleteSession={handleDeleteSession}
|
||||
/>
|
||||
</PageHeader>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="min-h-0 flex-1 overflow-y-auto px-4 py-6 md:px-8 lg:px-24 xl:px-48"
|
||||
>
|
||||
<div className="mx-auto flex w-full max-w-250 flex-col gap-8 pb-8">
|
||||
{messages.length === 0 && !isTyping && (
|
||||
<ChatEmptyState
|
||||
hasConfiguredModels={hasConfiguredModels}
|
||||
defaultModelName={defaultModelName}
|
||||
isConnected={isConnected}
|
||||
/>
|
||||
)}
|
||||
|
||||
{messages.map((msg) => (
|
||||
<div key={msg.id} className="flex w-full">
|
||||
{msg.role === "assistant" ? (
|
||||
<AssistantMessage
|
||||
content={msg.content}
|
||||
timestamp={msg.timestamp}
|
||||
/>
|
||||
) : (
|
||||
<UserMessage content={msg.content} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isTyping && <TypingIndicator />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChatComposer
|
||||
input={input}
|
||||
onInputChange={setInput}
|
||||
onSend={handleSend}
|
||||
isConnected={isConnected}
|
||||
hasDefaultModel={Boolean(defaultModelName)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import type { ModelInfo } from "@/api/models"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
|
||||
interface ModelSelectorProps {
|
||||
defaultModelName: string
|
||||
apiKeyModels: ModelInfo[]
|
||||
oauthModels: ModelInfo[]
|
||||
localModels: ModelInfo[]
|
||||
onValueChange: (modelName: string) => void
|
||||
}
|
||||
|
||||
export function ModelSelector({
|
||||
defaultModelName,
|
||||
apiKeyModels,
|
||||
oauthModels,
|
||||
localModels,
|
||||
onValueChange,
|
||||
}: ModelSelectorProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Select value={defaultModelName} onValueChange={onValueChange}>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:border-input h-8 max-w-[160px] min-w-[80px] bg-transparent shadow-none focus-visible:ring-0 sm:max-w-[220px]"
|
||||
>
|
||||
<SelectValue placeholder={t("chat.noModel")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{apiKeyModels.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel>{t("chat.modelGroup.apikey")}</SelectLabel>
|
||||
{apiKeyModels.map((model) => (
|
||||
<SelectItem key={model.index} value={model.model_name}>
|
||||
{model.model_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
{apiKeyModels.length > 0 &&
|
||||
(oauthModels.length > 0 || localModels.length > 0) && (
|
||||
<SelectSeparator />
|
||||
)}
|
||||
|
||||
{oauthModels.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel>{t("chat.modelGroup.oauth")}</SelectLabel>
|
||||
{oauthModels.map((model) => (
|
||||
<SelectItem key={model.index} value={model.model_name}>
|
||||
{model.model_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
{oauthModels.length > 0 &&
|
||||
(localModels.length > 0 || apiKeyModels.length > 0) && (
|
||||
<SelectSeparator />
|
||||
)}
|
||||
|
||||
{localModels.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel>{t("chat.modelGroup.local")}</SelectLabel>
|
||||
{localModels.map((model) => (
|
||||
<SelectItem key={model.index} value={model.model_name}>
|
||||
{model.model_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { IconHistory, IconTrash } from "@tabler/icons-react"
|
||||
import dayjs from "dayjs"
|
||||
import type { RefObject } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import type { SessionSummary } from "@/api/sessions"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
|
||||
interface SessionHistoryMenuProps {
|
||||
sessions: SessionSummary[]
|
||||
activeSessionId: string
|
||||
hasMore: boolean
|
||||
observerRef: RefObject<HTMLDivElement | null>
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSwitchSession: (sessionId: string) => void
|
||||
onDeleteSession: (sessionId: string) => void
|
||||
}
|
||||
|
||||
export function SessionHistoryMenu({
|
||||
sessions,
|
||||
activeSessionId,
|
||||
hasMore,
|
||||
observerRef,
|
||||
onOpenChange,
|
||||
onSwitchSession,
|
||||
onDeleteSession,
|
||||
}: SessionHistoryMenuProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<DropdownMenu onOpenChange={onOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-9 gap-2">
|
||||
<IconHistory className="size-4" />
|
||||
<span className="hidden sm:inline">{t("chat.history")}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-72">
|
||||
<ScrollArea className="max-h-[300px]">
|
||||
{sessions.length === 0 ? (
|
||||
<DropdownMenuItem disabled>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("chat.noHistory")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
sessions.map((session) => (
|
||||
<DropdownMenuItem
|
||||
key={session.id}
|
||||
className={`group relative my-0.5 flex flex-col items-start gap-0.5 pr-8 ${
|
||||
session.id === activeSessionId ? "bg-accent" : ""
|
||||
}`}
|
||||
onClick={() => onSwitchSession(session.id)}
|
||||
>
|
||||
<span className="line-clamp-1 text-sm font-medium">
|
||||
{session.preview}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("chat.messagesCount", {
|
||||
count: session.message_count,
|
||||
})}{" "}
|
||||
· {dayjs(session.updated).fromNow()}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("chat.deleteSession")}
|
||||
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive absolute top-1/2 right-2 h-6 w-6 -translate-y-1/2 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onDeleteSession(session.id)
|
||||
}}
|
||||
>
|
||||
<IconTrash className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
)}
|
||||
{hasMore && sessions.length > 0 && (
|
||||
<div ref={observerRef} className="py-2 text-center">
|
||||
<span className="text-muted-foreground animate-pulse text-xs">
|
||||
{t("chat.loadingMore")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
export function TypingIndicator() {
|
||||
const { t } = useTranslation()
|
||||
const thinkingSteps = [
|
||||
t("chat.thinking.step1"),
|
||||
t("chat.thinking.step2"),
|
||||
t("chat.thinking.step3"),
|
||||
t("chat.thinking.step4"),
|
||||
]
|
||||
const [stepIndex, setStepIndex] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const stepsCount = thinkingSteps.length
|
||||
const interval = setInterval(() => {
|
||||
setStepIndex((prev) => (prev + 1) % stepsCount)
|
||||
}, 3000)
|
||||
return () => clearInterval(interval)
|
||||
}, [thinkingSteps.length])
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<div className="text-muted-foreground flex items-center gap-2 px-1 text-xs opacity-70">
|
||||
<span>PicoClaw</span>
|
||||
</div>
|
||||
<div className="bg-card inline-flex w-fit max-w-xs flex-col gap-3 rounded-xl border px-5 py-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="size-2 animate-bounce rounded-full bg-violet-400/70 [animation-delay:-0.3s]" />
|
||||
<span className="size-2 animate-bounce rounded-full bg-violet-400/70 [animation-delay:-0.15s]" />
|
||||
<span className="size-2 animate-bounce rounded-full bg-violet-400/70" />
|
||||
</div>
|
||||
|
||||
<div className="bg-muted relative h-1 w-36 overflow-hidden rounded-full">
|
||||
<div className="absolute inset-0 animate-[shimmer_2s_infinite] rounded-full bg-gradient-to-r from-violet-500/60 via-violet-400/80 to-violet-500/60 bg-[length:200%_100%]" />
|
||||
</div>
|
||||
|
||||
<p
|
||||
key={stepIndex}
|
||||
className="text-muted-foreground animate-[fadeSlideIn_0.4s_ease-out] text-xs"
|
||||
>
|
||||
{thinkingSteps[stepIndex]}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
interface UserMessageProps {
|
||||
content: string
|
||||
}
|
||||
|
||||
export function UserMessage({ content }: UserMessageProps) {
|
||||
return (
|
||||
<div className="flex w-full flex-col items-end gap-1.5">
|
||||
<div className="max-w-[70%] rounded-2xl rounded-tr-sm bg-violet-500 px-5 py-3 text-[15px] leading-relaxed text-white shadow-sm">
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import { IconCode, IconDeviceFloppy } from "@tabler/icons-react"
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { patchAppConfig } from "@/api/channels"
|
||||
import {
|
||||
getAutoStartStatus,
|
||||
getLauncherConfig,
|
||||
setAutoStartEnabled as updateAutoStartEnabled,
|
||||
setLauncherConfig as updateLauncherConfig,
|
||||
} from "@/api/system"
|
||||
import {
|
||||
AdvancedSection,
|
||||
AgentDefaultsSection,
|
||||
DevicesSection,
|
||||
LauncherSection,
|
||||
RuntimeSection,
|
||||
} from "@/components/config/config-sections"
|
||||
import {
|
||||
type CoreConfigForm,
|
||||
EMPTY_FORM,
|
||||
EMPTY_LAUNCHER_FORM,
|
||||
type LauncherForm,
|
||||
buildFormFromConfig,
|
||||
parseCIDRText,
|
||||
parseIntField,
|
||||
} from "@/components/config/form-model"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
export function ConfigPage() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [form, setForm] = useState<CoreConfigForm>(EMPTY_FORM)
|
||||
const [baseline, setBaseline] = useState<CoreConfigForm>(EMPTY_FORM)
|
||||
const [launcherForm, setLauncherForm] =
|
||||
useState<LauncherForm>(EMPTY_LAUNCHER_FORM)
|
||||
const [launcherBaseline, setLauncherBaseline] =
|
||||
useState<LauncherForm>(EMPTY_LAUNCHER_FORM)
|
||||
const [autoStartEnabled, setAutoStartEnabled] = useState(false)
|
||||
const [autoStartBaseline, setAutoStartBaseline] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["config"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/config")
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to load config")
|
||||
}
|
||||
return res.json()
|
||||
},
|
||||
})
|
||||
|
||||
const {
|
||||
data: launcherConfig,
|
||||
isLoading: isLauncherLoading,
|
||||
error: launcherError,
|
||||
} = useQuery({
|
||||
queryKey: ["system", "launcher-config"],
|
||||
queryFn: getLauncherConfig,
|
||||
})
|
||||
|
||||
const {
|
||||
data: autoStartStatus,
|
||||
isLoading: isAutoStartLoading,
|
||||
error: autoStartError,
|
||||
} = useQuery({
|
||||
queryKey: ["system", "autostart"],
|
||||
queryFn: getAutoStartStatus,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return
|
||||
const parsed = buildFormFromConfig(data)
|
||||
setForm(parsed)
|
||||
setBaseline(parsed)
|
||||
}, [data])
|
||||
|
||||
useEffect(() => {
|
||||
if (!launcherConfig) return
|
||||
const parsed: LauncherForm = {
|
||||
port: String(launcherConfig.port),
|
||||
publicAccess: launcherConfig.public,
|
||||
allowedCIDRsText: (launcherConfig.allowed_cidrs ?? []).join("\n"),
|
||||
}
|
||||
setLauncherForm(parsed)
|
||||
setLauncherBaseline(parsed)
|
||||
}, [launcherConfig])
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoStartStatus) return
|
||||
setAutoStartEnabled(autoStartStatus.enabled)
|
||||
setAutoStartBaseline(autoStartStatus.enabled)
|
||||
}, [autoStartStatus])
|
||||
|
||||
const configDirty = JSON.stringify(form) !== JSON.stringify(baseline)
|
||||
const launcherDirty =
|
||||
JSON.stringify(launcherForm) !== JSON.stringify(launcherBaseline)
|
||||
const autoStartDirty = autoStartEnabled !== autoStartBaseline
|
||||
const isDirty = configDirty || launcherDirty || autoStartDirty
|
||||
|
||||
const autoStartSupported = autoStartStatus?.supported !== false
|
||||
const autoStartHint = autoStartError
|
||||
? t("pages.config.autostart_load_error")
|
||||
: !autoStartSupported
|
||||
? t("pages.config.autostart_unsupported")
|
||||
: t("pages.config.autostart_hint")
|
||||
|
||||
const launcherHint = launcherError
|
||||
? t("pages.config.launcher_load_error")
|
||||
: t("pages.config.launcher_restart_hint")
|
||||
|
||||
const updateField = <K extends keyof CoreConfigForm>(
|
||||
key: K,
|
||||
value: CoreConfigForm[K],
|
||||
) => {
|
||||
setForm((prev) => ({ ...prev, [key]: value }))
|
||||
}
|
||||
|
||||
const updateLauncherField = <K extends keyof LauncherForm>(
|
||||
key: K,
|
||||
value: LauncherForm[K],
|
||||
) => {
|
||||
setLauncherForm((prev) => ({ ...prev, [key]: value }))
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
setForm(baseline)
|
||||
setLauncherForm(launcherBaseline)
|
||||
setAutoStartEnabled(autoStartBaseline)
|
||||
toast.info(t("pages.config.reset_success"))
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setSaving(true)
|
||||
|
||||
if (configDirty) {
|
||||
const workspace = form.workspace.trim()
|
||||
const dmScope = form.dmScope.trim()
|
||||
|
||||
if (!workspace) {
|
||||
throw new Error("Workspace path is required.")
|
||||
}
|
||||
if (!dmScope) {
|
||||
throw new Error("Session scope is required.")
|
||||
}
|
||||
|
||||
const maxTokens = parseIntField(form.maxTokens, "Max tokens", {
|
||||
min: 1,
|
||||
})
|
||||
const maxToolIterations = parseIntField(
|
||||
form.maxToolIterations,
|
||||
"Max tool iterations",
|
||||
{ min: 1 },
|
||||
)
|
||||
const summarizeMessageThreshold = parseIntField(
|
||||
form.summarizeMessageThreshold,
|
||||
"Summarize message threshold",
|
||||
{ min: 1 },
|
||||
)
|
||||
const summarizeTokenPercent = parseIntField(
|
||||
form.summarizeTokenPercent,
|
||||
"Summarize token percent",
|
||||
{ min: 1, max: 100 },
|
||||
)
|
||||
const heartbeatInterval = parseIntField(
|
||||
form.heartbeatInterval,
|
||||
"Heartbeat interval",
|
||||
{ min: 1 },
|
||||
)
|
||||
|
||||
await patchAppConfig({
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace,
|
||||
restrict_to_workspace: form.restrictToWorkspace,
|
||||
max_tokens: maxTokens,
|
||||
max_tool_iterations: maxToolIterations,
|
||||
summarize_message_threshold: summarizeMessageThreshold,
|
||||
summarize_token_percent: summarizeTokenPercent,
|
||||
},
|
||||
},
|
||||
session: {
|
||||
dm_scope: dmScope,
|
||||
},
|
||||
heartbeat: {
|
||||
enabled: form.heartbeatEnabled,
|
||||
interval: heartbeatInterval,
|
||||
},
|
||||
devices: {
|
||||
enabled: form.devicesEnabled,
|
||||
monitor_usb: form.monitorUSB,
|
||||
},
|
||||
})
|
||||
|
||||
setBaseline(form)
|
||||
queryClient.invalidateQueries({ queryKey: ["config"] })
|
||||
}
|
||||
|
||||
if (launcherDirty) {
|
||||
const port = parseIntField(launcherForm.port, "Service port", {
|
||||
min: 1,
|
||||
max: 65535,
|
||||
})
|
||||
const allowedCIDRs = parseCIDRText(launcherForm.allowedCIDRsText)
|
||||
const savedLauncherConfig = await updateLauncherConfig({
|
||||
port,
|
||||
public: launcherForm.publicAccess,
|
||||
allowed_cidrs: allowedCIDRs,
|
||||
})
|
||||
const parsedLauncher: LauncherForm = {
|
||||
port: String(savedLauncherConfig.port),
|
||||
publicAccess: savedLauncherConfig.public,
|
||||
allowedCIDRsText: (savedLauncherConfig.allowed_cidrs ?? []).join(
|
||||
"\n",
|
||||
),
|
||||
}
|
||||
setLauncherForm(parsedLauncher)
|
||||
setLauncherBaseline(parsedLauncher)
|
||||
queryClient.setQueryData(
|
||||
["system", "launcher-config"],
|
||||
savedLauncherConfig,
|
||||
)
|
||||
}
|
||||
|
||||
if (autoStartDirty) {
|
||||
if (!autoStartSupported) {
|
||||
throw new Error(t("pages.config.autostart_unsupported"))
|
||||
}
|
||||
const status = await updateAutoStartEnabled(autoStartEnabled)
|
||||
setAutoStartEnabled(status.enabled)
|
||||
setAutoStartBaseline(status.enabled)
|
||||
queryClient.setQueryData(["system", "autostart"], status)
|
||||
}
|
||||
|
||||
toast.success(t("pages.config.save_success"))
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : t("pages.config.save_error"),
|
||||
)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader
|
||||
title={t("navigation.config")}
|
||||
children={
|
||||
<Button variant="outline" asChild>
|
||||
<Link to="/config/raw">
|
||||
<IconCode className="size-4" />
|
||||
{t("pages.config.open_raw")}
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<div className="flex-1 overflow-auto p-3 lg:p-6">
|
||||
<div className="mx-auto w-full max-w-[1000px] space-y-6">
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground py-6 text-sm">
|
||||
{t("labels.loading")}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-destructive py-6 text-sm">
|
||||
{t("pages.config.load_error")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{isDirty && (
|
||||
<div className="bg-yellow-50 px-3 py-2 text-sm text-yellow-700">
|
||||
{t("pages.config.unsaved_changes")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AgentDefaultsSection form={form} onFieldChange={updateField} />
|
||||
|
||||
<Separator />
|
||||
|
||||
<RuntimeSection form={form} onFieldChange={updateField} />
|
||||
|
||||
<Separator />
|
||||
|
||||
<LauncherSection
|
||||
launcherForm={launcherForm}
|
||||
onFieldChange={updateLauncherField}
|
||||
launcherHint={launcherHint}
|
||||
disabled={saving || isLauncherLoading}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<DevicesSection
|
||||
form={form}
|
||||
onFieldChange={updateField}
|
||||
autoStartEnabled={autoStartEnabled}
|
||||
autoStartHint={autoStartHint}
|
||||
autoStartDisabled={
|
||||
isAutoStartLoading ||
|
||||
Boolean(autoStartError) ||
|
||||
!autoStartSupported ||
|
||||
saving
|
||||
}
|
||||
onAutoStartChange={setAutoStartEnabled}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<AdvancedSection />
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReset}
|
||||
disabled={!isDirty || saving}
|
||||
>
|
||||
{t("common.reset")}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!isDirty || saving}>
|
||||
<IconDeviceFloppy className="size-4" />
|
||||
{saving ? t("common.saving") : t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import { IconCode } from "@tabler/icons-react"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import {
|
||||
type CoreConfigForm,
|
||||
DM_SCOPE_OPTIONS,
|
||||
type LauncherForm,
|
||||
} from "@/components/config/form-model"
|
||||
import { Field, SwitchCardField } from "@/components/shared-form"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
|
||||
type UpdateCoreField = <K extends keyof CoreConfigForm>(
|
||||
key: K,
|
||||
value: CoreConfigForm[K],
|
||||
) => void
|
||||
|
||||
type UpdateLauncherField = <K extends keyof LauncherForm>(
|
||||
key: K,
|
||||
value: LauncherForm[K],
|
||||
) => void
|
||||
|
||||
interface AgentDefaultsSectionProps {
|
||||
form: CoreConfigForm
|
||||
onFieldChange: UpdateCoreField
|
||||
}
|
||||
|
||||
export function AgentDefaultsSection({
|
||||
form,
|
||||
onFieldChange,
|
||||
}: AgentDefaultsSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div className="space-y-4">
|
||||
<Field
|
||||
label={t("pages.config.workspace")}
|
||||
hint={t("pages.config.workspace_hint")}
|
||||
>
|
||||
<Input
|
||||
value={form.workspace}
|
||||
onChange={(e) => onFieldChange("workspace", e.target.value)}
|
||||
placeholder="~/.picoclaw/workspace"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<SwitchCardField
|
||||
label={t("pages.config.restrict_workspace")}
|
||||
hint={t("pages.config.restrict_workspace_hint")}
|
||||
checked={form.restrictToWorkspace}
|
||||
onCheckedChange={(checked) =>
|
||||
onFieldChange("restrictToWorkspace", checked)
|
||||
}
|
||||
/>
|
||||
|
||||
<Field
|
||||
label={t("pages.config.max_tokens")}
|
||||
hint={t("pages.config.max_tokens_hint")}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.maxTokens}
|
||||
onChange={(e) => onFieldChange("maxTokens", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("pages.config.max_tool_iterations")}
|
||||
hint={t("pages.config.max_tool_iterations_hint")}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.maxToolIterations}
|
||||
onChange={(e) => onFieldChange("maxToolIterations", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("pages.config.summarize_threshold")}
|
||||
hint={t("pages.config.summarize_threshold_hint")}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.summarizeMessageThreshold}
|
||||
onChange={(e) =>
|
||||
onFieldChange("summarizeMessageThreshold", e.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("pages.config.summarize_token_percent")}
|
||||
hint={t("pages.config.summarize_token_percent_hint")}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={form.summarizeTokenPercent}
|
||||
onChange={(e) =>
|
||||
onFieldChange("summarizeTokenPercent", e.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
interface RuntimeSectionProps {
|
||||
form: CoreConfigForm
|
||||
onFieldChange: UpdateCoreField
|
||||
}
|
||||
|
||||
export function RuntimeSection({ form, onFieldChange }: RuntimeSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const selectedDmScopeOption = DM_SCOPE_OPTIONS.find(
|
||||
(scope) => scope.value === form.dmScope,
|
||||
)
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div className="space-y-4">
|
||||
<Field
|
||||
label={t("pages.config.session_scope")}
|
||||
hint={t("pages.config.session_scope_hint")}
|
||||
>
|
||||
<Select
|
||||
value={form.dmScope}
|
||||
onValueChange={(value) => onFieldChange("dmScope", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue>
|
||||
{selectedDmScopeOption
|
||||
? t(
|
||||
selectedDmScopeOption.labelKey,
|
||||
selectedDmScopeOption.labelDefault,
|
||||
)
|
||||
: form.dmScope}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DM_SCOPE_OPTIONS.map((scope) => (
|
||||
<SelectItem key={scope.value} value={scope.value}>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-medium">{t(scope.labelKey)}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t(scope.descKey)}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<SwitchCardField
|
||||
label={t("pages.config.heartbeat_enabled")}
|
||||
hint={t("pages.config.heartbeat_enabled_hint")}
|
||||
checked={form.heartbeatEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onFieldChange("heartbeatEnabled", checked)
|
||||
}
|
||||
/>
|
||||
|
||||
{form.heartbeatEnabled && (
|
||||
<Field
|
||||
label={t("pages.config.heartbeat_interval")}
|
||||
hint={t("pages.config.heartbeat_interval_hint")}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.heartbeatInterval}
|
||||
onChange={(e) =>
|
||||
onFieldChange("heartbeatInterval", e.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
interface LauncherSectionProps {
|
||||
launcherForm: LauncherForm
|
||||
onFieldChange: UpdateLauncherField
|
||||
launcherHint: string
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
export function LauncherSection({
|
||||
launcherForm,
|
||||
onFieldChange,
|
||||
launcherHint,
|
||||
disabled,
|
||||
}: LauncherSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div className="space-y-4">
|
||||
<Field
|
||||
label={t("pages.config.server_port")}
|
||||
hint={t("pages.config.server_port_hint")}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={launcherForm.port}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onFieldChange("port", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<SwitchCardField
|
||||
label={t("pages.config.lan_access")}
|
||||
hint={t("pages.config.lan_access_hint")}
|
||||
checked={launcherForm.publicAccess}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(checked) => onFieldChange("publicAccess", checked)}
|
||||
/>
|
||||
|
||||
<Field
|
||||
label={t("pages.config.allowed_cidrs")}
|
||||
hint={t("pages.config.allowed_cidrs_hint")}
|
||||
>
|
||||
<Textarea
|
||||
value={launcherForm.allowedCIDRsText}
|
||||
disabled={disabled}
|
||||
placeholder={t("pages.config.allowed_cidrs_placeholder")}
|
||||
className="min-h-[88px]"
|
||||
onChange={(e) => onFieldChange("allowedCIDRsText", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<p className="text-muted-foreground text-xs">{launcherHint}</p>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
interface DevicesSectionProps {
|
||||
form: CoreConfigForm
|
||||
onFieldChange: UpdateCoreField
|
||||
autoStartEnabled: boolean
|
||||
autoStartHint: string
|
||||
autoStartDisabled: boolean
|
||||
onAutoStartChange: (checked: boolean) => void
|
||||
}
|
||||
|
||||
export function DevicesSection({
|
||||
form,
|
||||
onFieldChange,
|
||||
autoStartEnabled,
|
||||
autoStartHint,
|
||||
autoStartDisabled,
|
||||
onAutoStartChange,
|
||||
}: DevicesSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div className="space-y-4">
|
||||
<SwitchCardField
|
||||
label={t("pages.config.devices_enabled")}
|
||||
hint={t("pages.config.devices_enabled_hint")}
|
||||
checked={form.devicesEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onFieldChange("devicesEnabled", checked)
|
||||
}
|
||||
/>
|
||||
|
||||
<SwitchCardField
|
||||
label={t("pages.config.monitor_usb")}
|
||||
hint={t("pages.config.monitor_usb_hint")}
|
||||
checked={form.monitorUSB}
|
||||
onCheckedChange={(checked) => onFieldChange("monitorUSB", checked)}
|
||||
/>
|
||||
|
||||
<SwitchCardField
|
||||
label={t("pages.config.autostart_label")}
|
||||
hint={autoStartHint}
|
||||
checked={autoStartEnabled}
|
||||
disabled={autoStartDisabled}
|
||||
onCheckedChange={onAutoStartChange}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function AdvancedSection() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("pages.config.advanced_desc")}
|
||||
</p>
|
||||
<div>
|
||||
<Button variant="outline" asChild>
|
||||
<Link to="/config/raw">
|
||||
<IconCode className="size-4" />
|
||||
{t("pages.config.open_raw")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
export type JsonRecord = Record<string, unknown>
|
||||
|
||||
export interface CoreConfigForm {
|
||||
workspace: string
|
||||
restrictToWorkspace: boolean
|
||||
maxTokens: string
|
||||
maxToolIterations: string
|
||||
summarizeMessageThreshold: string
|
||||
summarizeTokenPercent: string
|
||||
dmScope: string
|
||||
heartbeatEnabled: boolean
|
||||
heartbeatInterval: string
|
||||
devicesEnabled: boolean
|
||||
monitorUSB: boolean
|
||||
}
|
||||
|
||||
export interface LauncherForm {
|
||||
port: string
|
||||
publicAccess: boolean
|
||||
allowedCIDRsText: string
|
||||
}
|
||||
|
||||
export const DM_SCOPE_OPTIONS = [
|
||||
{
|
||||
value: "per-channel-peer",
|
||||
labelKey: "pages.config.session_scope_per_channel_peer",
|
||||
labelDefault: "Per Channel + Peer",
|
||||
descKey: "pages.config.session_scope_per_channel_peer_desc",
|
||||
descDefault: "Separate context for each user in each channel.",
|
||||
},
|
||||
{
|
||||
value: "per-channel",
|
||||
labelKey: "pages.config.session_scope_per_channel",
|
||||
labelDefault: "Per Channel",
|
||||
descKey: "pages.config.session_scope_per_channel_desc",
|
||||
descDefault: "One shared context per channel.",
|
||||
},
|
||||
{
|
||||
value: "per-peer",
|
||||
labelKey: "pages.config.session_scope_per_peer",
|
||||
labelDefault: "Per Peer",
|
||||
descKey: "pages.config.session_scope_per_peer_desc",
|
||||
descDefault: "One context per user across channels.",
|
||||
},
|
||||
{
|
||||
value: "global",
|
||||
labelKey: "pages.config.session_scope_global",
|
||||
labelDefault: "Global",
|
||||
descKey: "pages.config.session_scope_global_desc",
|
||||
descDefault: "All messages share one global context.",
|
||||
},
|
||||
] as const
|
||||
|
||||
export const EMPTY_FORM: CoreConfigForm = {
|
||||
workspace: "",
|
||||
restrictToWorkspace: true,
|
||||
maxTokens: "32768",
|
||||
maxToolIterations: "50",
|
||||
summarizeMessageThreshold: "20",
|
||||
summarizeTokenPercent: "75",
|
||||
dmScope: "per-channel-peer",
|
||||
heartbeatEnabled: true,
|
||||
heartbeatInterval: "30",
|
||||
devicesEnabled: false,
|
||||
monitorUSB: true,
|
||||
}
|
||||
|
||||
export const EMPTY_LAUNCHER_FORM: LauncherForm = {
|
||||
port: "18800",
|
||||
publicAccess: false,
|
||||
allowedCIDRsText: "",
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): JsonRecord {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
return value as JsonRecord
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
function asString(value: unknown): string {
|
||||
return typeof value === "string" ? value : ""
|
||||
}
|
||||
|
||||
function asBool(value: unknown): boolean {
|
||||
return value === true
|
||||
}
|
||||
|
||||
function asNumberString(value: unknown, fallback: string): string {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return String(value)
|
||||
}
|
||||
if (typeof value === "string" && value.trim() !== "") {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
export function buildFormFromConfig(config: unknown): CoreConfigForm {
|
||||
const root = asRecord(config)
|
||||
const agents = asRecord(root.agents)
|
||||
const defaults = asRecord(agents.defaults)
|
||||
const session = asRecord(root.session)
|
||||
const heartbeat = asRecord(root.heartbeat)
|
||||
const devices = asRecord(root.devices)
|
||||
|
||||
return {
|
||||
workspace: asString(defaults.workspace) || EMPTY_FORM.workspace,
|
||||
restrictToWorkspace:
|
||||
defaults.restrict_to_workspace === undefined
|
||||
? EMPTY_FORM.restrictToWorkspace
|
||||
: asBool(defaults.restrict_to_workspace),
|
||||
maxTokens: asNumberString(defaults.max_tokens, EMPTY_FORM.maxTokens),
|
||||
maxToolIterations: asNumberString(
|
||||
defaults.max_tool_iterations,
|
||||
EMPTY_FORM.maxToolIterations,
|
||||
),
|
||||
summarizeMessageThreshold: asNumberString(
|
||||
defaults.summarize_message_threshold,
|
||||
EMPTY_FORM.summarizeMessageThreshold,
|
||||
),
|
||||
summarizeTokenPercent: asNumberString(
|
||||
defaults.summarize_token_percent,
|
||||
EMPTY_FORM.summarizeTokenPercent,
|
||||
),
|
||||
dmScope: asString(session.dm_scope) || EMPTY_FORM.dmScope,
|
||||
heartbeatEnabled:
|
||||
heartbeat.enabled === undefined
|
||||
? EMPTY_FORM.heartbeatEnabled
|
||||
: asBool(heartbeat.enabled),
|
||||
heartbeatInterval: asNumberString(
|
||||
heartbeat.interval,
|
||||
EMPTY_FORM.heartbeatInterval,
|
||||
),
|
||||
devicesEnabled:
|
||||
devices.enabled === undefined
|
||||
? EMPTY_FORM.devicesEnabled
|
||||
: asBool(devices.enabled),
|
||||
monitorUSB:
|
||||
devices.monitor_usb === undefined
|
||||
? EMPTY_FORM.monitorUSB
|
||||
: asBool(devices.monitor_usb),
|
||||
}
|
||||
}
|
||||
|
||||
export function parseIntField(
|
||||
rawValue: string,
|
||||
label: string,
|
||||
options: { min?: number; max?: number } = {},
|
||||
): number {
|
||||
const value = Number(rawValue)
|
||||
if (!Number.isInteger(value)) {
|
||||
throw new Error(`${label} must be an integer.`)
|
||||
}
|
||||
if (options.min !== undefined && value < options.min) {
|
||||
throw new Error(`${label} must be >= ${options.min}.`)
|
||||
}
|
||||
if (options.max !== undefined && value > options.max) {
|
||||
throw new Error(`${label} must be <= ${options.max}.`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function parseCIDRText(raw: string): string[] {
|
||||
if (!raw.trim()) {
|
||||
return []
|
||||
}
|
||||
return raw
|
||||
.split(/[\n,]/)
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => v.length > 0)
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
|
||||
export function RawJsonPanel() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data: config, isLoading } = useQuery({
|
||||
queryKey: ["config"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/config")
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to fetch config")
|
||||
}
|
||||
return res.json()
|
||||
},
|
||||
})
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (newConfig: string) => {
|
||||
const res = await fetch("/api/config", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: newConfig,
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to save config")
|
||||
}
|
||||
},
|
||||
onSuccess: (_, submittedConfig) => {
|
||||
toast.success(t("pages.config.save_success"))
|
||||
try {
|
||||
const savedConfig = JSON.parse(submittedConfig)
|
||||
setLastSavedConfig(savedConfig)
|
||||
setIsDirty(false)
|
||||
queryClient.invalidateQueries({ queryKey: ["config"] })
|
||||
} catch {
|
||||
queryClient.invalidateQueries({ queryKey: ["config"] })
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("pages.config.save_error"))
|
||||
},
|
||||
})
|
||||
|
||||
const [editorValue, setEditorValue] = useState("")
|
||||
const [isDirty, setIsDirty] = useState(false)
|
||||
const [lastSavedConfig, setLastSavedConfig] = useState<Record<
|
||||
string,
|
||||
unknown
|
||||
> | null>(null)
|
||||
|
||||
const effectiveEditorValue =
|
||||
editorValue || (config ? JSON.stringify(config, null, 2) : "")
|
||||
|
||||
const handleSave = () => {
|
||||
try {
|
||||
JSON.parse(effectiveEditorValue)
|
||||
mutation.mutate(effectiveEditorValue)
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
t(
|
||||
"pages.config.invalid_json",
|
||||
error instanceof Error ? error.message : "Invalid JSON format.",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFormat = () => {
|
||||
try {
|
||||
const formatted = JSON.stringify(
|
||||
JSON.parse(effectiveEditorValue),
|
||||
null,
|
||||
2,
|
||||
)
|
||||
setEditorValue(formatted)
|
||||
toast.success(t("pages.config.format_success"))
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
t(
|
||||
"pages.config.format_error",
|
||||
error instanceof Error ? error.message : "Invalid JSON format.",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const [showResetDialog, setShowResetDialog] = useState(false)
|
||||
|
||||
const confirmReset = () => {
|
||||
if (lastSavedConfig) {
|
||||
setEditorValue(JSON.stringify(lastSavedConfig, null, 2))
|
||||
} else if (config) {
|
||||
setEditorValue(JSON.stringify(config, null, 2))
|
||||
}
|
||||
setIsDirty(false)
|
||||
toast.info(t("pages.config.reset_success"))
|
||||
setShowResetDialog(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("pages.config.raw_json_title")}</CardTitle>
|
||||
<CardDescription>{t("pages.config.raw_json_desc")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<p>{t("labels.loading")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{isDirty && (
|
||||
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-2 text-sm text-yellow-700">
|
||||
{t("pages.config.unsaved_changes")}
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-muted/30 relative rounded-lg border">
|
||||
<ScrollArea className="h-[calc(100vh-20rem)] min-h-[200px]">
|
||||
<Textarea
|
||||
value={effectiveEditorValue}
|
||||
onChange={(e) => {
|
||||
setEditorValue(e.target.value)
|
||||
setIsDirty(true)
|
||||
}}
|
||||
className="min-h-[200px] resize-none border-0 bg-transparent px-4 py-3 font-mono text-sm shadow-none focus-visible:ring-0"
|
||||
placeholder={t("pages.config.json_placeholder")}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleFormat}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{t("pages.config.format")}
|
||||
</Button>
|
||||
<AlertDialog
|
||||
open={showResetDialog}
|
||||
onOpenChange={setShowResetDialog}
|
||||
>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!isDirty}
|
||||
onClick={() => setShowResetDialog(true)}
|
||||
>
|
||||
{t("common.reset")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("pages.config.reset_confirm_title")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("pages.config.reset_confirm_desc")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={confirmReset}>
|
||||
{t("common.confirm")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<Button onClick={handleSave} disabled={mutation.isPending}>
|
||||
{mutation.isPending ? t("common.saving") : t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
IconKey,
|
||||
IconLoader2,
|
||||
IconPlayerStopFilled,
|
||||
IconSparkles,
|
||||
} from "@tabler/icons-react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import type { OAuthProviderStatus } from "@/api/oauth"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
import { CredentialCard } from "./credential-card"
|
||||
|
||||
interface AnthropicCredentialCardProps {
|
||||
status?: OAuthProviderStatus
|
||||
activeAction: string
|
||||
token: string
|
||||
onTokenChange: (value: string) => void
|
||||
onStopLoading: () => void
|
||||
onSaveToken: () => void
|
||||
onAskLogout: () => void
|
||||
}
|
||||
|
||||
export function AnthropicCredentialCard({
|
||||
status,
|
||||
activeAction,
|
||||
token,
|
||||
onTokenChange,
|
||||
onStopLoading,
|
||||
onSaveToken,
|
||||
onAskLogout,
|
||||
}: AnthropicCredentialCardProps) {
|
||||
const { t } = useTranslation()
|
||||
const actionBusy = activeAction !== ""
|
||||
const tokenLoading = activeAction === "anthropic:token"
|
||||
const stopLabel = t("credentials.actions.stopLoading")
|
||||
|
||||
return (
|
||||
<CredentialCard
|
||||
title={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="border-muted inline-flex size-6 items-center justify-center rounded-full border">
|
||||
<IconSparkles className="size-3.5" />
|
||||
</span>
|
||||
<span>Anthropic</span>
|
||||
</span>
|
||||
}
|
||||
description={t("credentials.providers.anthropic.description")}
|
||||
status={status?.status ?? "not_logged_in"}
|
||||
authMethod={status?.auth_method}
|
||||
actions={
|
||||
<div className="border-muted flex h-[120px] flex-col justify-center rounded-lg border p-3">
|
||||
<div className="flex h-full flex-col gap-3">
|
||||
<div className="flex h-full items-center gap-2">
|
||||
<Input
|
||||
value={token}
|
||||
onChange={(e) => onTokenChange(e.target.value)}
|
||||
type="password"
|
||||
placeholder={t("credentials.fields.anthropicToken")}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
disabled={actionBusy || !token.trim()}
|
||||
onClick={onSaveToken}
|
||||
>
|
||||
{tokenLoading && (
|
||||
<IconLoader2 className="size-4 animate-spin" />
|
||||
)}
|
||||
<IconKey className="size-4" />
|
||||
{t("credentials.actions.saveToken")}
|
||||
</Button>
|
||||
{tokenLoading && (
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={onStopLoading}
|
||||
aria-label={stopLabel}
|
||||
title={stopLabel}
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
<IconPlayerStopFilled className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
footer={
|
||||
status?.logged_in ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={actionBusy}
|
||||
onClick={onAskLogout}
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
{activeAction === "anthropic:logout" && (
|
||||
<IconLoader2 className="size-4 animate-spin" />
|
||||
)}
|
||||
{t("credentials.actions.logout")}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
IconBrandGoogle,
|
||||
IconLoader2,
|
||||
IconLockOpen,
|
||||
IconPlayerStopFilled,
|
||||
} from "@tabler/icons-react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import type { OAuthProviderStatus } from "@/api/oauth"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
import { CredentialCard } from "./credential-card"
|
||||
|
||||
interface AntigravityCredentialCardProps {
|
||||
status?: OAuthProviderStatus
|
||||
activeAction: string
|
||||
onStopLoading: () => void
|
||||
onStartBrowserOAuth: () => void
|
||||
onAskLogout: () => void
|
||||
}
|
||||
|
||||
export function AntigravityCredentialCard({
|
||||
status,
|
||||
activeAction,
|
||||
onStopLoading,
|
||||
onStartBrowserOAuth,
|
||||
onAskLogout,
|
||||
}: AntigravityCredentialCardProps) {
|
||||
const { t } = useTranslation()
|
||||
const actionBusy = activeAction !== ""
|
||||
const browserLoading = activeAction === "google-antigravity:browser"
|
||||
|
||||
return (
|
||||
<CredentialCard
|
||||
title={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="border-muted inline-flex size-6 items-center justify-center rounded-full border">
|
||||
<IconBrandGoogle className="size-3.5" />
|
||||
</span>
|
||||
<span>Google Antigravity</span>
|
||||
</span>
|
||||
}
|
||||
description={t("credentials.providers.antigravity.description")}
|
||||
status={status?.status ?? "not_logged_in"}
|
||||
authMethod={status?.auth_method}
|
||||
details={
|
||||
<div className="space-y-1">
|
||||
{status?.email && (
|
||||
<p>
|
||||
{t("credentials.labels.email")}: {status.email}
|
||||
</p>
|
||||
)}
|
||||
{status?.project_id && (
|
||||
<p>
|
||||
{t("credentials.labels.project")}: {status.project_id}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
<div className="border-muted flex h-[120px] flex-col justify-center rounded-lg border p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={actionBusy}
|
||||
onClick={onStartBrowserOAuth}
|
||||
>
|
||||
{browserLoading && (
|
||||
<IconLoader2 className="size-4 animate-spin" />
|
||||
)}
|
||||
<IconLockOpen className="size-4" />
|
||||
{t("credentials.actions.browser")}
|
||||
</Button>
|
||||
{browserLoading && (
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="secondary"
|
||||
onClick={onStopLoading}
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
<IconPlayerStopFilled className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
footer={
|
||||
status?.logged_in ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={actionBusy}
|
||||
onClick={onAskLogout}
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
{activeAction === "google-antigravity:logout" && (
|
||||
<IconLoader2 className="size-4 animate-spin" />
|
||||
)}
|
||||
{t("credentials.actions.logout")}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
import type { OAuthProviderStatus } from "@/api/oauth"
|
||||
|
||||
import { ProviderStatusLine } from "./provider-status-line"
|
||||
|
||||
interface CredentialCardProps {
|
||||
title: ReactNode
|
||||
description: string
|
||||
status: OAuthProviderStatus["status"]
|
||||
authMethod?: string
|
||||
details?: ReactNode
|
||||
actions: ReactNode
|
||||
footer?: ReactNode
|
||||
}
|
||||
|
||||
export function CredentialCard({
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
authMethod,
|
||||
details,
|
||||
actions,
|
||||
footer,
|
||||
}: CredentialCardProps) {
|
||||
return (
|
||||
<section className="bg-card flex h-full flex-col rounded-xl border p-4">
|
||||
<div className="min-h-16">
|
||||
<h3 className="text-base font-semibold">{title}</h3>
|
||||
<p className="text-muted-foreground mt-1 text-xs">{description}</p>
|
||||
</div>
|
||||
|
||||
<ProviderStatusLine status={status} authMethod={authMethod} />
|
||||
<div className="text-muted-foreground mt-3 min-h-11 text-xs leading-5">
|
||||
{details}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex flex-col gap-4 pt-4">
|
||||
<div className="min-h-[112px]">{actions}</div>
|
||||
<div className="min-h-8">{footer}</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { IconLoader2 } from "@tabler/icons-react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { useCredentialsPage } from "@/hooks/use-credentials-page"
|
||||
|
||||
import { AnthropicCredentialCard } from "./anthropic-credential-card"
|
||||
import { AntigravityCredentialCard } from "./antigravity-credential-card"
|
||||
import { DeviceCodeSheet } from "./device-code-sheet"
|
||||
import { LogoutConfirmDialog } from "./logout-confirm-dialog"
|
||||
import { OpenAICredentialCard } from "./openai-credential-card"
|
||||
|
||||
export function CredentialsPage() {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
loading,
|
||||
error,
|
||||
activeAction,
|
||||
activeFlow,
|
||||
flowHint,
|
||||
openAIToken,
|
||||
anthropicToken,
|
||||
openaiStatus,
|
||||
anthropicStatus,
|
||||
antigravityStatus,
|
||||
logoutDialogOpen,
|
||||
logoutConfirmProvider,
|
||||
logoutProviderLabel,
|
||||
deviceSheetOpen,
|
||||
deviceFlow,
|
||||
setOpenAIToken,
|
||||
setAnthropicToken,
|
||||
startBrowserOAuth,
|
||||
startOpenAIDeviceCode,
|
||||
stopLoading,
|
||||
saveToken,
|
||||
askLogout,
|
||||
handleConfirmLogout,
|
||||
handleLogoutDialogOpenChange,
|
||||
handleDeviceSheetOpenChange,
|
||||
} = useCredentialsPage()
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader title={t("navigation.credentials")} />
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 sm:px-6">
|
||||
<div className="pt-2">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("credentials.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-destructive bg-destructive/10 mt-4 rounded-lg px-4 py-3 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeFlow && (
|
||||
<div className="bg-muted mt-4 rounded-lg border px-4 py-3 text-sm">
|
||||
<p className="font-medium">{t("credentials.flow.current")}</p>
|
||||
<p className="text-muted-foreground mt-1">{flowHint}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2 py-10 text-sm">
|
||||
<IconLoader2 className="size-4 animate-spin" />
|
||||
{t("credentials.loading")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 py-5 lg:auto-rows-fr lg:grid-cols-3">
|
||||
<OpenAICredentialCard
|
||||
status={openaiStatus}
|
||||
activeAction={activeAction}
|
||||
token={openAIToken}
|
||||
onTokenChange={setOpenAIToken}
|
||||
onStartBrowserOAuth={() => void startBrowserOAuth("openai")}
|
||||
onStartDeviceCode={() => void startOpenAIDeviceCode()}
|
||||
onStopLoading={stopLoading}
|
||||
onSaveToken={() => void saveToken("openai", openAIToken.trim())}
|
||||
onAskLogout={() => askLogout("openai")}
|
||||
/>
|
||||
|
||||
<AnthropicCredentialCard
|
||||
status={anthropicStatus}
|
||||
activeAction={activeAction}
|
||||
token={anthropicToken}
|
||||
onTokenChange={setAnthropicToken}
|
||||
onStopLoading={stopLoading}
|
||||
onSaveToken={() =>
|
||||
void saveToken("anthropic", anthropicToken.trim())
|
||||
}
|
||||
onAskLogout={() => askLogout("anthropic")}
|
||||
/>
|
||||
|
||||
<AntigravityCredentialCard
|
||||
status={antigravityStatus}
|
||||
activeAction={activeAction}
|
||||
onStopLoading={stopLoading}
|
||||
onStartBrowserOAuth={() =>
|
||||
void startBrowserOAuth("google-antigravity")
|
||||
}
|
||||
onAskLogout={() => askLogout("google-antigravity")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<LogoutConfirmDialog
|
||||
open={logoutDialogOpen}
|
||||
providerLabel={logoutProviderLabel}
|
||||
isSubmitting={activeAction === `${logoutConfirmProvider}:logout`}
|
||||
onOpenChange={handleLogoutDialogOpenChange}
|
||||
onConfirm={handleConfirmLogout}
|
||||
/>
|
||||
|
||||
<DeviceCodeSheet
|
||||
open={deviceSheetOpen}
|
||||
flow={deviceFlow}
|
||||
flowHint={flowHint}
|
||||
onOpenChange={handleDeviceSheetOpenChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { IconRefresh } from "@tabler/icons-react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import type { OAuthFlowState } from "@/api/oauth"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet"
|
||||
|
||||
interface DeviceCodeSheetProps {
|
||||
open: boolean
|
||||
flow: OAuthFlowState | null
|
||||
flowHint: string
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function DeviceCodeSheet({
|
||||
open,
|
||||
flow,
|
||||
flowHint,
|
||||
onOpenChange,
|
||||
}: DeviceCodeSheetProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="data-[side=right]:!w-full data-[side=right]:sm:!w-[480px] data-[side=right]:sm:!max-w-[480px]"
|
||||
>
|
||||
<SheetHeader className="border-b-muted border-b px-6 py-5">
|
||||
<SheetTitle>{t("credentials.device.title")}</SheetTitle>
|
||||
<SheetDescription>
|
||||
{t("credentials.device.description")}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="space-y-4 px-6 py-5">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs uppercase">
|
||||
{t("credentials.device.code")}
|
||||
</p>
|
||||
<p className="mt-1 rounded-md border px-3 py-2 font-mono text-lg font-semibold tracking-wide">
|
||||
{flow?.user_code || "-"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs uppercase">
|
||||
{t("credentials.device.url")}
|
||||
</p>
|
||||
<a
|
||||
href={flow?.verify_url || "#"}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-primary mt-1 block text-sm break-all underline"
|
||||
>
|
||||
{flow?.verify_url || "-"}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<IconRefresh className="size-4" />
|
||||
{t("credentials.device.polling")}
|
||||
</div>
|
||||
|
||||
{flow && (
|
||||
<div className="bg-muted rounded-md border px-3 py-2 text-sm">
|
||||
{flowHint}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SheetFooter className="border-t-muted border-t px-6 py-4">
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button asChild disabled={!flow?.verify_url}>
|
||||
<a href={flow?.verify_url || "#"} target="_blank" rel="noreferrer">
|
||||
{t("credentials.device.open")}
|
||||
</a>
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||