Merge branch 'main' into feat/channel-tool-feedback-animation
# Conflicts: # pkg/channels/pico/pico.go # web/backend/api/session.go
This commit is contained in:
commit
c731ecdafc
87 changed files with 3614 additions and 991 deletions
60
.github/workflows/create-tag.yml
vendored
Normal file
60
.github/workflows/create-tag.yml
vendored
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
name: Create Tag
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Tag name (required, e.g. v0.2.0)"
|
||||
required: true
|
||||
type: string
|
||||
commit:
|
||||
description: "Target commit SHA (leave empty for latest main)"
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
jobs:
|
||||
create-tag:
|
||||
name: Create Git Tag
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: main
|
||||
|
||||
- name: Validate commit exists
|
||||
if: ${{ inputs.commit != '' }}
|
||||
shell: bash
|
||||
run: |
|
||||
if ! git cat-file -t "${{ inputs.commit }}" &>/dev/null; then
|
||||
echo "::error::Commit '${{ inputs.commit }}' does not exist."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Check tag does not already exist
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
if gh api "repos/${{ github.repository }}/git/ref/tags/${{ inputs.tag }}" --silent 2>/dev/null; then
|
||||
echo "::error::Tag '${{ inputs.tag }}' already exists."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Create and push tag
|
||||
shell: bash
|
||||
run: |
|
||||
TARGET="${{ inputs.commit || 'HEAD' }}"
|
||||
COMMIT_SHA=$(git rev-parse "$TARGET")
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git tag -a "${{ inputs.tag }}" "$COMMIT_SHA" -m "Release ${{ inputs.tag }}"
|
||||
git push origin "${{ inputs.tag }}"
|
||||
echo "### Tag Created" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- **Tag:** \`${{ inputs.tag }}\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- **Commit:** \`${COMMIT_SHA}\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- **Branch:** \`$(git branch -r --contains "$COMMIT_SHA" | head -1 | xargs)\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
36
.github/workflows/release.yml
vendored
36
.github/workflows/release.yml
vendored
|
|
@ -1,10 +1,10 @@
|
|||
name: Create Tag and Release
|
||||
name: Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Release tag (required, e.g. v0.2.0)"
|
||||
description: "Existing tag to release (e.g. v0.2.0)"
|
||||
required: true
|
||||
type: string
|
||||
prerelease:
|
||||
|
|
@ -24,35 +24,23 @@ on:
|
|||
default: true
|
||||
|
||||
jobs:
|
||||
create-tag:
|
||||
name: Create Git Tag
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Create and push tag
|
||||
shell: bash
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git tag -a "$RELEASE_TAG" -m "Release $RELEASE_TAG"
|
||||
git push origin "$RELEASE_TAG"
|
||||
|
||||
release:
|
||||
name: GoReleaser Release
|
||||
needs: create-tag
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
steps:
|
||||
- name: Verify tag exists
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
if ! gh api "repos/${{ github.repository }}/git/ref/tags/${{ inputs.tag }}" --silent 2>/dev/null; then
|
||||
echo "::error::Tag '${{ inputs.tag }}' does not exist. Create it first using the 'Create Tag' workflow."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Checkout tag
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
|
|
|
|||
|
|
@ -1,12 +1,53 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
pkgauth "github.com/sipeed/picoclaw/pkg/auth"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func captureAuthStdout(t *testing.T, fn func()) string {
|
||||
t.Helper()
|
||||
|
||||
oldStdout := os.Stdout
|
||||
r, w, err := os.Pipe()
|
||||
require.NoError(t, err)
|
||||
os.Stdout = w
|
||||
t.Cleanup(func() {
|
||||
os.Stdout = oldStdout
|
||||
})
|
||||
|
||||
fn()
|
||||
|
||||
require.NoError(t, w.Close())
|
||||
os.Stdout = oldStdout
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, err = io.Copy(&buf, r)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, r.Close())
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func setAuthStatusTestHome(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
t.Setenv(config.EnvHome, filepath.Join(tmpDir, ".picoclaw"))
|
||||
return tmpDir
|
||||
}
|
||||
|
||||
func TestNewStatusSubcommand(t *testing.T) {
|
||||
cmd := newStatusCommand()
|
||||
|
||||
|
|
@ -16,3 +57,47 @@ func TestNewStatusSubcommand(t *testing.T) {
|
|||
|
||||
assert.False(t, cmd.HasFlags())
|
||||
}
|
||||
|
||||
func TestAuthStatusCmdShowsCanonicalGoogleAntigravityAfterLegacyRefresh(t *testing.T) {
|
||||
tmpDir := setAuthStatusTestHome(t)
|
||||
|
||||
legacyExpiry := time.Date(2026, 4, 16, 10, 0, 0, 0, time.UTC)
|
||||
legacyStore := map[string]any{
|
||||
"credentials": map[string]any{
|
||||
"antigravity": map[string]any{
|
||||
"access_token": "legacy-token",
|
||||
"expires_at": legacyExpiry.Format(time.RFC3339),
|
||||
"provider": "antigravity",
|
||||
"auth_method": "oauth",
|
||||
"project_id": "legacy-project",
|
||||
},
|
||||
},
|
||||
}
|
||||
data, err := json.Marshal(legacyStore)
|
||||
require.NoError(t, err)
|
||||
|
||||
authPath := filepath.Join(tmpDir, ".picoclaw", "auth.json")
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(authPath), 0o755))
|
||||
require.NoError(t, os.WriteFile(authPath, data, 0o600))
|
||||
|
||||
refreshedExpiry := time.Date(2026, 4, 16, 12, 30, 0, 0, time.UTC)
|
||||
err = pkgauth.SetCredential("google-antigravity", &pkgauth.AuthCredential{
|
||||
AccessToken: "fresh-token",
|
||||
ExpiresAt: refreshedExpiry,
|
||||
Provider: "google-antigravity",
|
||||
AuthMethod: "oauth",
|
||||
ProjectID: "fresh-project",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
output := captureAuthStdout(t, func() {
|
||||
require.NoError(t, authStatusCmd())
|
||||
})
|
||||
|
||||
assert.Contains(t, output, "\nAuthenticated Providers:")
|
||||
assert.Contains(t, output, "\n google-antigravity:\n")
|
||||
assert.NotContains(t, output, "\n antigravity:\n")
|
||||
assert.Contains(t, output, " Project: fresh-project")
|
||||
assert.Contains(t, output, " Expires: 2026-04-16 12:30")
|
||||
assert.Equal(t, 1, strings.Count(output, ":\n Method: oauth"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@ Identify protocol via prefix in `model` field:
|
|||
| `openai/` | OpenAI-compatible | Most common, includes DeepSeek, Qwen, Groq, etc. |
|
||||
| `anthropic/` | Anthropic | Claude series specific |
|
||||
| `antigravity/` | Antigravity | Google Cloud Code Assist |
|
||||
| `gemini/` | Gemini | Google Gemini native API (if needed) |
|
||||
| `gemini/` | Gemini | Google Gemini native API |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -339,7 +339,7 @@ Répond HEARTBEAT_OK Utilisateur reçoit le résultat
|
|||
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Obtenir](https://console.anthropic.com) |
|
||||
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Obtenir](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
||||
| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Obtenir](https://platform.deepseek.com) |
|
||||
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Obtenir](https://aistudio.google.com/api-keys) |
|
||||
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [Obtenir](https://aistudio.google.com/api-keys) |
|
||||
| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Obtenir](https://console.groq.com) |
|
||||
| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Obtenir](https://dashscope.console.aliyun.com) |
|
||||
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (pas de clé) |
|
||||
|
|
@ -369,9 +369,12 @@ L'ancienne configuration `providers` est **dépréciée** et a été supprimée
|
|||
PicoClaw route les providers par famille de protocole :
|
||||
|
||||
- **Compatible OpenAI** : OpenRouter, Groq, Zhipu, endpoints vLLM et la plupart des autres.
|
||||
- **Gemini natif** : Google Gemini via les endpoints natifs `models/*:generateContent` et `models/*:streamGenerateContent`.
|
||||
- **Anthropic** : Comportement natif de l'API Claude.
|
||||
- **Codex/OAuth** : Route d'authentification OAuth/token OpenAI.
|
||||
|
||||
Cela maintient le runtime léger tout en faisant des nouveaux backends compatibles OpenAI principalement une opération de configuration (`api_base` + `api_keys`).
|
||||
|
||||
### Tâches Planifiées / Rappels
|
||||
|
||||
PicoClaw supporte les tâches planifiées via l'outil `cron`. L'agent peut définir, lister et annuler des rappels ou tâches récurrentes.
|
||||
|
|
|
|||
|
|
@ -340,7 +340,7 @@ HEARTBEAT_OK を返信 ユーザーが直接結果を受信
|
|||
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [取得](https://console.anthropic.com) |
|
||||
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [取得](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
||||
| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [取得](https://platform.deepseek.com) |
|
||||
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [取得](https://aistudio.google.com/api-keys) |
|
||||
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [取得](https://aistudio.google.com/api-keys) |
|
||||
| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [取得](https://console.groq.com) |
|
||||
| **通義千問 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [取得](https://dashscope.console.aliyun.com) |
|
||||
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | ローカル(キー不要) |
|
||||
|
|
@ -370,9 +370,12 @@ HEARTBEAT_OK を返信 ユーザーが直接結果を受信
|
|||
PicoClaw はプロトコルファミリーで Provider をルーティングします:
|
||||
|
||||
- **OpenAI 互換**:OpenRouter、Groq、Zhipu、vLLM スタイルのエンドポイントなど。
|
||||
- **Gemini ネイティブ**:Google Gemini のネイティブ `models/*:generateContent` / `models/*:streamGenerateContent` エンドポイント。
|
||||
- **Anthropic**:Claude ネイティブ API の動作。
|
||||
- **Codex/OAuth**:OpenAI OAuth/トークン認証ルート。
|
||||
|
||||
これによりランタイムを軽量に保ちつつ、新しい OpenAI 互換バックエンドの追加をほぼ設定操作(`api_base` + `api_keys`)のみで実現します。
|
||||
|
||||
### スケジュールタスク / リマインダー
|
||||
|
||||
PicoClaw は `cron` ツールを通じて cron スタイルのスケジュールタスクをサポートします。
|
||||
|
|
|
|||
|
|
@ -71,15 +71,16 @@ PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspa
|
|||
|
||||
### Web launcher dashboard
|
||||
|
||||
**picoclaw-launcher** serves a browser UI that requires sign-in first. By default, the **dashboard token** and **session signing key** are **generated in memory on each start** (a new random token after every restart). Set **`PICOCLAW_LAUNCHER_TOKEN`** to pin a fixed token for that process (startup logs do not print the secret when this env var is used).
|
||||
|
||||
**Where to read the token**: In **console mode** (`-console`), it is printed at startup. In **tray / GUI mode**, use the tray action **Copy dashboard token**, and check **`$PICOCLAW_HOME/logs/launcher.log`** (typically `~/.picoclaw/logs/launcher.log` if `PICOCLAW_HOME` is unset) for the random token logged on startup. The login page shows hints that match how the launcher is running (including the absolute log path); **responses do not include the token itself**.
|
||||
**picoclaw-launcher** serves a browser UI that requires password sign-in first. On first run, open `/launcher-setup` to create the dashboard password. Later manual sign-ins use `/launcher-login`.
|
||||
|
||||
- **Config file**: Same directory as `config.json` (or the file pointed to by `PICOCLAW_CONFIG`). The launcher-specific file is `launcher-config.json`.
|
||||
- **Sign-in and links**: Enter the token on the login page, or open with `?token=` when the browser is launched automatically. All responses include **`Referrer-Policy: no-referrer`** to reduce leakage of `token` via the `Referer` header.
|
||||
- **Password storage**: On supported platforms, the password is stored as a bcrypt hash in `launcher-auth.db`. On platforms where the SQLite password store is unavailable, the bcrypt hash is stored in `launcher-config.json`.
|
||||
- **Legacy migration**: Older `launcher_token` values are migrated once into password login and removed from saved launcher config.
|
||||
- **Local auto-login**: When the launcher auto-opens a local browser after startup, it uses a one-shot loopback-only bootstrap endpoint to set the session cookie automatically.
|
||||
- **Unsupported auth paths**: URL token login (`?token=...`), `PICOCLAW_LAUNCHER_TOKEN`, and `Authorization: Bearer` dashboard auth are no longer supported.
|
||||
- **Sign-out**: Use **`POST /api/auth/logout`** with **`Content-Type: application/json`** (body may be `{}`). Do not rely on a GET URL for logout (CSRF-safe pattern).
|
||||
- **Brute-force**: **`POST /api/auth/login`** is **rate-limited per client IP per minute** (HTTP 429 when exceeded).
|
||||
- **Session lifetime**: The HttpOnly session cookie lasts about **7 days** by default; sign in again with the token after it expires.
|
||||
- **Session lifetime**: The HttpOnly session cookie lasts about **31 days** by default, but sessions are invalidated when the launcher process restarts.
|
||||
|
||||
### Skill Sources
|
||||
|
||||
|
|
@ -576,7 +577,7 @@ For complete documentation, see [`../security/security_configuration.md`](../sec
|
|||
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) |
|
||||
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
||||
| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) |
|
||||
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Get Key](https://aistudio.google.com/api-keys) |
|
||||
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [Get Key](https://aistudio.google.com/api-keys) |
|
||||
| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) |
|
||||
| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) |
|
||||
| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) |
|
||||
|
|
@ -820,6 +821,7 @@ The old `providers` configuration is **deprecated** and has been removed in V2.
|
|||
PicoClaw routes providers by protocol family:
|
||||
|
||||
- **OpenAI-compatible**: OpenRouter, Groq, Zhipu, vLLM-style endpoints, and most others.
|
||||
- **Gemini native**: Google Gemini via the native `models/*:generateContent` and `models/*:streamGenerateContent` endpoints.
|
||||
- **Anthropic**: Claude-native API behavior.
|
||||
- **Codex/OAuth**: OpenAI OAuth/token authentication route.
|
||||
|
||||
|
|
|
|||
|
|
@ -340,7 +340,7 @@ Responde HEARTBEAT_OK Usuário recebe resultado diretamente
|
|||
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Obter](https://console.anthropic.com) |
|
||||
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Obter](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
||||
| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Obter](https://platform.deepseek.com) |
|
||||
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Obter](https://aistudio.google.com/api-keys) |
|
||||
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [Obter](https://aistudio.google.com/api-keys) |
|
||||
| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Obter](https://console.groq.com) |
|
||||
| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Obter](https://dashscope.console.aliyun.com) |
|
||||
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (sem chave) |
|
||||
|
|
@ -370,9 +370,12 @@ A configuração antiga `providers` está **depreciada** e foi removida no V2. C
|
|||
PicoClaw roteia providers por família de protocolo:
|
||||
|
||||
- **Compatível com OpenAI**: OpenRouter, Groq, Zhipu, endpoints vLLM e a maioria dos outros.
|
||||
- **Gemini nativo**: Google Gemini via endpoints nativos `models/*:generateContent` e `models/*:streamGenerateContent`.
|
||||
- **Anthropic**: Comportamento nativo da API Claude.
|
||||
- **Codex/OAuth**: Rota de autenticação OAuth/token OpenAI.
|
||||
|
||||
Isso mantém o runtime leve enquanto torna novos backends compatíveis com OpenAI basicamente uma operação de configuração (`api_base` + `api_keys`).
|
||||
|
||||
### Tarefas Agendadas / Lembretes
|
||||
|
||||
PicoClaw suporta tarefas agendadas via ferramenta `cron`.
|
||||
|
|
|
|||
|
|
@ -340,7 +340,7 @@ Trả lời HEARTBEAT_OK Người dùng nhận kết quả trực tiếp
|
|||
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Lấy](https://console.anthropic.com) |
|
||||
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Lấy](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
||||
| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Lấy](https://platform.deepseek.com) |
|
||||
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Lấy](https://aistudio.google.com/api-keys) |
|
||||
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [Lấy](https://aistudio.google.com/api-keys) |
|
||||
| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Lấy](https://console.groq.com) |
|
||||
| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Lấy](https://dashscope.console.aliyun.com) |
|
||||
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Cục bộ (không cần key) |
|
||||
|
|
@ -370,9 +370,12 @@ Cấu hình `providers` cũ đã **bị deprecated** và đã được loại b
|
|||
PicoClaw định tuyến provider theo họ giao thức:
|
||||
|
||||
- **Tương thích OpenAI**: OpenRouter, Groq, Zhipu, endpoint kiểu vLLM và hầu hết các provider khác.
|
||||
- **Gemini native**: Google Gemini qua các endpoint native `models/*:generateContent` và `models/*:streamGenerateContent`.
|
||||
- **Anthropic**: Hành vi API Claude gốc.
|
||||
- **Codex/OAuth**: Tuyến xác thực OAuth/token OpenAI.
|
||||
|
||||
Điều này giữ runtime nhẹ trong khi khiến backend OpenAI-compatible mới chủ yếu chỉ là thao tác cấu hình (`api_base` + `api_keys`).
|
||||
|
||||
### Tác Vụ Đã Lên Lịch / Nhắc Nhở
|
||||
|
||||
PicoClaw hỗ trợ tác vụ theo lịch qua công cụ `cron`.
|
||||
|
|
|
|||
|
|
@ -69,15 +69,16 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work
|
|||
|
||||
### Web 启动器控制台
|
||||
|
||||
用 **picoclaw-launcher** 打开浏览器控制台前需要先登录。**访问口令**与 **会话签名密钥**默认在**每次启动时在内存中生成**(重启后随机口令会变)。若设置环境变量 **`PICOCLAW_LAUNCHER_TOKEN`**,则该进程使用固定口令(启动日志中不会打印具体口令值)。
|
||||
|
||||
**到哪里找口令**:**控制台模式**(`-console`)请看启动时的终端输出;**托盘 / GUI 模式**可使用托盘菜单中的「复制控制台口令」,并在 **`$PICOCLAW_HOME/logs/launcher.log`**(未设置 `PICOCLAW_HOME` 时一般为 `~/.picoclaw/logs/launcher.log`)中查看本次启动写入的随机口令。登录页在未登录时会根据当前运行方式展示提示(含日志文件绝对路径等;**接口与页面均不会返回口令本身**)。
|
||||
用 **picoclaw-launcher** 打开浏览器控制台前需要先使用密码登录。首次启动时打开 `/launcher-setup` 创建 dashboard 登录密码;后续手动登录使用 `/launcher-login`。
|
||||
|
||||
- **配置文件**:与 `config.json` 同一目录(若设置了 `PICOCLAW_CONFIG`,则与它所指的文件同目录)。启动器专用文件名为 `launcher-config.json`。
|
||||
- **登录与链接**:在登录页输入口令;自动打开浏览器时可在 URL 上使用 `?token=`。全站响应携带 **`Referrer-Policy: no-referrer`**,减轻 `token` 经 `Referer` 头泄露的风险。
|
||||
- **密码存储**:支持的平台会把 bcrypt 后的密码哈希存入 `launcher-auth.db`。如果当前平台不支持 SQLite 密码存储,则把 bcrypt 哈希存入 `launcher-config.json`。
|
||||
- **旧配置迁移**:旧版 `launcher_token` 会一次性迁移为密码登录,并从保存后的 launcher 配置中移除。
|
||||
- **本地自动登录**:launcher 启动后自动打开本地浏览器时,会使用仅允许 loopback 访问的一次性引导入口自动设置会话 Cookie。
|
||||
- **不再支持的鉴权方式**:不再支持 URL token 登录(`?token=...`)、`PICOCLAW_LAUNCHER_TOKEN` 和 `Authorization: Bearer` dashboard 鉴权。
|
||||
- **退出登录**:应使用 **`POST /api/auth/logout`**,且请求头为 **`Content-Type: application/json`**(请求体可为 `{}`),勿使用可被第三方页面触发的 GET 链接登出。
|
||||
- **暴力尝试**:`POST /api/auth/login` 对同一远程地址有 **每分钟尝试次数上限**(超限返回 HTTP 429)。
|
||||
- **会话时长**:登录后的 HttpOnly 会话 Cookie 默认约 **7 天**有效,到期需重新用口令登录。
|
||||
- **会话时长**:登录后的 HttpOnly 会话 Cookie 默认约 **31 天**有效,但 launcher 进程重启后已有会话会失效。
|
||||
|
||||
### 技能来源 (Skill Sources)
|
||||
|
||||
|
|
@ -441,7 +442,7 @@ Agent 读取 HEARTBEAT.md
|
|||
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [获取](https://console.anthropic.com) |
|
||||
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [获取](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
||||
| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [获取](https://platform.deepseek.com) |
|
||||
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [获取](https://aistudio.google.com/api-keys) |
|
||||
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [获取](https://aistudio.google.com/api-keys) |
|
||||
| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [获取](https://console.groq.com) |
|
||||
| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [获取](https://platform.moonshot.cn) |
|
||||
| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取](https://dashscope.console.aliyun.com) |
|
||||
|
|
@ -652,10 +653,11 @@ PicoClaw 只剥离最外层的 `litellm/` 前缀再发送请求,因此 `litell
|
|||
PicoClaw 按协议族路由提供商:
|
||||
|
||||
- **OpenAI 兼容**:OpenRouter、Groq、智谱、vLLM 风格端点及大多数其他提供商。
|
||||
- **Gemini 原生**:Google Gemini 通过原生 `models/*:generateContent` 和 `models/*:streamGenerateContent` 端点接入。
|
||||
- **Anthropic**:Claude 原生 API 行为。
|
||||
- **Codex/OAuth**:OpenAI OAuth/Token 认证路由。
|
||||
|
||||
这使运行时保持轻量,同时让接入新的 OpenAI 兼容后端基本只需配置 `api_base` + `api_key`。
|
||||
这使运行时保持轻量,同时让接入新的 OpenAI 兼容后端基本只需配置 `api_base` + `api_keys`。
|
||||
|
||||
<details>
|
||||
<summary><b>智谱(旧版 providers 格式)</b></summary>
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d
|
|||
Ouvrez http://localhost:18800 dans votre navigateur. Le launcher gère automatiquement le processus gateway.
|
||||
|
||||
> [!WARNING]
|
||||
> La console web ne prend pas encore en charge l'authentification. Évitez de l'exposer sur Internet public.
|
||||
> La console web est protégée par un mot de passe de connexion au dashboard. Ne l'exposez pas à des réseaux non fiables ni à Internet public.
|
||||
|
||||
### Mode Agent (One-shot)
|
||||
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d
|
|||
ブラウザで http://localhost:18800 を開いてください。Launcher が Gateway プロセスを自動管理します。
|
||||
|
||||
> [!WARNING]
|
||||
> Web コンソールはまだ認証をサポートしていません。公開インターネットに公開しないでください。
|
||||
> Web コンソールは dashboard ログインパスワードで保護されます。信頼できないネットワークや公開インターネットには公開しないでください。
|
||||
|
||||
### Agent モード (ワンショット)
|
||||
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d
|
|||
Open http://localhost:18800 in your browser. The launcher manages the gateway process automatically.
|
||||
|
||||
> [!WARNING]
|
||||
> The web console uses a dashboard token (in-memory per run unless `PICOCLAW_LAUNCHER_TOKEN` is set). **Do not** expose the launcher to untrusted networks or the public internet. See [Web launcher dashboard](configuration.md#web-launcher-dashboard) in the Configuration Guide.
|
||||
> The web console is protected by dashboard password login. **Do not** expose the launcher to untrusted networks or the public internet. See [Web launcher dashboard](configuration.md#web-launcher-dashboard) in the Configuration Guide.
|
||||
|
||||
### Agent Mode (One-shot)
|
||||
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d
|
|||
Buka http://localhost:18800 dalam pelayar anda. Launcher mengurus proses gateway secara automatik.
|
||||
|
||||
> [!WARNING]
|
||||
> Konsol web belum menyokong autentikasi. Elakkan mendedahkannya ke internet awam.
|
||||
> Konsol web dilindungi oleh kata laluan log masuk dashboard. Jangan dedahkannya kepada rangkaian tidak dipercayai atau internet awam.
|
||||
|
||||
### Mod Agent (One-shot)
|
||||
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d
|
|||
Abra http://localhost:18800 no seu navegador. O launcher gerencia o processo do gateway automaticamente.
|
||||
|
||||
> [!WARNING]
|
||||
> O console web ainda não suporta autenticação. Evite expô-lo na internet pública.
|
||||
> O console web é protegido por senha de login do dashboard. Não exponha o launcher a redes não confiáveis nem à internet pública.
|
||||
|
||||
### Modo Agent (One-shot)
|
||||
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d
|
|||
Mở http://localhost:18800 trong trình duyệt. Launcher tự động quản lý tiến trình gateway.
|
||||
|
||||
> [!WARNING]
|
||||
> Web console chưa hỗ trợ xác thực. Tránh để lộ ra internet công cộng.
|
||||
> Web console được bảo vệ bằng mật khẩu đăng nhập dashboard. Không để lộ launcher ra mạng không tin cậy hoặc internet công cộng.
|
||||
|
||||
### Chế Độ Agent (One-shot)
|
||||
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d
|
|||
在浏览器中打开 <http://localhost:18800>。Launcher 会自动管理 Gateway 进程。
|
||||
|
||||
> [!WARNING]
|
||||
> Web 控制台通过 dashboard 令牌鉴权(默认每次启动在内存中生成;可用 `PICOCLAW_LAUNCHER_TOKEN` 固定)。**不要**将启动器暴露到不可信网络或公网。完整说明见 [配置指南](configuration.md) 中的「Web 启动器控制台」一节。
|
||||
> Web 控制台通过 dashboard 登录密码保护。**不要**将启动器暴露到不可信网络或公网。完整说明见 [配置指南](configuration.md) 中的「Web 启动器控制台」一节。
|
||||
|
||||
### Agent 模式 (一次性运行)
|
||||
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ Cette conception permet également le **support multi-agents** avec une sélecti
|
|||
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) |
|
||||
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
||||
| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) |
|
||||
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Get Key](https://aistudio.google.com/api-keys) |
|
||||
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [Get Key](https://aistudio.google.com/api-keys) |
|
||||
| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) |
|
||||
| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) |
|
||||
| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) |
|
||||
|
|
@ -108,7 +108,7 @@ Cette conception permet également le **support multi-agents** avec une sélecti
|
|||
| `api_keys` | string[] | Oui* | Clé(s) API pour l'authentification. Plusieurs clés permettent la rotation par requête. Non requis pour les fournisseurs locaux (Ollama, LM Studio, VLLM) |
|
||||
| `api_base` | string | Non | Remplace l'URL de base API par défaut |
|
||||
| `proxy` | string | Non | URL du proxy HTTP pour cette entrée de modèle |
|
||||
| `user_agent` | string | Non | En-tête `User-Agent` personnalisé pour les requêtes API (supporté par les providers OpenAI-compatible, Anthropic et Azure) |
|
||||
| `user_agent` | string | Non | En-tête `User-Agent` personnalisé pour les requêtes API (supporté par les providers compatibles OpenAI, Gemini, Anthropic et Azure) |
|
||||
| `request_timeout` | int | Non | Délai d'expiration de la requête en secondes (la valeur par défaut varie selon le provider) |
|
||||
| `max_tokens_field` | string | Non | Remplace le nom du champ max tokens dans le corps de la requête (ex : `max_completion_tokens` pour les modèles o1) |
|
||||
| `thinking_level` | string | Non | Niveau de pensée étendue : `off`, `low`, `medium`, `high`, `xhigh` ou `adaptive` |
|
||||
|
|
@ -299,10 +299,11 @@ Pour un guide de migration détaillé, voir [migration/model-list-migration.md](
|
|||
PicoClaw route les fournisseurs par famille de protocoles :
|
||||
|
||||
- Protocole compatible OpenAI : OpenRouter, passerelles compatibles OpenAI, Groq, Zhipu et endpoints de type vLLM.
|
||||
- Protocole Gemini natif : Google Gemini via les endpoints natifs `models/*:generateContent` et `models/*:streamGenerateContent`.
|
||||
- Protocole Anthropic : Comportement natif de l'API Claude.
|
||||
- Chemin Codex/OAuth : Route d'authentification OAuth/token OpenAI.
|
||||
|
||||
Cela maintient le runtime léger tout en faisant des nouveaux backends compatibles OpenAI principalement une opération de configuration (`api_base` + `api_key`).
|
||||
Cela maintient le runtime léger tout en faisant des nouveaux backends compatibles OpenAI principalement une opération de configuration (`api_base` + `api_keys`).
|
||||
|
||||
<details>
|
||||
<summary><b>Zhipu</b></summary>
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@
|
|||
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [キーを取得](https://console.anthropic.com) |
|
||||
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [キーを取得](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
||||
| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [キーを取得](https://platform.deepseek.com) |
|
||||
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [キーを取得](https://aistudio.google.com/api-keys) |
|
||||
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [キーを取得](https://aistudio.google.com/api-keys) |
|
||||
| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [キーを取得](https://console.groq.com) |
|
||||
| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [キーを取得](https://platform.moonshot.cn) |
|
||||
| **通義千問 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [キーを取得](https://dashscope.console.aliyun.com) |
|
||||
|
|
@ -109,7 +109,7 @@
|
|||
| `api_keys` | string[] | はい* | 認証キー。複数キーでリクエストごとのローテーションが可能。ローカル provider(Ollama、LM Studio、VLLM)には不要 |
|
||||
| `api_base` | string | いいえ | デフォルトの API エンドポイント URL を上書き |
|
||||
| `proxy` | string | いいえ | このモデルエントリの HTTP プロキシ URL |
|
||||
| `user_agent` | string | いいえ | カスタム `User-Agent` リクエストヘッダー(OpenAI 互換、Anthropic、Azure provider で対応) |
|
||||
| `user_agent` | string | いいえ | カスタム `User-Agent` リクエストヘッダー(OpenAI 互換、Gemini、Anthropic、Azure provider で対応) |
|
||||
| `request_timeout` | int | いいえ | リクエストタイムアウト(秒)。デフォルト値は provider により異なる |
|
||||
| `max_tokens_field` | string | いいえ | リクエストボディの max tokens フィールド名を上書き(例:o1 モデルでは `max_completion_tokens`) |
|
||||
| `thinking_level` | string | いいえ | 拡張思考レベル:`off`、`low`、`medium`、`high`、`xhigh`、`adaptive` |
|
||||
|
|
@ -311,6 +311,7 @@ PicoClaw はリクエスト送信前に外側の `litellm/` プレフィック
|
|||
PicoClaw はプロトコルファミリーごとに Provider をルーティングします:
|
||||
|
||||
- OpenAI 互換プロトコル:OpenRouter、OpenAI 互換ゲートウェイ、Groq、Zhipu、vLLM スタイルのエンドポイント。
|
||||
- Gemini ネイティブプロトコル:Google Gemini のネイティブ `models/*:generateContent` / `models/*:streamGenerateContent` エンドポイント。
|
||||
- Anthropic プロトコル:Claude ネイティブ API 動作。
|
||||
- Codex/OAuth パス:OpenAI OAuth/Token 認証ルート。
|
||||
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ This design also enables **multi-agent support** with flexible provider selectio
|
|||
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
||||
| **Z.AI Coding Plan** | `openai/` | `https://api.z.ai/api/coding/paas/v4` | OpenAI | [Get Key](https://z.ai/manage-apikey/apikey-list) |
|
||||
| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) |
|
||||
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Get Key](https://aistudio.google.com/api-keys) |
|
||||
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [Get Key](https://aistudio.google.com/api-keys) |
|
||||
| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) |
|
||||
| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) |
|
||||
| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) |
|
||||
|
|
@ -119,7 +119,7 @@ This design also enables **multi-agent support** with flexible provider selectio
|
|||
| `api_keys` | string[] | Yes* | API key(s) for authentication. Multiple keys enable per-request rotation. Not required for local providers (Ollama, LM Studio, VLLM) |
|
||||
| `api_base` | string | No | Override the default API endpoint URL |
|
||||
| `proxy` | string | No | HTTP proxy URL for this model entry |
|
||||
| `user_agent` | string | No | Custom `User-Agent` header sent with API requests (supported by OpenAI-compatible, Anthropic, and Azure providers) |
|
||||
| `user_agent` | string | No | Custom `User-Agent` header sent with API requests (supported by OpenAI-compatible, Gemini, Anthropic, and Azure providers) |
|
||||
| `request_timeout` | int | No | Request timeout in seconds (default varies by provider) |
|
||||
| `max_tokens_field` | string | No | Override the max tokens field name in request body (e.g., `max_completion_tokens` for o1 models) |
|
||||
| `thinking_level` | string | No | Extended thinking level: `off`, `low`, `medium`, `high`, `xhigh`, or `adaptive` |
|
||||
|
|
@ -415,10 +415,11 @@ For detailed migration guide, see [migration/model-list-migration.md](../migrati
|
|||
PicoClaw routes providers by protocol family:
|
||||
|
||||
- OpenAI-compatible protocol: OpenRouter, OpenAI-compatible gateways, Groq, Zhipu, and vLLM-style endpoints.
|
||||
- Gemini native protocol: Google Gemini via the native `models/*:generateContent` and `models/*:streamGenerateContent` endpoints.
|
||||
- Anthropic protocol: Claude-native API behavior.
|
||||
- Codex/OAuth path: OpenAI OAuth/token authentication route.
|
||||
|
||||
This keeps the runtime lightweight while making new OpenAI-compatible backends mostly a config operation (`api_base` + `api_key`).
|
||||
This keeps the runtime lightweight while making new OpenAI-compatible backends mostly a config operation (`api_base` + `api_keys`).
|
||||
|
||||
<details>
|
||||
<summary><b>Zhipu</b></summary>
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ Este design também permite **suporte multi-agente** com seleção flexível de
|
|||
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) |
|
||||
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
||||
| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) |
|
||||
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Get Key](https://aistudio.google.com/api-keys) |
|
||||
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [Get Key](https://aistudio.google.com/api-keys) |
|
||||
| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) |
|
||||
| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) |
|
||||
| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) |
|
||||
|
|
@ -108,7 +108,7 @@ Este design também permite **suporte multi-agente** com seleção flexível de
|
|||
| `api_keys` | string[] | Sim* | Chave(s) API para autenticação. Múltiplas chaves permitem rotação por requisição. Não necessário para providers locais (Ollama, LM Studio, VLLM) |
|
||||
| `api_base` | string | Não | Substitui a URL base da API padrão |
|
||||
| `proxy` | string | Não | URL do proxy HTTP para esta entrada de modelo |
|
||||
| `user_agent` | string | Não | Cabeçalho `User-Agent` personalizado enviado com requisições API (suportado por providers OpenAI-compatible, Anthropic e Azure) |
|
||||
| `user_agent` | string | Não | Cabeçalho `User-Agent` personalizado enviado com requisições API (suportado por providers OpenAI-compatible, Gemini, Anthropic e Azure) |
|
||||
| `request_timeout` | int | Não | Timeout de requisição em segundos (o padrão varia por provider) |
|
||||
| `max_tokens_field` | string | Não | Substitui o nome do campo max tokens no corpo da requisição (ex: `max_completion_tokens` para modelos o1) |
|
||||
| `thinking_level` | string | Não | Nível de pensamento estendido: `off`, `low`, `medium`, `high`, `xhigh` ou `adaptive` |
|
||||
|
|
@ -299,6 +299,7 @@ Para guia de migração detalhado, veja [migration/model-list-migration.md](../m
|
|||
O PicoClaw roteia provedores por família de protocolo:
|
||||
|
||||
- Protocolo compatível com OpenAI: OpenRouter, gateways compatíveis com OpenAI, Groq, Zhipu e endpoints estilo vLLM.
|
||||
- Protocolo Gemini nativo: Google Gemini via endpoints nativos `models/*:generateContent` e `models/*:streamGenerateContent`.
|
||||
- Protocolo Anthropic: Comportamento nativo da API Claude.
|
||||
- Caminho Codex/OAuth: Rota de autenticação OAuth/token da OpenAI.
|
||||
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr
|
|||
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) |
|
||||
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
||||
| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) |
|
||||
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Get Key](https://aistudio.google.com/api-keys) |
|
||||
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [Get Key](https://aistudio.google.com/api-keys) |
|
||||
| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) |
|
||||
| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) |
|
||||
| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) |
|
||||
|
|
@ -108,7 +108,7 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr
|
|||
| `api_keys` | string[] | Có* | Khóa API xác thực. Nhiều khóa cho phép xoay vòng theo yêu cầu. Không cần thiết cho provider nội bộ (Ollama, LM Studio, VLLM) |
|
||||
| `api_base` | string | Không | Ghi đè URL endpoint API mặc định |
|
||||
| `proxy` | string | Không | URL proxy HTTP cho entry model này |
|
||||
| `user_agent` | string | Không | Header `User-Agent` tùy chỉnh gửi với yêu cầu API (được hỗ trợ bởi provider OpenAI-compatible, Anthropic và Azure) |
|
||||
| `user_agent` | string | Không | Header `User-Agent` tùy chỉnh gửi với yêu cầu API (được hỗ trợ bởi provider OpenAI-compatible, Gemini, Anthropic và Azure) |
|
||||
| `request_timeout` | int | Không | Timeout yêu cầu tính bằng giây (mặc định khác nhau tùy provider) |
|
||||
| `max_tokens_field` | string | Không | Ghi đè tên trường max tokens trong request body (ví dụ: `max_completion_tokens` cho model o1) |
|
||||
| `thinking_level` | string | Không | Mức độ tư duy mở rộng: `off`, `low`, `medium`, `high`, `xhigh` hoặc `adaptive` |
|
||||
|
|
@ -299,6 +299,7 @@ Cấu hình `providers` cũ đã **bị deprecated** và đã được loại b
|
|||
PicoClaw định tuyến provider theo họ giao thức:
|
||||
|
||||
- Giao thức tương thích OpenAI: OpenRouter, gateway tương thích OpenAI, Groq, Zhipu, và endpoint kiểu vLLM.
|
||||
- Giao thức Gemini native: Google Gemini qua các endpoint native `models/*:generateContent` và `models/*:streamGenerateContent`.
|
||||
- Giao thức Anthropic: Hành vi API native của Claude.
|
||||
- Đường dẫn Codex/OAuth: Tuyến xác thực OAuth/token của OpenAI.
|
||||
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@
|
|||
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [获取密钥](https://console.anthropic.com) |
|
||||
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [获取密钥](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
||||
| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [获取密钥](https://platform.deepseek.com) |
|
||||
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [获取密钥](https://aistudio.google.com/api-keys) |
|
||||
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [获取密钥](https://aistudio.google.com/api-keys) |
|
||||
| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [获取密钥](https://console.groq.com) |
|
||||
| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [获取密钥](https://platform.moonshot.cn) |
|
||||
| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取密钥](https://dashscope.console.aliyun.com) |
|
||||
|
|
@ -116,7 +116,7 @@
|
|||
| `api_keys` | string[] | 是* | 认证密钥。多个密钥可按请求轮换。本地 provider(Ollama、LM Studio、VLLM)不需要 |
|
||||
| `api_base` | string | 否 | 覆盖默认的 API 端点 URL |
|
||||
| `proxy` | string | 否 | 此模型条目的 HTTP 代理 URL |
|
||||
| `user_agent` | string | 否 | 自定义 `User-Agent` 请求头(支持 OpenAI 兼容、Anthropic 和 Azure provider) |
|
||||
| `user_agent` | string | 否 | 自定义 `User-Agent` 请求头(支持 OpenAI 兼容、Gemini、Anthropic 和 Azure provider) |
|
||||
| `request_timeout` | int | 否 | 请求超时时间(秒),默认值因 provider 而异 |
|
||||
| `max_tokens_field` | string | 否 | 覆盖请求体中 max tokens 的字段名(如 o1 模型使用 `max_completion_tokens`) |
|
||||
| `thinking_level` | string | 否 | 扩展思考级别:`off`、`low`、`medium`、`high`、`xhigh` 或 `adaptive` |
|
||||
|
|
@ -386,10 +386,11 @@ PicoClaw 在发送请求前仅去除外层 `litellm/` 前缀,因此 `litellm/l
|
|||
PicoClaw 按协议族路由 Provider:
|
||||
|
||||
- OpenAI 兼容协议:OpenRouter、OpenAI 兼容网关、Groq、智谱、vLLM 风格端点。
|
||||
- Gemini 原生协议:Google Gemini 通过原生 `models/*:generateContent` 和 `models/*:streamGenerateContent` 端点接入。
|
||||
- Anthropic 协议:Claude 原生 API 行为。
|
||||
- Codex/OAuth 路径:OpenAI OAuth/Token 认证路由。
|
||||
|
||||
这使得运行时保持轻量,同时让新的 OpenAI 兼容后端基本只需配置操作(`api_base` + `api_key`)。
|
||||
这使得运行时保持轻量,同时让新的 OpenAI 兼容后端基本只需配置操作(`api_base` + `api_keys`)。
|
||||
|
||||
<details>
|
||||
<summary><b>智谱 (Zhipu) 配置示例</b></summary>
|
||||
|
|
|
|||
|
|
@ -528,10 +528,11 @@ func (al *AgentLoop) runAgentLoop(
|
|||
opts.Dispatch.ChatID(),
|
||||
opts.Dispatch.ReplyToMessageID(),
|
||||
),
|
||||
AgentID: agentID,
|
||||
SessionKey: sessionKey,
|
||||
Scope: scope,
|
||||
Content: result.finalContent,
|
||||
AgentID: agentID,
|
||||
SessionKey: sessionKey,
|
||||
Scope: scope,
|
||||
Content: result.finalContent,
|
||||
ContextUsage: computeContextUsage(agent, opts.Dispatch.SessionKey),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -214,6 +214,24 @@ func (al *AgentLoop) buildCommandsRuntime(
|
|||
rt.AskSideQuestion = func(ctx context.Context, question string) (string, error) {
|
||||
return al.askSideQuestion(ctx, agent, opts, question)
|
||||
}
|
||||
|
||||
rt.GetContextStats = func() *commands.ContextStats {
|
||||
if opts == nil || agent.Sessions == nil {
|
||||
return nil
|
||||
}
|
||||
usage := computeContextUsage(agent, opts.SessionKey)
|
||||
if usage == nil {
|
||||
return nil
|
||||
}
|
||||
history := agent.Sessions.GetHistory(opts.SessionKey)
|
||||
return &commands.ContextStats{
|
||||
UsedTokens: usage.UsedTokens,
|
||||
TotalTokens: usage.TotalTokens,
|
||||
CompressAtTokens: usage.CompressAtTokens,
|
||||
UsedPercent: usage.UsedPercent,
|
||||
MessageCount: len(history),
|
||||
}
|
||||
}
|
||||
}
|
||||
return rt
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,10 +60,14 @@ func (al *AgentLoop) PublishResponseIfNeeded(ctx context.Context, channel, chatI
|
|||
return
|
||||
}
|
||||
|
||||
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
||||
msg := bus.OutboundMessage{
|
||||
Context: bus.NewOutboundContext(channel, chatID, ""),
|
||||
Content: response,
|
||||
})
|
||||
}
|
||||
if sessionKey != "" {
|
||||
msg.ContextUsage = computeContextUsage(al.agentForSession(sessionKey), sessionKey)
|
||||
}
|
||||
al.bus.PublishOutbound(ctx, msg)
|
||||
logger.InfoCF("agent", "Published outbound response",
|
||||
map[string]any{
|
||||
"channel": channel,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
|
|
@ -210,6 +211,36 @@ func (cb *ContextBuilder) BuildSystemPromptWithCache() string {
|
|||
return prompt
|
||||
}
|
||||
|
||||
// EstimateSystemTokens estimates the token count of the full system message
|
||||
// that would be sent to the LLM, mirroring the composition logic in BuildMessages.
|
||||
// It includes: static prompt, dynamic context, active skills, and summary with
|
||||
// wrapping prefixes and separators. This avoids needing all per-request parameters
|
||||
// that BuildMessages requires (media, channel, chatID, sender, etc.).
|
||||
func (cb *ContextBuilder) EstimateSystemTokens(summary string, activeSkills []string) int {
|
||||
staticPrompt := cb.BuildSystemPromptWithCache()
|
||||
|
||||
// Dynamic context is small and varies per request; use a representative estimate.
|
||||
// Actual buildDynamicContext produces ~200-400 chars of time/runtime/session info.
|
||||
const dynamicContextChars = 300
|
||||
|
||||
totalChars := utf8.RuneCountInString(staticPrompt) + dynamicContextChars
|
||||
|
||||
if skillsText := cb.buildActiveSkillsContext(activeSkills); skillsText != "" {
|
||||
totalChars += utf8.RuneCountInString(skillsText)
|
||||
totalChars += 7 // separator \n\n---\n\n
|
||||
}
|
||||
|
||||
if summary != "" {
|
||||
// Matches the CONTEXT_SUMMARY: prefix added in BuildMessages
|
||||
const summaryPrefix = "CONTEXT_SUMMARY: The following is an approximate summary of prior conversation " +
|
||||
"for reference only. It may be incomplete or outdated — always defer to explicit instructions.\n\n"
|
||||
totalChars += utf8.RuneCountInString(summaryPrefix) + utf8.RuneCountInString(summary)
|
||||
totalChars += 7 // separator
|
||||
}
|
||||
|
||||
return totalChars * 2 / 5 // same heuristic as tokenizer.EstimateMessageTokens
|
||||
}
|
||||
|
||||
// InvalidateCache clears the cached system prompt.
|
||||
// Normally not needed because the cache auto-invalidates via mtime checks,
|
||||
// but this is useful for tests or explicit reload commands.
|
||||
|
|
|
|||
78
pkg/agent/context_usage.go
Normal file
78
pkg/agent/context_usage.go
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
)
|
||||
|
||||
// computeContextUsage estimates current context window consumption for the
|
||||
// given agent and session. Includes history, system prompt (with dynamic context,
|
||||
// summary, and skills — mirroring BuildMessages composition), and tool definitions.
|
||||
// The output reserve (MaxTokens) is not counted as "used" but reduces the
|
||||
// effective budget, matching isOverContextBudget's compression trigger:
|
||||
//
|
||||
// compress when: history + system + tools + maxTokens > contextWindow
|
||||
// equivalent to: history + system + tools > contextWindow - maxTokens
|
||||
//
|
||||
// Returns nil when the agent or session is unavailable.
|
||||
func computeContextUsage(agent *AgentInstance, sessionKey string) *bus.ContextUsage {
|
||||
if agent == nil || agent.Sessions == nil {
|
||||
return nil
|
||||
}
|
||||
contextWindow := agent.ContextWindow
|
||||
if contextWindow <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// History tokens
|
||||
history := agent.Sessions.GetHistory(sessionKey)
|
||||
historyTokens := 0
|
||||
for _, m := range history {
|
||||
historyTokens += EstimateMessageTokens(m)
|
||||
}
|
||||
|
||||
// System message tokens: uses EstimateSystemTokens which mirrors
|
||||
// the full system message composition in BuildMessages (static prompt,
|
||||
// dynamic context, active skills, summary with wrapping prefix).
|
||||
systemTokens := 0
|
||||
if agent.ContextBuilder != nil {
|
||||
summary := agent.Sessions.GetSummary(sessionKey)
|
||||
// Pass nil for active skills: skills are only injected when the user
|
||||
// explicitly activates them via /use, which is rare. Using nil matches
|
||||
// the common case and avoids over-counting all installed skills.
|
||||
systemTokens = agent.ContextBuilder.EstimateSystemTokens(summary, nil)
|
||||
}
|
||||
|
||||
// Tool definition tokens
|
||||
toolTokens := 0
|
||||
if agent.Tools != nil {
|
||||
toolTokens = EstimateToolDefsTokens(agent.Tools.ToProviderDefs())
|
||||
}
|
||||
|
||||
// Used = history + system (includes summary) + tools
|
||||
usedTokens := historyTokens + systemTokens + toolTokens
|
||||
|
||||
// Effective budget = contextWindow minus output reserve (maxTokens)
|
||||
effectiveWindow := contextWindow - agent.MaxTokens
|
||||
if effectiveWindow < 0 {
|
||||
effectiveWindow = contextWindow
|
||||
}
|
||||
|
||||
// compressAt = effectiveWindow: aligns with isOverContextBudget's
|
||||
// proactive trigger (msgTokens + toolTokens + maxTokens > contextWindow).
|
||||
compressAt := effectiveWindow
|
||||
|
||||
usedPercent := 0
|
||||
if compressAt > 0 {
|
||||
usedPercent = usedTokens * 100 / compressAt
|
||||
}
|
||||
if usedPercent > 100 {
|
||||
usedPercent = 100
|
||||
}
|
||||
|
||||
return &bus.ContextUsage{
|
||||
UsedTokens: usedTokens,
|
||||
TotalTokens: contextWindow,
|
||||
CompressAtTokens: compressAt,
|
||||
UsedPercent: usedPercent,
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
|
|
@ -25,6 +26,11 @@ type AuthStore struct {
|
|||
Credentials map[string]*AuthCredential `json:"credentials"`
|
||||
}
|
||||
|
||||
const (
|
||||
providerGoogleAntigravity = "google-antigravity"
|
||||
providerAntigravityAlias = "antigravity"
|
||||
)
|
||||
|
||||
func (c *AuthCredential) IsExpired() bool {
|
||||
if c.ExpiresAt.IsZero() {
|
||||
return false
|
||||
|
|
@ -43,6 +49,125 @@ func authFilePath() string {
|
|||
return filepath.Join(config.GetHome(), "auth.json")
|
||||
}
|
||||
|
||||
func canonicalProvider(provider string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(provider))
|
||||
switch normalized {
|
||||
case providerAntigravityAlias:
|
||||
return providerGoogleAntigravity
|
||||
default:
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
|
||||
func cloneCredential(cred *AuthCredential) *AuthCredential {
|
||||
if cred == nil {
|
||||
return nil
|
||||
}
|
||||
cp := *cred
|
||||
return &cp
|
||||
}
|
||||
|
||||
func mergeCredentials(primary, secondary *AuthCredential) *AuthCredential {
|
||||
if primary == nil {
|
||||
return cloneCredential(secondary)
|
||||
}
|
||||
|
||||
merged := *primary
|
||||
if secondary == nil {
|
||||
return &merged
|
||||
}
|
||||
if merged.AccessToken == "" {
|
||||
merged.AccessToken = secondary.AccessToken
|
||||
}
|
||||
if merged.RefreshToken == "" {
|
||||
merged.RefreshToken = secondary.RefreshToken
|
||||
}
|
||||
if merged.AccountID == "" {
|
||||
merged.AccountID = secondary.AccountID
|
||||
}
|
||||
if merged.ExpiresAt.IsZero() {
|
||||
merged.ExpiresAt = secondary.ExpiresAt
|
||||
}
|
||||
if merged.Provider == "" {
|
||||
merged.Provider = secondary.Provider
|
||||
}
|
||||
if merged.AuthMethod == "" {
|
||||
merged.AuthMethod = secondary.AuthMethod
|
||||
}
|
||||
if merged.Email == "" {
|
||||
merged.Email = secondary.Email
|
||||
}
|
||||
if merged.ProjectID == "" {
|
||||
merged.ProjectID = secondary.ProjectID
|
||||
}
|
||||
|
||||
return &merged
|
||||
}
|
||||
|
||||
func shouldPreferCredential(
|
||||
candidate *AuthCredential,
|
||||
candidateCanonical bool,
|
||||
current *AuthCredential,
|
||||
currentCanonical bool,
|
||||
) bool {
|
||||
if candidate == nil {
|
||||
return false
|
||||
}
|
||||
if current == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
switch {
|
||||
case candidate.ExpiresAt.After(current.ExpiresAt):
|
||||
return true
|
||||
case current.ExpiresAt.After(candidate.ExpiresAt):
|
||||
return false
|
||||
case candidateCanonical != currentCanonical:
|
||||
return candidateCanonical
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeStore(store *AuthStore) {
|
||||
if store == nil {
|
||||
return
|
||||
}
|
||||
if store.Credentials == nil {
|
||||
store.Credentials = make(map[string]*AuthCredential)
|
||||
return
|
||||
}
|
||||
|
||||
normalized := make(map[string]*AuthCredential, len(store.Credentials))
|
||||
canonicalFlags := make(map[string]bool, len(store.Credentials))
|
||||
|
||||
for provider, cred := range store.Credentials {
|
||||
normalizedProvider := strings.ToLower(strings.TrimSpace(provider))
|
||||
canonical := canonicalProvider(provider)
|
||||
normalizedCred := cloneCredential(cred)
|
||||
if normalizedCred != nil {
|
||||
normalizedCred.Provider = canonicalProvider(normalizedCred.Provider)
|
||||
if normalizedCred.Provider == "" {
|
||||
normalizedCred.Provider = canonical
|
||||
}
|
||||
}
|
||||
|
||||
current := normalized[canonical]
|
||||
currentCanonical := canonicalFlags[canonical]
|
||||
candidateCanonical := normalizedProvider == canonical
|
||||
|
||||
if shouldPreferCredential(normalizedCred, candidateCanonical, current, currentCanonical) {
|
||||
normalized[canonical] = mergeCredentials(normalizedCred, current)
|
||||
canonicalFlags[canonical] = candidateCanonical
|
||||
continue
|
||||
}
|
||||
|
||||
normalized[canonical] = mergeCredentials(current, normalizedCred)
|
||||
}
|
||||
|
||||
store.Credentials = normalized
|
||||
}
|
||||
|
||||
func LoadStore() (*AuthStore, error) {
|
||||
path := authFilePath()
|
||||
data, err := os.ReadFile(path)
|
||||
|
|
@ -57,9 +182,7 @@ func LoadStore() (*AuthStore, error) {
|
|||
if err := json.Unmarshal(data, &store); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if store.Credentials == nil {
|
||||
store.Credentials = make(map[string]*AuthCredential)
|
||||
}
|
||||
normalizeStore(&store)
|
||||
return &store, nil
|
||||
}
|
||||
|
||||
|
|
@ -79,7 +202,7 @@ func GetCredential(provider string) (*AuthCredential, error) {
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cred, ok := store.Credentials[provider]
|
||||
cred, ok := store.Credentials[canonicalProvider(provider)]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
|
|
@ -91,7 +214,17 @@ func SetCredential(provider string, cred *AuthCredential) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
store.Credentials[provider] = cred
|
||||
|
||||
canonical := canonicalProvider(provider)
|
||||
normalized := cloneCredential(cred)
|
||||
if normalized != nil {
|
||||
normalized.Provider = canonicalProvider(normalized.Provider)
|
||||
if normalized.Provider == "" {
|
||||
normalized.Provider = canonical
|
||||
}
|
||||
}
|
||||
|
||||
store.Credentials[canonical] = normalized
|
||||
return SaveStore(store)
|
||||
}
|
||||
|
||||
|
|
@ -100,7 +233,7 @@ func DeleteCredential(provider string) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
delete(store.Credentials, provider)
|
||||
delete(store.Credentials, canonicalProvider(provider))
|
||||
return SaveStore(store)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,24 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func setTestAuthHome(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
t.Setenv(config.EnvHome, filepath.Join(tmpDir, ".picoclaw"))
|
||||
return tmpDir
|
||||
}
|
||||
|
||||
func TestAuthCredentialIsExpired(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
@ -51,10 +63,7 @@ func TestAuthCredentialNeedsRefresh(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestStoreRoundtrip(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
origHome := os.Getenv("HOME")
|
||||
t.Setenv("HOME", tmpDir)
|
||||
defer os.Setenv("HOME", origHome)
|
||||
setTestAuthHome(t)
|
||||
|
||||
cred := &AuthCredential{
|
||||
AccessToken: "test-access-token",
|
||||
|
|
@ -88,10 +97,7 @@ func TestStoreRoundtrip(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestStoreFilePermissions(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
origHome := os.Getenv("HOME")
|
||||
t.Setenv("HOME", tmpDir)
|
||||
defer os.Setenv("HOME", origHome)
|
||||
tmpDir := setTestAuthHome(t)
|
||||
|
||||
cred := &AuthCredential{
|
||||
AccessToken: "secret-token",
|
||||
|
|
@ -108,16 +114,16 @@ func TestStoreFilePermissions(t *testing.T) {
|
|||
t.Fatalf("Stat() error: %v", err)
|
||||
}
|
||||
perm := info.Mode().Perm()
|
||||
if runtime.GOOS == "windows" {
|
||||
return
|
||||
}
|
||||
if perm != 0o600 {
|
||||
t.Errorf("file permissions = %o, want 0600", perm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreMultiProvider(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
origHome := os.Getenv("HOME")
|
||||
t.Setenv("HOME", tmpDir)
|
||||
defer os.Setenv("HOME", origHome)
|
||||
setTestAuthHome(t)
|
||||
|
||||
openaiCred := &AuthCredential{AccessToken: "openai-token", Provider: "openai", AuthMethod: "oauth"}
|
||||
anthropicCred := &AuthCredential{AccessToken: "anthropic-token", Provider: "anthropic", AuthMethod: "token"}
|
||||
|
|
@ -147,10 +153,7 @@ func TestStoreMultiProvider(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestDeleteCredential(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
origHome := os.Getenv("HOME")
|
||||
t.Setenv("HOME", tmpDir)
|
||||
defer os.Setenv("HOME", origHome)
|
||||
setTestAuthHome(t)
|
||||
|
||||
cred := &AuthCredential{AccessToken: "to-delete", Provider: "openai", AuthMethod: "oauth"}
|
||||
if err := SetCredential("openai", cred); err != nil {
|
||||
|
|
@ -171,10 +174,7 @@ func TestDeleteCredential(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestLoadStoreEmpty(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
origHome := os.Getenv("HOME")
|
||||
t.Setenv("HOME", tmpDir)
|
||||
defer os.Setenv("HOME", origHome)
|
||||
setTestAuthHome(t)
|
||||
|
||||
store, err := LoadStore()
|
||||
if err != nil {
|
||||
|
|
@ -187,3 +187,319 @@ func TestLoadStoreEmpty(t *testing.T) {
|
|||
t.Errorf("expected empty credentials, got %d", len(store.Credentials))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCredentialCanonicalizesLegacyAntigravityProvider(t *testing.T) {
|
||||
tmpDir := setTestAuthHome(t)
|
||||
|
||||
expiresAt := time.Date(2026, 4, 16, 10, 0, 0, 0, time.UTC)
|
||||
store := map[string]any{
|
||||
"credentials": map[string]any{
|
||||
"antigravity": map[string]any{
|
||||
"access_token": "legacy-token",
|
||||
"expires_at": expiresAt.Format(time.RFC3339),
|
||||
"provider": "antigravity",
|
||||
"auth_method": "oauth",
|
||||
"project_id": "project-1",
|
||||
},
|
||||
},
|
||||
}
|
||||
data, err := json.Marshal(store)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal() error: %v", err)
|
||||
}
|
||||
path := filepath.Join(tmpDir, ".picoclaw", "auth.json")
|
||||
err = os.MkdirAll(filepath.Dir(path), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("MkdirAll() error: %v", err)
|
||||
}
|
||||
err = os.WriteFile(path, data, 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
cred, err := GetCredential("google-antigravity")
|
||||
if err != nil {
|
||||
t.Fatalf("GetCredential() error: %v", err)
|
||||
}
|
||||
if cred == nil {
|
||||
t.Fatal("GetCredential() returned nil")
|
||||
}
|
||||
if cred.Provider != "google-antigravity" {
|
||||
t.Fatalf("Provider = %q, want %q", cred.Provider, "google-antigravity")
|
||||
}
|
||||
if !cred.ExpiresAt.Equal(expiresAt) {
|
||||
t.Fatalf("ExpiresAt = %v, want %v", cred.ExpiresAt, expiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadStoreMergesAntigravityAliasesPreferringNewerExpiry(t *testing.T) {
|
||||
tmpDir := setTestAuthHome(t)
|
||||
|
||||
legacyExpiry := time.Date(2026, 4, 16, 10, 0, 0, 0, time.UTC)
|
||||
refreshedExpiry := time.Date(2026, 4, 16, 12, 0, 0, 0, time.UTC)
|
||||
store := map[string]any{
|
||||
"credentials": map[string]any{
|
||||
"antigravity": map[string]any{
|
||||
"access_token": "legacy-token",
|
||||
"refresh_token": "legacy-refresh",
|
||||
"expires_at": legacyExpiry.Format(time.RFC3339),
|
||||
"provider": "antigravity",
|
||||
"auth_method": "oauth",
|
||||
"email": "legacy@example.com",
|
||||
},
|
||||
"google-antigravity": map[string]any{
|
||||
"access_token": "fresh-token",
|
||||
"expires_at": refreshedExpiry.Format(time.RFC3339),
|
||||
"provider": "google-antigravity",
|
||||
"auth_method": "oauth",
|
||||
"project_id": "project-2",
|
||||
},
|
||||
},
|
||||
}
|
||||
data, err := json.Marshal(store)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal() error: %v", err)
|
||||
}
|
||||
path := filepath.Join(tmpDir, ".picoclaw", "auth.json")
|
||||
err = os.MkdirAll(filepath.Dir(path), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("MkdirAll() error: %v", err)
|
||||
}
|
||||
err = os.WriteFile(path, data, 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadStore()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadStore() error: %v", err)
|
||||
}
|
||||
if len(loaded.Credentials) != 1 {
|
||||
t.Fatalf("credential count = %d, want 1", len(loaded.Credentials))
|
||||
}
|
||||
|
||||
cred := loaded.Credentials["google-antigravity"]
|
||||
if cred == nil {
|
||||
t.Fatal("google-antigravity credential missing")
|
||||
}
|
||||
if cred.AccessToken != "fresh-token" {
|
||||
t.Fatalf("AccessToken = %q, want %q", cred.AccessToken, "fresh-token")
|
||||
}
|
||||
if cred.RefreshToken != "legacy-refresh" {
|
||||
t.Fatalf("RefreshToken = %q, want %q", cred.RefreshToken, "legacy-refresh")
|
||||
}
|
||||
if cred.Email != "legacy@example.com" {
|
||||
t.Fatalf("Email = %q, want %q", cred.Email, "legacy@example.com")
|
||||
}
|
||||
if cred.ProjectID != "project-2" {
|
||||
t.Fatalf("ProjectID = %q, want %q", cred.ProjectID, "project-2")
|
||||
}
|
||||
if !cred.ExpiresAt.Equal(refreshedExpiry) {
|
||||
t.Fatalf("ExpiresAt = %v, want %v", cred.ExpiresAt, refreshedExpiry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadStorePrefersCanonicalKeyWhenExpiryMatchesAlias(t *testing.T) {
|
||||
tmpDir := setTestAuthHome(t)
|
||||
|
||||
expiresAt := time.Date(2026, 4, 16, 12, 0, 0, 0, time.UTC)
|
||||
store := map[string]any{
|
||||
"credentials": map[string]any{
|
||||
"antigravity": map[string]any{
|
||||
"access_token": "legacy-token",
|
||||
"refresh_token": "legacy-refresh",
|
||||
"expires_at": expiresAt.Format(time.RFC3339),
|
||||
"provider": "antigravity",
|
||||
"auth_method": "oauth",
|
||||
"email": "legacy@example.com",
|
||||
},
|
||||
" Google-Antigravity ": map[string]any{
|
||||
"access_token": "fresh-token",
|
||||
"expires_at": expiresAt.Format(time.RFC3339),
|
||||
"provider": " Google-Antigravity ",
|
||||
"auth_method": "oauth",
|
||||
"project_id": "project-2",
|
||||
},
|
||||
},
|
||||
}
|
||||
data, err := json.Marshal(store)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal() error: %v", err)
|
||||
}
|
||||
path := filepath.Join(tmpDir, ".picoclaw", "auth.json")
|
||||
err = os.MkdirAll(filepath.Dir(path), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("MkdirAll() error: %v", err)
|
||||
}
|
||||
err = os.WriteFile(path, data, 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadStore()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadStore() error: %v", err)
|
||||
}
|
||||
if len(loaded.Credentials) != 1 {
|
||||
t.Fatalf("credential count = %d, want 1", len(loaded.Credentials))
|
||||
}
|
||||
|
||||
cred := loaded.Credentials["google-antigravity"]
|
||||
if cred == nil {
|
||||
t.Fatal("google-antigravity credential missing")
|
||||
}
|
||||
if cred.AccessToken != "fresh-token" {
|
||||
t.Fatalf("AccessToken = %q, want %q", cred.AccessToken, "fresh-token")
|
||||
}
|
||||
if cred.RefreshToken != "legacy-refresh" {
|
||||
t.Fatalf("RefreshToken = %q, want %q", cred.RefreshToken, "legacy-refresh")
|
||||
}
|
||||
if cred.Email != "legacy@example.com" {
|
||||
t.Fatalf("Email = %q, want %q", cred.Email, "legacy@example.com")
|
||||
}
|
||||
if cred.ProjectID != "project-2" {
|
||||
t.Fatalf("ProjectID = %q, want %q", cred.ProjectID, "project-2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCredentialReplacesLegacyAntigravityEntry(t *testing.T) {
|
||||
tmpDir := setTestAuthHome(t)
|
||||
|
||||
legacyStore := map[string]any{
|
||||
"credentials": map[string]any{
|
||||
"antigravity": map[string]any{
|
||||
"access_token": "legacy-token",
|
||||
"expires_at": time.Date(2026, 4, 16, 10, 0, 0, 0, time.UTC).Format(time.RFC3339),
|
||||
"provider": "antigravity",
|
||||
"auth_method": "oauth",
|
||||
},
|
||||
},
|
||||
}
|
||||
data, err := json.Marshal(legacyStore)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal() error: %v", err)
|
||||
}
|
||||
path := filepath.Join(tmpDir, ".picoclaw", "auth.json")
|
||||
err = os.MkdirAll(filepath.Dir(path), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("MkdirAll() error: %v", err)
|
||||
}
|
||||
err = os.WriteFile(path, data, 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
refreshedExpiry := time.Date(2026, 4, 16, 12, 30, 0, 0, time.UTC)
|
||||
err = SetCredential("google-antigravity", &AuthCredential{
|
||||
AccessToken: "fresh-token",
|
||||
ExpiresAt: refreshedExpiry,
|
||||
Provider: "google-antigravity",
|
||||
AuthMethod: "oauth",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SetCredential() error: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadStore()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadStore() error: %v", err)
|
||||
}
|
||||
if len(loaded.Credentials) != 1 {
|
||||
t.Fatalf("credential count = %d, want 1", len(loaded.Credentials))
|
||||
}
|
||||
|
||||
cred := loaded.Credentials["google-antigravity"]
|
||||
if cred == nil {
|
||||
t.Fatal("google-antigravity credential missing")
|
||||
}
|
||||
if cred.AccessToken != "fresh-token" {
|
||||
t.Fatalf("AccessToken = %q, want %q", cred.AccessToken, "fresh-token")
|
||||
}
|
||||
if !cred.ExpiresAt.Equal(refreshedExpiry) {
|
||||
t.Fatalf("ExpiresAt = %v, want %v", cred.ExpiresAt, refreshedExpiry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteCredentialRemovesLegacyAntigravityAlias(t *testing.T) {
|
||||
tmpDir := setTestAuthHome(t)
|
||||
|
||||
legacyStore := map[string]any{
|
||||
"credentials": map[string]any{
|
||||
"antigravity": map[string]any{
|
||||
"access_token": "legacy-token",
|
||||
"provider": "antigravity",
|
||||
"auth_method": "oauth",
|
||||
},
|
||||
},
|
||||
}
|
||||
data, err := json.Marshal(legacyStore)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal() error: %v", err)
|
||||
}
|
||||
path := filepath.Join(tmpDir, ".picoclaw", "auth.json")
|
||||
err = os.MkdirAll(filepath.Dir(path), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("MkdirAll() error: %v", err)
|
||||
}
|
||||
err = os.WriteFile(path, data, 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
err = DeleteCredential(" google-antigravity ")
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteCredential() error: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadStore()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadStore() error: %v", err)
|
||||
}
|
||||
if len(loaded.Credentials) != 0 {
|
||||
t.Fatalf("credential count = %d, want 0", len(loaded.Credentials))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCredentialCanonicalizesTrimmedMixedCaseProvider(t *testing.T) {
|
||||
setTestAuthHome(t)
|
||||
|
||||
expiresAt := time.Date(2026, 4, 16, 13, 0, 0, 0, time.UTC)
|
||||
if err := SetCredential(" AnTiGrAvItY ", &AuthCredential{
|
||||
AccessToken: "fresh-token",
|
||||
ExpiresAt: expiresAt,
|
||||
Provider: " AnTiGrAvItY ",
|
||||
AuthMethod: "oauth",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetCredential() error: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadStore()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadStore() error: %v", err)
|
||||
}
|
||||
if len(loaded.Credentials) != 1 {
|
||||
t.Fatalf("credential count = %d, want 1", len(loaded.Credentials))
|
||||
}
|
||||
|
||||
cred := loaded.Credentials["google-antigravity"]
|
||||
if cred == nil {
|
||||
t.Fatal("google-antigravity credential missing")
|
||||
}
|
||||
if cred.Provider != "google-antigravity" {
|
||||
t.Fatalf("Provider = %q, want %q", cred.Provider, "google-antigravity")
|
||||
}
|
||||
if !cred.ExpiresAt.Equal(expiresAt) {
|
||||
t.Fatalf("ExpiresAt = %v, want %v", cred.ExpiresAt, expiresAt)
|
||||
}
|
||||
|
||||
got, err := GetCredential(" GoOgLe-AnTiGrAvItY ")
|
||||
if err != nil {
|
||||
t.Fatalf("GetCredential() error: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("GetCredential() returned nil")
|
||||
}
|
||||
if got.Provider != "google-antigravity" {
|
||||
t.Fatalf("GetCredential provider = %q, want %q", got.Provider, "google-antigravity")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,6 +61,15 @@ type OutboundScope struct {
|
|||
Values map[string]string `json:"values,omitempty"`
|
||||
}
|
||||
|
||||
// ContextUsage describes how much of the model's context window the current
|
||||
// session consumes, and how far it is from triggering compression.
|
||||
type ContextUsage struct {
|
||||
UsedTokens int `json:"used_tokens"`
|
||||
TotalTokens int `json:"total_tokens"` // model context window
|
||||
CompressAtTokens int `json:"compress_at_tokens"` // threshold that triggers compression
|
||||
UsedPercent int `json:"used_percent"` // 0-100
|
||||
}
|
||||
|
||||
type OutboundMessage struct {
|
||||
Channel string `json:"channel"`
|
||||
ChatID string `json:"chat_id"`
|
||||
|
|
@ -70,6 +79,7 @@ type OutboundMessage struct {
|
|||
Scope *OutboundScope `json:"scope,omitempty"`
|
||||
Content string `json:"content"`
|
||||
ReplyToMessageID string `json:"reply_to_message_id,omitempty"`
|
||||
ContextUsage *ContextUsage `json:"context_usage,omitempty"`
|
||||
}
|
||||
|
||||
// MediaPart describes a single media attachment to send.
|
||||
|
|
|
|||
|
|
@ -296,11 +296,13 @@ func (c *PicoChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]stri
|
|||
}
|
||||
msgID := uuid.New().String()
|
||||
|
||||
outMsg := newMessage(TypeMessageCreate, map[string]any{
|
||||
payload := map[string]any{
|
||||
PayloadKeyContent: content,
|
||||
PayloadKeyThought: isThought,
|
||||
"message_id": msgID,
|
||||
})
|
||||
}
|
||||
setContextUsagePayload(payload, msg.ContextUsage)
|
||||
outMsg := newMessage(TypeMessageCreate, payload)
|
||||
|
||||
if err := c.broadcastToSession(msg.ChatID, outMsg); err != nil {
|
||||
return nil, err
|
||||
|
|
@ -826,3 +828,16 @@ func validateInlineImageDataURL(mediaURL string) error {
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setContextUsagePayload adds context window usage stats to a pico payload.
|
||||
func setContextUsagePayload(payload map[string]any, u *bus.ContextUsage) {
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
payload["context_usage"] = map[string]any{
|
||||
"used_tokens": u.UsedTokens,
|
||||
"total_tokens": u.TotalTokens,
|
||||
"compress_at_tokens": u.CompressAtTokens,
|
||||
"used_percent": u.UsedPercent,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ func BuiltinDefinitions() []Definition {
|
|||
switchCommand(),
|
||||
checkCommand(),
|
||||
clearCommand(),
|
||||
contextCommand(),
|
||||
subagentsCommand(),
|
||||
reloadCommand(),
|
||||
}
|
||||
|
|
|
|||
42
pkg/commands/cmd_context.go
Normal file
42
pkg/commands/cmd_context.go
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func contextCommand() Definition {
|
||||
return Definition{
|
||||
Name: "context",
|
||||
Description: "Show current session context and token usage",
|
||||
Usage: "/context",
|
||||
Handler: func(_ context.Context, req Request, rt *Runtime) error {
|
||||
if rt == nil || rt.GetContextStats == nil {
|
||||
return req.Reply(unavailableMsg)
|
||||
}
|
||||
stats := rt.GetContextStats()
|
||||
if stats == nil {
|
||||
return req.Reply("No active session context.")
|
||||
}
|
||||
return req.Reply(formatContextStats(stats))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func formatContextStats(s *ContextStats) string {
|
||||
remaining := s.CompressAtTokens - s.UsedTokens
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
usedWindowPercent := s.UsedTokens * 100 / max(s.TotalTokens, 1)
|
||||
return fmt.Sprintf(
|
||||
"Context usage \nMessages: %d \nUsed: ~%d / %d tokens (%d%%) \nCompress at: %d tokens \nCompression progress: %d%% \nRemaining: ~%d tokens",
|
||||
s.MessageCount,
|
||||
s.UsedTokens,
|
||||
s.TotalTokens,
|
||||
usedWindowPercent,
|
||||
s.CompressAtTokens,
|
||||
s.UsedPercent,
|
||||
remaining,
|
||||
)
|
||||
}
|
||||
|
|
@ -6,6 +6,15 @@ import (
|
|||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
// ContextStats describes current session context window usage.
|
||||
type ContextStats struct {
|
||||
UsedTokens int
|
||||
TotalTokens int // model context window
|
||||
CompressAtTokens int // compression threshold
|
||||
UsedPercent int // 0-100
|
||||
MessageCount int
|
||||
}
|
||||
|
||||
// Runtime provides runtime dependencies to command handlers. It is constructed
|
||||
// per-request by the agent loop so that per-request state (like session scope)
|
||||
// can coexist with long-lived callbacks (like GetModelInfo).
|
||||
|
|
@ -18,6 +27,7 @@ type Runtime struct {
|
|||
ListSkillNames func() []string
|
||||
GetEnabledChannels func() []string
|
||||
GetActiveTurn func() any // Returning any to avoid circular dependency with agent package
|
||||
GetContextStats func() *ContextStats
|
||||
SwitchModel func(value string) (oldModel string, err error)
|
||||
SwitchChannel func(value string) error
|
||||
ClearHistory func() error
|
||||
|
|
|
|||
|
|
@ -22,6 +22,11 @@ import (
|
|||
type FlexibleStringSlice []string
|
||||
|
||||
func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
|
||||
if string(data) == "null" {
|
||||
*f = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// Accept a single JSON string for convenience, e.g.:
|
||||
// "text": "Thinking..."
|
||||
var singleString string
|
||||
|
|
|
|||
|
|
@ -1258,6 +1258,11 @@ func TestFlexibleStringSlice_UnmarshalJSON(t *testing.T) {
|
|||
input string
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "null",
|
||||
input: `null`,
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "single string",
|
||||
input: `"Thinking..."`,
|
||||
|
|
@ -1286,6 +1291,12 @@ func TestFlexibleStringSlice_UnmarshalJSON(t *testing.T) {
|
|||
if err := json.Unmarshal([]byte(tt.input), &f); err != nil {
|
||||
t.Fatalf("json.Unmarshal(%s) error = %v", tt.input, err)
|
||||
}
|
||||
if tt.expected == nil {
|
||||
if f != nil {
|
||||
t.Fatalf("json.Unmarshal(%s) = %#v, want nil slice", tt.input, f)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(f) != len(tt.expected) {
|
||||
t.Fatalf("json.Unmarshal(%s) len = %d, want %d", tt.input, len(f), len(tt.expected))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ func postStartPlatformIsolation(cmd *exec.Cmd, isolation config.IsolationConfig,
|
|||
|
||||
info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{}
|
||||
info.BasicLimitInformation.LimitFlags = windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
|
||||
if _, err := windows.SetInformationJobObject(
|
||||
if _, err = windows.SetInformationJobObject(
|
||||
job,
|
||||
windows.JobObjectExtendedLimitInformation,
|
||||
uintptr(unsafe.Pointer(&info)),
|
||||
|
|
|
|||
|
|
@ -121,23 +121,18 @@ When a gateway process is started by the launcher, the launcher:
|
|||
|
||||
### Launcher Authentication
|
||||
|
||||
The dashboard is protected by a launcher access token.
|
||||
The dashboard is protected by password login.
|
||||
|
||||
- If `PICOCLAW_LAUNCHER_TOKEN` is set, that token is used.
|
||||
- Otherwise a random token is generated for each launcher process.
|
||||
- The browser auto-open URL includes `?token=...` so local launches can sign in automatically.
|
||||
- First run uses `/launcher-setup` to create the dashboard password.
|
||||
- Manual login uses `/launcher-login`.
|
||||
- API clients may also authenticate with `Authorization: Bearer <token>`.
|
||||
|
||||
Where users can retrieve the token depends on launch mode:
|
||||
|
||||
- Console mode: printed to stdout
|
||||
- GUI mode: available through the tray menu on supported builds
|
||||
- GUI mode without stdout:
|
||||
- random per-run tokens are written to the launcher log
|
||||
- default log path: `~/.picoclaw/logs/launcher.log`
|
||||
- if `PICOCLAW_HOME` is set, use `$PICOCLAW_HOME/logs/launcher.log`
|
||||
- env-pinned tokens are not reprinted there; the log only notes that `PICOCLAW_LAUNCHER_TOKEN` is in use
|
||||
- Successful login sets an HttpOnly session cookie.
|
||||
- Existing sessions are invalidated when the launcher process restarts; otherwise the browser cookie expires after 31 days.
|
||||
- When the launcher auto-opens a local browser after startup, it uses a one-shot loopback-only bootstrap endpoint to set the session cookie automatically.
|
||||
- On supported platforms, the password is stored as a bcrypt hash in `launcher-auth.db`.
|
||||
- On platforms where the SQLite password store is unavailable, the launcher stores the bcrypt hash in `launcher-config.json`.
|
||||
- Legacy `launcher_token` values are migrated once into password login and are removed from saved launcher config.
|
||||
- `PICOCLAW_LAUNCHER_TOKEN` is deprecated and ignored; after upgrading from env-token auth, open `/launcher-setup` to create a password.
|
||||
- URL token login and `Authorization: Bearer` dashboard auth are not supported.
|
||||
|
||||
### Network Exposure
|
||||
|
||||
|
|
@ -155,7 +150,7 @@ With `-public` or `public: true`, it listens on all interfaces:
|
|||
|
||||
When public access is enabled:
|
||||
|
||||
- the launcher can still protect the dashboard with the access token
|
||||
- the launcher still protects the dashboard with password login
|
||||
- optional `allowed_cidrs` can restrict which client IP ranges may connect
|
||||
- the gateway host is overridden so remote clients can still use the launcher-managed proxy paths
|
||||
|
||||
|
|
@ -336,19 +331,8 @@ web/
|
|||
### You have to sign in again after the launcher restarts
|
||||
|
||||
Existing dashboard sessions do not survive launcher restarts.
|
||||
That is expected: each launcher process generates a new signed session value, so old cookies become invalid.
|
||||
|
||||
To make re-login easier, set a stable token:
|
||||
|
||||
```bash
|
||||
export PICOCLAW_LAUNCHER_TOKEN="replace-with-a-long-random-token"
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- a stable token does not preserve the old cookie-based session by itself
|
||||
- when the launcher opens the browser automatically, it appends `?token=...` and signs in again automatically
|
||||
- if you reopen the dashboard manually, use the same stable token on `/launcher-login`
|
||||
That is expected: each launcher process generates a new session value, so old cookies become invalid.
|
||||
Sign in again with the dashboard password on `/launcher-login`.
|
||||
|
||||
### "Start Gateway" stays disabled
|
||||
|
||||
|
|
|
|||
|
|
@ -12,9 +12,8 @@ import (
|
|||
"github.com/sipeed/picoclaw/web/backend/middleware"
|
||||
)
|
||||
|
||||
// PasswordStore is the interface for bcrypt-backed dashboard password persistence.
|
||||
// Implemented by dashboardauth.Store; a nil value falls back to the legacy
|
||||
// static-token comparison.
|
||||
// PasswordStore is the interface for dashboard password persistence.
|
||||
// Implemented by dashboardauth.Store and launcherconfig.PasswordStore.
|
||||
type PasswordStore interface {
|
||||
IsInitialized(ctx context.Context) (bool, error)
|
||||
SetPassword(ctx context.Context, plain string) error
|
||||
|
|
@ -23,18 +22,13 @@ type PasswordStore interface {
|
|||
|
||||
// LauncherAuthRouteOpts configures dashboard auth handlers.
|
||||
type LauncherAuthRouteOpts struct {
|
||||
// DashboardToken is the fallback plaintext token used when PasswordStore is
|
||||
// nil or not yet initialized (env-var / config-file source, and ?token= auto-login).
|
||||
DashboardToken string
|
||||
SessionCookie string
|
||||
SecureCookie func(*http.Request) bool
|
||||
// PasswordStore enables bcrypt-backed password persistence. When non-nil and
|
||||
// initialized, web-form login verifies against the stored hash instead of
|
||||
// the plaintext DashboardToken.
|
||||
SessionCookie string
|
||||
SecureCookie func(*http.Request) bool
|
||||
// PasswordStore enables password login. It must be non-nil for auth to work.
|
||||
PasswordStore PasswordStore
|
||||
// StoreError holds the error returned when opening the password store. When
|
||||
// non-nil and PasswordStore is nil, the auth endpoints surface a recovery
|
||||
// message instead of an opaque 501/503.
|
||||
// non-nil and PasswordStore is nil, auth endpoints fail closed with a
|
||||
// recovery message.
|
||||
StoreError error
|
||||
}
|
||||
|
||||
|
|
@ -59,7 +53,6 @@ func RegisterLauncherAuthRoutes(mux *http.ServeMux, opts LauncherAuthRouteOpts)
|
|||
secure = middleware.DefaultLauncherDashboardSecureCookie
|
||||
}
|
||||
h := &launcherAuthHandlers{
|
||||
token: opts.DashboardToken,
|
||||
sessionCookie: opts.SessionCookie,
|
||||
secureCookie: secure,
|
||||
store: opts.PasswordStore,
|
||||
|
|
@ -73,7 +66,6 @@ func RegisterLauncherAuthRoutes(mux *http.ServeMux, opts LauncherAuthRouteOpts)
|
|||
}
|
||||
|
||||
type launcherAuthHandlers struct {
|
||||
token string
|
||||
sessionCookie string
|
||||
secureCookie func(*http.Request) bool
|
||||
store PasswordStore
|
||||
|
|
@ -81,29 +73,18 @@ type launcherAuthHandlers struct {
|
|||
loginLimit *loginRateLimiter
|
||||
}
|
||||
|
||||
func (h *launcherAuthHandlers) usesLegacyTokenAuth() bool {
|
||||
return h.store == nil && h.storeErr == nil && h.token != ""
|
||||
}
|
||||
|
||||
// isStoreInitialized safely queries the store.
|
||||
// Returns (true, nil) when legacy token auth is active without a password store.
|
||||
// Returns (false, nil) when no store/token fallback is configured.
|
||||
// Returns (false, err) on store errors — callers must treat this as a 5xx, not as
|
||||
// "uninitialized", to keep auth fail-closed.
|
||||
// Exception: handleLogin swallows storeErr and falls back to token auth so
|
||||
// that a corrupt DB does not lock out all access.
|
||||
func (h *launcherAuthHandlers) isStoreInitialized(ctx context.Context) (bool, error) {
|
||||
if h.store == nil {
|
||||
if h.storeErr != nil {
|
||||
return false, fmt.Errorf(
|
||||
"password store unavailable (%w); "+
|
||||
"to recover, stop the application, delete the database file and restart ",
|
||||
"to recover, stop the application, reset dashboard password storage, and restart",
|
||||
h.storeErr)
|
||||
}
|
||||
if h.usesLegacyTokenAuth() {
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
return false, fmt.Errorf("password store not configured")
|
||||
}
|
||||
return h.store.IsInitialized(ctx)
|
||||
}
|
||||
|
|
@ -123,35 +104,25 @@ func (h *launcherAuthHandlers) handleLogin(w http.ResponseWriter, r *http.Reques
|
|||
return
|
||||
}
|
||||
in := strings.TrimSpace(body.Password)
|
||||
var ok bool
|
||||
|
||||
initialized, initErr := h.isStoreInitialized(r.Context())
|
||||
if initErr != nil {
|
||||
if h.storeErr != nil {
|
||||
// Store failed to open at startup — token login remains available.
|
||||
initialized = false
|
||||
} else {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
writeErrorf(w, "%v", initErr)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
writeErrorf(w, "%v", initErr)
|
||||
return
|
||||
}
|
||||
if !initialized {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
_, _ = w.Write([]byte(`{"error":"password has not been set"}`))
|
||||
return
|
||||
}
|
||||
|
||||
if initialized && h.store != nil {
|
||||
// Bcrypt path: verify against the stored hash.
|
||||
var err error
|
||||
ok, err = h.store.VerifyPassword(r.Context(), in)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
writeErrorf(w, "password verification failed: %v", err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Fallback: constant-time compare against the plaintext token.
|
||||
ok = len(in) == len(h.token) &&
|
||||
subtle.ConstantTimeCompare([]byte(in), []byte(h.token)) == 1
|
||||
ok, err := h.store.VerifyPassword(r.Context(), in)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
writeErrorf(w, "password verification failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"error":"invalid password"}`))
|
||||
|
|
@ -221,22 +192,19 @@ func (h *launcherAuthHandlers) handleStatus(w http.ResponseWriter, r *http.Reque
|
|||
// handleSetup sets or changes the dashboard password.
|
||||
//
|
||||
// Rules:
|
||||
// - If the store has no password yet, the endpoint is open (no session required).
|
||||
// - If the store has no password yet, anyone who can reach the setup endpoint
|
||||
// may initialize the password.
|
||||
// - If a password is already set, the caller must hold a valid session cookie.
|
||||
func (h *launcherAuthHandlers) handleSetup(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if h.usesLegacyTokenAuth() {
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
_, _ = w.Write(
|
||||
[]byte(`{"error":"password setup is unavailable on this platform; use the dashboard token instead"}`),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if h.store == nil {
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
_, _ = w.Write([]byte(`{"error":"password store not configured"}`))
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
if h.storeErr != nil {
|
||||
writeErrorf(w, "password store unavailable: %v", h.storeErr)
|
||||
} else {
|
||||
_, _ = w.Write([]byte(`{"error":"password store not configured"}`))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@ package api
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
|
|
@ -12,17 +14,43 @@ import (
|
|||
"github.com/sipeed/picoclaw/web/backend/middleware"
|
||||
)
|
||||
|
||||
func TestLauncherAuthLoginAndStatus(t *testing.T) {
|
||||
key := make([]byte, 32)
|
||||
for i := range key {
|
||||
key[i] = 0x55
|
||||
type fakePasswordStore struct {
|
||||
initialized bool
|
||||
password string
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *fakePasswordStore) IsInitialized(context.Context) (bool, error) {
|
||||
if s.err != nil {
|
||||
return false, s.err
|
||||
}
|
||||
const tok = "dashboard-test-token-9"
|
||||
sess := middleware.SessionCookieValue(key, tok)
|
||||
return s.initialized, nil
|
||||
}
|
||||
|
||||
func (s *fakePasswordStore) SetPassword(_ context.Context, plain string) error {
|
||||
if s.err != nil {
|
||||
return s.err
|
||||
}
|
||||
s.password = plain
|
||||
s.initialized = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakePasswordStore) VerifyPassword(_ context.Context, plain string) (bool, error) {
|
||||
if s.err != nil {
|
||||
return false, s.err
|
||||
}
|
||||
return s.initialized && plain == s.password, nil
|
||||
}
|
||||
|
||||
func TestLauncherAuthLoginAndStatus(t *testing.T) {
|
||||
const password = "dashboard-test-password"
|
||||
const sess = "session-cookie-value"
|
||||
store := &fakePasswordStore{initialized: true, password: password}
|
||||
mux := http.NewServeMux()
|
||||
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
|
||||
DashboardToken: tok,
|
||||
SessionCookie: sess,
|
||||
SessionCookie: sess,
|
||||
PasswordStore: store,
|
||||
})
|
||||
|
||||
t.Run("status_unauthenticated", func(t *testing.T) {
|
||||
|
|
@ -45,7 +73,7 @@ func TestLauncherAuthLoginAndStatus(t *testing.T) {
|
|||
|
||||
t.Run("login_ok", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"password":"`+tok+`"}`))
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"password":"`+password+`"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.RemoteAddr = "127.0.0.1:12345"
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
|
@ -75,14 +103,13 @@ func TestLauncherAuthLoginAndStatus(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestLauncherAuthLegacyTokenFallbackReportsInitialized(t *testing.T) {
|
||||
key := make([]byte, 32)
|
||||
const tok = "legacy-fallback-token"
|
||||
sess := middleware.SessionCookieValue(key, tok)
|
||||
func TestLauncherAuthUninitializedStoreRequiresSetup(t *testing.T) {
|
||||
const sess = "session-cookie-value"
|
||||
store := &fakePasswordStore{}
|
||||
mux := http.NewServeMux()
|
||||
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
|
||||
DashboardToken: tok,
|
||||
SessionCookie: sess,
|
||||
SessionCookie: sess,
|
||||
PasswordStore: store,
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
|
|
@ -98,29 +125,80 @@ func TestLauncherAuthLegacyTokenFallbackReportsInitialized(t *testing.T) {
|
|||
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !body.Initialized {
|
||||
t.Fatalf("initialized = false, want true in legacy token fallback mode")
|
||||
if body.Initialized {
|
||||
t.Fatalf("initialized = true, want false before setup")
|
||||
}
|
||||
if body.Authenticated {
|
||||
t.Fatalf("unexpected authenticated=true: %+v", body)
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"password":"`+tok+`"}`))
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"password":"not-set-yet"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("login before setup code = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/auth/setup",
|
||||
strings.NewReader(`{"password":"12345678","confirm":"12345678"}`),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("login code = %d body=%s", rec.Code, rec.Body.String())
|
||||
t.Fatalf("setup code = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"password":"12345678"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("login after setup code = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLauncherAuthSetupRejectedInLegacyTokenFallback(t *testing.T) {
|
||||
key := make([]byte, 32)
|
||||
sess := middleware.SessionCookieValue(key, "legacy-token")
|
||||
func TestLauncherAuthSetupRequiresSessionWhenInitialized(t *testing.T) {
|
||||
const sess = "session-cookie-value"
|
||||
store := &fakePasswordStore{initialized: true, password: "old-password"}
|
||||
mux := http.NewServeMux()
|
||||
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
|
||||
DashboardToken: "legacy-token",
|
||||
SessionCookie: sess,
|
||||
SessionCookie: sess,
|
||||
PasswordStore: store,
|
||||
})
|
||||
|
||||
body := strings.NewReader(`{"password":"new-password","confirm":"new-password"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/setup", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("setup without session code = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
body = strings.NewReader(`{"password":"new-password","confirm":"new-password"}`)
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/auth/setup", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(&http.Cookie{Name: middleware.LauncherDashboardCookieName, Value: sess})
|
||||
rec = httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("setup with session code = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if store.password != "new-password" {
|
||||
t.Fatalf("password = %q, want new-password", store.password)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLauncherAuthInitialSetupAllowsDirectSetup(t *testing.T) {
|
||||
store := &fakePasswordStore{}
|
||||
mux := http.NewServeMux()
|
||||
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
|
||||
SessionCookie: "session-cookie-value",
|
||||
PasswordStore: store,
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
|
|
@ -131,18 +209,46 @@ func TestLauncherAuthSetupRejectedInLegacyTokenFallback(t *testing.T) {
|
|||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNotImplemented {
|
||||
t.Fatalf("setup code = %d body=%s", rec.Code, rec.Body.String())
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("setup without grant code = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLauncherAuthStoreUnavailableFailsClosed(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
|
||||
SessionCookie: "session-cookie-value",
|
||||
StoreError: errors.New("open auth store"),
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
}{
|
||||
{name: "status", method: http.MethodGet, path: "/api/auth/status"},
|
||||
{name: "login", method: http.MethodPost, path: "/api/auth/login", body: `{"password":"password"}`},
|
||||
{name: "setup", method: http.MethodPost, path: "/api/auth/setup", body: `{"password":"12345678","confirm":"12345678"}`},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))
|
||||
if tc.body != "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("code = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLauncherAuthLogoutRequiresPostAndJSON(t *testing.T) {
|
||||
key := make([]byte, 32)
|
||||
sess := middleware.SessionCookieValue(key, "tok")
|
||||
mux := http.NewServeMux()
|
||||
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
|
||||
DashboardToken: "tok",
|
||||
SessionCookie: sess,
|
||||
SessionCookie: "session-cookie-value",
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
|
|
@ -169,16 +275,14 @@ func TestLauncherAuthLogoutRequiresPostAndJSON(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestLauncherAuthLoginRateLimit(t *testing.T) {
|
||||
key := make([]byte, 32)
|
||||
const tok = "rate-limit-tok-xxxxxxxx"
|
||||
sess := middleware.SessionCookieValue(key, tok)
|
||||
store := &fakePasswordStore{initialized: true, password: "correct-password"}
|
||||
mux := http.NewServeMux()
|
||||
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
|
||||
DashboardToken: tok,
|
||||
SessionCookie: sess,
|
||||
SessionCookie: "session-cookie-value",
|
||||
PasswordStore: store,
|
||||
})
|
||||
|
||||
// 11 failing logins by wrong token; each consumes allow() slot after valid JSON.
|
||||
// 11 failing logins by wrong password; each consumes allow() slot after valid JSON.
|
||||
wrongBody := `{"password":"wrong"}`
|
||||
for i := 0; i < loginAttemptsPerIP; i++ {
|
||||
rec := httptest.NewRecorder()
|
||||
|
|
@ -231,12 +335,9 @@ func TestReferrerPolicyMiddleware(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestLauncherAuthLogoutEmptyBody(t *testing.T) {
|
||||
key := make([]byte, 32)
|
||||
sess := middleware.SessionCookieValue(key, "tok")
|
||||
mux := http.NewServeMux()
|
||||
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
|
||||
DashboardToken: "tok",
|
||||
SessionCookie: sess,
|
||||
SessionCookie: "session-cookie-value",
|
||||
})
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil)
|
||||
|
|
@ -249,12 +350,9 @@ func TestLauncherAuthLogoutEmptyBody(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestLauncherAuthLogoutRejectsTrailingJSON(t *testing.T) {
|
||||
key := make([]byte, 32)
|
||||
sess := middleware.SessionCookieValue(key, "tok")
|
||||
mux := http.NewServeMux()
|
||||
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
|
||||
DashboardToken: "tok",
|
||||
SessionCookie: sess,
|
||||
SessionCookie: "session-cookie-value",
|
||||
})
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", strings.NewReader(`{}{}`))
|
||||
|
|
|
|||
|
|
@ -56,13 +56,22 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
var cfg config.Config
|
||||
if err = json.Unmarshal(body, &cfg); err != nil {
|
||||
var raw map[string]any
|
||||
if err = json.Unmarshal(body, &raw); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var raw map[string]any
|
||||
if err = json.Unmarshal(body, &raw); err != nil {
|
||||
if err = normalizeChannelArrayFields(raw); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Invalid channel array field: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
normalizedBody, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to normalize config payload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var cfg config.Config
|
||||
if err = json.Unmarshal(normalizedBody, &cfg); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
|
@ -154,6 +163,10 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
// Recursively merge patch into base
|
||||
mergeMap(base, patch)
|
||||
if err = normalizeChannelArrayFields(base); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Invalid channel array field: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Convert merged map back to Config struct
|
||||
merged, err := json.Marshal(base)
|
||||
|
|
@ -382,6 +395,184 @@ func asMapField(value map[string]any, key string) (map[string]any, bool) {
|
|||
return m, isMap
|
||||
}
|
||||
|
||||
var (
|
||||
allowFromHiddenCharsRe = regexp.MustCompile("[\u200B\u200C\u200D\u200E\u200F\u202A-\u202E\u2060-\u2069\uFEFF]")
|
||||
allowFromSplitRe = regexp.MustCompile("[,\uFF0C、;;\r\n\t]+")
|
||||
conservativeSplitRe = regexp.MustCompile("[,\uFF0C\r\n\t]+")
|
||||
)
|
||||
|
||||
type stringArrayParserOptions struct {
|
||||
stripHiddenChars bool
|
||||
}
|
||||
|
||||
func normalizeChannelArrayFields(raw map[string]any) error {
|
||||
channelsMap, hasChannels := asMapField(raw, "channel_list")
|
||||
if !hasChannels {
|
||||
return nil
|
||||
}
|
||||
|
||||
defaultCfg := config.DefaultConfig()
|
||||
for channelName, rawChannel := range channelsMap {
|
||||
chMap, ok := rawChannel.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if rawAllowFrom, exists := chMap["allow_from"]; exists {
|
||||
normalized, err := normalizeStringArrayValue(rawAllowFrom, stringArrayParserOptions{
|
||||
stripHiddenChars: true,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("channel_list.%s.allow_from: %w", channelName, err)
|
||||
}
|
||||
chMap["allow_from"] = normalized
|
||||
}
|
||||
|
||||
if groupTrigger, ok := asMapField(chMap, "group_trigger"); ok {
|
||||
if rawPrefixes, exists := groupTrigger["prefixes"]; exists {
|
||||
normalized, err := normalizeStringArrayValue(rawPrefixes, stringArrayParserOptions{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("channel_list.%s.group_trigger.prefixes: %w", channelName, err)
|
||||
}
|
||||
groupTrigger["prefixes"] = normalized
|
||||
}
|
||||
}
|
||||
|
||||
settingsMap, hasSettings := asMapField(chMap, "settings")
|
||||
if !hasSettings {
|
||||
continue
|
||||
}
|
||||
|
||||
settingsType := channelSettingsType(defaultCfg, channelName, chMap)
|
||||
if settingsType == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for i := range settingsType.NumField() {
|
||||
field := settingsType.Field(i)
|
||||
if !field.IsExported() || !isStringSliceType(field.Type) {
|
||||
continue
|
||||
}
|
||||
jsonKey := strings.Split(field.Tag.Get("json"), ",")[0]
|
||||
if jsonKey == "" || jsonKey == "-" {
|
||||
continue
|
||||
}
|
||||
rawValue, exists := settingsMap[jsonKey]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
|
||||
options := stringArrayParserOptions{}
|
||||
if jsonKey == "allow_from" {
|
||||
options.stripHiddenChars = true
|
||||
}
|
||||
normalized, err := normalizeStringArrayValue(rawValue, options)
|
||||
if err != nil {
|
||||
return fmt.Errorf("channel_list.%s.settings.%s: %w", channelName, jsonKey, err)
|
||||
}
|
||||
settingsMap[jsonKey] = normalized
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func channelSettingsType(
|
||||
defaultCfg *config.Config,
|
||||
channelName string,
|
||||
channelMap map[string]any,
|
||||
) reflect.Type {
|
||||
if channelType, _ := channelMap["type"].(string); channelType != "" {
|
||||
if bc := defaultCfg.Channels.GetByType(channelType); bc != nil {
|
||||
if decoded, err := bc.GetDecoded(); err == nil && decoded != nil {
|
||||
return derefType(reflect.TypeOf(decoded))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if bc := defaultCfg.Channels.Get(channelName); bc != nil {
|
||||
if decoded, err := bc.GetDecoded(); err == nil && decoded != nil {
|
||||
return derefType(reflect.TypeOf(decoded))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func derefType(typ reflect.Type) reflect.Type {
|
||||
for typ != nil && typ.Kind() == reflect.Ptr {
|
||||
typ = typ.Elem()
|
||||
}
|
||||
return typ
|
||||
}
|
||||
|
||||
func isStringSliceType(typ reflect.Type) bool {
|
||||
typ = derefType(typ)
|
||||
return typ != nil && typ.Kind() == reflect.Slice && typ.Elem().Kind() == reflect.String
|
||||
}
|
||||
|
||||
func normalizeStringArrayValue(value any, options stringArrayParserOptions) ([]string, error) {
|
||||
switch typed := value.(type) {
|
||||
case nil:
|
||||
return nil, nil
|
||||
case string:
|
||||
return parseStringArrayValue(typed, options), nil
|
||||
case float64:
|
||||
return normalizeStringArrayItems([]string{fmt.Sprintf("%.0f", typed)}, options), nil
|
||||
case []string:
|
||||
return normalizeStringArrayItems(typed, options), nil
|
||||
case []any:
|
||||
items := make([]string, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
switch raw := item.(type) {
|
||||
case string:
|
||||
items = append(items, raw)
|
||||
case float64:
|
||||
items = append(items, fmt.Sprintf("%.0f", raw))
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported list item type %T", item)
|
||||
}
|
||||
}
|
||||
return normalizeStringArrayItems(items, options), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported list field type %T", value)
|
||||
}
|
||||
}
|
||||
|
||||
func parseStringArrayValue(raw string, options stringArrayParserOptions) []string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return []string{}
|
||||
}
|
||||
splitRe := conservativeSplitRe
|
||||
if options.stripHiddenChars {
|
||||
splitRe = allowFromSplitRe
|
||||
}
|
||||
return normalizeStringArrayItems(splitRe.Split(raw, -1), options)
|
||||
}
|
||||
|
||||
func normalizeStringArrayItems(items []string, options stringArrayParserOptions) []string {
|
||||
result := make([]string, 0, len(items))
|
||||
seen := make(map[string]struct{}, len(items))
|
||||
for _, item := range items {
|
||||
normalized := item
|
||||
if options.stripHiddenChars {
|
||||
normalized = allowFromHiddenCharsRe.ReplaceAllString(normalized, "")
|
||||
}
|
||||
normalized = strings.TrimSpace(normalized)
|
||||
if normalized == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[normalized]; exists {
|
||||
continue
|
||||
}
|
||||
seen[normalized] = struct{}{}
|
||||
result = append(result, normalized)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return []string{}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func getSecretString(m map[string]any, key string) (string, bool) {
|
||||
if raw, exists := m[key]; exists {
|
||||
s, isString := raw.(string)
|
||||
|
|
|
|||
|
|
@ -230,6 +230,285 @@ func TestHandlePatchConfig_SavesChannelListSettingsPatch(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestHandlePatchConfig_NormalizesStringChannelArrayFields(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
|
||||
"channel_list": {
|
||||
"pico": {
|
||||
"type": "pico",
|
||||
"allow_from": " ou_a\u200b,\u2060ou_b\tou_c\u202e,ou_a ",
|
||||
"group_trigger": {
|
||||
"prefixes": "/,!;\n?,/"
|
||||
},
|
||||
"settings": {
|
||||
"allow_origins": "https://a.example.com,http://localhost:5173,https://a.example.com"
|
||||
}
|
||||
},
|
||||
"irc": {
|
||||
"type": "irc",
|
||||
"settings": {
|
||||
"channels": "#ops,\n#dev,\n#ops",
|
||||
"request_caps": "multi-prefix,echo-message\tbatch,multi-prefix"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
|
||||
picoChannel := cfg.Channels[config.ChannelPico]
|
||||
if len(picoChannel.AllowFrom) != 3 ||
|
||||
picoChannel.AllowFrom[0] != "ou_a" ||
|
||||
picoChannel.AllowFrom[1] != "ou_b" ||
|
||||
picoChannel.AllowFrom[2] != "ou_c" {
|
||||
t.Fatalf("pico allow_from = %#v, want [\"ou_a\", \"ou_b\", \"ou_c\"]", picoChannel.AllowFrom)
|
||||
}
|
||||
if len(picoChannel.GroupTrigger.Prefixes) != 3 ||
|
||||
picoChannel.GroupTrigger.Prefixes[0] != "/" ||
|
||||
picoChannel.GroupTrigger.Prefixes[1] != "!;" ||
|
||||
picoChannel.GroupTrigger.Prefixes[2] != "?" {
|
||||
t.Fatalf(
|
||||
"pico group_trigger.prefixes = %#v, want [\"/\", \"!;\", \"?\"]",
|
||||
picoChannel.GroupTrigger.Prefixes,
|
||||
)
|
||||
}
|
||||
|
||||
decoded, err := picoChannel.GetDecoded()
|
||||
if err != nil {
|
||||
t.Fatalf("GetDecoded() pico error = %v", err)
|
||||
}
|
||||
picoCfg := decoded.(*config.PicoSettings)
|
||||
if len(picoCfg.AllowOrigins) != 2 ||
|
||||
picoCfg.AllowOrigins[0] != "https://a.example.com" ||
|
||||
picoCfg.AllowOrigins[1] != "http://localhost:5173" {
|
||||
t.Fatalf(
|
||||
"pico allow_origins = %#v, want [\"https://a.example.com\", \"http://localhost:5173\"]",
|
||||
picoCfg.AllowOrigins,
|
||||
)
|
||||
}
|
||||
|
||||
ircChannel := cfg.Channels[config.ChannelIRC]
|
||||
decoded, err = ircChannel.GetDecoded()
|
||||
if err != nil {
|
||||
t.Fatalf("GetDecoded() irc error = %v", err)
|
||||
}
|
||||
ircCfg := decoded.(*config.IRCSettings)
|
||||
if len(ircCfg.Channels) != 2 ||
|
||||
ircCfg.Channels[0] != "#ops" ||
|
||||
ircCfg.Channels[1] != "#dev" {
|
||||
t.Fatalf("irc channels = %#v, want [\"#ops\", \"#dev\"]", ircCfg.Channels)
|
||||
}
|
||||
if len(ircCfg.RequestCaps) != 3 ||
|
||||
ircCfg.RequestCaps[0] != "multi-prefix" ||
|
||||
ircCfg.RequestCaps[1] != "echo-message" ||
|
||||
ircCfg.RequestCaps[2] != "batch" {
|
||||
t.Fatalf(
|
||||
"irc request_caps = %#v, want [\"multi-prefix\", \"echo-message\", \"batch\"]",
|
||||
ircCfg.RequestCaps,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePatchConfig_NormalizesSingleNumericAllowFrom(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
|
||||
"channel_list": {
|
||||
"telegram": {
|
||||
"type": "telegram",
|
||||
"allow_from": 123456
|
||||
}
|
||||
}
|
||||
}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
telegramChannel := cfg.Channels[config.ChannelTelegram]
|
||||
if len(telegramChannel.AllowFrom) != 1 || telegramChannel.AllowFrom[0] != "123456" {
|
||||
t.Fatalf("telegram allow_from = %#v, want [\"123456\"]", telegramChannel.AllowFrom)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePatchConfig_RejectsInvalidChannelArrayFields(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
telegramChannel := cfg.Channels[config.ChannelTelegram]
|
||||
telegramChannel.AllowFrom = config.FlexibleStringSlice{"existing-user"}
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{
|
||||
name: "object allow_from",
|
||||
body: `{
|
||||
"channel_list": {
|
||||
"telegram": {
|
||||
"type": "telegram",
|
||||
"allow_from": {"id": "bad"}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "boolean allow_from",
|
||||
body: `{
|
||||
"channel_list": {
|
||||
"telegram": {
|
||||
"type": "telegram",
|
||||
"allow_from": true
|
||||
}
|
||||
}
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "object settings array",
|
||||
body: `{
|
||||
"channel_list": {
|
||||
"irc": {
|
||||
"type": "irc",
|
||||
"settings": {
|
||||
"channels": {"name": "#ops"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(tt.body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf(
|
||||
"PATCH /api/config status = %d, want %d, body=%s",
|
||||
rec.Code,
|
||||
http.StatusBadRequest,
|
||||
rec.Body.String(),
|
||||
)
|
||||
}
|
||||
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
telegramChannel := cfg.Channels[config.ChannelTelegram]
|
||||
if len(telegramChannel.AllowFrom) != 1 || telegramChannel.AllowFrom[0] != "existing-user" {
|
||||
t.Fatalf("telegram allow_from = %#v, want unchanged [\"existing-user\"]", telegramChannel.AllowFrom)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePatchConfig_ClearingAllowFromDoesNotLeaveEmptyStringItem(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
feishuChannel := cfg.Channels[config.ChannelFeishu]
|
||||
feishuChannel.Enabled = true
|
||||
feishuChannel.AllowFrom = config.FlexibleStringSlice{"ou_existing_user"}
|
||||
decoded, err := feishuChannel.GetDecoded()
|
||||
if err != nil {
|
||||
t.Fatalf("GetDecoded() error = %v", err)
|
||||
}
|
||||
feishuCfg := decoded.(*config.FeishuSettings)
|
||||
feishuCfg.AppID = "cli_existing_app"
|
||||
feishuCfg.AppSecret = *config.NewSecureString("existing-secret")
|
||||
if err = config.SaveConfig(configPath, cfg); err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
|
||||
"channel_list": {
|
||||
"feishu": {
|
||||
"enabled": true,
|
||||
"allow_from": "",
|
||||
"settings": {
|
||||
"app_id": "cli_existing_app"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
cfg, err = config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
feishuChannel = cfg.Channels[config.ChannelFeishu]
|
||||
if len(feishuChannel.AllowFrom) != 0 {
|
||||
t.Fatalf("feishu allow_from = %#v, want empty slice", feishuChannel.AllowFrom)
|
||||
}
|
||||
|
||||
configData, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(configPath) error = %v", err)
|
||||
}
|
||||
if strings.Contains(string(configData), `"allow_from": [""]`) {
|
||||
t.Fatalf("config file should not contain empty-string allow_from item: %s", string(configData))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePatchConfig_CreatesMissingChannelWithTypeAndSecret(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
|
|
|||
|
|
@ -4,16 +4,14 @@ import (
|
|||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
|
||||
)
|
||||
|
||||
type launcherConfigPayload struct {
|
||||
Port int `json:"port"`
|
||||
Public bool `json:"public"`
|
||||
AllowedCIDRs []string `json:"allowed_cidrs"`
|
||||
LauncherToken string `json:"launcher_token"`
|
||||
Port int `json:"port"`
|
||||
Public bool `json:"public"`
|
||||
AllowedCIDRs []string `json:"allowed_cidrs"`
|
||||
}
|
||||
|
||||
func (h *Handler) registerLauncherConfigRoutes(mux *http.ServeMux) {
|
||||
|
|
@ -50,10 +48,9 @@ func (h *Handler) handleGetLauncherConfig(w http.ResponseWriter, r *http.Request
|
|||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(launcherConfigPayload{
|
||||
Port: cfg.Port,
|
||||
Public: cfg.Public,
|
||||
AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...),
|
||||
LauncherToken: cfg.LauncherToken,
|
||||
Port: cfg.Port,
|
||||
Public: cfg.Public,
|
||||
AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -64,12 +61,15 @@ func (h *Handler) handleUpdateLauncherConfig(w http.ResponseWriter, r *http.Requ
|
|||
return
|
||||
}
|
||||
|
||||
cfg := launcherconfig.Config{
|
||||
Port: payload.Port,
|
||||
Public: payload.Public,
|
||||
AllowedCIDRs: append([]string(nil), payload.AllowedCIDRs...),
|
||||
LauncherToken: strings.TrimSpace(payload.LauncherToken),
|
||||
cfg, err := h.loadLauncherConfig()
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to load launcher config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
cfg.Port = payload.Port
|
||||
cfg.Public = payload.Public
|
||||
cfg.AllowedCIDRs = append([]string(nil), payload.AllowedCIDRs...)
|
||||
cfg.LegacyLauncherToken = ""
|
||||
if err := launcherconfig.Validate(cfg); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
|
|
@ -82,9 +82,8 @@ func (h *Handler) handleUpdateLauncherConfig(w http.ResponseWriter, r *http.Requ
|
|||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(launcherConfigPayload{
|
||||
Port: cfg.Port,
|
||||
Public: cfg.Public,
|
||||
AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...),
|
||||
LauncherToken: cfg.LauncherToken,
|
||||
Port: cfg.Port,
|
||||
Public: cfg.Public,
|
||||
AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
|
@ -34,9 +35,6 @@ func TestGetLauncherConfigUsesRuntimeFallback(t *testing.T) {
|
|||
if got.Port != 19999 || !got.Public {
|
||||
t.Fatalf("response = %+v, want port=19999 public=true", got)
|
||||
}
|
||||
if got.LauncherToken != "" {
|
||||
t.Fatalf("response launcher_token = %q, want empty", got.LauncherToken)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
|
@ -44,6 +42,14 @@ func TestGetLauncherConfigUsesRuntimeFallback(t *testing.T) {
|
|||
|
||||
func TestPutLauncherConfigPersists(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
path := launcherconfig.PathForAppConfig(configPath)
|
||||
if err := os.WriteFile(
|
||||
path,
|
||||
[]byte(`{"port":18800,"public":false,"dashboard_password_hash":"saved-hash","launcher_token":"legacy-token"}`),
|
||||
0o600,
|
||||
); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
h := NewHandler(configPath)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
|
@ -54,7 +60,7 @@ func TestPutLauncherConfigPersists(t *testing.T) {
|
|||
http.MethodPut,
|
||||
"/api/system/launcher-config",
|
||||
strings.NewReader(
|
||||
`{"port":18080,"public":true,"allowed_cidrs":["192.168.1.0/24"],"launcher_token":"saved-token"}`,
|
||||
`{"port":18080,"public":true,"allowed_cidrs":["192.168.1.0/24"]}`,
|
||||
),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
|
@ -64,7 +70,6 @@ func TestPutLauncherConfigPersists(t *testing.T) {
|
|||
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)
|
||||
|
|
@ -72,8 +77,11 @@ func TestPutLauncherConfigPersists(t *testing.T) {
|
|||
if cfg.Port != 18080 || !cfg.Public {
|
||||
t.Fatalf("saved config = %+v, want port=18080 public=true", cfg)
|
||||
}
|
||||
if cfg.LauncherToken != "saved-token" {
|
||||
t.Fatalf("saved launcher_token = %q, want %q", cfg.LauncherToken, "saved-token")
|
||||
if cfg.DashboardPasswordHash != "saved-hash" {
|
||||
t.Fatalf("saved dashboard_password_hash = %q, want saved-hash", cfg.DashboardPasswordHash)
|
||||
}
|
||||
if cfg.LegacyLauncherToken != "" {
|
||||
t.Fatalf("saved legacy launcher_token = %q, want empty", cfg.LegacyLauncherToken)
|
||||
}
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -460,6 +460,9 @@ func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLen
|
|||
|
||||
for _, msg := range messages {
|
||||
switch msg.Role {
|
||||
case "tool":
|
||||
continue
|
||||
|
||||
case "user":
|
||||
if sessionMessageVisible(msg) {
|
||||
transcript = append(transcript, sessionChatMessage{
|
||||
|
|
@ -510,7 +513,18 @@ func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLen
|
|||
}
|
||||
}
|
||||
|
||||
return transcript
|
||||
return filterSessionChatMessages(transcript)
|
||||
}
|
||||
|
||||
func filterSessionChatMessages(messages []sessionChatMessage) []sessionChatMessage {
|
||||
filtered := messages[:0]
|
||||
for _, msg := range messages {
|
||||
if msg.Role != "user" && msg.Role != "assistant" {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, msg)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func assistantToolCallContentDuplicated(
|
||||
|
|
@ -574,15 +588,22 @@ func visibleAssistantToolSummaryMessages(
|
|||
|
||||
messages := make([]sessionChatMessage, 0, len(toolCalls))
|
||||
for _, tc := range toolCalls {
|
||||
name := tc.Name
|
||||
if tc.Function != nil {
|
||||
if name == "" {
|
||||
name = tc.Function.Name
|
||||
name, argsJSON := toolCallNameAndArguments(tc)
|
||||
if strings.TrimSpace(name) == "" {
|
||||
continue
|
||||
}
|
||||
if name == "web_search" || name == "web_fetch" {
|
||||
continue
|
||||
}
|
||||
if name == "message" {
|
||||
if _, ok := parseMessageToolContent(argsJSON); ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(name) == "" {
|
||||
continue
|
||||
argsPreview := strings.TrimSpace(argsJSON)
|
||||
if argsPreview == "" {
|
||||
argsPreview = "{}"
|
||||
}
|
||||
|
||||
messages = append(messages, sessionChatMessage{
|
||||
|
|
@ -627,36 +648,53 @@ func visibleAssistantToolMessages(toolCalls []providers.ToolCall) []sessionChatM
|
|||
|
||||
messages := make([]sessionChatMessage, 0, len(toolCalls))
|
||||
for _, tc := range toolCalls {
|
||||
name := tc.Name
|
||||
argsJSON := ""
|
||||
if tc.Function != nil {
|
||||
if name == "" {
|
||||
name = tc.Function.Name
|
||||
}
|
||||
argsJSON = tc.Function.Arguments
|
||||
name, argsJSON := toolCallNameAndArguments(tc)
|
||||
if name != "message" {
|
||||
continue
|
||||
}
|
||||
|
||||
switch name {
|
||||
case "message":
|
||||
var args struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(args.Content) == "" {
|
||||
continue
|
||||
}
|
||||
messages = append(messages, sessionChatMessage{
|
||||
Role: "assistant",
|
||||
Content: args.Content,
|
||||
})
|
||||
content, ok := parseMessageToolContent(argsJSON)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
messages = append(messages, sessionChatMessage{
|
||||
Role: "assistant",
|
||||
Content: content,
|
||||
})
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
func toolCallNameAndArguments(tc providers.ToolCall) (string, string) {
|
||||
name := tc.Name
|
||||
argsJSON := ""
|
||||
if tc.Function != nil {
|
||||
if name == "" {
|
||||
name = tc.Function.Name
|
||||
}
|
||||
argsJSON = tc.Function.Arguments
|
||||
}
|
||||
if strings.TrimSpace(argsJSON) == "" && len(tc.Arguments) > 0 {
|
||||
if encodedArgs, err := json.Marshal(tc.Arguments); err == nil {
|
||||
argsJSON = string(encodedArgs)
|
||||
}
|
||||
}
|
||||
return name, argsJSON
|
||||
}
|
||||
|
||||
func parseMessageToolContent(argsJSON string) (string, bool) {
|
||||
var args struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
|
||||
return "", false
|
||||
}
|
||||
if strings.TrimSpace(args.Content) == "" {
|
||||
return "", false
|
||||
}
|
||||
return args.Content, true
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
|
|
|||
|
|
@ -346,7 +346,7 @@ func TestHandleGetSession_OmitsTransientThoughtMessages(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestHandleGetSession_ReconstructsVisibleMessageToolOutput(t *testing.T) {
|
||||
func TestHandleGetSession_ReconstructsVisibleMessageToolOutputWithoutDuplicateSummary(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
|
|
@ -402,14 +402,19 @@ func TestHandleGetSession_ReconstructsVisibleMessageToolOutput(t *testing.T) {
|
|||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
if len(resp.Messages) != 3 {
|
||||
t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages))
|
||||
if len(resp.Messages) != 2 {
|
||||
t.Fatalf("len(resp.Messages) = %d, want 2", len(resp.Messages))
|
||||
}
|
||||
if !strings.Contains(resp.Messages[1].Content, "`message`") {
|
||||
t.Fatalf("tool summary message = %#v, want message tool summary", resp.Messages[1])
|
||||
if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "test" {
|
||||
t.Fatalf("first message = %#v, want user/test", resp.Messages[0])
|
||||
}
|
||||
if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "visible tool output" {
|
||||
t.Fatalf("assistant message = %#v, want visible tool output", resp.Messages[2])
|
||||
if resp.Messages[1].Role != "assistant" || resp.Messages[1].Content != "visible tool output" {
|
||||
t.Fatalf("assistant message = %#v, want visible tool output", resp.Messages[1])
|
||||
}
|
||||
for _, msg := range resp.Messages {
|
||||
if msg.Role == "tool" || strings.Contains(msg.Content, "`message`") {
|
||||
t.Fatalf("unexpected raw tool or duplicate message-tool summary: %#v", msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -468,17 +473,17 @@ func TestHandleGetSession_PreservesFinalAssistantReplyAfterMessageToolOutput(t *
|
|||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
if len(resp.Messages) != 4 {
|
||||
t.Fatalf("len(resp.Messages) = %d, want 4", len(resp.Messages))
|
||||
if len(resp.Messages) != 3 {
|
||||
t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages))
|
||||
}
|
||||
if !strings.Contains(resp.Messages[1].Content, "`message`") {
|
||||
t.Fatalf("tool summary message = %#v, want message tool summary", resp.Messages[1])
|
||||
if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "test" {
|
||||
t.Fatalf("first message = %#v, want user/test", resp.Messages[0])
|
||||
}
|
||||
if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "visible tool output" {
|
||||
t.Fatalf("interim assistant message = %#v, want visible tool output", resp.Messages[2])
|
||||
if resp.Messages[1].Role != "assistant" || resp.Messages[1].Content != "visible tool output" {
|
||||
t.Fatalf("interim assistant message = %#v, want visible tool output", resp.Messages[1])
|
||||
}
|
||||
if resp.Messages[3].Role != "assistant" || resp.Messages[3].Content != "final assistant reply" {
|
||||
t.Fatalf("final assistant message = %#v, want final assistant reply", resp.Messages[3])
|
||||
if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "final assistant reply" {
|
||||
t.Fatalf("final assistant message = %#v, want final assistant reply", resp.Messages[2])
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -535,8 +540,8 @@ func TestHandleListSessions_MessageCountUsesVisibleTranscript(t *testing.T) {
|
|||
if len(items) != 1 {
|
||||
t.Fatalf("len(items) = %d, want 1", len(items))
|
||||
}
|
||||
if items[0].MessageCount != 3 {
|
||||
t.Fatalf("items[0].MessageCount = %d, want 3", items[0].MessageCount)
|
||||
if items[0].MessageCount != 2 {
|
||||
t.Fatalf("items[0].MessageCount = %d, want 2", items[0].MessageCount)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -570,6 +575,7 @@ func TestHandleGetSession_DoesNotDuplicateAssistantToolCallContent(t *testing.T)
|
|||
},
|
||||
},
|
||||
},
|
||||
{Role: "tool", Content: "raw read_file result", ToolCallID: "call_1"},
|
||||
} {
|
||||
if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
|
||||
t.Fatalf("AddFullMessage() error = %v", err)
|
||||
|
|
@ -754,6 +760,11 @@ func TestHandleGetSession_PreservesMediaWhenAssistantToolCallContentDuplicatesSu
|
|||
if len(resp.Messages[2].Media) != 1 || resp.Messages[2].Media[0] != "data:image/png;base64,abc123" {
|
||||
t.Fatalf("assistant media = %#v, want preserved media", resp.Messages[2].Media)
|
||||
}
|
||||
for _, msg := range resp.Messages {
|
||||
if msg.Role == "tool" || strings.Contains(msg.Content, "raw read_file result") {
|
||||
t.Fatalf("unexpected raw tool result in history: %#v", msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetSession_UsesConfiguredToolFeedbackMaxArgsLength(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
package launcherconfig
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
|
|
@ -16,31 +14,19 @@ const (
|
|||
FileName = "launcher-config.json"
|
||||
// DefaultPort is the default port for the web launcher.
|
||||
DefaultPort = 18800
|
||||
// EnvLauncherToken overrides launcher dashboard token.
|
||||
EnvLauncherToken = "PICOCLAW_LAUNCHER_TOKEN"
|
||||
// EnvLauncherHost overrides launcher listen host.
|
||||
EnvLauncherHost = "PICOCLAW_LAUNCHER_HOST"
|
||||
|
||||
// dashboardSigningKeyBytes is the HMAC-SHA256 key size (256 bits).
|
||||
dashboardSigningKeyBytes = 32
|
||||
// dashboardTokenEntropyBytes is CSPRNG length before base64 for the per-run dashboard token (256 bits).
|
||||
dashboardTokenEntropyBytes = 32
|
||||
)
|
||||
|
||||
type DashboardTokenSource string
|
||||
|
||||
const (
|
||||
DashboardTokenSourceEnv DashboardTokenSource = "env"
|
||||
DashboardTokenSourceConfig DashboardTokenSource = "config"
|
||||
DashboardTokenSourceRandom DashboardTokenSource = "random"
|
||||
)
|
||||
|
||||
// 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"`
|
||||
LauncherToken string `json:"launcher_token,omitempty"`
|
||||
Port int `json:"port"`
|
||||
Public bool `json:"public"`
|
||||
AllowedCIDRs []string `json:"allowed_cidrs,omitempty"`
|
||||
DashboardPasswordHash string `json:"dashboard_password_hash,omitempty"`
|
||||
// LegacyLauncherToken is read only for one-time migration from the removed
|
||||
// token login flow. Save always clears it so new configs do not persist it.
|
||||
LegacyLauncherToken string `json:"launcher_token,omitempty"`
|
||||
}
|
||||
|
||||
// Default returns default launcher settings.
|
||||
|
|
@ -61,41 +47,6 @@ func Validate(cfg Config) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// EnsureDashboardSecrets returns signing key bytes and the effective dashboard token for this
|
||||
// process. The signing key is freshly random each call; the token comes from
|
||||
// EnvLauncherToken when set, otherwise launcher-config.json launcher_token,
|
||||
// otherwise a new random token.
|
||||
func EnsureDashboardSecrets(
|
||||
cfg Config,
|
||||
) (effectiveToken string, signingKey []byte, source DashboardTokenSource, err error) {
|
||||
signingKey = make([]byte, dashboardSigningKeyBytes)
|
||||
if _, err = rand.Read(signingKey); err != nil {
|
||||
return "", nil, "", err
|
||||
}
|
||||
|
||||
effectiveToken = strings.TrimSpace(os.Getenv(EnvLauncherToken))
|
||||
if effectiveToken != "" {
|
||||
return effectiveToken, signingKey, DashboardTokenSourceEnv, nil
|
||||
}
|
||||
effectiveToken = strings.TrimSpace(cfg.LauncherToken)
|
||||
if effectiveToken != "" {
|
||||
return effectiveToken, signingKey, DashboardTokenSourceConfig, nil
|
||||
}
|
||||
tok, genErr := randomDashboardToken()
|
||||
if genErr != nil {
|
||||
return "", nil, "", genErr
|
||||
}
|
||||
return tok, signingKey, DashboardTokenSourceRandom, nil
|
||||
}
|
||||
|
||||
func randomDashboardToken() (string, error) {
|
||||
buf := make([]byte, dashboardTokenEntropyBytes)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
// NormalizeCIDRs trims entries, removes empty values, and deduplicates CIDRs.
|
||||
func NormalizeCIDRs(cidrs []string) []string {
|
||||
if len(cidrs) == 0 {
|
||||
|
|
@ -144,7 +95,8 @@ func Load(path string, fallback Config) (Config, error) {
|
|||
return Config{}, err
|
||||
}
|
||||
cfg.AllowedCIDRs = NormalizeCIDRs(cfg.AllowedCIDRs)
|
||||
cfg.LauncherToken = strings.TrimSpace(cfg.LauncherToken)
|
||||
cfg.DashboardPasswordHash = strings.TrimSpace(cfg.DashboardPasswordHash)
|
||||
cfg.LegacyLauncherToken = strings.TrimSpace(cfg.LegacyLauncherToken)
|
||||
if err := Validate(cfg); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
|
@ -154,7 +106,8 @@ func Load(path string, fallback Config) (Config, error) {
|
|||
// Save writes launcher settings to disk.
|
||||
func Save(path string, cfg Config) error {
|
||||
cfg.AllowedCIDRs = NormalizeCIDRs(cfg.AllowedCIDRs)
|
||||
cfg.LauncherToken = strings.TrimSpace(cfg.LauncherToken)
|
||||
cfg.DashboardPasswordHash = strings.TrimSpace(cfg.DashboardPasswordHash)
|
||||
cfg.LegacyLauncherToken = ""
|
||||
if err := Validate(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
package launcherconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/web/backend/middleware"
|
||||
)
|
||||
|
||||
func TestLoadReturnsFallbackWhenMissing(t *testing.T) {
|
||||
|
|
@ -25,10 +24,11 @@ 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"},
|
||||
LauncherToken: "saved-launcher-token",
|
||||
Port: 18080,
|
||||
Public: true,
|
||||
AllowedCIDRs: []string{"192.168.1.0/24", "10.0.0.0/8"},
|
||||
DashboardPasswordHash: "$2a$12$saved-dashboard-password-hash",
|
||||
LegacyLauncherToken: "legacy-token-should-not-persist",
|
||||
}
|
||||
|
||||
if err := Save(path, want); err != nil {
|
||||
|
|
@ -41,8 +41,11 @@ func TestSaveAndLoadRoundTrip(t *testing.T) {
|
|||
if got.Port != want.Port || got.Public != want.Public {
|
||||
t.Fatalf("Load() = %+v, want %+v", got, want)
|
||||
}
|
||||
if got.LauncherToken != want.LauncherToken {
|
||||
t.Fatalf("launcher_token = %q, want %q", got.LauncherToken, want.LauncherToken)
|
||||
if got.DashboardPasswordHash != want.DashboardPasswordHash {
|
||||
t.Fatalf("dashboard_password_hash = %q, want %q", got.DashboardPasswordHash, want.DashboardPasswordHash)
|
||||
}
|
||||
if got.LegacyLauncherToken != "" {
|
||||
t.Fatalf("legacy launcher_token = %q, want empty after Save", got.LegacyLauncherToken)
|
||||
}
|
||||
if len(got.AllowedCIDRs) != len(want.AllowedCIDRs) {
|
||||
t.Fatalf("allowed_cidrs len = %d, want %d", len(got.AllowedCIDRs), len(want.AllowedCIDRs))
|
||||
|
|
@ -62,6 +65,21 @@ func TestSaveAndLoadRoundTrip(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestLoadReadsLegacyLauncherTokenForMigration(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "launcher-config.json")
|
||||
if err := os.WriteFile(path, []byte(`{"port":18800,"launcher_token":"legacy-token"}`), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
got, err := Load(path, Default())
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if got.LegacyLauncherToken != "legacy-token" {
|
||||
t.Fatalf("legacy launcher_token = %q, want legacy-token", got.LegacyLauncherToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsInvalidPort(t *testing.T) {
|
||||
if err := Validate(Config{Port: 0, Public: false}); err == nil {
|
||||
t.Fatal("Validate() expected error for port 0")
|
||||
|
|
@ -81,66 +99,6 @@ func TestValidateRejectsInvalidCIDR(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestEnsureDashboardSecrets_GeneratesEphemeral(t *testing.T) {
|
||||
t.Setenv("PICOCLAW_LAUNCHER_TOKEN", "")
|
||||
|
||||
tok, key, source, err := EnsureDashboardSecrets(Default())
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureDashboardSecrets() error = %v", err)
|
||||
}
|
||||
if source != DashboardTokenSourceRandom || tok == "" || len(key) != dashboardSigningKeyBytes {
|
||||
t.Fatalf("unexpected first call: source=%q tok=%q keyLen=%d", source, tok, len(key))
|
||||
}
|
||||
mac := middleware.SessionCookieValue(key, tok)
|
||||
if mac == "" {
|
||||
t.Fatal("empty session mac")
|
||||
}
|
||||
|
||||
tok2, key2, source2, err := EnsureDashboardSecrets(Default())
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureDashboardSecrets() second error = %v", err)
|
||||
}
|
||||
if source2 != DashboardTokenSourceRandom {
|
||||
t.Fatalf("second call source = %q, want %q", source2, DashboardTokenSourceRandom)
|
||||
}
|
||||
if tok2 == tok {
|
||||
t.Fatal("expected a new random dashboard token")
|
||||
}
|
||||
if string(key2) == string(key) {
|
||||
t.Fatal("expected a new signing key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureDashboardSecrets_EnvOverridesGenerated(t *testing.T) {
|
||||
t.Setenv("PICOCLAW_LAUNCHER_TOKEN", "env-only-token-override")
|
||||
|
||||
tok, _, source, err := EnsureDashboardSecrets(Config{LauncherToken: "config-token"})
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureDashboardSecrets() error = %v", err)
|
||||
}
|
||||
if tok != "env-only-token-override" {
|
||||
t.Fatalf("token = %q, want env value", tok)
|
||||
}
|
||||
if source != DashboardTokenSourceEnv {
|
||||
t.Fatalf("source = %q, want %q", source, DashboardTokenSourceEnv)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureDashboardSecrets_ConfigOverridesGenerated(t *testing.T) {
|
||||
t.Setenv("PICOCLAW_LAUNCHER_TOKEN", "")
|
||||
|
||||
tok, _, source, err := EnsureDashboardSecrets(Config{LauncherToken: "config-token"})
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureDashboardSecrets() error = %v", err)
|
||||
}
|
||||
if tok != "config-token" {
|
||||
t.Fatalf("token = %q, want config value", tok)
|
||||
}
|
||||
if source != DashboardTokenSourceConfig {
|
||||
t.Fatalf("source = %q, want %q", source, DashboardTokenSourceConfig)
|
||||
}
|
||||
}
|
||||
|
||||
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"}
|
||||
|
|
@ -153,3 +111,42 @@ func TestNormalizeCIDRs(t *testing.T) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordStoreSetAndVerify(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "launcher-config.json")
|
||||
store := NewPasswordStore(path, Default())
|
||||
ctx := context.Background()
|
||||
|
||||
initialized, err := store.IsInitialized(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("IsInitialized() error = %v", err)
|
||||
}
|
||||
if initialized {
|
||||
t.Fatal("IsInitialized() = true, want false before SetPassword")
|
||||
}
|
||||
|
||||
if err = store.SetPassword(ctx, "dashboard-password"); err != nil {
|
||||
t.Fatalf("SetPassword() error = %v", err)
|
||||
}
|
||||
initialized, err = store.IsInitialized(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("IsInitialized() after SetPassword error = %v", err)
|
||||
}
|
||||
if !initialized {
|
||||
t.Fatal("IsInitialized() = false, want true after SetPassword")
|
||||
}
|
||||
ok, err := store.VerifyPassword(ctx, "dashboard-password")
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyPassword() error = %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("VerifyPassword(correct) = false, want true")
|
||||
}
|
||||
ok, err = store.VerifyPassword(ctx, "wrong-password")
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyPassword(wrong) error = %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("VerifyPassword(wrong) = true, want false")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
62
web/backend/launcherconfig/migration.go
Normal file
62
web/backend/launcherconfig/migration.go
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
package launcherconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
loadConfigForMigration = Load
|
||||
saveConfigForMigration = Save
|
||||
)
|
||||
|
||||
type dashboardPasswordStore interface {
|
||||
IsInitialized(ctx context.Context) (bool, error)
|
||||
SetPassword(ctx context.Context, plain string) error
|
||||
}
|
||||
|
||||
// LegacyLauncherTokenMigrationResult reports the outcome of converting a
|
||||
// removed launcher_token value into the current password-based auth flow.
|
||||
type LegacyLauncherTokenMigrationResult struct {
|
||||
Migrated bool
|
||||
// CleanupErr is non-nil when password migration succeeded (or was already in
|
||||
// place) but removing launcher_token from launcher-config.json failed.
|
||||
CleanupErr error
|
||||
}
|
||||
|
||||
// MigrateLegacyLauncherToken converts the removed launcher_token setting into
|
||||
// the current password-login store, then removes launcher_token from config.
|
||||
func MigrateLegacyLauncherToken(
|
||||
ctx context.Context,
|
||||
store dashboardPasswordStore,
|
||||
launcherPath string,
|
||||
fallback Config,
|
||||
) (LegacyLauncherTokenMigrationResult, error) {
|
||||
legacyToken := strings.TrimSpace(fallback.LegacyLauncherToken)
|
||||
if legacyToken == "" || store == nil {
|
||||
return LegacyLauncherTokenMigrationResult{}, nil
|
||||
}
|
||||
|
||||
result := LegacyLauncherTokenMigrationResult{}
|
||||
initialized, err := store.IsInitialized(ctx)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
if !initialized {
|
||||
if err = store.SetPassword(ctx, legacyToken); err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Migrated = true
|
||||
}
|
||||
result.CleanupErr = cleanupLegacyLauncherTokenConfig(launcherPath, fallback)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func cleanupLegacyLauncherTokenConfig(launcherPath string, fallback Config) error {
|
||||
cfg, err := loadConfigForMigration(launcherPath, fallback)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.LegacyLauncherToken = ""
|
||||
return saveConfigForMigration(launcherPath, cfg)
|
||||
}
|
||||
135
web/backend/launcherconfig/migration_test.go
Normal file
135
web/backend/launcherconfig/migration_test.go
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
package launcherconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type stubMigrationPasswordStore struct {
|
||||
initialized bool
|
||||
password string
|
||||
}
|
||||
|
||||
func (s *stubMigrationPasswordStore) IsInitialized(context.Context) (bool, error) {
|
||||
return s.initialized, nil
|
||||
}
|
||||
|
||||
func (s *stubMigrationPasswordStore) SetPassword(_ context.Context, plain string) error {
|
||||
s.password = plain
|
||||
s.initialized = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestMigrateLegacyLauncherToken(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
launcherPath := filepath.Join(dir, FileName)
|
||||
cfg := Config{
|
||||
Port: DefaultPort,
|
||||
LegacyLauncherToken: "legacy-password",
|
||||
}
|
||||
if err := os.WriteFile(
|
||||
launcherPath,
|
||||
[]byte("{\n \"port\": 18800,\n \"launcher_token\": \"legacy-password\"\n}\n"),
|
||||
0o600,
|
||||
); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
store := NewPasswordStore(launcherPath, Default())
|
||||
result, err := MigrateLegacyLauncherToken(context.Background(), store, launcherPath, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("MigrateLegacyLauncherToken() error = %v", err)
|
||||
}
|
||||
if !result.Migrated {
|
||||
t.Fatal("MigrateLegacyLauncherToken().Migrated = false, want true")
|
||||
}
|
||||
if result.CleanupErr != nil {
|
||||
t.Fatalf("MigrateLegacyLauncherToken().CleanupErr = %v, want nil", result.CleanupErr)
|
||||
}
|
||||
|
||||
loaded, err := Load(launcherPath, Default())
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if loaded.LegacyLauncherToken != "" {
|
||||
t.Fatalf("legacy launcher token = %q, want empty", loaded.LegacyLauncherToken)
|
||||
}
|
||||
if loaded.DashboardPasswordHash == "" {
|
||||
t.Fatal("dashboard password hash should be set after migration")
|
||||
}
|
||||
ok, err := store.VerifyPassword(context.Background(), "legacy-password")
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyPassword() error = %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("VerifyPassword() = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateLegacyLauncherTokenCleanupFailureIsNonFatal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
launcherPath := filepath.Join(dir, FileName)
|
||||
cfg := Config{
|
||||
Port: DefaultPort,
|
||||
LegacyLauncherToken: "legacy-password",
|
||||
}
|
||||
if err := os.WriteFile(
|
||||
launcherPath,
|
||||
[]byte("{\n \"port\": 18800,\n \"launcher_token\": \"legacy-password\"\n}\n"),
|
||||
0o600,
|
||||
); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
store := &stubMigrationPasswordStore{}
|
||||
origSave := saveConfigForMigration
|
||||
saveConfigForMigration = func(string, Config) error {
|
||||
return errors.New("write launcher config")
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
saveConfigForMigration = origSave
|
||||
})
|
||||
|
||||
result, err := MigrateLegacyLauncherToken(context.Background(), store, launcherPath, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("MigrateLegacyLauncherToken() error = %v, want nil", err)
|
||||
}
|
||||
if !result.Migrated {
|
||||
t.Fatal("MigrateLegacyLauncherToken().Migrated = false, want true")
|
||||
}
|
||||
if result.CleanupErr == nil {
|
||||
t.Fatal("MigrateLegacyLauncherToken().CleanupErr = nil, want non-nil")
|
||||
}
|
||||
if store.password != "legacy-password" {
|
||||
t.Fatalf("password = %q, want legacy-password", store.password)
|
||||
}
|
||||
|
||||
loaded, err := Load(launcherPath, Default())
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if loaded.LegacyLauncherToken != "legacy-password" {
|
||||
t.Fatalf(
|
||||
"legacy launcher token = %q, want legacy-password after cleanup failure",
|
||||
loaded.LegacyLauncherToken,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateLegacyLauncherTokenNoopWithoutToken(t *testing.T) {
|
||||
launcherPath := filepath.Join(t.TempDir(), FileName)
|
||||
store := NewPasswordStore(launcherPath, Default())
|
||||
result, err := MigrateLegacyLauncherToken(context.Background(), store, launcherPath, Default())
|
||||
if err != nil {
|
||||
t.Fatalf("MigrateLegacyLauncherToken() error = %v", err)
|
||||
}
|
||||
if result.Migrated {
|
||||
t.Fatal("MigrateLegacyLauncherToken().Migrated = true, want false")
|
||||
}
|
||||
if result.CleanupErr != nil {
|
||||
t.Fatalf("MigrateLegacyLauncherToken().CleanupErr = %v, want nil", result.CleanupErr)
|
||||
}
|
||||
}
|
||||
92
web/backend/launcherconfig/password_store.go
Normal file
92
web/backend/launcherconfig/password_store.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
package launcherconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const passwordBcryptCost = 12
|
||||
|
||||
// PasswordStore keeps the dashboard bcrypt hash in launcher-config.json.
|
||||
// It is used on platforms where the SQLite-backed dashboard auth store is not
|
||||
// available.
|
||||
type PasswordStore struct {
|
||||
path string
|
||||
fallback Config
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewPasswordStore returns a config-backed password store.
|
||||
func NewPasswordStore(path string, fallback Config) *PasswordStore {
|
||||
return &PasswordStore{
|
||||
path: path,
|
||||
fallback: fallback,
|
||||
}
|
||||
}
|
||||
|
||||
// IsInitialized reports whether a dashboard password hash exists in config.
|
||||
func (s *PasswordStore) IsInitialized(ctx context.Context) (bool, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
cfg, err := s.load()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return strings.TrimSpace(cfg.DashboardPasswordHash) != "", nil
|
||||
}
|
||||
|
||||
// SetPassword hashes plain with bcrypt and writes it to launcher-config.json.
|
||||
func (s *PasswordStore) SetPassword(ctx context.Context, plain string) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len([]rune(plain)) == 0 {
|
||||
return errors.New("password must not be empty")
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(plain), passwordBcryptCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
cfg, err := Load(s.path, s.fallback)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.DashboardPasswordHash = string(hash)
|
||||
cfg.LegacyLauncherToken = ""
|
||||
return Save(s.path, cfg)
|
||||
}
|
||||
|
||||
// VerifyPassword returns true iff plain matches the stored bcrypt hash.
|
||||
func (s *PasswordStore) VerifyPassword(ctx context.Context, plain string) (bool, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
cfg, err := s.load()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
hash := strings.TrimSpace(cfg.DashboardPasswordHash)
|
||||
if hash == "" {
|
||||
return false, nil
|
||||
}
|
||||
err = bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain))
|
||||
if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
|
||||
return false, nil
|
||||
}
|
||||
return err == nil, err
|
||||
}
|
||||
|
||||
func (s *PasswordStore) load() (Config, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return Load(s.path, s.fallback)
|
||||
}
|
||||
|
|
@ -12,12 +12,12 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
|
|
@ -51,7 +51,6 @@ var (
|
|||
servers []*http.Server
|
||||
serverAddr string
|
||||
// browserLaunchURL is opened by openBrowser() (auto-open + tray "open console").
|
||||
// Includes ?token= for same-machine dashboard login; keep serverAddr without secrets for other use.
|
||||
browserLaunchURL string
|
||||
apiHandler *api.Handler
|
||||
|
||||
|
|
@ -62,11 +61,34 @@ func shouldEnableLauncherFileLogging(enableConsole, debug bool) bool {
|
|||
return !enableConsole || debug
|
||||
}
|
||||
|
||||
func dashboardTokenConfigHelpPath(source launcherconfig.DashboardTokenSource, launcherPath string) string {
|
||||
if source != launcherconfig.DashboardTokenSourceConfig {
|
||||
return ""
|
||||
func shouldEnableLocalAutoLogin(noBrowser bool, probeHost string) bool {
|
||||
return !noBrowser && isLoopbackLaunchHost(probeHost)
|
||||
}
|
||||
|
||||
func isLoopbackLaunchHost(host string) bool {
|
||||
host = strings.TrimSpace(host)
|
||||
if strings.EqualFold(host, "localhost") {
|
||||
return true
|
||||
}
|
||||
return launcherPath
|
||||
host = strings.Trim(host, "[]")
|
||||
if i := strings.LastIndex(host, "%"); i >= 0 {
|
||||
host = host[:i]
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
|
||||
func launcherBrowserLaunchSuffix(
|
||||
needsSetup bool,
|
||||
localAutoLogin *middleware.LauncherDashboardLocalAutoLogin,
|
||||
) string {
|
||||
if needsSetup {
|
||||
return middleware.LauncherDashboardSetupPath
|
||||
}
|
||||
if localAutoLogin != nil {
|
||||
return localAutoLogin.URLPath()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func resolveLauncherHostInput(flagHost string, explicitFlag bool, envHost string) (string, bool, error) {
|
||||
|
|
@ -318,24 +340,6 @@ func firstNonEmpty(values ...string) string {
|
|||
return ""
|
||||
}
|
||||
|
||||
// maskSecret masks a secret for display. It always shows up to the first 3
|
||||
// runes. The last 4 runes are only appended when at least 5 runes remain
|
||||
// hidden in the middle (i.e. string length >= 12), so an 8-char minimum
|
||||
// password never exposes its tail. Strings of 3 chars or fewer are fully
|
||||
// masked.
|
||||
func maskSecret(s string) string {
|
||||
runes := []rune(s)
|
||||
n := len(runes)
|
||||
const prefixLen, suffixLen, minHidden = 3, 4, 5
|
||||
if n < prefixLen+suffixLen+minHidden {
|
||||
if n <= prefixLen {
|
||||
return "**********"
|
||||
}
|
||||
return string(runes[:prefixLen]) + "**********"
|
||||
}
|
||||
return string(runes[:prefixLen]) + "**********" + string(runes[n-suffixLen:])
|
||||
}
|
||||
|
||||
func main() {
|
||||
port := flag.String("port", "18800", "Port to listen on")
|
||||
host := flag.String("host", "", "Host to listen on (overrides -public when set)")
|
||||
|
|
@ -503,15 +507,11 @@ func main() {
|
|||
}
|
||||
listeners := openResult.Listeners
|
||||
|
||||
dashboardToken, dashboardSigningKey, _, dashErr := launcherconfig.EnsureDashboardSecrets(
|
||||
launcherCfg,
|
||||
)
|
||||
dashboardSessionCookie, dashErr := middleware.NewLauncherDashboardSessionCookie()
|
||||
if dashErr != nil {
|
||||
logger.Fatalf("Dashboard auth setup failed: %v", dashErr)
|
||||
}
|
||||
dashboardSessionCookie := middleware.SessionCookieValue(dashboardSigningKey, dashboardToken)
|
||||
|
||||
fmt.Println("dashboardToken: ", dashboardToken)
|
||||
// Open the bcrypt password store (creates the DB file on first run).
|
||||
authStore, authStoreErr := dashboardauth.New(picoHome)
|
||||
var passwordStore api.PasswordStore
|
||||
|
|
@ -522,23 +522,62 @@ func main() {
|
|||
logger.InfoC(
|
||||
"web",
|
||||
fmt.Sprintf(
|
||||
"Dashboard password store unavailable on this platform; falling back to token login: %v",
|
||||
"Dashboard SQLite password store unavailable on this platform; using launcher-config password storage: %v",
|
||||
authStoreErr,
|
||||
),
|
||||
)
|
||||
passwordStore = launcherconfig.NewPasswordStore(launcherPath, launcherCfg)
|
||||
authStoreErr = nil
|
||||
} else {
|
||||
logger.ErrorC("web", fmt.Sprintf("Warning: could not open auth store: %v", authStoreErr))
|
||||
}
|
||||
|
||||
migrationResult, migrationErr := launcherconfig.MigrateLegacyLauncherToken(
|
||||
context.Background(),
|
||||
passwordStore,
|
||||
launcherPath,
|
||||
launcherCfg,
|
||||
)
|
||||
if migrationErr != nil {
|
||||
logger.Fatalf("Failed to migrate legacy launcher token to password login: %v", migrationErr)
|
||||
}
|
||||
if migrationResult.Migrated {
|
||||
logger.InfoC("web", "Migrated legacy launcher token to dashboard password login")
|
||||
}
|
||||
if migrationResult.CleanupErr != nil {
|
||||
logger.WarnC(
|
||||
"web",
|
||||
fmt.Sprintf(
|
||||
"Legacy launcher token password migration succeeded, but failed to remove launcher_token from %s: %v",
|
||||
launcherPath,
|
||||
migrationResult.CleanupErr,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
var localAutoLogin *middleware.LauncherDashboardLocalAutoLogin
|
||||
needsInitialSetup := false
|
||||
if passwordStore != nil {
|
||||
initialized, initErr := passwordStore.IsInitialized(context.Background())
|
||||
if initErr != nil {
|
||||
logger.ErrorC("web", fmt.Sprintf("Warning: could not check dashboard password state: %v", initErr))
|
||||
} else if !initialized {
|
||||
needsInitialSetup = true
|
||||
} else if shouldEnableLocalAutoLogin(*noBrowser, openResult.ProbeHost) {
|
||||
localAutoLogin, err = middleware.NewLauncherDashboardLocalAutoLogin(5 * time.Minute)
|
||||
if err != nil {
|
||||
logger.Fatalf("Failed to create local auto-login grant: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize Server components
|
||||
mux := http.NewServeMux()
|
||||
|
||||
api.RegisterLauncherAuthRoutes(mux, api.LauncherAuthRouteOpts{
|
||||
DashboardToken: dashboardToken,
|
||||
SessionCookie: dashboardSessionCookie,
|
||||
PasswordStore: passwordStore,
|
||||
StoreError: authStoreErr,
|
||||
SessionCookie: dashboardSessionCookie,
|
||||
PasswordStore: passwordStore,
|
||||
StoreError: authStoreErr,
|
||||
})
|
||||
|
||||
// API Routes (e.g. /api/status)
|
||||
|
|
@ -561,7 +600,7 @@ func main() {
|
|||
|
||||
dashAuth := middleware.LauncherDashboardAuth(middleware.LauncherDashboardAuthConfig{
|
||||
ExpectedCookie: dashboardSessionCookie,
|
||||
Token: dashboardToken,
|
||||
LocalAutoLogin: localAutoLogin,
|
||||
}, accessControlledMux)
|
||||
|
||||
// Apply middleware stack
|
||||
|
|
@ -573,13 +612,21 @@ func main() {
|
|||
),
|
||||
)
|
||||
|
||||
// Print startup banner and token (console mode only).
|
||||
// Print startup banner (console mode only).
|
||||
if enableConsole || debug {
|
||||
consoleHosts := launcherConsoleHosts(hostInput, effectivePublic)
|
||||
|
||||
fmt.Print(utils.Banner)
|
||||
fmt.Println()
|
||||
fmt.Println(" Open the following URL in your browser:")
|
||||
if needsInitialSetup {
|
||||
if *noBrowser {
|
||||
fmt.Println(" First-time setup: open /launcher-setup to create the dashboard password.")
|
||||
} else {
|
||||
fmt.Println(" Launcher will open /launcher-setup automatically.")
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
fmt.Println(" Dashboard address:")
|
||||
fmt.Println()
|
||||
for _, host := range consoleHosts {
|
||||
fmt.Printf(" >> http://%s <<\n", net.JoinHostPort(host, effectivePort))
|
||||
|
|
@ -599,11 +646,7 @@ func main() {
|
|||
|
||||
// Share the local URL with the launcher runtime.
|
||||
serverAddr = fmt.Sprintf("http://%s", net.JoinHostPort(openResult.ProbeHost, effectivePort))
|
||||
if dashboardToken != "" {
|
||||
browserLaunchURL = serverAddr + "?token=" + url.QueryEscape(dashboardToken)
|
||||
} else {
|
||||
browserLaunchURL = serverAddr
|
||||
}
|
||||
browserLaunchURL = serverAddr + launcherBrowserLaunchSuffix(needsInitialSetup, localAutoLogin)
|
||||
|
||||
// Auto-open browser will be handled by the launcher runtime.
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/netbind"
|
||||
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
|
||||
"github.com/sipeed/picoclaw/web/backend/middleware"
|
||||
)
|
||||
|
||||
func TestShouldEnableLauncherFileLogging(t *testing.T) {
|
||||
|
|
@ -43,60 +43,50 @@ func TestShouldEnableLauncherFileLogging(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestDashboardTokenConfigHelpPath(t *testing.T) {
|
||||
const launcherPath = "/tmp/launcher-config.json"
|
||||
|
||||
func TestShouldEnableLocalAutoLogin(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
source launcherconfig.DashboardTokenSource
|
||||
want string
|
||||
name string
|
||||
noBrowser bool
|
||||
probeHost string
|
||||
wantEnable bool
|
||||
}{
|
||||
{
|
||||
name: "env token does not expose config path",
|
||||
source: launcherconfig.DashboardTokenSourceEnv,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "config token exposes config path",
|
||||
source: launcherconfig.DashboardTokenSourceConfig,
|
||||
want: launcherPath,
|
||||
},
|
||||
{
|
||||
name: "random token does not expose config path",
|
||||
source: launcherconfig.DashboardTokenSourceRandom,
|
||||
want: "",
|
||||
},
|
||||
{name: "loopback localhost", probeHost: "localhost", wantEnable: true},
|
||||
{name: "loopback ipv4", probeHost: "127.0.0.1", wantEnable: true},
|
||||
{name: "loopback ipv6", probeHost: "::1", wantEnable: true},
|
||||
{name: "browser disabled", noBrowser: true, probeHost: "localhost", wantEnable: false},
|
||||
{name: "non-loopback host", probeHost: "192.168.1.50", wantEnable: false},
|
||||
{name: "non-loopback hostname", probeHost: "example.com", wantEnable: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := dashboardTokenConfigHelpPath(tt.source, launcherPath); got != tt.want {
|
||||
t.Fatalf("dashboardTokenConfigHelpPath(%q, %q) = %q, want %q", tt.source, launcherPath, got, tt.want)
|
||||
if got := shouldEnableLocalAutoLogin(tt.noBrowser, tt.probeHost); got != tt.wantEnable {
|
||||
t.Fatalf(
|
||||
"shouldEnableLocalAutoLogin(%t, %q) = %t, want %t",
|
||||
tt.noBrowser,
|
||||
tt.probeHost,
|
||||
got,
|
||||
tt.wantEnable,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskSecret(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"sdhjflsjdflksdf", "sdh**********ksdf"},
|
||||
{"abcdefghijklmnopqrstuvwxyz", "abc**********wxyz"},
|
||||
{"abcdefghijkl", "abc**********ijkl"},
|
||||
{"abcdefgh", "abc**********"},
|
||||
{"abcdefghijk", "abc**********"},
|
||||
{"abcdefg", "abc**********"},
|
||||
{"abcd", "abc**********"},
|
||||
{"abc", "**********"},
|
||||
{"", "**********"},
|
||||
func TestLauncherBrowserLaunchSuffix(t *testing.T) {
|
||||
autoLogin, err := middleware.NewLauncherDashboardLocalAutoLogin(time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLauncherDashboardLocalAutoLogin() error = %v", err)
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := maskSecret(tt.input); got != tt.want {
|
||||
t.Errorf("maskSecret(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
if got := launcherBrowserLaunchSuffix(true, autoLogin); got != middleware.LauncherDashboardSetupPath {
|
||||
t.Fatalf("setup suffix = %q", got)
|
||||
}
|
||||
if got := launcherBrowserLaunchSuffix(false, autoLogin); !strings.HasPrefix(got, "/launcher-auto-login?nonce=") {
|
||||
t.Fatalf("auto-login suffix = %q", got)
|
||||
}
|
||||
if got := launcherBrowserLaunchSuffix(false, nil); got != "" {
|
||||
t.Fatalf("empty suffix = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,41 +1,88 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LauncherDashboardCookieName is the HttpOnly cookie set after a successful token login.
|
||||
// LauncherDashboardCookieName is the HttpOnly cookie set after a successful password login.
|
||||
const LauncherDashboardCookieName = "picoclaw_launcher_auth"
|
||||
|
||||
// launcherDashboardSessionMaxAgeSec is the session cookie lifetime (7 days).
|
||||
const launcherDashboardSessionMaxAgeSec = 7 * 24 * 3600
|
||||
// launcherDashboardSessionMaxAgeSec is the dashboard session cookie lifetime (31 days).
|
||||
const launcherDashboardSessionMaxAgeSec = 31 * 24 * 3600
|
||||
|
||||
const launcherSessionMACLabel = "picoclaw-launcher-v1"
|
||||
const (
|
||||
launcherSessionCookieBytes = 32
|
||||
launcherGrantNonceBytes = 32
|
||||
// LauncherDashboardLocalAutoLoginPath is the one-shot local browser
|
||||
// bootstrap endpoint used by the launcher-managed auto-open flow.
|
||||
LauncherDashboardLocalAutoLoginPath = "/launcher-auto-login"
|
||||
// LauncherDashboardSetupPath is the setup page used before the dashboard
|
||||
// password is initialized.
|
||||
LauncherDashboardSetupPath = "/launcher-setup"
|
||||
)
|
||||
|
||||
// SessionCookieValue is the expected cookie value for the given signing key and dashboard token.
|
||||
func SessionCookieValue(signingKey []byte, dashboardToken string) string {
|
||||
mac := hmac.New(sha256.New, signingKey)
|
||||
_, _ = mac.Write([]byte(launcherSessionMACLabel))
|
||||
_, _ = mac.Write([]byte{0})
|
||||
_, _ = mac.Write([]byte(dashboardToken))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
// NewLauncherDashboardSessionCookie creates the per-process session cookie value.
|
||||
func NewLauncherDashboardSessionCookie() (string, error) {
|
||||
return randomURLToken(launcherSessionCookieBytes)
|
||||
}
|
||||
|
||||
func randomURLToken(n int) (string, error) {
|
||||
buf := make([]byte, n)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
// LauncherDashboardAuthConfig holds runtime material for dashboard access checks.
|
||||
type LauncherDashboardAuthConfig struct {
|
||||
ExpectedCookie string
|
||||
Token string
|
||||
// LocalAutoLogin enables one-shot startup auto-login.
|
||||
LocalAutoLogin *LauncherDashboardLocalAutoLogin
|
||||
// SecureCookie sets the session cookie's Secure flag. If nil, DefaultLauncherDashboardSecureCookie is used.
|
||||
SecureCookie func(*http.Request) bool
|
||||
}
|
||||
|
||||
// LauncherDashboardLocalAutoLogin is an in-memory, one-shot startup grant.
|
||||
// It is not a reusable credential; it only lets the launcher-opened browser
|
||||
// receive the current process session cookie.
|
||||
type LauncherDashboardLocalAutoLogin struct {
|
||||
grant *launcherDashboardOneTimeGrant
|
||||
}
|
||||
|
||||
type launcherDashboardOneTimeGrant struct {
|
||||
mu sync.Mutex
|
||||
expires time.Time
|
||||
consumed bool
|
||||
nonce string
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewLauncherDashboardLocalAutoLogin creates a one-shot local auto-login grant.
|
||||
func NewLauncherDashboardLocalAutoLogin(ttl time.Duration) (*LauncherDashboardLocalAutoLogin, error) {
|
||||
grant, err := newLauncherDashboardOneTimeGrant(ttl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &LauncherDashboardLocalAutoLogin{
|
||||
grant: grant,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// URLPath returns the one-shot local auto-login URL path including its nonce.
|
||||
func (a *LauncherDashboardLocalAutoLogin) URLPath() string {
|
||||
return launcherGrantQueryPath(LauncherDashboardLocalAutoLoginPath, a.grant)
|
||||
}
|
||||
|
||||
// DefaultLauncherDashboardSecureCookie mirrors typical production HTTPS detection (TLS or X-Forwarded-Proto).
|
||||
func DefaultLauncherDashboardSecureCookie(r *http.Request) bool {
|
||||
if r.TLS != nil {
|
||||
|
|
@ -44,7 +91,7 @@ func DefaultLauncherDashboardSecureCookie(r *http.Request) bool {
|
|||
return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
|
||||
}
|
||||
|
||||
// SetLauncherDashboardSessionCookie writes the HttpOnly session cookie after successful dashboard token login.
|
||||
// SetLauncherDashboardSessionCookie writes the HttpOnly session cookie after successful dashboard password login.
|
||||
func SetLauncherDashboardSessionCookie(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
|
|
@ -82,12 +129,13 @@ func ClearLauncherDashboardSessionCookie(w http.ResponseWriter, r *http.Request,
|
|||
})
|
||||
}
|
||||
|
||||
// LauncherDashboardAuth requires a valid session cookie or Authorization: Bearer <token>
|
||||
// before calling next. Public paths are login page and /api/auth/* handlers.
|
||||
// LauncherDashboardAuth requires a valid session cookie before calling next.
|
||||
// Public paths are login/setup pages and /api/auth/* handlers.
|
||||
func LauncherDashboardAuth(cfg LauncherDashboardAuthConfig, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
p := canonicalAuthPath(r.URL.Path)
|
||||
if handled := tryLauncherQueryTokenLogin(w, r, p, cfg); handled {
|
||||
if p == LauncherDashboardLocalAutoLoginPath {
|
||||
handleLauncherLocalAutoLogin(w, r, cfg)
|
||||
return
|
||||
}
|
||||
if isPublicLauncherDashboardPath(r.Method, p) {
|
||||
|
|
@ -105,45 +153,84 @@ func LauncherDashboardAuth(cfg LauncherDashboardAuthConfig, next http.Handler) h
|
|||
// canonicalAuthPath matches path cleaning used for routing decisions so
|
||||
// prefixes like /assets/../ cannot bypass auth (CVE-class traversal).
|
||||
|
||||
// tryLauncherQueryTokenLogin validates ?token= on GET only (non-/api), sets the session
|
||||
// cookie when correct, and redirects with 303 so the follow-up is a plain GET without side effects.
|
||||
// Invalid token is rejected like any other unauthenticated browser request.
|
||||
func tryLauncherQueryTokenLogin(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
canonicalPath string,
|
||||
cfg LauncherDashboardAuthConfig,
|
||||
) bool {
|
||||
if r.Method != http.MethodGet {
|
||||
return false
|
||||
func handleLauncherLocalAutoLogin(w http.ResponseWriter, r *http.Request, cfg LauncherDashboardAuthConfig) {
|
||||
if validLauncherDashboardAuth(r, cfg) {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if canonicalPath == "/api" || strings.HasPrefix(canonicalPath, "/api/") {
|
||||
return false
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
_, _ = w.Write([]byte("method not allowed"))
|
||||
return
|
||||
}
|
||||
qToken := strings.TrimSpace(r.URL.Query().Get("token"))
|
||||
if qToken == "" {
|
||||
return false
|
||||
if r.Method == http.MethodHead {
|
||||
rejectLauncherDashboardAuth(w, r, LauncherDashboardLocalAutoLoginPath)
|
||||
return
|
||||
}
|
||||
if len(qToken) != len(cfg.Token) || subtle.ConstantTimeCompare([]byte(qToken), []byte(cfg.Token)) != 1 {
|
||||
rejectLauncherDashboardAuth(w, r, canonicalPath)
|
||||
return true
|
||||
if cfg.LocalAutoLogin != nil && cfg.LocalAutoLogin.consume(r.URL.Query().Get("nonce")) {
|
||||
SetLauncherDashboardSessionCookie(w, r, cfg.ExpectedCookie, cfg.SecureCookie)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
SetLauncherDashboardSessionCookie(w, r, cfg.ExpectedCookie, cfg.SecureCookie)
|
||||
http.Redirect(w, r, redirectAfterQueryTokenLogin(r, canonicalPath), http.StatusSeeOther)
|
||||
return true
|
||||
rejectLauncherDashboardAuth(w, r, LauncherDashboardLocalAutoLoginPath)
|
||||
}
|
||||
|
||||
func redirectAfterQueryTokenLogin(r *http.Request, canonicalPath string) string {
|
||||
if canonicalPath == "/launcher-login" {
|
||||
return "/"
|
||||
func (a *LauncherDashboardLocalAutoLogin) consume(nonce string) bool {
|
||||
if a == nil || a.grant == nil {
|
||||
return false
|
||||
}
|
||||
q := r.URL.Query()
|
||||
q.Del("token")
|
||||
enc := q.Encode()
|
||||
if enc != "" {
|
||||
return canonicalPath + "?" + enc
|
||||
return a.grant.use(nonce, nil) == nil
|
||||
}
|
||||
|
||||
func newLauncherDashboardOneTimeGrant(ttl time.Duration) (*launcherDashboardOneTimeGrant, error) {
|
||||
nonce, err := randomURLToken(launcherGrantNonceBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return canonicalPath
|
||||
return &launcherDashboardOneTimeGrant{
|
||||
expires: time.Now().Add(ttl),
|
||||
nonce: nonce,
|
||||
now: time.Now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func launcherGrantQueryPath(basePath string, grant *launcherDashboardOneTimeGrant) string {
|
||||
if grant == nil {
|
||||
return basePath
|
||||
}
|
||||
return basePath + "?nonce=" + url.QueryEscape(grant.nonce)
|
||||
}
|
||||
|
||||
// ErrInvalidLauncherDashboardGrant reports that an auto-login grant is missing,
|
||||
// expired, already consumed, or otherwise invalid.
|
||||
var ErrInvalidLauncherDashboardGrant = errors.New("invalid launcher dashboard grant")
|
||||
|
||||
func (g *launcherDashboardOneTimeGrant) use(nonce string, fn func() error) error {
|
||||
if g == nil {
|
||||
return ErrInvalidLauncherDashboardGrant
|
||||
}
|
||||
if len(nonce) != len(g.nonce) ||
|
||||
subtle.ConstantTimeCompare([]byte(nonce), []byte(g.nonce)) != 1 {
|
||||
return ErrInvalidLauncherDashboardGrant
|
||||
}
|
||||
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
now := time.Now
|
||||
if g.now != nil {
|
||||
now = g.now
|
||||
}
|
||||
if g.consumed || !now().Before(g.expires) {
|
||||
return ErrInvalidLauncherDashboardGrant
|
||||
}
|
||||
if fn != nil {
|
||||
if err := fn(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
g.consumed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func canonicalAuthPath(raw string) string {
|
||||
|
|
@ -206,14 +293,6 @@ func validLauncherDashboardAuth(r *http.Request, cfg LauncherDashboardAuthConfig
|
|||
return true
|
||||
}
|
||||
}
|
||||
auth := r.Header.Get("Authorization")
|
||||
const prefix = "Bearer "
|
||||
if strings.HasPrefix(auth, prefix) {
|
||||
token := strings.TrimSpace(auth[len(prefix):])
|
||||
if len(token) == len(cfg.Token) && subtle.ConstantTimeCompare([]byte(token), []byte(cfg.Token)) == 1 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,26 +4,37 @@ import (
|
|||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSessionCookieValue_Deterministic(t *testing.T) {
|
||||
key := make([]byte, 32)
|
||||
for i := range key {
|
||||
key[i] = byte(i)
|
||||
func TestNewLauncherDashboardSessionCookie(t *testing.T) {
|
||||
a, err := NewLauncherDashboardSessionCookie()
|
||||
if err != nil {
|
||||
t.Fatalf("NewLauncherDashboardSessionCookie() error = %v", err)
|
||||
}
|
||||
a := SessionCookieValue(key, "tok-a")
|
||||
b := SessionCookieValue(key, "tok-a")
|
||||
if a != b || a == "" {
|
||||
t.Fatalf("SessionCookieValue mismatch or empty: %q vs %q", a, b)
|
||||
b, err := NewLauncherDashboardSessionCookie()
|
||||
if err != nil {
|
||||
t.Fatalf("NewLauncherDashboardSessionCookie() second error = %v", err)
|
||||
}
|
||||
c := SessionCookieValue(key, "tok-b")
|
||||
if c == a {
|
||||
t.Fatal("SessionCookieValue should differ for different tokens")
|
||||
if a == "" || b == "" {
|
||||
t.Fatalf("session cookie values should be non-empty: %q %q", a, b)
|
||||
}
|
||||
if a == b {
|
||||
t.Fatal("session cookie values should be random")
|
||||
}
|
||||
}
|
||||
|
||||
func mustLocalAutoLogin(t *testing.T, ttl time.Duration) *LauncherDashboardLocalAutoLogin {
|
||||
t.Helper()
|
||||
autoLogin, err := NewLauncherDashboardLocalAutoLogin(ttl)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLauncherDashboardLocalAutoLogin() error = %v", err)
|
||||
}
|
||||
return autoLogin
|
||||
}
|
||||
|
||||
func TestLauncherDashboardAuth_AllowsPublicPaths(t *testing.T) {
|
||||
cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef", Token: "x"}
|
||||
cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef"}
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusTeapot)
|
||||
})
|
||||
|
|
@ -34,9 +45,11 @@ func TestLauncherDashboardAuth_AllowsPublicPaths(t *testing.T) {
|
|||
want int
|
||||
}{
|
||||
{http.MethodGet, "/launcher-login", http.StatusTeapot},
|
||||
{http.MethodGet, "/launcher-setup", http.StatusTeapot},
|
||||
{http.MethodGet, "/assets/index.js", http.StatusTeapot},
|
||||
{http.MethodPost, "/api/auth/login", http.StatusTeapot},
|
||||
{http.MethodGet, "/api/auth/status", http.StatusTeapot},
|
||||
{http.MethodPost, "/api/auth/setup", http.StatusTeapot},
|
||||
{http.MethodPost, "/api/auth/logout", http.StatusTeapot},
|
||||
{http.MethodGet, "/api/auth/logout", http.StatusUnauthorized},
|
||||
{http.MethodGet, "/api/config", http.StatusUnauthorized},
|
||||
|
|
@ -51,68 +64,143 @@ func TestLauncherDashboardAuth_AllowsPublicPaths(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestLauncherDashboardAuth_URLTokenBootstrapGET(t *testing.T) {
|
||||
const tok = "secret"
|
||||
cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef", Token: tok}
|
||||
func TestLauncherDashboardAuth_QueryTokenDoesNotAuthenticate(t *testing.T) {
|
||||
cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef"}
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusTeapot)
|
||||
t.Fatal("next handler should not run without session cookie")
|
||||
})
|
||||
h := LauncherDashboardAuth(cfg, next)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/?token="+tok, nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/?token=secret", nil)
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("GET /?token=valid: status = %d, want %d", rec.Code, http.StatusSeeOther)
|
||||
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/launcher-login" {
|
||||
t.Fatalf("GET /?token=secret: code=%d loc=%q", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
if got := rec.Header().Get("Location"); got != "/" {
|
||||
t.Fatalf("Location = %q, want %q", got, "/")
|
||||
}
|
||||
|
||||
func TestLauncherDashboardAuth_LocalAutoLogin(t *testing.T) {
|
||||
const cookieVal = "session-cookie-value"
|
||||
autoLogin := mustLocalAutoLogin(t, time.Minute)
|
||||
cfg := LauncherDashboardAuthConfig{
|
||||
ExpectedCookie: cookieVal,
|
||||
LocalAutoLogin: autoLogin,
|
||||
}
|
||||
if c := rec.Result().Cookies(); len(c) != 1 || c[0].Name != LauncherDashboardCookieName {
|
||||
t.Fatalf("expected one session cookie, got %#v", c)
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
h := LauncherDashboardAuth(cfg, next)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, LauncherDashboardLocalAutoLoginPath, nil)
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/launcher-login" ||
|
||||
len(rec.Result().Cookies()) != 0 {
|
||||
t.Fatalf(
|
||||
"auto-login without nonce code=%d loc=%q cookies=%#v",
|
||||
rec.Code,
|
||||
rec.Header().Get("Location"),
|
||||
rec.Result().Cookies(),
|
||||
)
|
||||
}
|
||||
|
||||
rec1b := httptest.NewRecorder()
|
||||
req1b := httptest.NewRequest(http.MethodGet, "/config?token="+tok+"&keep=1", nil)
|
||||
h.ServeHTTP(rec1b, req1b)
|
||||
if rec1b.Code != http.StatusSeeOther {
|
||||
t.Fatalf("GET /config?token=valid: status = %d", rec1b.Code)
|
||||
}
|
||||
if got := rec1b.Header().Get("Location"); got != "/config?keep=1" {
|
||||
t.Fatalf("Location = %q, want /config?keep=1", got)
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, LauncherDashboardLocalAutoLoginPath+"?nonce=wrong", nil)
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/launcher-login" ||
|
||||
len(rec.Result().Cookies()) != 0 {
|
||||
t.Fatalf(
|
||||
"auto-login with wrong nonce code=%d loc=%q cookies=%#v",
|
||||
rec.Code,
|
||||
rec.Header().Get("Location"),
|
||||
rec.Result().Cookies(),
|
||||
)
|
||||
}
|
||||
|
||||
recBad := httptest.NewRecorder()
|
||||
reqBad := httptest.NewRequest(http.MethodGet, "/?token=wrong", nil)
|
||||
h.ServeHTTP(recBad, reqBad)
|
||||
if recBad.Code != http.StatusFound || recBad.Header().Get("Location") != "/launcher-login" {
|
||||
t.Fatalf("GET /?token=invalid: code=%d loc=%q", recBad.Code, recBad.Header().Get("Location"))
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodHead, autoLogin.URLPath(), nil)
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/launcher-login" ||
|
||||
len(rec.Result().Cookies()) != 0 {
|
||||
t.Fatalf(
|
||||
"auto-login HEAD code=%d loc=%q cookies=%#v",
|
||||
rec.Code,
|
||||
rec.Header().Get("Location"),
|
||||
rec.Result().Cookies(),
|
||||
)
|
||||
}
|
||||
|
||||
rec2 := httptest.NewRecorder()
|
||||
req2 := httptest.NewRequest(http.MethodGet, "/api/config?token="+tok, nil)
|
||||
h.ServeHTTP(rec2, req2)
|
||||
if rec2.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("GET /api with token query: status = %d, want %d", rec2.Code, http.StatusUnauthorized)
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, autoLogin.URLPath(), nil)
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" {
|
||||
t.Fatalf("local auto-login code=%d loc=%q", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
cookies := rec.Result().Cookies()
|
||||
if len(cookies) != 1 || cookies[0].Name != LauncherDashboardCookieName || cookies[0].Value != cookieVal {
|
||||
t.Fatalf("cookies = %#v", cookies)
|
||||
}
|
||||
if cookies[0].MaxAge != 31*24*3600 {
|
||||
t.Fatalf("session cookie MaxAge = %d, want 31 days", cookies[0].MaxAge)
|
||||
}
|
||||
|
||||
rec3 := httptest.NewRecorder()
|
||||
req3 := httptest.NewRequest(http.MethodGet, "/?token=", nil)
|
||||
h.ServeHTTP(rec3, req3)
|
||||
if rec3.Code != http.StatusFound {
|
||||
t.Fatalf("GET /?token=empty: status = %d, want redirect", rec3.Code)
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.AddCookie(&http.Cookie{Name: LauncherDashboardCookieName, Value: cookieVal})
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("cookie auth after auto-login status = %d", rec.Code)
|
||||
}
|
||||
|
||||
recLogin := httptest.NewRecorder()
|
||||
reqLogin := httptest.NewRequest(http.MethodGet, "/launcher-login?token="+tok, nil)
|
||||
h.ServeHTTP(recLogin, reqLogin)
|
||||
if recLogin.Code != http.StatusSeeOther || recLogin.Header().Get("Location") != "/" {
|
||||
t.Fatalf("GET /launcher-login?token=valid: code=%d loc=%q", recLogin.Code, recLogin.Header().Get("Location"))
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, autoLogin.URLPath(), nil)
|
||||
req.AddCookie(&http.Cookie{Name: LauncherDashboardCookieName, Value: cookieVal})
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" {
|
||||
t.Fatalf("auto-login path with existing session code=%d loc=%q", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, autoLogin.URLPath(), nil)
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/launcher-login" {
|
||||
t.Fatalf("consumed auto-login code=%d loc=%q", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLauncherDashboardAuth_LocalAutoLoginRequiresValidNonceAndUnexpired(t *testing.T) {
|
||||
const cookieVal = "session-cookie-value"
|
||||
newHandler := func(autoLogin *LauncherDashboardLocalAutoLogin) http.Handler {
|
||||
return LauncherDashboardAuth(LauncherDashboardAuthConfig{
|
||||
ExpectedCookie: cookieVal,
|
||||
LocalAutoLogin: autoLogin,
|
||||
}, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
}
|
||||
|
||||
autoLogin := mustLocalAutoLogin(t, time.Minute)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, autoLogin.URLPath(), nil)
|
||||
req.RemoteAddr = "192.168.1.50:12345"
|
||||
req.Host = "192.168.1.50:18800"
|
||||
newHandler(autoLogin).ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusSeeOther || len(rec.Result().Cookies()) != 1 {
|
||||
t.Fatalf("capability auto-login code=%d cookies=%#v", rec.Code, rec.Result().Cookies())
|
||||
}
|
||||
|
||||
expired := mustLocalAutoLogin(t, -time.Second)
|
||||
h := newHandler(expired)
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, expired.URLPath(), nil)
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusFound || len(rec.Result().Cookies()) != 0 {
|
||||
t.Fatalf("expired auto-login code=%d cookies=%#v", rec.Code, rec.Result().Cookies())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLauncherDashboardAuth_DotDotCannotBypass(t *testing.T) {
|
||||
cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef", Token: "x"}
|
||||
cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef"}
|
||||
next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
|
||||
t.Fatal("next handler should not run without auth")
|
||||
})
|
||||
|
|
@ -132,14 +220,9 @@ func TestLauncherDashboardAuth_DotDotCannotBypass(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestLauncherDashboardAuth_CookieAndBearer(t *testing.T) {
|
||||
key := make([]byte, 32)
|
||||
for i := range key {
|
||||
key[i] = 0xab
|
||||
}
|
||||
token := "dashboard-secret-9"
|
||||
cookieVal := SessionCookieValue(key, token)
|
||||
cfg := LauncherDashboardAuthConfig{ExpectedCookie: cookieVal, Token: token}
|
||||
func TestLauncherDashboardAuth_CookieOnly(t *testing.T) {
|
||||
cookieVal := "session-cookie-value"
|
||||
cfg := LauncherDashboardAuthConfig{ExpectedCookie: cookieVal}
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
|
@ -154,16 +237,16 @@ func TestLauncherDashboardAuth_CookieAndBearer(t *testing.T) {
|
|||
}
|
||||
|
||||
rec2 := httptest.NewRecorder()
|
||||
req2 := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req2.Header.Set("Authorization", "Bearer "+token)
|
||||
req2 := httptest.NewRequest(http.MethodGet, "/api/config", nil)
|
||||
req2.Header.Set("Authorization", "Bearer dashboard-secret-9")
|
||||
h.ServeHTTP(rec2, req2)
|
||||
if rec2.Code != http.StatusOK {
|
||||
t.Fatalf("bearer auth: status = %d", rec2.Code)
|
||||
if rec2.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("bearer auth should not be accepted: status = %d", rec2.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLauncherDashboardAuth_WebSocketUnauthorizedDoesNotRedirect(t *testing.T) {
|
||||
cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef", Token: "x"}
|
||||
cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef"}
|
||||
next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
|
||||
t.Fatal("next handler should not run without auth")
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ package middleware
|
|||
|
||||
import "net/http"
|
||||
|
||||
// ReferrerPolicyNoReferrer sets Referrer-Policy: no-referrer on every response so sensitive
|
||||
// query parameters (e.g. ?token= for dashboard bootstrap) are not leaked via the Referer header.
|
||||
// ReferrerPolicyNoReferrer sets Referrer-Policy: no-referrer on every response
|
||||
// so sensitive paths and query parameters are not leaked via the Referer header.
|
||||
func ReferrerPolicyNoReferrer(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
|
|
|
|||
|
|
@ -2,16 +2,26 @@
|
|||
* Dashboard launcher auth API.
|
||||
* Uses plain fetch (not launcherFetch) to avoid redirect loops on auth pages.
|
||||
*/
|
||||
export type LoginResult =
|
||||
| { ok: true }
|
||||
| { ok: false; status: number; error: string }
|
||||
|
||||
export async function postLauncherDashboardLogin(
|
||||
password: string,
|
||||
): Promise<boolean> {
|
||||
): Promise<LoginResult> {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({ password: password.trim() }),
|
||||
})
|
||||
return res.ok
|
||||
if (res.ok) return { ok: true }
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
status: res.status,
|
||||
error: await readLauncherAuthError(res),
|
||||
}
|
||||
}
|
||||
|
||||
export type LauncherAuthStatus = {
|
||||
|
|
@ -57,12 +67,16 @@ export async function postLauncherDashboardSetup(
|
|||
}),
|
||||
})
|
||||
if (res.ok) return { ok: true }
|
||||
let msg = "Unknown error"
|
||||
return { ok: false, error: await readLauncherAuthError(res) }
|
||||
}
|
||||
|
||||
async function readLauncherAuthError(res: Response): Promise<string> {
|
||||
let msg = `Request failed with status ${res.status}`
|
||||
try {
|
||||
const j = (await res.json()) as { error?: string }
|
||||
if (j.error) msg = j.error
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return { ok: false, error: msg }
|
||||
return msg
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ export interface LauncherConfig {
|
|||
port: number
|
||||
public: boolean
|
||||
allowed_cidrs: string[]
|
||||
launcher_token: string
|
||||
}
|
||||
|
||||
export interface SystemVersionInfo {
|
||||
|
|
|
|||
|
|
@ -295,6 +295,22 @@ export function AppHeader() {
|
|||
</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>
|
||||
|
||||
<Separator className="mx-2 my-2" orientation="vertical" />
|
||||
|
||||
{/* Logout */}
|
||||
<Tooltip delayDuration={700}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
|
|
@ -309,19 +325,6 @@ export function AppHeader() {
|
|||
</TooltipTrigger>
|
||||
<TooltipContent>{t("header.logout.tooltip")}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<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,180 @@
|
|||
import { IconX } from "@tabler/icons-react"
|
||||
import {
|
||||
type KeyboardEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import {
|
||||
mergeUniqueStringItems,
|
||||
parseConservativeStringListInput,
|
||||
} from "@/components/channels/channel-array-utils"
|
||||
import { Field } from "@/components/shared-form"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
type StringListParser = (raw: string) => string[]
|
||||
export type ArrayFieldFlusher = () => string[] | null
|
||||
|
||||
type RegisterArrayFieldFlusher = (
|
||||
fieldPath: string,
|
||||
flusher: ArrayFieldFlusher | null,
|
||||
) => void
|
||||
|
||||
function areStringArraysEqual(left: string[], right: string[]): boolean {
|
||||
if (left.length !== right.length) {
|
||||
return false
|
||||
}
|
||||
return left.every((item, index) => item === right[index])
|
||||
}
|
||||
|
||||
interface ChannelArrayListFieldProps {
|
||||
label: string
|
||||
hint?: string
|
||||
error?: string
|
||||
required?: boolean
|
||||
value: string[]
|
||||
onChange: (value: string[]) => void
|
||||
placeholder?: string
|
||||
parser?: StringListParser
|
||||
fieldPath?: string
|
||||
registerFlusher?: RegisterArrayFieldFlusher
|
||||
resetVersion?: number
|
||||
}
|
||||
|
||||
export function ChannelArrayListField({
|
||||
label,
|
||||
hint,
|
||||
error,
|
||||
required,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
parser = parseConservativeStringListInput,
|
||||
fieldPath,
|
||||
registerFlusher,
|
||||
resetVersion,
|
||||
}: ChannelArrayListFieldProps) {
|
||||
const { t } = useTranslation()
|
||||
const [draft, setDraft] = useState("")
|
||||
const draftRef = useRef("")
|
||||
const valueRef = useRef(value)
|
||||
const localValueRef = useRef(value)
|
||||
const parserRef = useRef(parser)
|
||||
const onChangeRef = useRef(onChange)
|
||||
|
||||
useEffect(() => {
|
||||
valueRef.current = value
|
||||
localValueRef.current = value
|
||||
}, [value])
|
||||
|
||||
useEffect(() => {
|
||||
draftRef.current = ""
|
||||
setDraft("")
|
||||
}, [resetVersion])
|
||||
|
||||
useEffect(() => {
|
||||
parserRef.current = parser
|
||||
}, [parser])
|
||||
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange
|
||||
}, [onChange])
|
||||
|
||||
const commitDraft = useCallback(() => {
|
||||
const rawDraft = draftRef.current
|
||||
if (rawDraft.trim() === "") {
|
||||
if (!areStringArraysEqual(localValueRef.current, valueRef.current)) {
|
||||
return localValueRef.current
|
||||
}
|
||||
draftRef.current = ""
|
||||
setDraft("")
|
||||
return null
|
||||
}
|
||||
draftRef.current = ""
|
||||
setDraft("")
|
||||
const nextItems = parserRef.current(rawDraft)
|
||||
if (nextItems.length === 0) {
|
||||
return null
|
||||
}
|
||||
const mergedItems = mergeUniqueStringItems(localValueRef.current, nextItems)
|
||||
localValueRef.current = mergedItems
|
||||
onChangeRef.current(mergedItems)
|
||||
return mergedItems
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!fieldPath || !registerFlusher) {
|
||||
return
|
||||
}
|
||||
registerFlusher(fieldPath, commitDraft)
|
||||
return () => registerFlusher(fieldPath, null)
|
||||
}, [commitDraft, fieldPath, registerFlusher])
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key !== "Enter") {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
commitDraft()
|
||||
}
|
||||
|
||||
const handleRemove = (index: number) => {
|
||||
const nextValue = value.filter((_, itemIndex) => itemIndex !== index)
|
||||
localValueRef.current = nextValue
|
||||
onChangeRef.current(nextValue)
|
||||
}
|
||||
|
||||
return (
|
||||
<Field label={label} hint={hint} error={error} required={required}>
|
||||
<div className="space-y-3">
|
||||
{value.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{value.map((item, index) => (
|
||||
<span
|
||||
key={`${item}-${index}`}
|
||||
className="bg-muted text-foreground inline-flex max-w-full items-center gap-1 rounded-md border px-2 py-1 text-xs"
|
||||
>
|
||||
<span className="break-all">{item}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemove(index)}
|
||||
className="text-muted-foreground hover:text-foreground shrink-0 transition-colors"
|
||||
aria-label={t("channels.field.removeListItem", {
|
||||
value: item,
|
||||
})}
|
||||
>
|
||||
<IconX className="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={draft}
|
||||
onChange={(event) => {
|
||||
const nextDraft = event.target.value
|
||||
draftRef.current = nextDraft
|
||||
setDraft(nextDraft)
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={commitDraft}
|
||||
disabled={draft.trim() === ""}
|
||||
>
|
||||
{t("common.confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
72
web/frontend/src/components/channels/channel-array-utils.ts
Normal file
72
web/frontend/src/components/channels/channel-array-utils.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
const ALLOW_FROM_HIDDEN_CHARS_RE =
|
||||
/\u200b|\u200c|\u200d|\u200e|\u200f|\u202a|\u202b|\u202c|\u202d|\u202e|\u2060|\u2061|\u2062|\u2063|\u2064|\u2066|\u2067|\u2068|\u2069|\ufeff/g
|
||||
|
||||
function normalizeStringListItems(
|
||||
items: string[],
|
||||
options: { stripHiddenChars?: boolean } = {},
|
||||
): string[] {
|
||||
const result: string[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (const item of items) {
|
||||
const normalized = options.stripHiddenChars
|
||||
? item.replace(ALLOW_FROM_HIDDEN_CHARS_RE, "")
|
||||
: item
|
||||
const trimmed = normalized.trim()
|
||||
if (trimmed.length === 0 || seen.has(trimmed)) {
|
||||
continue
|
||||
}
|
||||
seen.add(trimmed)
|
||||
result.push(trimmed)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function splitStringList(
|
||||
raw: string,
|
||||
separators: RegExp,
|
||||
options: { stripHiddenChars?: boolean } = {},
|
||||
): string[] {
|
||||
if (raw.trim() === "") {
|
||||
return []
|
||||
}
|
||||
return normalizeStringListItems(raw.split(separators), options)
|
||||
}
|
||||
|
||||
export function asStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return []
|
||||
}
|
||||
return value.filter((item): item is string => typeof item === "string")
|
||||
}
|
||||
|
||||
export function parseAllowFromInput(raw: string): string[] {
|
||||
return splitStringList(raw, /[,\uFF0C、;;\n\r\t]+/, {
|
||||
stripHiddenChars: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function parseConservativeStringListInput(raw: string): string[] {
|
||||
return splitStringList(raw, /[,\uFF0C\n\r\t]+/)
|
||||
}
|
||||
|
||||
export function normalizeAllowFromValues(value: unknown): string[] {
|
||||
return normalizeStringListItems(asStringArray(value), {
|
||||
stripHiddenChars: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function mergeUniqueStringItems(
|
||||
currentItems: string[],
|
||||
nextItems: string[],
|
||||
): string[] {
|
||||
return normalizeStringListItems([...currentItems, ...nextItems])
|
||||
}
|
||||
|
||||
export function serializeStringArrayForSubmit(value: unknown): unknown {
|
||||
if (!Array.isArray(value)) {
|
||||
return value
|
||||
}
|
||||
return normalizeStringListItems(asStringArray(value)).join("\n")
|
||||
}
|
||||
|
|
@ -9,6 +9,11 @@ import {
|
|||
getChannelsCatalog,
|
||||
patchAppConfig,
|
||||
} from "@/api/channels"
|
||||
import { type ArrayFieldFlusher } from "@/components/channels/channel-array-list-field"
|
||||
import {
|
||||
normalizeAllowFromValues,
|
||||
serializeStringArrayForSubmit,
|
||||
} from "@/components/channels/channel-array-utils"
|
||||
import {
|
||||
SECRET_FIELD_MAP,
|
||||
buildEditConfig,
|
||||
|
|
@ -48,6 +53,43 @@ function asBool(value: unknown): boolean {
|
|||
return value === true
|
||||
}
|
||||
|
||||
function setRecordValueByPath(
|
||||
source: Record<string, unknown>,
|
||||
pathSegments: string[],
|
||||
value: unknown,
|
||||
): Record<string, unknown> {
|
||||
const [segment, ...rest] = pathSegments
|
||||
if (!segment) {
|
||||
return source
|
||||
}
|
||||
if (rest.length === 0) {
|
||||
return { ...source, [segment]: value }
|
||||
}
|
||||
return {
|
||||
...source,
|
||||
[segment]: setRecordValueByPath(asRecord(source[segment]), rest, value),
|
||||
}
|
||||
}
|
||||
|
||||
function setConfigValueByPath(
|
||||
source: ChannelConfig,
|
||||
fieldPath: string,
|
||||
value: unknown,
|
||||
): ChannelConfig {
|
||||
return setRecordValueByPath(source, fieldPath.split("."), value)
|
||||
}
|
||||
|
||||
function serializeGroupTriggerForSubmit(value: unknown): unknown {
|
||||
const groupTrigger = asRecord(value)
|
||||
if (Object.keys(groupTrigger).length === 0) {
|
||||
return value
|
||||
}
|
||||
return {
|
||||
...groupTrigger,
|
||||
prefixes: serializeStringArrayForSubmit(groupTrigger.prefixes),
|
||||
}
|
||||
}
|
||||
|
||||
const CHANNEL_COMMON_CONFIG_KEYS = new Set([
|
||||
"allow_from",
|
||||
"group_trigger",
|
||||
|
|
@ -82,12 +124,20 @@ function buildSavePayload(
|
|||
if (key.startsWith("_")) continue
|
||||
if (key === "enabled") continue
|
||||
if (CHANNEL_COMMON_CONFIG_KEYS.has(key)) {
|
||||
payload[key] = value
|
||||
if (key === "allow_from") {
|
||||
payload[key] = serializeStringArrayForSubmit(
|
||||
normalizeAllowFromValues(value),
|
||||
)
|
||||
} else if (key === "group_trigger") {
|
||||
payload[key] = serializeGroupTriggerForSubmit(value)
|
||||
} else {
|
||||
payload[key] = value
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (isSecretField(key)) continue
|
||||
|
||||
settings[key] = value
|
||||
settings[key] = serializeStringArrayForSubmit(value)
|
||||
}
|
||||
|
||||
for (const [secretKey, editKey] of Object.entries(SECRET_FIELD_MAP)) {
|
||||
|
|
@ -244,6 +294,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
|||
const [editConfig, setEditConfig] = useState<ChannelConfig>({})
|
||||
const [configuredSecrets, setConfiguredSecrets] = useState<string[]>([])
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [arrayFieldResetVersion, setArrayFieldResetVersion] = useState(0)
|
||||
const arrayFieldFlushersRef = useRef(new Map<string, ArrayFieldFlusher>())
|
||||
|
||||
const loadData = useCallback(
|
||||
async (silent = false) => {
|
||||
|
|
@ -302,11 +354,6 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
|||
previousGatewayStatusRef.current = gatewayState
|
||||
}, [gatewayState, loadData])
|
||||
|
||||
const savePayload = useMemo(() => {
|
||||
if (!channel) return null
|
||||
return buildSavePayload(channel, editConfig, enabled)
|
||||
}, [channel, editConfig, enabled])
|
||||
|
||||
const configured = useMemo(() => {
|
||||
if (!channel) return false
|
||||
return isConfigured(channel, editConfig, configuredSecrets)
|
||||
|
|
@ -362,20 +409,52 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
|||
})
|
||||
}, [])
|
||||
|
||||
const registerArrayFieldFlusher = useCallback(
|
||||
(fieldPath: string, flusher: ArrayFieldFlusher | null) => {
|
||||
if (flusher) {
|
||||
arrayFieldFlushersRef.current.set(fieldPath, flusher)
|
||||
return
|
||||
}
|
||||
arrayFieldFlushersRef.current.delete(fieldPath)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const flushPendingArrayFieldDrafts = useCallback(
|
||||
(sourceConfig: ChannelConfig): ChannelConfig => {
|
||||
let nextConfig = sourceConfig
|
||||
for (const [fieldPath, flusher] of arrayFieldFlushersRef.current) {
|
||||
const flushedValue = flusher()
|
||||
if (flushedValue === null) {
|
||||
continue
|
||||
}
|
||||
nextConfig = setConfigValueByPath(nextConfig, fieldPath, flushedValue)
|
||||
}
|
||||
return nextConfig
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const handleReset = () => {
|
||||
if (!channel) return
|
||||
setEditConfig(buildEditConfig(channel.name, baseConfig))
|
||||
setEnabled(asBool(baseConfig.enabled))
|
||||
setServerError("")
|
||||
setFieldErrors({})
|
||||
setArrayFieldResetVersion((version) => version + 1)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!channel || !savePayload) return
|
||||
if (!channel) return
|
||||
|
||||
const preparedEditConfig = flushPendingArrayFieldDrafts(editConfig)
|
||||
if (preparedEditConfig !== editConfig) {
|
||||
setEditConfig(preparedEditConfig)
|
||||
}
|
||||
|
||||
const missingRequiredFields = requiredKeys.filter((key) =>
|
||||
isMissingRequiredValue(
|
||||
getFieldValueForValidation(editConfig, configuredSecrets, key),
|
||||
getFieldValueForValidation(preparedEditConfig, configuredSecrets, key),
|
||||
),
|
||||
)
|
||||
if (missingRequiredFields.length > 0) {
|
||||
|
|
@ -393,6 +472,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
|||
setServerError("")
|
||||
setFieldErrors({})
|
||||
try {
|
||||
const savePayload = buildSavePayload(channel, preparedEditConfig, enabled)
|
||||
await patchAppConfig({
|
||||
channel_list: {
|
||||
[channel.config_key]: savePayload,
|
||||
|
|
@ -462,6 +542,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
|||
onChange={handleChange}
|
||||
configuredSecrets={configuredSecrets}
|
||||
fieldErrors={fieldErrors}
|
||||
registerArrayFieldFlusher={registerArrayFieldFlusher}
|
||||
arrayFieldResetVersion={arrayFieldResetVersion}
|
||||
/>
|
||||
)
|
||||
case "discord":
|
||||
|
|
@ -471,6 +553,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
|||
onChange={handleChange}
|
||||
configuredSecrets={configuredSecrets}
|
||||
fieldErrors={fieldErrors}
|
||||
registerArrayFieldFlusher={registerArrayFieldFlusher}
|
||||
arrayFieldResetVersion={arrayFieldResetVersion}
|
||||
/>
|
||||
)
|
||||
case "slack":
|
||||
|
|
@ -480,6 +564,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
|||
onChange={handleChange}
|
||||
configuredSecrets={configuredSecrets}
|
||||
fieldErrors={fieldErrors}
|
||||
registerArrayFieldFlusher={registerArrayFieldFlusher}
|
||||
arrayFieldResetVersion={arrayFieldResetVersion}
|
||||
/>
|
||||
)
|
||||
case "feishu":
|
||||
|
|
@ -489,6 +575,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
|||
onChange={handleChange}
|
||||
configuredSecrets={configuredSecrets}
|
||||
fieldErrors={fieldErrors}
|
||||
registerArrayFieldFlusher={registerArrayFieldFlusher}
|
||||
arrayFieldResetVersion={arrayFieldResetVersion}
|
||||
/>
|
||||
)
|
||||
case "weixin":
|
||||
|
|
@ -498,6 +586,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
|||
onChange={handleChange}
|
||||
isEdit={isEdit}
|
||||
onBindSuccess={() => void handleWeixinBindSuccess()}
|
||||
registerArrayFieldFlusher={registerArrayFieldFlusher}
|
||||
arrayFieldResetVersion={arrayFieldResetVersion}
|
||||
/>
|
||||
)
|
||||
case "wecom":
|
||||
|
|
@ -518,6 +608,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
|||
hiddenKeys={[...hiddenKeys, "bot_id"]}
|
||||
requiredKeys={requiredKeys}
|
||||
fieldErrors={fieldErrors}
|
||||
registerArrayFieldFlusher={registerArrayFieldFlusher}
|
||||
arrayFieldResetVersion={arrayFieldResetVersion}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
|
@ -530,6 +622,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
|||
hiddenKeys={hiddenKeys}
|
||||
requiredKeys={requiredKeys}
|
||||
fieldErrors={fieldErrors}
|
||||
registerArrayFieldFlusher={registerArrayFieldFlusher}
|
||||
arrayFieldResetVersion={arrayFieldResetVersion}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,14 @@
|
|||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import type { ChannelConfig } from "@/api/channels"
|
||||
import {
|
||||
type ArrayFieldFlusher,
|
||||
ChannelArrayListField,
|
||||
} from "@/components/channels/channel-array-list-field"
|
||||
import {
|
||||
asStringArray,
|
||||
parseAllowFromInput,
|
||||
} from "@/components/channels/channel-array-utils"
|
||||
import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields"
|
||||
import { Field, KeyInput, SwitchCardField } from "@/components/shared-form"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
|
|
@ -11,17 +19,17 @@ interface DiscordFormProps {
|
|||
onChange: (key: string, value: unknown) => void
|
||||
configuredSecrets: string[]
|
||||
fieldErrors?: Record<string, string>
|
||||
registerArrayFieldFlusher?: (
|
||||
fieldPath: string,
|
||||
flusher: ArrayFieldFlusher | null,
|
||||
) => void
|
||||
arrayFieldResetVersion?: number
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
|
@ -38,6 +46,8 @@ export function DiscordForm({
|
|||
onChange,
|
||||
configuredSecrets,
|
||||
fieldErrors = {},
|
||||
registerArrayFieldFlusher,
|
||||
arrayFieldResetVersion,
|
||||
}: DiscordFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const groupTriggerConfig = asRecord(config.group_trigger)
|
||||
|
|
@ -78,24 +88,17 @@ export function DiscordForm({
|
|||
placeholder="http://127.0.0.1:7890"
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
<ChannelArrayListField
|
||||
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>
|
||||
value={asStringArray(config.allow_from)}
|
||||
onChange={(value) => onChange("allow_from", value)}
|
||||
placeholder={t("channels.field.allowFromPlaceholder")}
|
||||
parser={parseAllowFromInput}
|
||||
fieldPath="allow_from"
|
||||
registerFlusher={registerArrayFieldFlusher}
|
||||
resetVersion={arrayFieldResetVersion}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<SwitchCardField
|
||||
|
|
|
|||
|
|
@ -1,6 +1,15 @@
|
|||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import type { ChannelConfig } from "@/api/channels"
|
||||
import {
|
||||
type ArrayFieldFlusher,
|
||||
ChannelArrayListField,
|
||||
} from "@/components/channels/channel-array-list-field"
|
||||
import {
|
||||
asStringArray,
|
||||
parseAllowFromInput,
|
||||
parseConservativeStringListInput,
|
||||
} from "@/components/channels/channel-array-utils"
|
||||
import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields"
|
||||
import { Field, KeyInput, SwitchCardField } from "@/components/shared-form"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
|
|
@ -11,6 +20,11 @@ interface FeishuFormProps {
|
|||
onChange: (key: string, value: unknown) => void
|
||||
configuredSecrets: string[]
|
||||
fieldErrors?: Record<string, string>
|
||||
registerArrayFieldFlusher?: (
|
||||
fieldPath: string,
|
||||
flusher: ArrayFieldFlusher | null,
|
||||
) => void
|
||||
arrayFieldResetVersion?: number
|
||||
}
|
||||
|
||||
function asString(value: unknown): string {
|
||||
|
|
@ -21,9 +35,11 @@ function asBool(value: unknown): boolean {
|
|||
return typeof value === "boolean" ? value : false
|
||||
}
|
||||
|
||||
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 {}
|
||||
}
|
||||
|
||||
export function FeishuForm({
|
||||
|
|
@ -31,8 +47,11 @@ export function FeishuForm({
|
|||
onChange,
|
||||
configuredSecrets,
|
||||
fieldErrors = {},
|
||||
registerArrayFieldFlusher,
|
||||
arrayFieldResetVersion,
|
||||
}: FeishuFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const groupTriggerConfig = asRecord(config.group_trigger)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
|
@ -104,24 +123,17 @@ export function FeishuForm({
|
|||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
<ChannelArrayListField
|
||||
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>
|
||||
value={asStringArray(config.allow_from)}
|
||||
onChange={(value) => onChange("allow_from", value)}
|
||||
placeholder={t("channels.field.allowFromPlaceholder")}
|
||||
parser={parseAllowFromInput}
|
||||
fieldPath="allow_from"
|
||||
registerFlusher={registerArrayFieldFlusher}
|
||||
resetVersion={arrayFieldResetVersion}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<SwitchCardField
|
||||
|
|
@ -134,6 +146,37 @@ export function FeishuForm({
|
|||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="py-3 shadow-sm">
|
||||
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
|
||||
<div>
|
||||
<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")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ChannelArrayListField
|
||||
label={t("channels.field.randomReactionEmoji")}
|
||||
hint={t("channels.form.desc.randomReactionEmoji")}
|
||||
value={asStringArray(config.random_reaction_emoji)}
|
||||
onChange={(value) => onChange("random_reaction_emoji", value)}
|
||||
placeholder={t("channels.field.randomReactionEmojiPlaceholder")}
|
||||
parser={parseConservativeStringListInput}
|
||||
fieldPath="random_reaction_emoji"
|
||||
registerFlusher={registerArrayFieldFlusher}
|
||||
resetVersion={arrayFieldResetVersion}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,14 @@
|
|||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import type { ChannelConfig } from "@/api/channels"
|
||||
import {
|
||||
type ArrayFieldFlusher,
|
||||
ChannelArrayListField,
|
||||
} from "@/components/channels/channel-array-list-field"
|
||||
import {
|
||||
asStringArray,
|
||||
parseAllowFromInput,
|
||||
} from "@/components/channels/channel-array-utils"
|
||||
import {
|
||||
getSecretInputPlaceholder,
|
||||
isSecretField,
|
||||
|
|
@ -16,6 +24,11 @@ interface GenericFormProps {
|
|||
hiddenKeys?: string[]
|
||||
requiredKeys?: string[]
|
||||
fieldErrors?: Record<string, string>
|
||||
registerArrayFieldFlusher?: (
|
||||
fieldPath: string,
|
||||
flusher: ArrayFieldFlusher | null,
|
||||
) => void
|
||||
arrayFieldResetVersion?: number
|
||||
}
|
||||
|
||||
// Fields to skip in the generic form (handled by enabled toggle or internal).
|
||||
|
|
@ -48,11 +61,6 @@ 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>
|
||||
|
|
@ -71,6 +79,8 @@ export function GenericForm({
|
|||
hiddenKeys = [],
|
||||
requiredKeys = [],
|
||||
fieldErrors = {},
|
||||
registerArrayFieldFlusher,
|
||||
arrayFieldResetVersion,
|
||||
}: GenericFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const hiddenFieldSet = new Set(hiddenKeys)
|
||||
|
|
@ -187,26 +197,18 @@ export function GenericForm({
|
|||
|
||||
if (Array.isArray(value)) {
|
||||
return (
|
||||
<Field
|
||||
<ChannelArrayListField
|
||||
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>
|
||||
value={asStringArray(value)}
|
||||
onChange={(nextValue) => onChange(key, nextValue)}
|
||||
fieldPath={key}
|
||||
registerFlusher={registerArrayFieldFlusher}
|
||||
resetVersion={arrayFieldResetVersion}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -281,46 +283,31 @@ export function GenericForm({
|
|||
|
||||
{config.allow_from !== undefined &&
|
||||
!hiddenFieldSet.has("allow_from") && (
|
||||
<Field
|
||||
<ChannelArrayListField
|
||||
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>
|
||||
value={asStringArray(config.allow_from)}
|
||||
onChange={(value) => onChange("allow_from", value)}
|
||||
placeholder={t("channels.field.allowFromPlaceholder")}
|
||||
parser={parseAllowFromInput}
|
||||
fieldPath="allow_from"
|
||||
registerFlusher={registerArrayFieldFlusher}
|
||||
resetVersion={arrayFieldResetVersion}
|
||||
/>
|
||||
)}
|
||||
|
||||
{config.allow_origins !== undefined &&
|
||||
!hiddenFieldSet.has("allow_origins") && (
|
||||
<Field
|
||||
<ChannelArrayListField
|
||||
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>
|
||||
value={asStringArray(config.allow_origins)}
|
||||
onChange={(value) => onChange("allow_origins", value)}
|
||||
placeholder={t("channels.field.allowOriginsPlaceholder")}
|
||||
fieldPath="allow_origins"
|
||||
registerFlusher={registerArrayFieldFlusher}
|
||||
resetVersion={arrayFieldResetVersion}
|
||||
/>
|
||||
)}
|
||||
|
||||
{config.allow_token_query !== undefined &&
|
||||
|
|
@ -356,26 +343,21 @@ export function GenericForm({
|
|||
/>
|
||||
</div>
|
||||
|
||||
<Field
|
||||
<ChannelArrayListField
|
||||
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>
|
||||
value={asStringArray(groupTriggerConfig.prefixes)}
|
||||
onChange={(value) =>
|
||||
onChange("group_trigger", {
|
||||
...groupTriggerConfig,
|
||||
prefixes: value,
|
||||
})
|
||||
}
|
||||
placeholder={t("channels.field.groupTriggerPrefixes")}
|
||||
fieldPath="group_trigger.prefixes"
|
||||
registerFlusher={registerArrayFieldFlusher}
|
||||
resetVersion={arrayFieldResetVersion}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,32 +1,41 @@
|
|||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import type { ChannelConfig } from "@/api/channels"
|
||||
import {
|
||||
type ArrayFieldFlusher,
|
||||
ChannelArrayListField,
|
||||
} from "@/components/channels/channel-array-list-field"
|
||||
import {
|
||||
asStringArray,
|
||||
parseAllowFromInput,
|
||||
} from "@/components/channels/channel-array-utils"
|
||||
import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields"
|
||||
import { Field, KeyInput } from "@/components/shared-form"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
interface SlackFormProps {
|
||||
config: ChannelConfig
|
||||
onChange: (key: string, value: unknown) => void
|
||||
configuredSecrets: string[]
|
||||
fieldErrors?: Record<string, string>
|
||||
registerArrayFieldFlusher?: (
|
||||
fieldPath: string,
|
||||
flusher: ArrayFieldFlusher | null,
|
||||
) => void
|
||||
arrayFieldResetVersion?: number
|
||||
}
|
||||
|
||||
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,
|
||||
configuredSecrets,
|
||||
fieldErrors = {},
|
||||
registerArrayFieldFlusher,
|
||||
arrayFieldResetVersion,
|
||||
}: SlackFormProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
|
|
@ -72,24 +81,17 @@ export function SlackForm({
|
|||
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
|
||||
<Field
|
||||
<ChannelArrayListField
|
||||
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>
|
||||
value={asStringArray(config.allow_from)}
|
||||
onChange={(value) => onChange("allow_from", value)}
|
||||
placeholder={t("channels.field.allowFromPlaceholder")}
|
||||
parser={parseAllowFromInput}
|
||||
fieldPath="allow_from"
|
||||
registerFlusher={registerArrayFieldFlusher}
|
||||
resetVersion={arrayFieldResetVersion}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,14 @@
|
|||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import type { ChannelConfig } from "@/api/channels"
|
||||
import {
|
||||
type ArrayFieldFlusher,
|
||||
ChannelArrayListField,
|
||||
} from "@/components/channels/channel-array-list-field"
|
||||
import {
|
||||
asStringArray,
|
||||
parseAllowFromInput,
|
||||
} from "@/components/channels/channel-array-utils"
|
||||
import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields"
|
||||
import { Field, KeyInput, SwitchCardField } from "@/components/shared-form"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
|
|
@ -11,17 +19,17 @@ interface TelegramFormProps {
|
|||
onChange: (key: string, value: unknown) => void
|
||||
configuredSecrets: string[]
|
||||
fieldErrors?: Record<string, string>
|
||||
registerArrayFieldFlusher?: (
|
||||
fieldPath: string,
|
||||
flusher: ArrayFieldFlusher | null,
|
||||
) => void
|
||||
arrayFieldResetVersion?: number
|
||||
}
|
||||
|
||||
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>
|
||||
|
|
@ -38,6 +46,8 @@ export function TelegramForm({
|
|||
onChange,
|
||||
configuredSecrets,
|
||||
fieldErrors = {},
|
||||
registerArrayFieldFlusher,
|
||||
arrayFieldResetVersion,
|
||||
}: TelegramFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const typingConfig = asRecord(config.typing)
|
||||
|
|
@ -91,24 +101,17 @@ export function TelegramForm({
|
|||
placeholder="http://127.0.0.1:7890"
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
<ChannelArrayListField
|
||||
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>
|
||||
value={asStringArray(config.allow_from)}
|
||||
onChange={(value) => onChange("allow_from", value)}
|
||||
placeholder={t("channels.field.allowFromPlaceholder")}
|
||||
parser={parseAllowFromInput}
|
||||
fieldPath="allow_from"
|
||||
registerFlusher={registerArrayFieldFlusher}
|
||||
resetVersion={arrayFieldResetVersion}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<SwitchCardField
|
||||
|
|
|
|||
|
|
@ -10,6 +10,14 @@ import { useTranslation } from "react-i18next"
|
|||
|
||||
import type { ChannelConfig } from "@/api/channels"
|
||||
import { pollWeixinFlow, startWeixinFlow } from "@/api/channels"
|
||||
import {
|
||||
type ArrayFieldFlusher,
|
||||
ChannelArrayListField,
|
||||
} from "@/components/channels/channel-array-list-field"
|
||||
import {
|
||||
asStringArray,
|
||||
parseAllowFromInput,
|
||||
} from "@/components/channels/channel-array-utils"
|
||||
import { Field } from "@/components/shared-form"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
|
|
@ -35,22 +43,24 @@ interface WeixinFormProps {
|
|||
onChange: (key: string, value: unknown) => void
|
||||
isEdit: boolean
|
||||
onBindSuccess?: () => void
|
||||
registerArrayFieldFlusher?: (
|
||||
fieldPath: string,
|
||||
flusher: ArrayFieldFlusher | null,
|
||||
) => void
|
||||
arrayFieldResetVersion?: number
|
||||
}
|
||||
|
||||
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 WeixinForm({
|
||||
config,
|
||||
onChange,
|
||||
isEdit,
|
||||
onBindSuccess,
|
||||
registerArrayFieldFlusher,
|
||||
arrayFieldResetVersion,
|
||||
}: WeixinFormProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
|
|
@ -321,24 +331,17 @@ export function WeixinForm({
|
|||
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
|
||||
<Field
|
||||
<ChannelArrayListField
|
||||
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>
|
||||
value={asStringArray(config.allow_from)}
|
||||
onChange={(value) => onChange("allow_from", value)}
|
||||
placeholder={t("channels.field.allowFromPlaceholder")}
|
||||
parser={parseAllowFromInput}
|
||||
fieldPath="allow_from"
|
||||
registerFlusher={registerArrayFieldFlusher}
|
||||
resetVersion={arrayFieldResetVersion}
|
||||
/>
|
||||
|
||||
<Field
|
||||
label={t("channels.field.proxy")}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
import { IconBrain, IconCheck, IconCopy } from "@tabler/icons-react"
|
||||
import {
|
||||
IconBrain,
|
||||
IconCheck,
|
||||
IconChevronDown,
|
||||
IconCopy,
|
||||
} from "@tabler/icons-react"
|
||||
import { useAtom } from "jotai"
|
||||
import { useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import ReactMarkdown from "react-markdown"
|
||||
|
|
@ -10,6 +16,7 @@ import remarkGfm from "remark-gfm"
|
|||
import { Button } from "@/components/ui/button"
|
||||
import { formatMessageTime } from "@/hooks/use-pico-chat"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { showThoughtsAtom } from "@/store/chat"
|
||||
|
||||
interface AssistantMessageProps {
|
||||
content: string
|
||||
|
|
@ -24,6 +31,7 @@ export function AssistantMessage({
|
|||
}: AssistantMessageProps) {
|
||||
const { t } = useTranslation()
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
const [isExpanded, setIsExpanded] = useAtom(showThoughtsAtom)
|
||||
const formattedTimestamp =
|
||||
timestamp !== "" ? formatMessageTime(timestamp) : ""
|
||||
|
||||
|
|
@ -36,64 +44,76 @@ export function AssistantMessage({
|
|||
|
||||
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>
|
||||
{isThought && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-amber-300/80 bg-amber-100/80 px-2 py-0.5 text-[11px] font-medium text-amber-800 dark:border-amber-500/40 dark:bg-amber-500/15 dark:text-amber-200">
|
||||
<IconBrain className="size-3" />
|
||||
<span>{t("chat.reasoningLabel")}</span>
|
||||
</span>
|
||||
)}
|
||||
{formattedTimestamp && (
|
||||
<>
|
||||
<span className="opacity-50">•</span>
|
||||
<span>{formattedTimestamp}</span>
|
||||
</>
|
||||
)}
|
||||
{!isThought && (
|
||||
<div className="text-muted-foreground/60 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>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-xl border",
|
||||
isThought
|
||||
? "border-amber-200/90 bg-amber-50/70 text-amber-950 dark:border-amber-500/35 dark:bg-amber-500/10 dark:text-amber-100"
|
||||
: "bg-card text-card-foreground",
|
||||
? "border-border/30 bg-muted/20 text-muted-foreground dark:border-border/20 dark:bg-muted/10"
|
||||
: "bg-card text-card-foreground border-border/60",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"prose dark:prose-invert prose-pre:my-2 prose-pre:overflow-x-auto prose-pre:rounded-lg prose-pre:border prose-pre:bg-zinc-100 prose-pre:p-0 dark:prose-pre:bg-zinc-950 max-w-none [overflow-wrap:anywhere] break-words",
|
||||
isThought
|
||||
? "prose-p:my-1.5 p-3 text-[13px] leading-relaxed opacity-90"
|
||||
: "prose-p:my-2 p-4 text-[15px] leading-relaxed",
|
||||
)}
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw, rehypeSanitize, rehypeHighlight]}
|
||||
{isThought && (
|
||||
<div
|
||||
className="text-muted-foreground/60 hover:text-muted-foreground/80 flex cursor-pointer items-center justify-between px-3 py-2 text-[12px] font-medium transition-colors select-none"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"absolute top-2 right-2 h-7 w-7 opacity-0 transition-opacity group-hover:opacity-100",
|
||||
isThought
|
||||
? "bg-amber-100/70 hover:bg-amber-200/80 dark:bg-amber-500/20 dark:hover:bg-amber-400/30"
|
||||
: "bg-background/50 hover:bg-background/80",
|
||||
)}
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{isCopied ? (
|
||||
<IconCheck className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<IconCopy className="text-muted-foreground h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<IconBrain className="size-3.5" />
|
||||
<span>{t("chat.reasoningLabel")}</span>
|
||||
</div>
|
||||
<IconChevronDown
|
||||
className={cn(
|
||||
"size-3.5 opacity-0 transition-all duration-200 group-hover:opacity-100",
|
||||
isExpanded ? "rotate-180" : "",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(!isThought || isExpanded) && (
|
||||
<div
|
||||
className={cn(
|
||||
"prose dark:prose-invert prose-pre:my-2 prose-pre:overflow-x-auto prose-pre:rounded-lg prose-pre:border prose-pre:bg-zinc-100 prose-pre:p-0 prose-pre:text-zinc-900 dark:prose-pre:bg-zinc-950 dark:prose-pre:text-zinc-100 max-w-none [overflow-wrap:anywhere] break-words",
|
||||
isThought
|
||||
? "prose-p:my-1.5 px-3 pt-0 pb-3 text-[13px] leading-relaxed opacity-70"
|
||||
: "prose-p:my-2 p-4 text-[15px] leading-relaxed",
|
||||
)}
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw, rehypeSanitize, rehypeHighlight]}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
{!isThought && (
|
||||
<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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,9 +3,15 @@ import type { KeyboardEvent } from "react"
|
|||
import { useTranslation } from "react-i18next"
|
||||
import TextareaAutosize from "react-textarea-autosize"
|
||||
|
||||
import { ContextUsageRing } from "@/components/chat/context-usage-ring"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { ChatAttachment } from "@/store/chat"
|
||||
import type { ChatAttachment, ContextUsage } from "@/store/chat"
|
||||
|
||||
export type ChatInputDisabledReason =
|
||||
| "gatewayUnknown"
|
||||
|
|
@ -26,8 +32,10 @@ interface ChatComposerProps {
|
|||
onAddImages: () => void
|
||||
onRemoveAttachment: (index: number) => void
|
||||
onSend: () => void
|
||||
onContextDetail?: () => void
|
||||
inputDisabledReason: ChatInputDisabledReason | null
|
||||
canSend: boolean
|
||||
contextUsage?: ContextUsage
|
||||
}
|
||||
|
||||
export function ChatComposer({
|
||||
|
|
@ -37,8 +45,10 @@ export function ChatComposer({
|
|||
onAddImages,
|
||||
onRemoveAttachment,
|
||||
onSend,
|
||||
onContextDetail,
|
||||
inputDisabledReason,
|
||||
canSend,
|
||||
contextUsage,
|
||||
}: ChatComposerProps) {
|
||||
const { t } = useTranslation()
|
||||
const canInput = inputDisabledReason === null
|
||||
|
|
@ -57,8 +67,8 @@ export function ChatComposer({
|
|||
}
|
||||
|
||||
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">
|
||||
<div className="before:bg-background pointer-events-none relative z-10 -mt-[24px] shrink-0 overflow-y-auto px-4 pb-[calc(1rem+env(safe-area-inset-bottom))] [scrollbar-gutter:stable] before:pointer-events-none before:absolute before:inset-x-0 before:top-[24px] before:bottom-0 before:content-[''] md:px-8 md:pb-8 lg:px-24 xl:px-48">
|
||||
<div className="bg-card border-border/60 pointer-events-auto relative mx-auto flex max-w-[1000px] flex-col rounded-2xl border p-3 shadow-sm">
|
||||
{attachments.length > 0 && (
|
||||
<div className="mb-3 flex flex-wrap gap-2 px-2">
|
||||
{attachments.map((attachment, index) => (
|
||||
|
|
@ -93,17 +103,12 @@ export function ChatComposer({
|
|||
disabled={!canInput}
|
||||
title={disabledMessage || undefined}
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground/50 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",
|
||||
"placeholder:text-muted-foreground/50 max-h-[200px] min-h-[64px] 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}
|
||||
/>
|
||||
{!canInput && disabledMessage && (
|
||||
<div className="text-muted-foreground px-3 py-1 text-xs">
|
||||
{disabledMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex items-center justify-between px-1">
|
||||
<div className="flex items-center gap-1">
|
||||
|
|
@ -121,17 +126,35 @@ export function ChatComposer({
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
{canInput ? (
|
||||
<Button
|
||||
type="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={!canSend}
|
||||
>
|
||||
<IconArrowUp className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{contextUsage && (
|
||||
<ContextUsageRing usage={contextUsage} onDetailClick={onContextDetail} />
|
||||
)}
|
||||
{canInput ? (
|
||||
<Tooltip delayDuration={700}>
|
||||
<TooltipTrigger asChild>
|
||||
<span tabIndex={!canSend ? 0 : undefined}>
|
||||
<Button
|
||||
type="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={!canSend}
|
||||
aria-label={t("chat.sendMessage")}
|
||||
>
|
||||
<IconArrowUp className="size-4" />
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
className="border-border/70 bg-muted text-foreground border text-center whitespace-pre-line shadow-lg shadow-black/10 dark:shadow-black/30"
|
||||
arrowClassName="bg-muted fill-muted"
|
||||
>
|
||||
{t("chat.sendHint")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -115,6 +115,7 @@ export function ChatPage() {
|
|||
connectionState,
|
||||
isTyping,
|
||||
activeSessionId,
|
||||
contextUsage,
|
||||
sendMessage,
|
||||
switchSession,
|
||||
newChat,
|
||||
|
|
@ -153,7 +154,7 @@ export function ChatPage() {
|
|||
})
|
||||
|
||||
const syncScrollState = (element: HTMLDivElement) => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = element
|
||||
const { clientHeight, scrollHeight, scrollTop } = element
|
||||
setHasScrolled(scrollTop > 0)
|
||||
setIsAtBottom(scrollHeight - scrollTop <= clientHeight + 10)
|
||||
}
|
||||
|
|
@ -294,7 +295,7 @@ export function ChatPage() {
|
|||
<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"
|
||||
className="min-h-0 flex-1 overflow-y-auto px-4 py-6 [scrollbar-gutter:stable] 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 && (
|
||||
|
|
@ -341,8 +342,14 @@ export function ChatPage() {
|
|||
onAddImages={handleAddImages}
|
||||
onRemoveAttachment={handleRemoveAttachment}
|
||||
onSend={handleSend}
|
||||
onContextDetail={() => {
|
||||
if (sendMessage({ content: "/context", attachments: [] })) {
|
||||
setInput("")
|
||||
}
|
||||
}}
|
||||
inputDisabledReason={inputDisabledReason}
|
||||
canSend={canSubmit}
|
||||
contextUsage={contextUsage}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
161
web/frontend/src/components/chat/context-usage-ring.tsx
Normal file
161
web/frontend/src/components/chat/context-usage-ring.tsx
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import { IconArrowRight } from "@tabler/icons-react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import type { ContextUsage } from "@/store/chat"
|
||||
|
||||
interface ContextUsageRingProps {
|
||||
usage: ContextUsage
|
||||
onDetailClick?: () => void
|
||||
}
|
||||
|
||||
function formatTokens(n: number): string {
|
||||
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
|
||||
return String(n)
|
||||
}
|
||||
|
||||
export function ContextUsageRing({
|
||||
usage,
|
||||
onDetailClick,
|
||||
}: ContextUsageRingProps) {
|
||||
const { t } = useTranslation()
|
||||
const [intent, setIntent] = useState(false) // user wants open
|
||||
const [visible, setVisible] = useState(false) // DOM mounted
|
||||
const [animated, setAnimated] = useState(false) // CSS target state
|
||||
const [cooldown, setCooldown] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>(null)
|
||||
const hoverIntent = useRef<ReturnType<typeof setTimeout>>(null)
|
||||
const closeTimer = useRef<ReturnType<typeof setTimeout>>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (intent) {
|
||||
// Mount first, animate in on next frame
|
||||
if (closeTimer.current) clearTimeout(closeTimer.current)
|
||||
setVisible(true)
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => setAnimated(true))
|
||||
})
|
||||
} else if (visible) {
|
||||
// Animate out, then unmount
|
||||
setAnimated(false)
|
||||
closeTimer.current = setTimeout(() => setVisible(false), 150)
|
||||
}
|
||||
}, [intent, visible])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
if (hoverIntent.current) clearTimeout(hoverIntent.current)
|
||||
if (closeTimer.current) clearTimeout(closeTimer.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const percent = Math.min(usage.used_percent, 100)
|
||||
const radius = 8
|
||||
const circumference = 2 * Math.PI * radius
|
||||
const offset = circumference - (percent / 100) * circumference
|
||||
const barPercent = Math.min(percent, 100)
|
||||
|
||||
const handleDetail = () => {
|
||||
if (cooldown || !onDetailClick) return
|
||||
setCooldown(true)
|
||||
onDetailClick()
|
||||
setIntent(false)
|
||||
timerRef.current = setTimeout(() => setCooldown(false), 1000)
|
||||
}
|
||||
|
||||
// Desktop: hover to open, mouse leave to close (with small delay)
|
||||
const handleMouseEnter = () => {
|
||||
if (hoverIntent.current) clearTimeout(hoverIntent.current)
|
||||
setIntent(true)
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
hoverIntent.current = setTimeout(() => setIntent(false), 150)
|
||||
}
|
||||
|
||||
// Mobile: tap to toggle (preventDefault suppresses synthetic mouseenter)
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
e.preventDefault()
|
||||
setIntent((v) => !v)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onTouchStart={handleTouchStart}
|
||||
className="relative flex h-6 w-6 cursor-pointer items-center justify-center transition-opacity hover:opacity-70"
|
||||
>
|
||||
<svg className="h-6 w-6 -rotate-90" viewBox="0 0 20 20">
|
||||
<circle
|
||||
cx="10"
|
||||
cy="10"
|
||||
r={radius}
|
||||
fill="none"
|
||||
className="stroke-muted-foreground/30"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<circle
|
||||
cx="10"
|
||||
cy="10"
|
||||
r={radius}
|
||||
fill="none"
|
||||
className="stroke-muted-foreground"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-muted-foreground absolute text-[8px] font-medium tabular-nums">
|
||||
{percent}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{visible && (
|
||||
<div
|
||||
className={`bg-popover text-popover-foreground absolute right-0 bottom-full z-50 mb-3 w-[220px] rounded-xl border p-4 shadow-lg transition-all duration-150 ${
|
||||
animated
|
||||
? "scale-100 opacity-100"
|
||||
: "pointer-events-none scale-95 opacity-0"
|
||||
}`}
|
||||
>
|
||||
<div className="bg-popover absolute -bottom-1.5 right-3 h-3 w-3 rotate-45 border-r border-b" />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("chat.contextTitle")}
|
||||
</span>
|
||||
<span className="text-xs font-medium">
|
||||
{formatTokens(usage.used_tokens)} /{" "}
|
||||
{formatTokens(usage.compress_at_tokens)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-muted mt-1.5 h-1.5 w-full overflow-hidden rounded-full">
|
||||
<div
|
||||
className="h-full rounded-full bg-violet-500 transition-all"
|
||||
style={{ width: `${barPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDetail}
|
||||
disabled={cooldown}
|
||||
className="mt-3 inline-flex items-center gap-1 text-xs font-medium text-violet-600 transition-opacity hover:opacity-70 disabled:opacity-40 dark:text-violet-400"
|
||||
>
|
||||
{t("chat.contextDetail")}
|
||||
<IconArrowRight className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -21,10 +21,7 @@ export function TypingIndicator() {
|
|||
|
||||
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="bg-card border-border/50 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]" />
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { cn } from "@/lib/utils"
|
||||
import type { ChatAttachment } from "@/store/chat"
|
||||
|
||||
interface UserMessageProps {
|
||||
|
|
@ -7,6 +8,7 @@ interface UserMessageProps {
|
|||
|
||||
export function UserMessage({ content, attachments = [] }: UserMessageProps) {
|
||||
const hasText = content.trim().length > 0
|
||||
const isCommand = content.trim().startsWith("/")
|
||||
const imageAttachments = attachments.filter(
|
||||
(attachment) => attachment.type === "image",
|
||||
)
|
||||
|
|
@ -27,8 +29,24 @@ export function UserMessage({ content, attachments = [] }: UserMessageProps) {
|
|||
)}
|
||||
|
||||
{hasText && (
|
||||
<div className="max-w-[70%] rounded-2xl rounded-tr-sm bg-violet-500 px-5 py-3 text-[15px] leading-relaxed wrap-break-word whitespace-pre-wrap text-white shadow-sm">
|
||||
{content}
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[70%] wrap-break-word whitespace-pre-wrap",
|
||||
isCommand
|
||||
? "rounded-xl border border-zinc-200 bg-transparent px-4 py-3 font-mono text-[14px] text-zinc-800 dark:border-zinc-800/60 dark:bg-[#121212] dark:text-zinc-200 dark:shadow-sm"
|
||||
: "rounded-2xl rounded-tr-sm bg-violet-500 px-5 py-3 text-[15px] leading-relaxed text-white shadow-sm",
|
||||
)}
|
||||
>
|
||||
{isCommand ? (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<span className="font-bold text-emerald-600 select-none dark:text-emerald-400">
|
||||
❯
|
||||
</span>
|
||||
<span className="mt-[1px]">{content}</span>
|
||||
</div>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { toast } from "sonner"
|
|||
|
||||
import { patchAppConfig } from "@/api/channels"
|
||||
import { launcherFetch } from "@/api/http"
|
||||
import { postLauncherDashboardSetup } from "@/api/launcher-auth"
|
||||
import {
|
||||
getAutoStartStatus,
|
||||
getLauncherConfig,
|
||||
|
|
@ -94,7 +95,8 @@ export function ConfigPage() {
|
|||
port: String(launcherConfig.port),
|
||||
publicAccess: launcherConfig.public,
|
||||
allowedCIDRsText: (launcherConfig.allowed_cidrs ?? []).join("\n"),
|
||||
launcherToken: launcherConfig.launcher_token ?? "",
|
||||
dashboardPassword: "",
|
||||
dashboardPasswordConfirm: "",
|
||||
}
|
||||
setLauncherForm(parsed)
|
||||
setLauncherBaseline(parsed)
|
||||
|
|
@ -107,8 +109,14 @@ export function ConfigPage() {
|
|||
}, [autoStartStatus])
|
||||
|
||||
const configDirty = JSON.stringify(form) !== JSON.stringify(baseline)
|
||||
const launcherDirty =
|
||||
JSON.stringify(launcherForm) !== JSON.stringify(launcherBaseline)
|
||||
const launcherSettingsDirty =
|
||||
launcherForm.port !== launcherBaseline.port ||
|
||||
launcherForm.publicAccess !== launcherBaseline.publicAccess ||
|
||||
launcherForm.allowedCIDRsText !== launcherBaseline.allowedCIDRsText
|
||||
const launcherPasswordDirty =
|
||||
launcherForm.dashboardPassword.trim() !== "" ||
|
||||
launcherForm.dashboardPasswordConfirm.trim() !== ""
|
||||
const launcherDirty = launcherSettingsDirty || launcherPasswordDirty
|
||||
const autoStartDirty = autoStartEnabled !== autoStartBaseline
|
||||
const isDirty = configDirty || launcherDirty || autoStartDirty
|
||||
|
||||
|
|
@ -143,6 +151,19 @@ export function ConfigPage() {
|
|||
const handleSave = async () => {
|
||||
try {
|
||||
setSaving(true)
|
||||
const password = launcherForm.dashboardPassword.trim()
|
||||
const confirm = launcherForm.dashboardPasswordConfirm.trim()
|
||||
if (launcherPasswordDirty) {
|
||||
if (!password) {
|
||||
throw new Error(t("pages.config.dashboard_password_required"))
|
||||
}
|
||||
if (password !== confirm) {
|
||||
throw new Error(t("pages.config.dashboard_password_mismatch"))
|
||||
}
|
||||
if (Array.from(password).length < 8) {
|
||||
throw new Error(t("pages.config.dashboard_password_min_length"))
|
||||
}
|
||||
}
|
||||
|
||||
if (configDirty) {
|
||||
const workspace = form.workspace.trim()
|
||||
|
|
@ -255,7 +276,8 @@ export function ConfigPage() {
|
|||
queryClient.invalidateQueries({ queryKey: ["config"] })
|
||||
}
|
||||
|
||||
if (launcherDirty) {
|
||||
let savedLauncherForm: LauncherForm | null = null
|
||||
if (launcherSettingsDirty) {
|
||||
const port = parseIntField(launcherForm.port, "Service port", {
|
||||
min: 1,
|
||||
max: 65535,
|
||||
|
|
@ -265,7 +287,6 @@ export function ConfigPage() {
|
|||
port,
|
||||
public: launcherForm.publicAccess,
|
||||
allowed_cidrs: allowedCIDRs,
|
||||
launcher_token: launcherForm.launcherToken.trim(),
|
||||
})
|
||||
const parsedLauncher: LauncherForm = {
|
||||
port: String(savedLauncherConfig.port),
|
||||
|
|
@ -273,8 +294,10 @@ export function ConfigPage() {
|
|||
allowedCIDRsText: (savedLauncherConfig.allowed_cidrs ?? []).join(
|
||||
"\n",
|
||||
),
|
||||
launcherToken: savedLauncherConfig.launcher_token ?? "",
|
||||
dashboardPassword: "",
|
||||
dashboardPasswordConfirm: "",
|
||||
}
|
||||
savedLauncherForm = parsedLauncher
|
||||
setLauncherForm(parsedLauncher)
|
||||
setLauncherBaseline(parsedLauncher)
|
||||
queryClient.setQueryData(
|
||||
|
|
@ -283,6 +306,23 @@ export function ConfigPage() {
|
|||
)
|
||||
}
|
||||
|
||||
if (launcherPasswordDirty) {
|
||||
const result = await postLauncherDashboardSetup(password, confirm)
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
|
||||
const clearedLauncherForm = savedLauncherForm ?? {
|
||||
...launcherForm,
|
||||
dashboardPassword: "",
|
||||
dashboardPasswordConfirm: "",
|
||||
}
|
||||
setLauncherForm(clearedLauncherForm)
|
||||
if (savedLauncherForm) {
|
||||
setLauncherBaseline(savedLauncherForm)
|
||||
}
|
||||
}
|
||||
|
||||
if (autoStartDirty) {
|
||||
if (!autoStartSupported) {
|
||||
throw new Error(t("pages.config.autostart_unsupported"))
|
||||
|
|
@ -304,6 +344,22 @@ export function ConfigPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const actionButtons = (
|
||||
<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>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader
|
||||
|
|
@ -340,12 +396,6 @@ export function ConfigPage() {
|
|||
</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>
|
||||
)}
|
||||
|
||||
<LauncherSection
|
||||
launcherForm={launcherForm}
|
||||
onFieldChange={updateLauncherField}
|
||||
|
|
@ -374,23 +424,21 @@ export function ConfigPage() {
|
|||
onAutoStartChange={setAutoStartEnabled}
|
||||
/>
|
||||
|
||||
<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>
|
||||
{!isDirty && actionButtons}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isDirty && (
|
||||
<div className="border-border/70 bg-background/95 supports-backdrop-filter:bg-background/80 shrink-0 border-t px-3 py-3 shadow-[0_-12px_30px_rgba(15,23,42,0.10)] backdrop-blur lg:px-6">
|
||||
<div className="mx-auto flex w-full max-w-[1000px] flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="text-muted-foreground/70 text-xs">
|
||||
{t("pages.config.unsaved_changes")}
|
||||
</div>
|
||||
{actionButtons}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -519,23 +519,48 @@ export function LauncherSection({
|
|||
return (
|
||||
<ConfigSectionCard
|
||||
title={t("pages.config.sections.launcher")}
|
||||
description={t("pages.config.launcher_token_section_hint")}
|
||||
description={t("pages.config.launcher_section_hint")}
|
||||
>
|
||||
<Field
|
||||
label={t("pages.config.launcher_token")}
|
||||
hint={t("pages.config.launcher_token_hint")}
|
||||
label={t("pages.config.dashboard_password")}
|
||||
hint={t("pages.config.dashboard_password_hint")}
|
||||
layout="setting-row"
|
||||
controlClassName="md:max-w-md"
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
value={launcherForm.launcherToken}
|
||||
value={launcherForm.dashboardPassword}
|
||||
disabled={disabled}
|
||||
autoComplete="off"
|
||||
placeholder={t("pages.config.launcher_token_placeholder")}
|
||||
onChange={(e) => onFieldChange("launcherToken", e.target.value)}
|
||||
autoComplete="new-password"
|
||||
placeholder={t("pages.config.dashboard_password_placeholder")}
|
||||
onChange={(e) =>
|
||||
onFieldChange("dashboardPassword", e.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{launcherForm.dashboardPassword.trim() !== "" && (
|
||||
<Field
|
||||
label={t("pages.config.dashboard_password_confirm")}
|
||||
hint={t("pages.config.dashboard_password_confirm_hint")}
|
||||
layout="setting-row"
|
||||
controlClassName="md:max-w-md"
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
value={launcherForm.dashboardPasswordConfirm}
|
||||
disabled={disabled}
|
||||
autoComplete="new-password"
|
||||
placeholder={t(
|
||||
"pages.config.dashboard_password_confirm_placeholder",
|
||||
)}
|
||||
onChange={(e) =>
|
||||
onFieldChange("dashboardPasswordConfirm", e.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<SwitchCardField
|
||||
label={t("pages.config.lan_access")}
|
||||
hint={t("pages.config.lan_access_hint")}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,8 @@ export interface LauncherForm {
|
|||
port: string
|
||||
publicAccess: boolean
|
||||
allowedCIDRsText: string
|
||||
launcherToken: string
|
||||
dashboardPassword: string
|
||||
dashboardPasswordConfirm: string
|
||||
}
|
||||
|
||||
export const DM_SCOPE_OPTIONS = [
|
||||
|
|
@ -94,7 +95,8 @@ export const EMPTY_LAUNCHER_FORM: LauncherForm = {
|
|||
port: "18800",
|
||||
publicAccess: false,
|
||||
allowedCIDRsText: "",
|
||||
launcherToken: "",
|
||||
dashboardPassword: "",
|
||||
dashboardPasswordConfirm: "",
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): JsonRecord {
|
||||
|
|
|
|||
|
|
@ -30,10 +30,13 @@ function TooltipTrigger({
|
|||
|
||||
function TooltipContent({
|
||||
className,
|
||||
arrowClassName,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content> & {
|
||||
arrowClassName?: string
|
||||
}) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
|
|
@ -46,7 +49,12 @@ function TooltipContent({
|
|||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
|
||||
<TooltipPrimitive.Arrow
|
||||
className={cn(
|
||||
"z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground",
|
||||
arrowClassName
|
||||
)}
|
||||
/>
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -392,6 +392,7 @@ export async function switchChatSession(sessionId: string) {
|
|||
messages: historyMessages,
|
||||
isTyping: false,
|
||||
hasHydratedActiveSession: true,
|
||||
contextUsage: undefined,
|
||||
})
|
||||
|
||||
if (store.get(gatewayAtom).status === "running") {
|
||||
|
|
@ -415,6 +416,7 @@ export async function newChatSession() {
|
|||
messages: [],
|
||||
isTyping: false,
|
||||
hasHydratedActiveSession: true,
|
||||
contextUsage: undefined,
|
||||
})
|
||||
|
||||
if (store.get(gatewayAtom).status === "running") {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import { toast } from "sonner"
|
||||
|
||||
import { normalizeUnixTimestamp } from "@/features/chat/state"
|
||||
import { type AssistantMessageKind, updateChatStore } from "@/store/chat"
|
||||
import {
|
||||
type AssistantMessageKind,
|
||||
type ContextUsage,
|
||||
updateChatStore,
|
||||
} from "@/store/chat"
|
||||
|
||||
export interface PicoMessage {
|
||||
type: string
|
||||
|
|
@ -21,6 +25,24 @@ function hasAssistantKindPayload(payload: Record<string, unknown>): boolean {
|
|||
return typeof payload.thought === "boolean"
|
||||
}
|
||||
|
||||
function parseContextUsage(
|
||||
payload: Record<string, unknown>,
|
||||
): ContextUsage | undefined {
|
||||
const raw = payload.context_usage
|
||||
if (!raw || typeof raw !== "object") return undefined
|
||||
const obj = raw as Record<string, unknown>
|
||||
const used = Number(obj.used_tokens)
|
||||
const total = Number(obj.total_tokens)
|
||||
if (!Number.isFinite(used) || !Number.isFinite(total) || total <= 0)
|
||||
return undefined
|
||||
return {
|
||||
used_tokens: used,
|
||||
total_tokens: total,
|
||||
compress_at_tokens: Number(obj.compress_at_tokens) || 0,
|
||||
used_percent: Number(obj.used_percent) || 0,
|
||||
}
|
||||
}
|
||||
|
||||
export function handlePicoMessage(
|
||||
message: PicoMessage,
|
||||
expectedSessionId: string,
|
||||
|
|
@ -36,6 +58,7 @@ export function handlePicoMessage(
|
|||
const content = (payload.content as string) || ""
|
||||
const messageId = (payload.message_id as string) || `pico-${Date.now()}`
|
||||
const kind = parseAssistantMessageKind(payload)
|
||||
const contextUsage = parseContextUsage(payload)
|
||||
const timestamp =
|
||||
message.timestamp !== undefined &&
|
||||
Number.isFinite(Number(message.timestamp))
|
||||
|
|
@ -54,6 +77,7 @@ export function handlePicoMessage(
|
|||
},
|
||||
],
|
||||
isTyping: false,
|
||||
...(contextUsage ? { contextUsage } : {}),
|
||||
}))
|
||||
break
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ export function formatMessageTime(dateRaw: number | string | Date): string {
|
|||
}
|
||||
|
||||
export function usePicoChat() {
|
||||
const { messages, connectionState, isTyping, activeSessionId } =
|
||||
const { messages, connectionState, isTyping, activeSessionId, contextUsage } =
|
||||
useAtomValue(chatAtom)
|
||||
|
||||
return {
|
||||
|
|
@ -63,6 +63,7 @@ export function usePicoChat() {
|
|||
connectionState,
|
||||
isTyping,
|
||||
activeSessionId,
|
||||
contextUsage,
|
||||
sendMessage: sendChatMessage,
|
||||
switchSession: switchChatSession,
|
||||
newChat: newChatSession,
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@
|
|||
"chat": {
|
||||
"welcome": "How can I help you today?",
|
||||
"welcomeDesc": "Ask me about weather, settings, or any other tasks. I'm here to assist you.",
|
||||
"placeholder": "Start a new message...\nPress Enter to send, Shift + Enter for a new line",
|
||||
"placeholder": "Start a new message...",
|
||||
"disabledPlaceholder": {
|
||||
"gatewayUnknown": "Unable to chat: Gateway status is still being checked. Please wait, then refresh the page or restart Launcher if needed.",
|
||||
"gatewayStarting": "Unable to chat: Gateway is starting. Wait for startup to complete, then try again.",
|
||||
|
|
@ -60,6 +60,7 @@
|
|||
"step4": "Almost there..."
|
||||
},
|
||||
"reasoningLabel": "Reasoning",
|
||||
"toolLabel": "Tool",
|
||||
"history": "History",
|
||||
"noHistory": "No chat history yet",
|
||||
"historyLoadFailed": "Failed to load chat history",
|
||||
|
|
@ -72,6 +73,10 @@
|
|||
"notConnected": "Gateway is not running. Start it to chat.",
|
||||
"noModel": "No default model configured. Go to Models page to set one."
|
||||
},
|
||||
"sendMessage": "Send message",
|
||||
"sendHint": "Press Enter to send\nShift + Enter for a new line",
|
||||
"contextTitle": "Context",
|
||||
"contextDetail": "View Details",
|
||||
"attachImage": "Add images",
|
||||
"removeImage": "Remove image",
|
||||
"uploadedImage": "Uploaded image",
|
||||
|
|
@ -354,11 +359,15 @@
|
|||
"placeholderText": "Placeholder Text",
|
||||
"groupTriggerMentionOnly": "Group Mention Only",
|
||||
"groupTriggerPrefixes": "Group Trigger Prefixes",
|
||||
"groupTriggerPrefixesPlaceholder": "e.g. /, !, ?",
|
||||
"randomReactionEmoji": "Random Reaction Emoji",
|
||||
"randomReactionEmojiPlaceholder": "e.g. THUMBSUP, HEART, SMILE",
|
||||
"isLark": "Lark (International)",
|
||||
"allowFrom": "Allow From",
|
||||
"allowFromPlaceholder": "e.g. 123456, 789012",
|
||||
"allowOrigins": "Allow Origins",
|
||||
"allowOriginsPlaceholder": "e.g. https://example.com, http://localhost:5173",
|
||||
"removeListItem": "Remove {{value}}",
|
||||
"secretPlaceholder": "Enter secret",
|
||||
"secretHintSet": "A value is already set. Leave blank to keep it unchanged."
|
||||
},
|
||||
|
|
@ -386,10 +395,11 @@
|
|||
"typingEnabled": "Display typing status while the assistant is generating a response.",
|
||||
"placeholderEnabled": "Enable temporary placeholder messages before the final reply is sent.",
|
||||
"groupTriggerMentionOnly": "In group chats, respond only when the bot is mentioned.",
|
||||
"groupTriggerPrefixes": "Custom group-chat trigger prefixes, separated by commas.",
|
||||
"groupTriggerPrefixes": "Custom group-chat trigger prefixes. Add items one by one, or paste multiple values at once.",
|
||||
"randomReactionEmoji": "PicoClaw adds emoji reactions to user messages to confirm receipt. Example: \"THUMBSUP\", \"HEART\", \"SMILE\". Leave empty to use the default \"Pin\" emoji.",
|
||||
"isLark": "Use Lark international domain (open.larksuite.com) instead of Feishu domain (open.feishu.cn).",
|
||||
"allowFrom": "Allowed user or group IDs, separated by commas.",
|
||||
"allowOrigins": "Allowed origin domains, separated by commas.",
|
||||
"allowFrom": "Allowed user or group IDs. Add items one by one, or paste multiple values at once.",
|
||||
"allowOrigins": "Allowed origin domains. Add items one by one, or paste multiple values at once.",
|
||||
"wsUrl": "WebSocket service URL.",
|
||||
"reconnectInterval": "Reconnect interval after disconnection (seconds).",
|
||||
"bridgeUrl": "Bridge service URL.",
|
||||
|
|
@ -653,10 +663,16 @@
|
|||
"autostart_load_error": "Failed to load launch-at-login status.",
|
||||
"server_port": "Service Port",
|
||||
"server_port_hint": "HTTP port used by PicoClaw Web.",
|
||||
"launcher_token": "Login Token",
|
||||
"launcher_token_section_hint": "Changes in this section take effect after the launcher restarts.",
|
||||
"launcher_token_hint": "Used to sign in on the launcher login page.",
|
||||
"launcher_token_placeholder": "Enter login token",
|
||||
"launcher_section_hint": "Changes in this section take effect after the launcher restarts.",
|
||||
"dashboard_password": "Login Password",
|
||||
"dashboard_password_hint": "Set a new login password.",
|
||||
"dashboard_password_placeholder": "At least 8 characters",
|
||||
"dashboard_password_confirm": "Confirm New Password",
|
||||
"dashboard_password_confirm_hint": "Enter the new login password again.",
|
||||
"dashboard_password_confirm_placeholder": "Repeat password",
|
||||
"dashboard_password_required": "Enter and confirm the new login password.",
|
||||
"dashboard_password_mismatch": "The login passwords do not match.",
|
||||
"dashboard_password_min_length": "Login password must be at least 8 characters.",
|
||||
"lan_access": "Enable LAN Access",
|
||||
"lan_access_hint": "Allow access from other devices on your local network.",
|
||||
"allowed_cidrs": "Allowed Network CIDRs",
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@
|
|||
"chat": {
|
||||
"welcome": "今天我能为您做些什么?",
|
||||
"welcomeDesc": "您可以询问我天气、设置或其他任何任务,我随时为您效劳。",
|
||||
"placeholder": "输入新消息...\n按 Enter 发送,Shift + Enter 换行",
|
||||
"placeholder": "输入新消息...",
|
||||
"disabledPlaceholder": {
|
||||
"gatewayUnknown": "无法对话:网关状态仍在检测中。请稍候重试,如仍无效请刷新页面或重启 Launcher。",
|
||||
"gatewayStarting": "无法对话:网关正在启动。请等待启动完成后重试。",
|
||||
|
|
@ -60,6 +60,7 @@
|
|||
"step4": "马上就好..."
|
||||
},
|
||||
"reasoningLabel": "思考",
|
||||
"toolLabel": "工具",
|
||||
"history": "历史记录",
|
||||
"noHistory": "暂无对话历史",
|
||||
"historyLoadFailed": "加载历史记录失败",
|
||||
|
|
@ -72,6 +73,10 @@
|
|||
"notConnected": "服务未运行,请先启动以进行对话。",
|
||||
"noModel": "未设置默认模型,请前往模型页面进行配置。"
|
||||
},
|
||||
"sendMessage": "发送消息",
|
||||
"sendHint": "按 Enter 发送\nShift + Enter 换行",
|
||||
"contextTitle": "上下文",
|
||||
"contextDetail": "查看详情",
|
||||
"attachImage": "添加图片",
|
||||
"removeImage": "移除图片",
|
||||
"uploadedImage": "已上传图片",
|
||||
|
|
@ -354,11 +359,15 @@
|
|||
"placeholderText": "占位文案",
|
||||
"groupTriggerMentionOnly": "群聊仅提及时响应",
|
||||
"groupTriggerPrefixes": "群聊触发前缀",
|
||||
"groupTriggerPrefixesPlaceholder": "例如 /, !, ?",
|
||||
"randomReactionEmoji": "随机表情回应",
|
||||
"randomReactionEmojiPlaceholder": "例如 THUMBSUP, HEART, SMILE",
|
||||
"isLark": "Lark(国际版)",
|
||||
"allowFrom": "允许来源",
|
||||
"allowFromPlaceholder": "例如 123456, 789012",
|
||||
"allowOrigins": "允许来源域名",
|
||||
"allowOriginsPlaceholder": "例如 https://example.com, http://localhost:5173",
|
||||
"removeListItem": "删除 {{value}}",
|
||||
"secretPlaceholder": "输入密钥",
|
||||
"secretHintSet": "配置已保存,留空表示不修改"
|
||||
},
|
||||
|
|
@ -386,10 +395,11 @@
|
|||
"typingEnabled": "在生成回复时显示“正在输入”状态",
|
||||
"placeholderEnabled": "在最终回复发送前,先发送临时占位消息",
|
||||
"groupTriggerMentionOnly": "在群聊中仅当提及机器人时才响应",
|
||||
"groupTriggerPrefixes": "群聊触发前缀,多个值用逗号分隔",
|
||||
"groupTriggerPrefixes": "群聊触发前缀。可逐项添加,也支持一次粘贴多个值。",
|
||||
"randomReactionEmoji": "PicoClaw 会对用户消息添加表情回复以确认已收到。例如:\"THUMBSUP\", \"HEART\", \"SMILE\"。留空则使用默认的 \"Pin\" 表情。",
|
||||
"isLark": "使用 Lark 国际版域名(open.larksuite.com)替代飞书域名(open.feishu.cn)",
|
||||
"allowFrom": "允许访问的用户或群组 ID,多个值用逗号分隔",
|
||||
"allowOrigins": "允许访问的来源域名,多个值用逗号分隔",
|
||||
"allowFrom": "允许访问的用户或群组 ID。可逐项添加,也支持一次粘贴多个值。",
|
||||
"allowOrigins": "允许访问的来源域名。可逐项添加,也支持一次粘贴多个值。",
|
||||
"wsUrl": "WebSocket 服务地址",
|
||||
"reconnectInterval": "断线后的重连间隔(秒)",
|
||||
"bridgeUrl": "桥接服务地址",
|
||||
|
|
@ -653,10 +663,16 @@
|
|||
"autostart_load_error": "加载开机自启状态失败",
|
||||
"server_port": "服务端口",
|
||||
"server_port_hint": "PicoClaw Web 的 HTTP 监听端口",
|
||||
"launcher_token": "登录令牌",
|
||||
"launcher_token_section_hint": "此分组中的改动需要在重启 launcher 后生效",
|
||||
"launcher_token_hint": "用于在 launcher 登录页进行登录",
|
||||
"launcher_token_placeholder": "输入登录令牌",
|
||||
"launcher_section_hint": "此分组中的改动需要在重启 launcher 后生效",
|
||||
"dashboard_password": "登录密码",
|
||||
"dashboard_password_hint": "设置新的登录密码",
|
||||
"dashboard_password_placeholder": "至少 8 个字符",
|
||||
"dashboard_password_confirm": "确认新密码",
|
||||
"dashboard_password_confirm_hint": "再次输入新的登录密码",
|
||||
"dashboard_password_confirm_placeholder": "再次输入密码",
|
||||
"dashboard_password_required": "请输入并确认新的登录密码",
|
||||
"dashboard_password_mismatch": "两次输入的登录密码不一致",
|
||||
"dashboard_password_min_length": "登录密码至少需要 8 个字符",
|
||||
"lan_access": "启用局域网访问",
|
||||
"lan_access_hint": "允许局域网中的其他设备访问当前服务",
|
||||
"allowed_cidrs": "允许访问网段",
|
||||
|
|
|
|||
|
|
@ -33,7 +33,6 @@ const RootLayout = () => {
|
|||
const [authError, setAuthError] = useState<string | null>(null)
|
||||
|
||||
// Session guard: proactively check auth status on every page load.
|
||||
// This catches the case where ?token= auto-login bypassed the login/setup UI.
|
||||
useEffect(() => {
|
||||
if (isAuthPage) return
|
||||
void getLauncherAuthStatus()
|
||||
|
|
@ -55,7 +54,7 @@ const RootLayout = () => {
|
|||
setAuthError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Auth service unavailable, please try to delete the launcher-auth.db at picoclaw home directory and restart the application.",
|
||||
: "Auth service unavailable. Reset dashboard password storage and restart the application.",
|
||||
)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import { useTheme } from "@/hooks/use-theme"
|
|||
function LauncherLoginPage() {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
const [token, setToken] = React.useState("")
|
||||
const [password, setPassword] = React.useState("")
|
||||
const [submitting, setSubmitting] = React.useState(false)
|
||||
const [error, setError] = React.useState("")
|
||||
|
||||
|
|
@ -45,17 +45,25 @@ function LauncherLoginPage() {
|
|||
})
|
||||
}, [])
|
||||
|
||||
const loginWithToken = React.useCallback(
|
||||
async (tokenValue: string) => {
|
||||
const loginWithPassword = React.useCallback(
|
||||
async (passwordValue: string) => {
|
||||
setError("")
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const ok = await postLauncherDashboardLogin(tokenValue)
|
||||
if (ok) {
|
||||
const result = await postLauncherDashboardLogin(passwordValue)
|
||||
if (result.ok) {
|
||||
globalThis.location.assign("/")
|
||||
return
|
||||
}
|
||||
setError(t("launcherLogin.errorInvalid"))
|
||||
if (result.status === 409) {
|
||||
globalThis.location.assign("/launcher-setup")
|
||||
return
|
||||
}
|
||||
if (result.status === 401) {
|
||||
setError(t("launcherLogin.errorInvalid"))
|
||||
return
|
||||
}
|
||||
setError(result.error)
|
||||
} catch {
|
||||
setError(t("launcherLogin.errorNetwork"))
|
||||
} finally {
|
||||
|
|
@ -67,7 +75,7 @@ function LauncherLoginPage() {
|
|||
|
||||
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
await loginWithToken(token)
|
||||
await loginWithPassword(password)
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -112,17 +120,17 @@ function LauncherLoginPage() {
|
|||
<CardContent>
|
||||
<form className="flex flex-col gap-4" onSubmit={onSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="launcher-token">
|
||||
<Label htmlFor="launcher-password">
|
||||
{t("launcherLogin.passwordLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="launcher-token"
|
||||
id="launcher-password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t("launcherLogin.passwordPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,13 @@ export interface ChatMessage {
|
|||
attachments?: ChatAttachment[]
|
||||
}
|
||||
|
||||
export interface ContextUsage {
|
||||
used_tokens: number
|
||||
total_tokens: number
|
||||
compress_at_tokens: number
|
||||
used_percent: number
|
||||
}
|
||||
|
||||
export type ConnectionState =
|
||||
| "disconnected"
|
||||
| "connecting"
|
||||
|
|
@ -34,6 +41,7 @@ export interface ChatStoreState {
|
|||
isTyping: boolean
|
||||
activeSessionId: string
|
||||
hasHydratedActiveSession: boolean
|
||||
contextUsage?: ContextUsage
|
||||
}
|
||||
|
||||
type ChatStorePatch = Partial<ChatStoreState>
|
||||
|
|
@ -48,6 +56,8 @@ const DEFAULT_CHAT_STATE: ChatStoreState = {
|
|||
|
||||
export const chatAtom = atom<ChatStoreState>(DEFAULT_CHAT_STATE)
|
||||
|
||||
export const showThoughtsAtom = atom<boolean>(true)
|
||||
|
||||
const store = getDefaultStore()
|
||||
|
||||
export function getChatState() {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue