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:
lxowalle 2026-04-22 11:55:42 +08:00
commit c731ecdafc
87 changed files with 3614 additions and 991 deletions

60
.github/workflows/create-tag.yml vendored Normal file
View 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"

View file

@ -1,10 +1,10 @@
name: Create Tag and Release name: Release
on: on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
tag: tag:
description: "Release tag (required, e.g. v0.2.0)" description: "Existing tag to release (e.g. v0.2.0)"
required: true required: true
type: string type: string
prerelease: prerelease:
@ -24,35 +24,23 @@ on:
default: true default: true
jobs: 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: release:
name: GoReleaser Release name: GoReleaser Release
needs: create-tag
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: write contents: write
packages: write packages: write
steps: 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 - name: Checkout tag
uses: actions/checkout@v6 uses: actions/checkout@v6
with: with:

View file

@ -1,12 +1,53 @@
package auth package auth
import ( import (
"bytes"
"encoding/json"
"io"
"os"
"path/filepath"
"strings"
"testing" "testing"
"time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "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) { func TestNewStatusSubcommand(t *testing.T) {
cmd := newStatusCommand() cmd := newStatusCommand()
@ -16,3 +57,47 @@ func TestNewStatusSubcommand(t *testing.T) {
assert.False(t, cmd.HasFlags()) 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"))
}

View file

@ -154,7 +154,7 @@ Identify protocol via prefix in `model` field:
| `openai/` | OpenAI-compatible | Most common, includes DeepSeek, Qwen, Groq, etc. | | `openai/` | OpenAI-compatible | Most common, includes DeepSeek, Qwen, Groq, etc. |
| `anthropic/` | Anthropic | Claude series specific | | `anthropic/` | Anthropic | Claude series specific |
| `antigravity/` | Antigravity | Google Cloud Code Assist | | `antigravity/` | Antigravity | Google Cloud Code Assist |
| `gemini/` | Gemini | Google Gemini native API (if needed) | | `gemini/` | Gemini | Google Gemini native API |
--- ---

View file

@ -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) | | **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) | | **智谱 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) | | **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) | | **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) | | **通义千问 (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é) | | **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 : PicoClaw route les providers par famille de protocole :
- **Compatible OpenAI** : OpenRouter, Groq, Zhipu, endpoints vLLM et la plupart des autres. - **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. - **Anthropic** : Comportement natif de l'API Claude.
- **Codex/OAuth** : Route d'authentification OAuth/token OpenAI. - **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 ### 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. 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.

View file

@ -340,7 +340,7 @@ HEARTBEAT_OK を返信 ユーザーが直接結果を受信
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [取得](https://console.anthropic.com) | | **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) | | **智谱 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) | | **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) | | **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) | | **通義千問 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [取得](https://dashscope.console.aliyun.com) |
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | ローカル(キー不要) | | **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | ローカル(キー不要) |
@ -370,9 +370,12 @@ HEARTBEAT_OK を返信 ユーザーが直接結果を受信
PicoClaw はプロトコルファミリーで Provider をルーティングします: PicoClaw はプロトコルファミリーで Provider をルーティングします:
- **OpenAI 互換**OpenRouter、Groq、Zhipu、vLLM スタイルのエンドポイントなど。 - **OpenAI 互換**OpenRouter、Groq、Zhipu、vLLM スタイルのエンドポイントなど。
- **Gemini ネイティブ**Google Gemini のネイティブ `models/*:generateContent` / `models/*:streamGenerateContent` エンドポイント。
- **Anthropic**Claude ネイティブ API の動作。 - **Anthropic**Claude ネイティブ API の動作。
- **Codex/OAuth**OpenAI OAuth/トークン認証ルート。 - **Codex/OAuth**OpenAI OAuth/トークン認証ルート。
これによりランタイムを軽量に保ちつつ、新しい OpenAI 互換バックエンドの追加をほぼ設定操作(`api_base` + `api_keys`)のみで実現します。
### スケジュールタスク / リマインダー ### スケジュールタスク / リマインダー
PicoClaw は `cron` ツールを通じて cron スタイルのスケジュールタスクをサポートします。 PicoClaw は `cron` ツールを通じて cron スタイルのスケジュールタスクをサポートします。

View file

@ -71,15 +71,16 @@ PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspa
### Web launcher dashboard ### 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). **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`.
**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**.
- **Config file**: Same directory as `config.json` (or the file pointed to by `PICOCLAW_CONFIG`). The launcher-specific file is `launcher-config.json`. - **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). - **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). - **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 ### 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) | | **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) | | **智谱 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) | | **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) | | **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) | | **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) | | **通义千问 (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: PicoClaw routes providers by protocol family:
- **OpenAI-compatible**: OpenRouter, Groq, Zhipu, vLLM-style endpoints, and most others. - **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. - **Anthropic**: Claude-native API behavior.
- **Codex/OAuth**: OpenAI OAuth/token authentication route. - **Codex/OAuth**: OpenAI OAuth/token authentication route.

View file

@ -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) | | **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) | | **智谱 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) | | **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) | | **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) | | **通义千问 (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) | | **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: PicoClaw roteia providers por família de protocolo:
- **Compatível com OpenAI**: OpenRouter, Groq, Zhipu, endpoints vLLM e a maioria dos outros. - **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. - **Anthropic**: Comportamento nativo da API Claude.
- **Codex/OAuth**: Rota de autenticação OAuth/token OpenAI. - **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 ### Tarefas Agendadas / Lembretes
PicoClaw suporta tarefas agendadas via ferramenta `cron`. PicoClaw suporta tarefas agendadas via ferramenta `cron`.

View file

@ -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) | | **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) | | **智谱 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) | | **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) | | **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) | | **通义千问 (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) | | **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: 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. - **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``models/*:streamGenerateContent`.
- **Anthropic**: Hành vi API Claude gốc. - **Anthropic**: Hành vi API Claude gốc.
- **Codex/OAuth**: Tuyến xác thực OAuth/token OpenAI. - **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ở ### Tác Vụ Đã Lên Lịch / Nhắc Nhở
PicoClaw hỗ trợ tác vụ theo lịch qua công cụ `cron`. PicoClaw hỗ trợ tác vụ theo lịch qua công cụ `cron`.

View file

@ -69,15 +69,16 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work
### Web 启动器控制台 ### Web 启动器控制台
**picoclaw-launcher** 打开浏览器控制台前需要先登录。**访问口令**与 **会话签名密钥**默认在**每次启动时在内存中生成**(重启后随机口令会变)。若设置环境变量 **`PICOCLAW_LAUNCHER_TOKEN`**,则该进程使用固定口令(启动日志中不会打印具体口令值)。 **picoclaw-launcher** 打开浏览器控制台前需要先使用密码登录。首次启动时打开 `/launcher-setup` 创建 dashboard 登录密码;后续手动登录使用 `/launcher-login`
**到哪里找口令****控制台模式**`-console`)请看启动时的终端输出;**托盘 / GUI 模式**可使用托盘菜单中的「复制控制台口令」,并在 **`$PICOCLAW_HOME/logs/launcher.log`**(未设置 `PICOCLAW_HOME` 时一般为 `~/.picoclaw/logs/launcher.log`)中查看本次启动写入的随机口令。登录页在未登录时会根据当前运行方式展示提示(含日志文件绝对路径等;**接口与页面均不会返回口令本身**)。
- **配置文件**:与 `config.json` 同一目录(若设置了 `PICOCLAW_CONFIG`,则与它所指的文件同目录)。启动器专用文件名为 `launcher-config.json` - **配置文件**:与 `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/logout`**,且请求头为 **`Content-Type: application/json`**(请求体可为 `{}`),勿使用可被第三方页面触发的 GET 链接登出。
- **暴力尝试**`POST /api/auth/login` 对同一远程地址有 **每分钟尝试次数上限**(超限返回 HTTP 429 - **暴力尝试**`POST /api/auth/login` 对同一远程地址有 **每分钟尝试次数上限**(超限返回 HTTP 429
- **会话时长**:登录后的 HttpOnly 会话 Cookie 默认约 **7 天**有效,到期需重新用口令登录。 - **会话时长**:登录后的 HttpOnly 会话 Cookie 默认约 **31 天**有效,但 launcher 进程重启后已有会话会失效
### 技能来源 (Skill Sources) ### 技能来源 (Skill Sources)
@ -441,7 +442,7 @@ Agent 读取 HEARTBEAT.md
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [获取](https://console.anthropic.com) | | **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) | | **智谱 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) | | **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) | | **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) | | **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) | | **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取](https://dashscope.console.aliyun.com) |
@ -652,10 +653,11 @@ PicoClaw 只剥离最外层的 `litellm/` 前缀再发送请求,因此 `litell
PicoClaw 按协议族路由提供商: PicoClaw 按协议族路由提供商:
- **OpenAI 兼容**OpenRouter、Groq、智谱、vLLM 风格端点及大多数其他提供商。 - **OpenAI 兼容**OpenRouter、Groq、智谱、vLLM 风格端点及大多数其他提供商。
- **Gemini 原生**Google Gemini 通过原生 `models/*:generateContent``models/*:streamGenerateContent` 端点接入。
- **Anthropic**Claude 原生 API 行为。 - **Anthropic**Claude 原生 API 行为。
- **Codex/OAuth**OpenAI OAuth/Token 认证路由。 - **Codex/OAuth**OpenAI OAuth/Token 认证路由。
这使运行时保持轻量,同时让接入新的 OpenAI 兼容后端基本只需配置 `api_base` + `api_key`。 这使运行时保持轻量,同时让接入新的 OpenAI 兼容后端基本只需配置 `api_base` + `api_keys`。
<details> <details>
<summary><b>智谱(旧版 providers 格式)</b></summary> <summary><b>智谱(旧版 providers 格式)</b></summary>

View file

@ -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. Ouvrez http://localhost:18800 dans votre navigateur. Le launcher gère automatiquement le processus gateway.
> [!WARNING] > [!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) ### Mode Agent (One-shot)

View file

@ -45,7 +45,7 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d
ブラウザで http://localhost:18800 を開いてください。Launcher が Gateway プロセスを自動管理します。 ブラウザで http://localhost:18800 を開いてください。Launcher が Gateway プロセスを自動管理します。
> [!WARNING] > [!WARNING]
> Web コンソールはまだ認証をサポートしていません。公開インターネットに公開しないでください。 > Web コンソールは dashboard ログインパスワードで保護されます。信頼できないネットワークや公開インターネットには公開しないでください。
### Agent モード (ワンショット) ### Agent モード (ワンショット)

View file

@ -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. Open http://localhost:18800 in your browser. The launcher manages the gateway process automatically.
> [!WARNING] > [!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) ### Agent Mode (One-shot)

View file

@ -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. Buka http://localhost:18800 dalam pelayar anda. Launcher mengurus proses gateway secara automatik.
> [!WARNING] > [!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) ### Mod Agent (One-shot)

View file

@ -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. Abra http://localhost:18800 no seu navegador. O launcher gerencia o processo do gateway automaticamente.
> [!WARNING] > [!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) ### Modo Agent (One-shot)

View file

@ -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. Mở http://localhost:18800 trong trình duyệt. Launcher tự động quản lý tiến trình gateway.
> [!WARNING] > [!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) ### Chế Độ Agent (One-shot)

View file

@ -45,7 +45,7 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d
在浏览器中打开 <http://localhost:18800>。Launcher 会自动管理 Gateway 进程。 在浏览器中打开 <http://localhost:18800>。Launcher 会自动管理 Gateway 进程。
> [!WARNING] > [!WARNING]
> Web 控制台通过 dashboard 令牌鉴权(默认每次启动在内存中生成;可用 `PICOCLAW_LAUNCHER_TOKEN` 固定)。**不要**将启动器暴露到不可信网络或公网。完整说明见 [配置指南](configuration.md) 中的「Web 启动器控制台」一节。 > Web 控制台通过 dashboard 登录密码保护。**不要**将启动器暴露到不可信网络或公网。完整说明见 [配置指南](configuration.md) 中的「Web 启动器控制台」一节。
### Agent 模式 (一次性运行) ### Agent 模式 (一次性运行)

View file

@ -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) | | **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) | | **智谱 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) | | **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) | | **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) | | **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) | | **通义千问 (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_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 | | `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 | | `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) | | `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) | | `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` | | `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 : PicoClaw route les fournisseurs par famille de protocoles :
- Protocole compatible OpenAI : OpenRouter, passerelles compatibles OpenAI, Groq, Zhipu et endpoints de type vLLM. - 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. - Protocole Anthropic : Comportement natif de l'API Claude.
- Chemin Codex/OAuth : Route d'authentification OAuth/token OpenAI. - 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> <details>
<summary><b>Zhipu</b></summary> <summary><b>Zhipu</b></summary>

View file

@ -47,7 +47,7 @@
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [キーを取得](https://console.anthropic.com) | | **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) | | **智谱 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) | | **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) | | **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) | | **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) | | **通義千問 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [キーを取得](https://dashscope.console.aliyun.com) |
@ -109,7 +109,7 @@
| `api_keys` | string[] | はい* | 認証キー。複数キーでリクエストごとのローテーションが可能。ローカル providerOllama、LM Studio、VLLMには不要 | | `api_keys` | string[] | はい* | 認証キー。複数キーでリクエストごとのローテーションが可能。ローカル providerOllama、LM Studio、VLLMには不要 |
| `api_base` | string | いいえ | デフォルトの API エンドポイント URL を上書き | | `api_base` | string | いいえ | デフォルトの API エンドポイント URL を上書き |
| `proxy` | string | いいえ | このモデルエントリの HTTP プロキシ 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 により異なる | | `request_timeout` | int | いいえ | リクエストタイムアウト(秒)。デフォルト値は provider により異なる |
| `max_tokens_field` | string | いいえ | リクエストボディの max tokens フィールド名を上書きo1 モデルでは `max_completion_tokens` | | `max_tokens_field` | string | いいえ | リクエストボディの max tokens フィールド名を上書きo1 モデルでは `max_completion_tokens` |
| `thinking_level` | string | いいえ | 拡張思考レベル:`off``low``medium``high``xhigh``adaptive` | | `thinking_level` | string | いいえ | 拡張思考レベル:`off``low``medium``high``xhigh``adaptive` |
@ -311,6 +311,7 @@ PicoClaw はリクエスト送信前に外側の `litellm/` プレフィック
PicoClaw はプロトコルファミリーごとに Provider をルーティングします: PicoClaw はプロトコルファミリーごとに Provider をルーティングします:
- OpenAI 互換プロトコルOpenRouter、OpenAI 互換ゲートウェイ、Groq、Zhipu、vLLM スタイルのエンドポイント。 - OpenAI 互換プロトコルOpenRouter、OpenAI 互換ゲートウェイ、Groq、Zhipu、vLLM スタイルのエンドポイント。
- Gemini ネイティブプロトコルGoogle Gemini のネイティブ `models/*:generateContent` / `models/*:streamGenerateContent` エンドポイント。
- Anthropic プロトコルClaude ネイティブ API 動作。 - Anthropic プロトコルClaude ネイティブ API 動作。
- Codex/OAuth パスOpenAI OAuth/Token 認証ルート。 - Codex/OAuth パスOpenAI OAuth/Token 認証ルート。

View file

@ -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) | | **智谱 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) | | **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) | | **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) | | **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) | | **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) | | **通义千问 (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_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 | | `api_base` | string | No | Override the default API endpoint URL |
| `proxy` | string | No | HTTP proxy URL for this model entry | | `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) | | `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) | | `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` | | `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: PicoClaw routes providers by protocol family:
- OpenAI-compatible protocol: OpenRouter, OpenAI-compatible gateways, Groq, Zhipu, and vLLM-style endpoints. - 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. - Anthropic protocol: Claude-native API behavior.
- Codex/OAuth path: OpenAI OAuth/token authentication route. - 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> <details>
<summary><b>Zhipu</b></summary> <summary><b>Zhipu</b></summary>

View file

@ -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) | | **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) | | **智谱 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) | | **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) | | **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) | | **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) | | **通义千问 (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_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 | | `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 | | `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) | | `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) | | `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` | | `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: 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 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. - Protocolo Anthropic: Comportamento nativo da API Claude.
- Caminho Codex/OAuth: Rota de autenticação OAuth/token da OpenAI. - Caminho Codex/OAuth: Rota de autenticação OAuth/token da OpenAI.

View file

@ -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) | | **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) | | **智谱 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) | | **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) | | **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) | | **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) | | **通义千问 (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_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 | | `api_base` | string | Không | Ghi đè URL endpoint API mặc định |
| `proxy` | string | Không | URL proxy HTTP cho entry model này | | `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) | | `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) | | `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` | | `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: 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 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``models/*:streamGenerateContent`.
- Giao thức Anthropic: Hành vi API native của Claude. - 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. - Đường dẫn Codex/OAuth: Tuyến xác thực OAuth/token của OpenAI.

View file

@ -52,7 +52,7 @@
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [获取密钥](https://console.anthropic.com) | | **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) | | **智谱 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) | | **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) | | **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) | | **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) | | **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取密钥](https://dashscope.console.aliyun.com) |
@ -116,7 +116,7 @@
| `api_keys` | string[] | 是* | 认证密钥。多个密钥可按请求轮换。本地 providerOllama、LM Studio、VLLM不需要 | | `api_keys` | string[] | 是* | 认证密钥。多个密钥可按请求轮换。本地 providerOllama、LM Studio、VLLM不需要 |
| `api_base` | string | 否 | 覆盖默认的 API 端点 URL | | `api_base` | string | 否 | 覆盖默认的 API 端点 URL |
| `proxy` | string | 否 | 此模型条目的 HTTP 代理 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 而异 | | `request_timeout` | int | 否 | 请求超时时间(秒),默认值因 provider 而异 |
| `max_tokens_field` | string | 否 | 覆盖请求体中 max tokens 的字段名(如 o1 模型使用 `max_completion_tokens` | | `max_tokens_field` | string | 否 | 覆盖请求体中 max tokens 的字段名(如 o1 模型使用 `max_completion_tokens` |
| `thinking_level` | string | 否 | 扩展思考级别:`off``low``medium``high``xhigh``adaptive` | | `thinking_level` | string | 否 | 扩展思考级别:`off``low``medium``high``xhigh``adaptive` |
@ -386,10 +386,11 @@ PicoClaw 在发送请求前仅去除外层 `litellm/` 前缀,因此 `litellm/l
PicoClaw 按协议族路由 Provider PicoClaw 按协议族路由 Provider
- OpenAI 兼容协议OpenRouter、OpenAI 兼容网关、Groq、智谱、vLLM 风格端点。 - OpenAI 兼容协议OpenRouter、OpenAI 兼容网关、Groq、智谱、vLLM 风格端点。
- Gemini 原生协议Google Gemini 通过原生 `models/*:generateContent``models/*:streamGenerateContent` 端点接入。
- Anthropic 协议Claude 原生 API 行为。 - Anthropic 协议Claude 原生 API 行为。
- Codex/OAuth 路径OpenAI OAuth/Token 认证路由。 - Codex/OAuth 路径OpenAI OAuth/Token 认证路由。
这使得运行时保持轻量,同时让新的 OpenAI 兼容后端基本只需配置操作(`api_base` + `api_key`)。 这使得运行时保持轻量,同时让新的 OpenAI 兼容后端基本只需配置操作(`api_base` + `api_keys`)。
<details> <details>
<summary><b>智谱 (Zhipu) 配置示例</b></summary> <summary><b>智谱 (Zhipu) 配置示例</b></summary>

View file

@ -528,10 +528,11 @@ func (al *AgentLoop) runAgentLoop(
opts.Dispatch.ChatID(), opts.Dispatch.ChatID(),
opts.Dispatch.ReplyToMessageID(), opts.Dispatch.ReplyToMessageID(),
), ),
AgentID: agentID, AgentID: agentID,
SessionKey: sessionKey, SessionKey: sessionKey,
Scope: scope, Scope: scope,
Content: result.finalContent, Content: result.finalContent,
ContextUsage: computeContextUsage(agent, opts.Dispatch.SessionKey),
}) })
} }

View file

@ -214,6 +214,24 @@ func (al *AgentLoop) buildCommandsRuntime(
rt.AskSideQuestion = func(ctx context.Context, question string) (string, error) { rt.AskSideQuestion = func(ctx context.Context, question string) (string, error) {
return al.askSideQuestion(ctx, agent, opts, question) 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 return rt
} }

View file

@ -60,10 +60,14 @@ func (al *AgentLoop) PublishResponseIfNeeded(ctx context.Context, channel, chatI
return return
} }
al.bus.PublishOutbound(ctx, bus.OutboundMessage{ msg := bus.OutboundMessage{
Context: bus.NewOutboundContext(channel, chatID, ""), Context: bus.NewOutboundContext(channel, chatID, ""),
Content: response, Content: response,
}) }
if sessionKey != "" {
msg.ContextUsage = computeContextUsage(al.agentForSession(sessionKey), sessionKey)
}
al.bus.PublishOutbound(ctx, msg)
logger.InfoCF("agent", "Published outbound response", logger.InfoCF("agent", "Published outbound response",
map[string]any{ map[string]any{
"channel": channel, "channel": channel,

View file

@ -11,6 +11,7 @@ import (
"strings" "strings"
"sync" "sync"
"time" "time"
"unicode/utf8"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
@ -210,6 +211,36 @@ func (cb *ContextBuilder) BuildSystemPromptWithCache() string {
return prompt 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. // InvalidateCache clears the cached system prompt.
// Normally not needed because the cache auto-invalidates via mtime checks, // Normally not needed because the cache auto-invalidates via mtime checks,
// but this is useful for tests or explicit reload commands. // but this is useful for tests or explicit reload commands.

View 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,
}
}

View file

@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
@ -25,6 +26,11 @@ type AuthStore struct {
Credentials map[string]*AuthCredential `json:"credentials"` Credentials map[string]*AuthCredential `json:"credentials"`
} }
const (
providerGoogleAntigravity = "google-antigravity"
providerAntigravityAlias = "antigravity"
)
func (c *AuthCredential) IsExpired() bool { func (c *AuthCredential) IsExpired() bool {
if c.ExpiresAt.IsZero() { if c.ExpiresAt.IsZero() {
return false return false
@ -43,6 +49,125 @@ func authFilePath() string {
return filepath.Join(config.GetHome(), "auth.json") 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) { func LoadStore() (*AuthStore, error) {
path := authFilePath() path := authFilePath()
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
@ -57,9 +182,7 @@ func LoadStore() (*AuthStore, error) {
if err := json.Unmarshal(data, &store); err != nil { if err := json.Unmarshal(data, &store); err != nil {
return nil, err return nil, err
} }
if store.Credentials == nil { normalizeStore(&store)
store.Credentials = make(map[string]*AuthCredential)
}
return &store, nil return &store, nil
} }
@ -79,7 +202,7 @@ func GetCredential(provider string) (*AuthCredential, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
cred, ok := store.Credentials[provider] cred, ok := store.Credentials[canonicalProvider(provider)]
if !ok { if !ok {
return nil, nil return nil, nil
} }
@ -91,7 +214,17 @@ func SetCredential(provider string, cred *AuthCredential) error {
if err != nil { if err != nil {
return err 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) return SaveStore(store)
} }
@ -100,7 +233,7 @@ func DeleteCredential(provider string) error {
if err != nil { if err != nil {
return err return err
} }
delete(store.Credentials, provider) delete(store.Credentials, canonicalProvider(provider))
return SaveStore(store) return SaveStore(store)
} }

View file

@ -1,12 +1,24 @@
package auth package auth
import ( import (
"encoding/json"
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"testing" "testing"
"time" "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) { func TestAuthCredentialIsExpired(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@ -51,10 +63,7 @@ func TestAuthCredentialNeedsRefresh(t *testing.T) {
} }
func TestStoreRoundtrip(t *testing.T) { func TestStoreRoundtrip(t *testing.T) {
tmpDir := t.TempDir() setTestAuthHome(t)
origHome := os.Getenv("HOME")
t.Setenv("HOME", tmpDir)
defer os.Setenv("HOME", origHome)
cred := &AuthCredential{ cred := &AuthCredential{
AccessToken: "test-access-token", AccessToken: "test-access-token",
@ -88,10 +97,7 @@ func TestStoreRoundtrip(t *testing.T) {
} }
func TestStoreFilePermissions(t *testing.T) { func TestStoreFilePermissions(t *testing.T) {
tmpDir := t.TempDir() tmpDir := setTestAuthHome(t)
origHome := os.Getenv("HOME")
t.Setenv("HOME", tmpDir)
defer os.Setenv("HOME", origHome)
cred := &AuthCredential{ cred := &AuthCredential{
AccessToken: "secret-token", AccessToken: "secret-token",
@ -108,16 +114,16 @@ func TestStoreFilePermissions(t *testing.T) {
t.Fatalf("Stat() error: %v", err) t.Fatalf("Stat() error: %v", err)
} }
perm := info.Mode().Perm() perm := info.Mode().Perm()
if runtime.GOOS == "windows" {
return
}
if perm != 0o600 { if perm != 0o600 {
t.Errorf("file permissions = %o, want 0600", perm) t.Errorf("file permissions = %o, want 0600", perm)
} }
} }
func TestStoreMultiProvider(t *testing.T) { func TestStoreMultiProvider(t *testing.T) {
tmpDir := t.TempDir() setTestAuthHome(t)
origHome := os.Getenv("HOME")
t.Setenv("HOME", tmpDir)
defer os.Setenv("HOME", origHome)
openaiCred := &AuthCredential{AccessToken: "openai-token", Provider: "openai", AuthMethod: "oauth"} openaiCred := &AuthCredential{AccessToken: "openai-token", Provider: "openai", AuthMethod: "oauth"}
anthropicCred := &AuthCredential{AccessToken: "anthropic-token", Provider: "anthropic", AuthMethod: "token"} anthropicCred := &AuthCredential{AccessToken: "anthropic-token", Provider: "anthropic", AuthMethod: "token"}
@ -147,10 +153,7 @@ func TestStoreMultiProvider(t *testing.T) {
} }
func TestDeleteCredential(t *testing.T) { func TestDeleteCredential(t *testing.T) {
tmpDir := t.TempDir() setTestAuthHome(t)
origHome := os.Getenv("HOME")
t.Setenv("HOME", tmpDir)
defer os.Setenv("HOME", origHome)
cred := &AuthCredential{AccessToken: "to-delete", Provider: "openai", AuthMethod: "oauth"} cred := &AuthCredential{AccessToken: "to-delete", Provider: "openai", AuthMethod: "oauth"}
if err := SetCredential("openai", cred); err != nil { if err := SetCredential("openai", cred); err != nil {
@ -171,10 +174,7 @@ func TestDeleteCredential(t *testing.T) {
} }
func TestLoadStoreEmpty(t *testing.T) { func TestLoadStoreEmpty(t *testing.T) {
tmpDir := t.TempDir() setTestAuthHome(t)
origHome := os.Getenv("HOME")
t.Setenv("HOME", tmpDir)
defer os.Setenv("HOME", origHome)
store, err := LoadStore() store, err := LoadStore()
if err != nil { if err != nil {
@ -187,3 +187,319 @@ func TestLoadStoreEmpty(t *testing.T) {
t.Errorf("expected empty credentials, got %d", len(store.Credentials)) 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")
}
}

View file

@ -61,6 +61,15 @@ type OutboundScope struct {
Values map[string]string `json:"values,omitempty"` 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 { type OutboundMessage struct {
Channel string `json:"channel"` Channel string `json:"channel"`
ChatID string `json:"chat_id"` ChatID string `json:"chat_id"`
@ -70,6 +79,7 @@ type OutboundMessage struct {
Scope *OutboundScope `json:"scope,omitempty"` Scope *OutboundScope `json:"scope,omitempty"`
Content string `json:"content"` Content string `json:"content"`
ReplyToMessageID string `json:"reply_to_message_id,omitempty"` ReplyToMessageID string `json:"reply_to_message_id,omitempty"`
ContextUsage *ContextUsage `json:"context_usage,omitempty"`
} }
// MediaPart describes a single media attachment to send. // MediaPart describes a single media attachment to send.

View file

@ -296,11 +296,13 @@ func (c *PicoChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]stri
} }
msgID := uuid.New().String() msgID := uuid.New().String()
outMsg := newMessage(TypeMessageCreate, map[string]any{ payload := map[string]any{
PayloadKeyContent: content, PayloadKeyContent: content,
PayloadKeyThought: isThought, PayloadKeyThought: isThought,
"message_id": msgID, "message_id": msgID,
}) }
setContextUsagePayload(payload, msg.ContextUsage)
outMsg := newMessage(TypeMessageCreate, payload)
if err := c.broadcastToSession(msg.ChatID, outMsg); err != nil { if err := c.broadcastToSession(msg.ChatID, outMsg); err != nil {
return nil, err return nil, err
@ -826,3 +828,16 @@ func validateInlineImageDataURL(mediaURL string) error {
return nil 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,
}
}

View file

@ -15,6 +15,7 @@ func BuiltinDefinitions() []Definition {
switchCommand(), switchCommand(),
checkCommand(), checkCommand(),
clearCommand(), clearCommand(),
contextCommand(),
subagentsCommand(), subagentsCommand(),
reloadCommand(), reloadCommand(),
} }

View 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,
)
}

View file

@ -6,6 +6,15 @@ import (
"github.com/sipeed/picoclaw/pkg/config" "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 // Runtime provides runtime dependencies to command handlers. It is constructed
// per-request by the agent loop so that per-request state (like session scope) // per-request by the agent loop so that per-request state (like session scope)
// can coexist with long-lived callbacks (like GetModelInfo). // can coexist with long-lived callbacks (like GetModelInfo).
@ -18,6 +27,7 @@ type Runtime struct {
ListSkillNames func() []string ListSkillNames func() []string
GetEnabledChannels func() []string GetEnabledChannels func() []string
GetActiveTurn func() any // Returning any to avoid circular dependency with agent package GetActiveTurn func() any // Returning any to avoid circular dependency with agent package
GetContextStats func() *ContextStats
SwitchModel func(value string) (oldModel string, err error) SwitchModel func(value string) (oldModel string, err error)
SwitchChannel func(value string) error SwitchChannel func(value string) error
ClearHistory func() error ClearHistory func() error

View file

@ -22,6 +22,11 @@ import (
type FlexibleStringSlice []string type FlexibleStringSlice []string
func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error { func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
if string(data) == "null" {
*f = nil
return nil
}
// Accept a single JSON string for convenience, e.g.: // Accept a single JSON string for convenience, e.g.:
// "text": "Thinking..." // "text": "Thinking..."
var singleString string var singleString string

View file

@ -1258,6 +1258,11 @@ func TestFlexibleStringSlice_UnmarshalJSON(t *testing.T) {
input string input string
expected []string expected []string
}{ }{
{
name: "null",
input: `null`,
expected: nil,
},
{ {
name: "single string", name: "single string",
input: `"Thinking..."`, input: `"Thinking..."`,
@ -1286,6 +1291,12 @@ func TestFlexibleStringSlice_UnmarshalJSON(t *testing.T) {
if err := json.Unmarshal([]byte(tt.input), &f); err != nil { if err := json.Unmarshal([]byte(tt.input), &f); err != nil {
t.Fatalf("json.Unmarshal(%s) error = %v", tt.input, err) 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) { if len(f) != len(tt.expected) {
t.Fatalf("json.Unmarshal(%s) len = %d, want %d", tt.input, len(f), len(tt.expected)) t.Fatalf("json.Unmarshal(%s) len = %d, want %d", tt.input, len(f), len(tt.expected))
} }

View file

@ -76,7 +76,7 @@ func postStartPlatformIsolation(cmd *exec.Cmd, isolation config.IsolationConfig,
info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{} info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{}
info.BasicLimitInformation.LimitFlags = windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE info.BasicLimitInformation.LimitFlags = windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
if _, err := windows.SetInformationJobObject( if _, err = windows.SetInformationJobObject(
job, job,
windows.JobObjectExtendedLimitInformation, windows.JobObjectExtendedLimitInformation,
uintptr(unsafe.Pointer(&info)), uintptr(unsafe.Pointer(&info)),

View file

@ -121,23 +121,18 @@ When a gateway process is started by the launcher, the launcher:
### Launcher Authentication ### 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. - First run uses `/launcher-setup` to create the dashboard password.
- Otherwise a random token is generated for each launcher process.
- The browser auto-open URL includes `?token=...` so local launches can sign in automatically.
- Manual login uses `/launcher-login`. - Manual login uses `/launcher-login`.
- API clients may also authenticate with `Authorization: Bearer <token>`. - Successful login sets an HttpOnly session cookie.
- Existing sessions are invalidated when the launcher process restarts; otherwise the browser cookie expires after 31 days.
Where users can retrieve the token depends on launch mode: - 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`.
- Console mode: printed to stdout - On platforms where the SQLite password store is unavailable, the launcher stores the bcrypt hash in `launcher-config.json`.
- GUI mode: available through the tray menu on supported builds - Legacy `launcher_token` values are migrated once into password login and are removed from saved launcher config.
- GUI mode without stdout: - `PICOCLAW_LAUNCHER_TOKEN` is deprecated and ignored; after upgrading from env-token auth, open `/launcher-setup` to create a password.
- random per-run tokens are written to the launcher log - URL token login and `Authorization: Bearer` dashboard auth are not supported.
- 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
### Network Exposure ### Network Exposure
@ -155,7 +150,7 @@ With `-public` or `public: true`, it listens on all interfaces:
When public access is enabled: 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 - 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 - 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 ### You have to sign in again after the launcher restarts
Existing dashboard sessions do not survive 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. 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`.
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`
### "Start Gateway" stays disabled ### "Start Gateway" stays disabled

View file

@ -12,9 +12,8 @@ import (
"github.com/sipeed/picoclaw/web/backend/middleware" "github.com/sipeed/picoclaw/web/backend/middleware"
) )
// PasswordStore is the interface for bcrypt-backed dashboard password persistence. // PasswordStore is the interface for dashboard password persistence.
// Implemented by dashboardauth.Store; a nil value falls back to the legacy // Implemented by dashboardauth.Store and launcherconfig.PasswordStore.
// static-token comparison.
type PasswordStore interface { type PasswordStore interface {
IsInitialized(ctx context.Context) (bool, error) IsInitialized(ctx context.Context) (bool, error)
SetPassword(ctx context.Context, plain string) error SetPassword(ctx context.Context, plain string) error
@ -23,18 +22,13 @@ type PasswordStore interface {
// LauncherAuthRouteOpts configures dashboard auth handlers. // LauncherAuthRouteOpts configures dashboard auth handlers.
type LauncherAuthRouteOpts struct { type LauncherAuthRouteOpts struct {
// DashboardToken is the fallback plaintext token used when PasswordStore is SessionCookie string
// nil or not yet initialized (env-var / config-file source, and ?token= auto-login). SecureCookie func(*http.Request) bool
DashboardToken string // PasswordStore enables password login. It must be non-nil for auth to work.
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.
PasswordStore PasswordStore PasswordStore PasswordStore
// StoreError holds the error returned when opening the password store. When // StoreError holds the error returned when opening the password store. When
// non-nil and PasswordStore is nil, the auth endpoints surface a recovery // non-nil and PasswordStore is nil, auth endpoints fail closed with a
// message instead of an opaque 501/503. // recovery message.
StoreError error StoreError error
} }
@ -59,7 +53,6 @@ func RegisterLauncherAuthRoutes(mux *http.ServeMux, opts LauncherAuthRouteOpts)
secure = middleware.DefaultLauncherDashboardSecureCookie secure = middleware.DefaultLauncherDashboardSecureCookie
} }
h := &launcherAuthHandlers{ h := &launcherAuthHandlers{
token: opts.DashboardToken,
sessionCookie: opts.SessionCookie, sessionCookie: opts.SessionCookie,
secureCookie: secure, secureCookie: secure,
store: opts.PasswordStore, store: opts.PasswordStore,
@ -73,7 +66,6 @@ func RegisterLauncherAuthRoutes(mux *http.ServeMux, opts LauncherAuthRouteOpts)
} }
type launcherAuthHandlers struct { type launcherAuthHandlers struct {
token string
sessionCookie string sessionCookie string
secureCookie func(*http.Request) bool secureCookie func(*http.Request) bool
store PasswordStore store PasswordStore
@ -81,29 +73,18 @@ type launcherAuthHandlers struct {
loginLimit *loginRateLimiter loginLimit *loginRateLimiter
} }
func (h *launcherAuthHandlers) usesLegacyTokenAuth() bool {
return h.store == nil && h.storeErr == nil && h.token != ""
}
// isStoreInitialized safely queries the store. // 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 // Returns (false, err) on store errors — callers must treat this as a 5xx, not as
// "uninitialized", to keep auth fail-closed. // "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) { func (h *launcherAuthHandlers) isStoreInitialized(ctx context.Context) (bool, error) {
if h.store == nil { if h.store == nil {
if h.storeErr != nil { if h.storeErr != nil {
return false, fmt.Errorf( return false, fmt.Errorf(
"password store unavailable (%w); "+ "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) h.storeErr)
} }
if h.usesLegacyTokenAuth() { return false, fmt.Errorf("password store not configured")
return true, nil
}
return false, nil
} }
return h.store.IsInitialized(ctx) return h.store.IsInitialized(ctx)
} }
@ -123,35 +104,25 @@ func (h *launcherAuthHandlers) handleLogin(w http.ResponseWriter, r *http.Reques
return return
} }
in := strings.TrimSpace(body.Password) in := strings.TrimSpace(body.Password)
var ok bool
initialized, initErr := h.isStoreInitialized(r.Context()) initialized, initErr := h.isStoreInitialized(r.Context())
if initErr != nil { if initErr != nil {
if h.storeErr != nil { w.WriteHeader(http.StatusServiceUnavailable)
// Store failed to open at startup — token login remains available. writeErrorf(w, "%v", initErr)
initialized = false return
} else { }
w.WriteHeader(http.StatusInternalServerError) if !initialized {
writeErrorf(w, "%v", initErr) w.WriteHeader(http.StatusConflict)
return _, _ = w.Write([]byte(`{"error":"password has not been set"}`))
} return
} }
if initialized && h.store != nil { ok, err := h.store.VerifyPassword(r.Context(), in)
// Bcrypt path: verify against the stored hash. if err != nil {
var err error w.WriteHeader(http.StatusInternalServerError)
ok, err = h.store.VerifyPassword(r.Context(), in) writeErrorf(w, "password verification failed: %v", err)
if err != nil { return
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
} }
if !ok { if !ok {
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"invalid password"}`)) _, _ = 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. // handleSetup sets or changes the dashboard password.
// //
// Rules: // 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. // - If a password is already set, the caller must hold a valid session cookie.
func (h *launcherAuthHandlers) handleSetup(w http.ResponseWriter, r *http.Request) { func (h *launcherAuthHandlers) handleSetup(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") 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 { if h.store == nil {
w.WriteHeader(http.StatusNotImplemented) w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte(`{"error":"password store not configured"}`)) if h.storeErr != nil {
writeErrorf(w, "password store unavailable: %v", h.storeErr)
} else {
_, _ = w.Write([]byte(`{"error":"password store not configured"}`))
}
return return
} }

View file

@ -2,7 +2,9 @@ package api
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
@ -12,17 +14,43 @@ import (
"github.com/sipeed/picoclaw/web/backend/middleware" "github.com/sipeed/picoclaw/web/backend/middleware"
) )
func TestLauncherAuthLoginAndStatus(t *testing.T) { type fakePasswordStore struct {
key := make([]byte, 32) initialized bool
for i := range key { password string
key[i] = 0x55 err error
}
func (s *fakePasswordStore) IsInitialized(context.Context) (bool, error) {
if s.err != nil {
return false, s.err
} }
const tok = "dashboard-test-token-9" return s.initialized, nil
sess := middleware.SessionCookieValue(key, tok) }
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() mux := http.NewServeMux()
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: tok, SessionCookie: sess,
SessionCookie: sess, PasswordStore: store,
}) })
t.Run("status_unauthenticated", func(t *testing.T) { 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) { t.Run("login_ok", func(t *testing.T) {
rec := httptest.NewRecorder() 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.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "127.0.0.1:12345" req.RemoteAddr = "127.0.0.1:12345"
mux.ServeHTTP(rec, req) mux.ServeHTTP(rec, req)
@ -75,14 +103,13 @@ func TestLauncherAuthLoginAndStatus(t *testing.T) {
}) })
} }
func TestLauncherAuthLegacyTokenFallbackReportsInitialized(t *testing.T) { func TestLauncherAuthUninitializedStoreRequiresSetup(t *testing.T) {
key := make([]byte, 32) const sess = "session-cookie-value"
const tok = "legacy-fallback-token" store := &fakePasswordStore{}
sess := middleware.SessionCookieValue(key, tok)
mux := http.NewServeMux() mux := http.NewServeMux()
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: tok, SessionCookie: sess,
SessionCookie: sess, PasswordStore: store,
}) })
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@ -98,29 +125,80 @@ func TestLauncherAuthLegacyTokenFallbackReportsInitialized(t *testing.T) {
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if !body.Initialized { if body.Initialized {
t.Fatalf("initialized = false, want true in legacy token fallback mode") t.Fatalf("initialized = true, want false before setup")
} }
if body.Authenticated { if body.Authenticated {
t.Fatalf("unexpected authenticated=true: %+v", body) t.Fatalf("unexpected authenticated=true: %+v", body)
} }
rec = httptest.NewRecorder() 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") req.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(rec, req) mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK { 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) { func TestLauncherAuthSetupRequiresSessionWhenInitialized(t *testing.T) {
key := make([]byte, 32) const sess = "session-cookie-value"
sess := middleware.SessionCookieValue(key, "legacy-token") store := &fakePasswordStore{initialized: true, password: "old-password"}
mux := http.NewServeMux() mux := http.NewServeMux()
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ 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() rec := httptest.NewRecorder()
@ -131,18 +209,46 @@ func TestLauncherAuthSetupRejectedInLegacyTokenFallback(t *testing.T) {
) )
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(rec, req) mux.ServeHTTP(rec, req)
if rec.Code != http.StatusNotImplemented { if rec.Code != http.StatusOK {
t.Fatalf("setup code = %d body=%s", rec.Code, rec.Body.String()) 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) { func TestLauncherAuthLogoutRequiresPostAndJSON(t *testing.T) {
key := make([]byte, 32)
sess := middleware.SessionCookieValue(key, "tok")
mux := http.NewServeMux() mux := http.NewServeMux()
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: "tok", SessionCookie: "session-cookie-value",
SessionCookie: sess,
}) })
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@ -169,16 +275,14 @@ func TestLauncherAuthLogoutRequiresPostAndJSON(t *testing.T) {
} }
func TestLauncherAuthLoginRateLimit(t *testing.T) { func TestLauncherAuthLoginRateLimit(t *testing.T) {
key := make([]byte, 32) store := &fakePasswordStore{initialized: true, password: "correct-password"}
const tok = "rate-limit-tok-xxxxxxxx"
sess := middleware.SessionCookieValue(key, tok)
mux := http.NewServeMux() mux := http.NewServeMux()
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: tok, SessionCookie: "session-cookie-value",
SessionCookie: sess, 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"}` wrongBody := `{"password":"wrong"}`
for i := 0; i < loginAttemptsPerIP; i++ { for i := 0; i < loginAttemptsPerIP; i++ {
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@ -231,12 +335,9 @@ func TestReferrerPolicyMiddleware(t *testing.T) {
} }
func TestLauncherAuthLogoutEmptyBody(t *testing.T) { func TestLauncherAuthLogoutEmptyBody(t *testing.T) {
key := make([]byte, 32)
sess := middleware.SessionCookieValue(key, "tok")
mux := http.NewServeMux() mux := http.NewServeMux()
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: "tok", SessionCookie: "session-cookie-value",
SessionCookie: sess,
}) })
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil) req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil)
@ -249,12 +350,9 @@ func TestLauncherAuthLogoutEmptyBody(t *testing.T) {
} }
func TestLauncherAuthLogoutRejectsTrailingJSON(t *testing.T) { func TestLauncherAuthLogoutRejectsTrailingJSON(t *testing.T) {
key := make([]byte, 32)
sess := middleware.SessionCookieValue(key, "tok")
mux := http.NewServeMux() mux := http.NewServeMux()
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: "tok", SessionCookie: "session-cookie-value",
SessionCookie: sess,
}) })
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", strings.NewReader(`{}{}`)) req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", strings.NewReader(`{}{}`))

View file

@ -56,13 +56,22 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
} }
defer r.Body.Close() defer r.Body.Close()
var cfg config.Config var raw map[string]any
if err = json.Unmarshal(body, &cfg); err != nil { if err = json.Unmarshal(body, &raw); err != nil {
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
return return
} }
var raw map[string]any if err = normalizeChannelArrayFields(raw); err != nil {
if err = json.Unmarshal(body, &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) http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
return return
} }
@ -154,6 +163,10 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) {
// Recursively merge patch into base // Recursively merge patch into base
mergeMap(base, patch) 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 // Convert merged map back to Config struct
merged, err := json.Marshal(base) merged, err := json.Marshal(base)
@ -382,6 +395,184 @@ func asMapField(value map[string]any, key string) (map[string]any, bool) {
return m, isMap 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) { func getSecretString(m map[string]any, key string) (string, bool) {
if raw, exists := m[key]; exists { if raw, exists := m[key]; exists {
s, isString := raw.(string) s, isString := raw.(string)

View file

@ -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\u202eou_a ",
"group_trigger": {
"prefixes": "/!;\n?/"
},
"settings": {
"allow_origins": "https://a.example.comhttp://localhost:5173https://a.example.com"
}
},
"irc": {
"type": "irc",
"settings": {
"channels": "#ops,\n#dev,\n#ops",
"request_caps": "multi-prefixecho-message\tbatchmulti-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) { func TestHandlePatchConfig_CreatesMissingChannelWithTypeAndSecret(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t) configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup() defer cleanup()

View file

@ -4,16 +4,14 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"strings"
"github.com/sipeed/picoclaw/web/backend/launcherconfig" "github.com/sipeed/picoclaw/web/backend/launcherconfig"
) )
type launcherConfigPayload struct { type launcherConfigPayload struct {
Port int `json:"port"` Port int `json:"port"`
Public bool `json:"public"` Public bool `json:"public"`
AllowedCIDRs []string `json:"allowed_cidrs"` AllowedCIDRs []string `json:"allowed_cidrs"`
LauncherToken string `json:"launcher_token"`
} }
func (h *Handler) registerLauncherConfigRoutes(mux *http.ServeMux) { 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") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(launcherConfigPayload{ json.NewEncoder(w).Encode(launcherConfigPayload{
Port: cfg.Port, Port: cfg.Port,
Public: cfg.Public, Public: cfg.Public,
AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...), AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...),
LauncherToken: cfg.LauncherToken,
}) })
} }
@ -64,12 +61,15 @@ func (h *Handler) handleUpdateLauncherConfig(w http.ResponseWriter, r *http.Requ
return return
} }
cfg := launcherconfig.Config{ cfg, err := h.loadLauncherConfig()
Port: payload.Port, if err != nil {
Public: payload.Public, http.Error(w, fmt.Sprintf("Failed to load launcher config: %v", err), http.StatusInternalServerError)
AllowedCIDRs: append([]string(nil), payload.AllowedCIDRs...), return
LauncherToken: strings.TrimSpace(payload.LauncherToken),
} }
cfg.Port = payload.Port
cfg.Public = payload.Public
cfg.AllowedCIDRs = append([]string(nil), payload.AllowedCIDRs...)
cfg.LegacyLauncherToken = ""
if err := launcherconfig.Validate(cfg); err != nil { if err := launcherconfig.Validate(cfg); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
@ -82,9 +82,8 @@ func (h *Handler) handleUpdateLauncherConfig(w http.ResponseWriter, r *http.Requ
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(launcherConfigPayload{ json.NewEncoder(w).Encode(launcherConfigPayload{
Port: cfg.Port, Port: cfg.Port,
Public: cfg.Public, Public: cfg.Public,
AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...), AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...),
LauncherToken: cfg.LauncherToken,
}) })
} }

View file

@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
@ -34,9 +35,6 @@ func TestGetLauncherConfigUsesRuntimeFallback(t *testing.T) {
if got.Port != 19999 || !got.Public { if got.Port != 19999 || !got.Public {
t.Fatalf("response = %+v, want port=19999 public=true", got) 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" { 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) 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) { func TestPutLauncherConfigPersists(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json") 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) h := NewHandler(configPath)
mux := http.NewServeMux() mux := http.NewServeMux()
@ -54,7 +60,7 @@ func TestPutLauncherConfigPersists(t *testing.T) {
http.MethodPut, http.MethodPut,
"/api/system/launcher-config", "/api/system/launcher-config",
strings.NewReader( 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") 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()) 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()) cfg, err := launcherconfig.Load(path, launcherconfig.Default())
if err != nil { if err != nil {
t.Fatalf("launcherconfig.Load() error = %v", err) t.Fatalf("launcherconfig.Load() error = %v", err)
@ -72,8 +77,11 @@ func TestPutLauncherConfigPersists(t *testing.T) {
if cfg.Port != 18080 || !cfg.Public { if cfg.Port != 18080 || !cfg.Public {
t.Fatalf("saved config = %+v, want port=18080 public=true", cfg) t.Fatalf("saved config = %+v, want port=18080 public=true", cfg)
} }
if cfg.LauncherToken != "saved-token" { if cfg.DashboardPasswordHash != "saved-hash" {
t.Fatalf("saved launcher_token = %q, want %q", cfg.LauncherToken, "saved-token") 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" { 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) t.Fatalf("saved config allowed_cidrs = %v, want [192.168.1.0/24]", cfg.AllowedCIDRs)

View file

@ -460,6 +460,9 @@ func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLen
for _, msg := range messages { for _, msg := range messages {
switch msg.Role { switch msg.Role {
case "tool":
continue
case "user": case "user":
if sessionMessageVisible(msg) { if sessionMessageVisible(msg) {
transcript = append(transcript, sessionChatMessage{ 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( func assistantToolCallContentDuplicated(
@ -574,15 +588,22 @@ func visibleAssistantToolSummaryMessages(
messages := make([]sessionChatMessage, 0, len(toolCalls)) messages := make([]sessionChatMessage, 0, len(toolCalls))
for _, tc := range toolCalls { for _, tc := range toolCalls {
name := tc.Name name, argsJSON := toolCallNameAndArguments(tc)
if tc.Function != nil { if strings.TrimSpace(name) == "" {
if name == "" { continue
name = tc.Function.Name }
if name == "web_search" || name == "web_fetch" {
continue
}
if name == "message" {
if _, ok := parseMessageToolContent(argsJSON); ok {
continue
} }
} }
if strings.TrimSpace(name) == "" { argsPreview := strings.TrimSpace(argsJSON)
continue if argsPreview == "" {
argsPreview = "{}"
} }
messages = append(messages, sessionChatMessage{ messages = append(messages, sessionChatMessage{
@ -627,36 +648,53 @@ func visibleAssistantToolMessages(toolCalls []providers.ToolCall) []sessionChatM
messages := make([]sessionChatMessage, 0, len(toolCalls)) messages := make([]sessionChatMessage, 0, len(toolCalls))
for _, tc := range toolCalls { for _, tc := range toolCalls {
name := tc.Name name, argsJSON := toolCallNameAndArguments(tc)
argsJSON := "" if name != "message" {
if tc.Function != nil { continue
if name == "" {
name = tc.Function.Name
}
argsJSON = tc.Function.Arguments
} }
content, ok := parseMessageToolContent(argsJSON)
switch name { if !ok {
case "message": continue
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,
})
} }
messages = append(messages, sessionChatMessage{
Role: "assistant",
Content: content,
})
} }
return messages 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. // sessionsDir resolves the path to the gateway's session storage directory.
// It reads the workspace from config, falling back to ~/.picoclaw/workspace. // It reads the workspace from config, falling back to ~/.picoclaw/workspace.
func (h *Handler) sessionsDir() (string, error) { func (h *Handler) sessionsDir() (string, error) {

View file

@ -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) configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup() defer cleanup()
@ -402,14 +402,19 @@ func TestHandleGetSession_ReconstructsVisibleMessageToolOutput(t *testing.T) {
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err) t.Fatalf("Unmarshal() error = %v", err)
} }
if len(resp.Messages) != 3 { if len(resp.Messages) != 2 {
t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages)) t.Fatalf("len(resp.Messages) = %d, want 2", len(resp.Messages))
} }
if !strings.Contains(resp.Messages[1].Content, "`message`") { if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "test" {
t.Fatalf("tool summary message = %#v, want message tool summary", resp.Messages[1]) t.Fatalf("first message = %#v, want user/test", resp.Messages[0])
} }
if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "visible tool output" { if resp.Messages[1].Role != "assistant" || resp.Messages[1].Content != "visible tool output" {
t.Fatalf("assistant message = %#v, want visible tool output", resp.Messages[2]) 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 { if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err) t.Fatalf("Unmarshal() error = %v", err)
} }
if len(resp.Messages) != 4 { if len(resp.Messages) != 3 {
t.Fatalf("len(resp.Messages) = %d, want 4", len(resp.Messages)) t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages))
} }
if !strings.Contains(resp.Messages[1].Content, "`message`") { if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "test" {
t.Fatalf("tool summary message = %#v, want message tool summary", resp.Messages[1]) t.Fatalf("first message = %#v, want user/test", resp.Messages[0])
} }
if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "visible tool output" { 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[2]) 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" { 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[3]) 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 { if len(items) != 1 {
t.Fatalf("len(items) = %d, want 1", len(items)) t.Fatalf("len(items) = %d, want 1", len(items))
} }
if items[0].MessageCount != 3 { if items[0].MessageCount != 2 {
t.Fatalf("items[0].MessageCount = %d, want 3", items[0].MessageCount) 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 { if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
t.Fatalf("AddFullMessage() error = %v", err) 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" { 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) 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) { func TestHandleGetSession_UsesConfiguredToolFeedbackMaxArgsLength(t *testing.T) {

View file

@ -1,8 +1,6 @@
package launcherconfig package launcherconfig
import ( import (
"crypto/rand"
"encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net" "net"
@ -16,31 +14,19 @@ const (
FileName = "launcher-config.json" FileName = "launcher-config.json"
// DefaultPort is the default port for the web launcher. // DefaultPort is the default port for the web launcher.
DefaultPort = 18800 DefaultPort = 18800
// EnvLauncherToken overrides launcher dashboard token.
EnvLauncherToken = "PICOCLAW_LAUNCHER_TOKEN"
// EnvLauncherHost overrides launcher listen host. // EnvLauncherHost overrides launcher listen host.
EnvLauncherHost = "PICOCLAW_LAUNCHER_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. // Config stores launch parameters for the web backend service.
type Config struct { type Config struct {
Port int `json:"port"` Port int `json:"port"`
Public bool `json:"public"` Public bool `json:"public"`
AllowedCIDRs []string `json:"allowed_cidrs,omitempty"` AllowedCIDRs []string `json:"allowed_cidrs,omitempty"`
LauncherToken string `json:"launcher_token,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. // Default returns default launcher settings.
@ -61,41 +47,6 @@ func Validate(cfg Config) error {
return nil 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. // NormalizeCIDRs trims entries, removes empty values, and deduplicates CIDRs.
func NormalizeCIDRs(cidrs []string) []string { func NormalizeCIDRs(cidrs []string) []string {
if len(cidrs) == 0 { if len(cidrs) == 0 {
@ -144,7 +95,8 @@ func Load(path string, fallback Config) (Config, error) {
return Config{}, err return Config{}, err
} }
cfg.AllowedCIDRs = NormalizeCIDRs(cfg.AllowedCIDRs) 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 { if err := Validate(cfg); err != nil {
return Config{}, err return Config{}, err
} }
@ -154,7 +106,8 @@ func Load(path string, fallback Config) (Config, error) {
// Save writes launcher settings to disk. // Save writes launcher settings to disk.
func Save(path string, cfg Config) error { func Save(path string, cfg Config) error {
cfg.AllowedCIDRs = NormalizeCIDRs(cfg.AllowedCIDRs) 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 { if err := Validate(cfg); err != nil {
return err return err
} }

View file

@ -1,11 +1,10 @@
package launcherconfig package launcherconfig
import ( import (
"context"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"github.com/sipeed/picoclaw/web/backend/middleware"
) )
func TestLoadReturnsFallbackWhenMissing(t *testing.T) { func TestLoadReturnsFallbackWhenMissing(t *testing.T) {
@ -25,10 +24,11 @@ func TestSaveAndLoadRoundTrip(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
path := filepath.Join(dir, "launcher-config.json") path := filepath.Join(dir, "launcher-config.json")
want := Config{ want := Config{
Port: 18080, Port: 18080,
Public: true, Public: true,
AllowedCIDRs: []string{"192.168.1.0/24", "10.0.0.0/8"}, AllowedCIDRs: []string{"192.168.1.0/24", "10.0.0.0/8"},
LauncherToken: "saved-launcher-token", DashboardPasswordHash: "$2a$12$saved-dashboard-password-hash",
LegacyLauncherToken: "legacy-token-should-not-persist",
} }
if err := Save(path, want); err != nil { 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 { if got.Port != want.Port || got.Public != want.Public {
t.Fatalf("Load() = %+v, want %+v", got, want) t.Fatalf("Load() = %+v, want %+v", got, want)
} }
if got.LauncherToken != want.LauncherToken { if got.DashboardPasswordHash != want.DashboardPasswordHash {
t.Fatalf("launcher_token = %q, want %q", got.LauncherToken, want.LauncherToken) 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) { if len(got.AllowedCIDRs) != len(want.AllowedCIDRs) {
t.Fatalf("allowed_cidrs len = %d, want %d", 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) { func TestValidateRejectsInvalidPort(t *testing.T) {
if err := Validate(Config{Port: 0, Public: false}); err == nil { if err := Validate(Config{Port: 0, Public: false}); err == nil {
t.Fatal("Validate() expected error for port 0") 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) { func TestNormalizeCIDRs(t *testing.T) {
got := NormalizeCIDRs([]string{" 192.168.1.0/24 ", "", "10.0.0.0/8", "192.168.1.0/24"}) 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"} 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")
}
}

View 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)
}

View 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)
}
}

View 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)
}

View file

@ -12,12 +12,12 @@
package main package main
import ( import (
"context"
"errors" "errors"
"flag" "flag"
"fmt" "fmt"
"net" "net"
"net/http" "net/http"
"net/url"
"os" "os"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
@ -51,7 +51,6 @@ var (
servers []*http.Server servers []*http.Server
serverAddr string serverAddr string
// browserLaunchURL is opened by openBrowser() (auto-open + tray "open console"). // 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 browserLaunchURL string
apiHandler *api.Handler apiHandler *api.Handler
@ -62,11 +61,34 @@ func shouldEnableLauncherFileLogging(enableConsole, debug bool) bool {
return !enableConsole || debug return !enableConsole || debug
} }
func dashboardTokenConfigHelpPath(source launcherconfig.DashboardTokenSource, launcherPath string) string { func shouldEnableLocalAutoLogin(noBrowser bool, probeHost string) bool {
if source != launcherconfig.DashboardTokenSourceConfig { return !noBrowser && isLoopbackLaunchHost(probeHost)
return "" }
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) { func resolveLauncherHostInput(flagHost string, explicitFlag bool, envHost string) (string, bool, error) {
@ -318,24 +340,6 @@ func firstNonEmpty(values ...string) string {
return "" 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() { func main() {
port := flag.String("port", "18800", "Port to listen on") port := flag.String("port", "18800", "Port to listen on")
host := flag.String("host", "", "Host to listen on (overrides -public when set)") host := flag.String("host", "", "Host to listen on (overrides -public when set)")
@ -503,15 +507,11 @@ func main() {
} }
listeners := openResult.Listeners listeners := openResult.Listeners
dashboardToken, dashboardSigningKey, _, dashErr := launcherconfig.EnsureDashboardSecrets( dashboardSessionCookie, dashErr := middleware.NewLauncherDashboardSessionCookie()
launcherCfg,
)
if dashErr != nil { if dashErr != nil {
logger.Fatalf("Dashboard auth setup failed: %v", dashErr) 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). // Open the bcrypt password store (creates the DB file on first run).
authStore, authStoreErr := dashboardauth.New(picoHome) authStore, authStoreErr := dashboardauth.New(picoHome)
var passwordStore api.PasswordStore var passwordStore api.PasswordStore
@ -522,23 +522,62 @@ func main() {
logger.InfoC( logger.InfoC(
"web", "web",
fmt.Sprintf( 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, authStoreErr,
), ),
) )
passwordStore = launcherconfig.NewPasswordStore(launcherPath, launcherCfg)
authStoreErr = nil authStoreErr = nil
} else { } else {
logger.ErrorC("web", fmt.Sprintf("Warning: could not open auth store: %v", authStoreErr)) 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 // Initialize Server components
mux := http.NewServeMux() mux := http.NewServeMux()
api.RegisterLauncherAuthRoutes(mux, api.LauncherAuthRouteOpts{ api.RegisterLauncherAuthRoutes(mux, api.LauncherAuthRouteOpts{
DashboardToken: dashboardToken, SessionCookie: dashboardSessionCookie,
SessionCookie: dashboardSessionCookie, PasswordStore: passwordStore,
PasswordStore: passwordStore, StoreError: authStoreErr,
StoreError: authStoreErr,
}) })
// API Routes (e.g. /api/status) // API Routes (e.g. /api/status)
@ -561,7 +600,7 @@ func main() {
dashAuth := middleware.LauncherDashboardAuth(middleware.LauncherDashboardAuthConfig{ dashAuth := middleware.LauncherDashboardAuth(middleware.LauncherDashboardAuthConfig{
ExpectedCookie: dashboardSessionCookie, ExpectedCookie: dashboardSessionCookie,
Token: dashboardToken, LocalAutoLogin: localAutoLogin,
}, accessControlledMux) }, accessControlledMux)
// Apply middleware stack // 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 { if enableConsole || debug {
consoleHosts := launcherConsoleHosts(hostInput, effectivePublic) consoleHosts := launcherConsoleHosts(hostInput, effectivePublic)
fmt.Print(utils.Banner) fmt.Print(utils.Banner)
fmt.Println() 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() fmt.Println()
for _, host := range consoleHosts { for _, host := range consoleHosts {
fmt.Printf(" >> http://%s <<\n", net.JoinHostPort(host, effectivePort)) fmt.Printf(" >> http://%s <<\n", net.JoinHostPort(host, effectivePort))
@ -599,11 +646,7 @@ func main() {
// Share the local URL with the launcher runtime. // Share the local URL with the launcher runtime.
serverAddr = fmt.Sprintf("http://%s", net.JoinHostPort(openResult.ProbeHost, effectivePort)) serverAddr = fmt.Sprintf("http://%s", net.JoinHostPort(openResult.ProbeHost, effectivePort))
if dashboardToken != "" { browserLaunchURL = serverAddr + launcherBrowserLaunchSuffix(needsInitialSetup, localAutoLogin)
browserLaunchURL = serverAddr + "?token=" + url.QueryEscape(dashboardToken)
} else {
browserLaunchURL = serverAddr
}
// Auto-open browser will be handled by the launcher runtime. // Auto-open browser will be handled by the launcher runtime.

View file

@ -12,7 +12,7 @@ import (
"time" "time"
"github.com/sipeed/picoclaw/pkg/netbind" "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) { func TestShouldEnableLauncherFileLogging(t *testing.T) {
@ -43,60 +43,50 @@ func TestShouldEnableLauncherFileLogging(t *testing.T) {
} }
} }
func TestDashboardTokenConfigHelpPath(t *testing.T) { func TestShouldEnableLocalAutoLogin(t *testing.T) {
const launcherPath = "/tmp/launcher-config.json"
tests := []struct { tests := []struct {
name string name string
source launcherconfig.DashboardTokenSource noBrowser bool
want string probeHost string
wantEnable bool
}{ }{
{ {name: "loopback localhost", probeHost: "localhost", wantEnable: true},
name: "env token does not expose config path", {name: "loopback ipv4", probeHost: "127.0.0.1", wantEnable: true},
source: launcherconfig.DashboardTokenSourceEnv, {name: "loopback ipv6", probeHost: "::1", wantEnable: true},
want: "", {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},
name: "config token exposes config path",
source: launcherconfig.DashboardTokenSourceConfig,
want: launcherPath,
},
{
name: "random token does not expose config path",
source: launcherconfig.DashboardTokenSourceRandom,
want: "",
},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
if got := dashboardTokenConfigHelpPath(tt.source, launcherPath); got != tt.want { if got := shouldEnableLocalAutoLogin(tt.noBrowser, tt.probeHost); got != tt.wantEnable {
t.Fatalf("dashboardTokenConfigHelpPath(%q, %q) = %q, want %q", tt.source, launcherPath, got, tt.want) t.Fatalf(
"shouldEnableLocalAutoLogin(%t, %q) = %t, want %t",
tt.noBrowser,
tt.probeHost,
got,
tt.wantEnable,
)
} }
}) })
} }
} }
func TestMaskSecret(t *testing.T) { func TestLauncherBrowserLaunchSuffix(t *testing.T) {
tests := []struct { autoLogin, err := middleware.NewLauncherDashboardLocalAutoLogin(time.Minute)
input string if err != nil {
want string t.Fatalf("NewLauncherDashboardLocalAutoLogin() error = %v", err)
}{
{"sdhjflsjdflksdf", "sdh**********ksdf"},
{"abcdefghijklmnopqrstuvwxyz", "abc**********wxyz"},
{"abcdefghijkl", "abc**********ijkl"},
{"abcdefgh", "abc**********"},
{"abcdefghijk", "abc**********"},
{"abcdefg", "abc**********"},
{"abcd", "abc**********"},
{"abc", "**********"},
{"", "**********"},
} }
for _, tt := range tests { if got := launcherBrowserLaunchSuffix(true, autoLogin); got != middleware.LauncherDashboardSetupPath {
if got := maskSecret(tt.input); got != tt.want { t.Fatalf("setup suffix = %q", got)
t.Errorf("maskSecret(%q) = %q, want %q", tt.input, got, tt.want) }
} 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)
} }
} }

View file

@ -1,41 +1,88 @@
package middleware package middleware
import ( import (
"crypto/hmac" "crypto/rand"
"crypto/sha256"
"crypto/subtle" "crypto/subtle"
"encoding/hex" "encoding/base64"
"errors"
"net/http" "net/http"
"net/url"
"path" "path"
"strings" "strings"
"sync"
"time" "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" const LauncherDashboardCookieName = "picoclaw_launcher_auth"
// launcherDashboardSessionMaxAgeSec is the session cookie lifetime (7 days). // launcherDashboardSessionMaxAgeSec is the dashboard session cookie lifetime (31 days).
const launcherDashboardSessionMaxAgeSec = 7 * 24 * 3600 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. // NewLauncherDashboardSessionCookie creates the per-process session cookie value.
func SessionCookieValue(signingKey []byte, dashboardToken string) string { func NewLauncherDashboardSessionCookie() (string, error) {
mac := hmac.New(sha256.New, signingKey) return randomURLToken(launcherSessionCookieBytes)
_, _ = mac.Write([]byte(launcherSessionMACLabel)) }
_, _ = mac.Write([]byte{0})
_, _ = mac.Write([]byte(dashboardToken)) func randomURLToken(n int) (string, error) {
return hex.EncodeToString(mac.Sum(nil)) 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. // LauncherDashboardAuthConfig holds runtime material for dashboard access checks.
type LauncherDashboardAuthConfig struct { type LauncherDashboardAuthConfig struct {
ExpectedCookie string 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 sets the session cookie's Secure flag. If nil, DefaultLauncherDashboardSecureCookie is used.
SecureCookie func(*http.Request) bool 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). // DefaultLauncherDashboardSecureCookie mirrors typical production HTTPS detection (TLS or X-Forwarded-Proto).
func DefaultLauncherDashboardSecureCookie(r *http.Request) bool { func DefaultLauncherDashboardSecureCookie(r *http.Request) bool {
if r.TLS != nil { if r.TLS != nil {
@ -44,7 +91,7 @@ func DefaultLauncherDashboardSecureCookie(r *http.Request) bool {
return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") 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( func SetLauncherDashboardSessionCookie(
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request, 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> // LauncherDashboardAuth requires a valid session cookie before calling next.
// before calling next. Public paths are login page and /api/auth/* handlers. // Public paths are login/setup pages and /api/auth/* handlers.
func LauncherDashboardAuth(cfg LauncherDashboardAuthConfig, next http.Handler) http.Handler { func LauncherDashboardAuth(cfg LauncherDashboardAuthConfig, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := canonicalAuthPath(r.URL.Path) p := canonicalAuthPath(r.URL.Path)
if handled := tryLauncherQueryTokenLogin(w, r, p, cfg); handled { if p == LauncherDashboardLocalAutoLoginPath {
handleLauncherLocalAutoLogin(w, r, cfg)
return return
} }
if isPublicLauncherDashboardPath(r.Method, p) { 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 // canonicalAuthPath matches path cleaning used for routing decisions so
// prefixes like /assets/../ cannot bypass auth (CVE-class traversal). // prefixes like /assets/../ cannot bypass auth (CVE-class traversal).
// tryLauncherQueryTokenLogin validates ?token= on GET only (non-/api), sets the session func handleLauncherLocalAutoLogin(w http.ResponseWriter, r *http.Request, cfg LauncherDashboardAuthConfig) {
// cookie when correct, and redirects with 303 so the follow-up is a plain GET without side effects. if validLauncherDashboardAuth(r, cfg) {
// Invalid token is rejected like any other unauthenticated browser request. http.Redirect(w, r, "/", http.StatusSeeOther)
func tryLauncherQueryTokenLogin( return
w http.ResponseWriter,
r *http.Request,
canonicalPath string,
cfg LauncherDashboardAuthConfig,
) bool {
if r.Method != http.MethodGet {
return false
} }
if canonicalPath == "/api" || strings.HasPrefix(canonicalPath, "/api/") { if r.Method != http.MethodGet && r.Method != http.MethodHead {
return false w.WriteHeader(http.StatusMethodNotAllowed)
_, _ = w.Write([]byte("method not allowed"))
return
} }
qToken := strings.TrimSpace(r.URL.Query().Get("token")) if r.Method == http.MethodHead {
if qToken == "" { rejectLauncherDashboardAuth(w, r, LauncherDashboardLocalAutoLoginPath)
return false return
} }
if len(qToken) != len(cfg.Token) || subtle.ConstantTimeCompare([]byte(qToken), []byte(cfg.Token)) != 1 { if cfg.LocalAutoLogin != nil && cfg.LocalAutoLogin.consume(r.URL.Query().Get("nonce")) {
rejectLauncherDashboardAuth(w, r, canonicalPath) SetLauncherDashboardSessionCookie(w, r, cfg.ExpectedCookie, cfg.SecureCookie)
return true http.Redirect(w, r, "/", http.StatusSeeOther)
return
} }
SetLauncherDashboardSessionCookie(w, r, cfg.ExpectedCookie, cfg.SecureCookie) rejectLauncherDashboardAuth(w, r, LauncherDashboardLocalAutoLoginPath)
http.Redirect(w, r, redirectAfterQueryTokenLogin(r, canonicalPath), http.StatusSeeOther)
return true
} }
func redirectAfterQueryTokenLogin(r *http.Request, canonicalPath string) string { func (a *LauncherDashboardLocalAutoLogin) consume(nonce string) bool {
if canonicalPath == "/launcher-login" { if a == nil || a.grant == nil {
return "/" return false
} }
q := r.URL.Query() return a.grant.use(nonce, nil) == nil
q.Del("token") }
enc := q.Encode()
if enc != "" { func newLauncherDashboardOneTimeGrant(ttl time.Duration) (*launcherDashboardOneTimeGrant, error) {
return canonicalPath + "?" + enc 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 { func canonicalAuthPath(raw string) string {
@ -206,14 +293,6 @@ func validLauncherDashboardAuth(r *http.Request, cfg LauncherDashboardAuthConfig
return true 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 return false
} }

View file

@ -4,26 +4,37 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"time"
) )
func TestSessionCookieValue_Deterministic(t *testing.T) { func TestNewLauncherDashboardSessionCookie(t *testing.T) {
key := make([]byte, 32) a, err := NewLauncherDashboardSessionCookie()
for i := range key { if err != nil {
key[i] = byte(i) t.Fatalf("NewLauncherDashboardSessionCookie() error = %v", err)
} }
a := SessionCookieValue(key, "tok-a") b, err := NewLauncherDashboardSessionCookie()
b := SessionCookieValue(key, "tok-a") if err != nil {
if a != b || a == "" { t.Fatalf("NewLauncherDashboardSessionCookie() second error = %v", err)
t.Fatalf("SessionCookieValue mismatch or empty: %q vs %q", a, b)
} }
c := SessionCookieValue(key, "tok-b") if a == "" || b == "" {
if c == a { t.Fatalf("session cookie values should be non-empty: %q %q", a, b)
t.Fatal("SessionCookieValue should differ for different tokens") }
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) { 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) { next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTeapot) w.WriteHeader(http.StatusTeapot)
}) })
@ -34,9 +45,11 @@ func TestLauncherDashboardAuth_AllowsPublicPaths(t *testing.T) {
want int want int
}{ }{
{http.MethodGet, "/launcher-login", http.StatusTeapot}, {http.MethodGet, "/launcher-login", http.StatusTeapot},
{http.MethodGet, "/launcher-setup", http.StatusTeapot},
{http.MethodGet, "/assets/index.js", http.StatusTeapot}, {http.MethodGet, "/assets/index.js", http.StatusTeapot},
{http.MethodPost, "/api/auth/login", http.StatusTeapot}, {http.MethodPost, "/api/auth/login", http.StatusTeapot},
{http.MethodGet, "/api/auth/status", http.StatusTeapot}, {http.MethodGet, "/api/auth/status", http.StatusTeapot},
{http.MethodPost, "/api/auth/setup", http.StatusTeapot},
{http.MethodPost, "/api/auth/logout", http.StatusTeapot}, {http.MethodPost, "/api/auth/logout", http.StatusTeapot},
{http.MethodGet, "/api/auth/logout", http.StatusUnauthorized}, {http.MethodGet, "/api/auth/logout", http.StatusUnauthorized},
{http.MethodGet, "/api/config", http.StatusUnauthorized}, {http.MethodGet, "/api/config", http.StatusUnauthorized},
@ -51,68 +64,143 @@ func TestLauncherDashboardAuth_AllowsPublicPaths(t *testing.T) {
} }
} }
func TestLauncherDashboardAuth_URLTokenBootstrapGET(t *testing.T) { func TestLauncherDashboardAuth_QueryTokenDoesNotAuthenticate(t *testing.T) {
const tok = "secret" cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef"}
cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef", Token: tok}
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { 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) h := LauncherDashboardAuth(cfg, next)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/?token="+tok, nil) req := httptest.NewRequest(http.MethodGet, "/?token=secret", nil)
h.ServeHTTP(rec, req) h.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther { if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/launcher-login" {
t.Fatalf("GET /?token=valid: status = %d, want %d", rec.Code, http.StatusSeeOther) 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 { next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
t.Fatalf("expected one session cookie, got %#v", c) 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() rec = httptest.NewRecorder()
req1b := httptest.NewRequest(http.MethodGet, "/config?token="+tok+"&keep=1", nil) req = httptest.NewRequest(http.MethodGet, LauncherDashboardLocalAutoLoginPath+"?nonce=wrong", nil)
h.ServeHTTP(rec1b, req1b) h.ServeHTTP(rec, req)
if rec1b.Code != http.StatusSeeOther { if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/launcher-login" ||
t.Fatalf("GET /config?token=valid: status = %d", rec1b.Code) len(rec.Result().Cookies()) != 0 {
} t.Fatalf(
if got := rec1b.Header().Get("Location"); got != "/config?keep=1" { "auto-login with wrong nonce code=%d loc=%q cookies=%#v",
t.Fatalf("Location = %q, want /config?keep=1", got) rec.Code,
rec.Header().Get("Location"),
rec.Result().Cookies(),
)
} }
recBad := httptest.NewRecorder() rec = httptest.NewRecorder()
reqBad := httptest.NewRequest(http.MethodGet, "/?token=wrong", nil) req = httptest.NewRequest(http.MethodHead, autoLogin.URLPath(), nil)
h.ServeHTTP(recBad, reqBad) h.ServeHTTP(rec, req)
if recBad.Code != http.StatusFound || recBad.Header().Get("Location") != "/launcher-login" { if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/launcher-login" ||
t.Fatalf("GET /?token=invalid: code=%d loc=%q", recBad.Code, recBad.Header().Get("Location")) 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() rec = httptest.NewRecorder()
req2 := httptest.NewRequest(http.MethodGet, "/api/config?token="+tok, nil) req = httptest.NewRequest(http.MethodGet, autoLogin.URLPath(), nil)
h.ServeHTTP(rec2, req2) h.ServeHTTP(rec, req)
if rec2.Code != http.StatusUnauthorized { if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" {
t.Fatalf("GET /api with token query: status = %d, want %d", rec2.Code, http.StatusUnauthorized) 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() rec = httptest.NewRecorder()
req3 := httptest.NewRequest(http.MethodGet, "/?token=", nil) req = httptest.NewRequest(http.MethodGet, "/", nil)
h.ServeHTTP(rec3, req3) req.AddCookie(&http.Cookie{Name: LauncherDashboardCookieName, Value: cookieVal})
if rec3.Code != http.StatusFound { h.ServeHTTP(rec, req)
t.Fatalf("GET /?token=empty: status = %d, want redirect", rec3.Code) if rec.Code != http.StatusOK {
t.Fatalf("cookie auth after auto-login status = %d", rec.Code)
} }
recLogin := httptest.NewRecorder() rec = httptest.NewRecorder()
reqLogin := httptest.NewRequest(http.MethodGet, "/launcher-login?token="+tok, nil) req = httptest.NewRequest(http.MethodGet, autoLogin.URLPath(), nil)
h.ServeHTTP(recLogin, reqLogin) req.AddCookie(&http.Cookie{Name: LauncherDashboardCookieName, Value: cookieVal})
if recLogin.Code != http.StatusSeeOther || recLogin.Header().Get("Location") != "/" { h.ServeHTTP(rec, req)
t.Fatalf("GET /launcher-login?token=valid: code=%d loc=%q", recLogin.Code, recLogin.Header().Get("Location")) 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) { func TestLauncherDashboardAuth_DotDotCannotBypass(t *testing.T) {
cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef", Token: "x"} cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef"}
next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
t.Fatal("next handler should not run without auth") 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) { func TestLauncherDashboardAuth_CookieOnly(t *testing.T) {
key := make([]byte, 32) cookieVal := "session-cookie-value"
for i := range key { cfg := LauncherDashboardAuthConfig{ExpectedCookie: cookieVal}
key[i] = 0xab
}
token := "dashboard-secret-9"
cookieVal := SessionCookieValue(key, token)
cfg := LauncherDashboardAuthConfig{ExpectedCookie: cookieVal, Token: token}
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
}) })
@ -154,16 +237,16 @@ func TestLauncherDashboardAuth_CookieAndBearer(t *testing.T) {
} }
rec2 := httptest.NewRecorder() rec2 := httptest.NewRecorder()
req2 := httptest.NewRequest(http.MethodGet, "/", nil) req2 := httptest.NewRequest(http.MethodGet, "/api/config", nil)
req2.Header.Set("Authorization", "Bearer "+token) req2.Header.Set("Authorization", "Bearer dashboard-secret-9")
h.ServeHTTP(rec2, req2) h.ServeHTTP(rec2, req2)
if rec2.Code != http.StatusOK { if rec2.Code != http.StatusUnauthorized {
t.Fatalf("bearer auth: status = %d", rec2.Code) t.Fatalf("bearer auth should not be accepted: status = %d", rec2.Code)
} }
} }
func TestLauncherDashboardAuth_WebSocketUnauthorizedDoesNotRedirect(t *testing.T) { func TestLauncherDashboardAuth_WebSocketUnauthorizedDoesNotRedirect(t *testing.T) {
cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef", Token: "x"} cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef"}
next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
t.Fatal("next handler should not run without auth") t.Fatal("next handler should not run without auth")
}) })

View file

@ -2,8 +2,8 @@ package middleware
import "net/http" import "net/http"
// ReferrerPolicyNoReferrer sets Referrer-Policy: no-referrer on every response so sensitive // ReferrerPolicyNoReferrer sets Referrer-Policy: no-referrer on every response
// query parameters (e.g. ?token= for dashboard bootstrap) are not leaked via the Referer header. // so sensitive paths and query parameters are not leaked via the Referer header.
func ReferrerPolicyNoReferrer(next http.Handler) http.Handler { func ReferrerPolicyNoReferrer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Referrer-Policy", "no-referrer") w.Header().Set("Referrer-Policy", "no-referrer")

View file

@ -2,16 +2,26 @@
* Dashboard launcher auth API. * Dashboard launcher auth API.
* Uses plain fetch (not launcherFetch) to avoid redirect loops on auth pages. * 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( export async function postLauncherDashboardLogin(
password: string, password: string,
): Promise<boolean> { ): Promise<LoginResult> {
const res = await fetch("/api/auth/login", { const res = await fetch("/api/auth/login", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: "same-origin", credentials: "same-origin",
body: JSON.stringify({ password: password.trim() }), 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 = { export type LauncherAuthStatus = {
@ -57,12 +67,16 @@ export async function postLauncherDashboardSetup(
}), }),
}) })
if (res.ok) return { ok: true } 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 { try {
const j = (await res.json()) as { error?: string } const j = (await res.json()) as { error?: string }
if (j.error) msg = j.error if (j.error) msg = j.error
} catch { } catch {
/* ignore */ /* ignore */
} }
return { ok: false, error: msg } return msg
} }

View file

@ -11,7 +11,6 @@ export interface LauncherConfig {
port: number port: number
public: boolean public: boolean
allowed_cidrs: string[] allowed_cidrs: string[]
launcher_token: string
} }
export interface SystemVersionInfo { export interface SystemVersionInfo {

View file

@ -295,6 +295,22 @@ export function AppHeader() {
</DropdownMenu> </DropdownMenu>
{/* Theme Toggle */} {/* 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}> <Tooltip delayDuration={700}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
@ -309,19 +325,6 @@ export function AppHeader() {
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>{t("header.logout.tooltip")}</TooltipContent> <TooltipContent>{t("header.logout.tooltip")}</TooltipContent>
</Tooltip> </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> </div>
</header> </header>
) )

View file

@ -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>
)
}

View 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")
}

View file

@ -9,6 +9,11 @@ import {
getChannelsCatalog, getChannelsCatalog,
patchAppConfig, patchAppConfig,
} from "@/api/channels" } from "@/api/channels"
import { type ArrayFieldFlusher } from "@/components/channels/channel-array-list-field"
import {
normalizeAllowFromValues,
serializeStringArrayForSubmit,
} from "@/components/channels/channel-array-utils"
import { import {
SECRET_FIELD_MAP, SECRET_FIELD_MAP,
buildEditConfig, buildEditConfig,
@ -48,6 +53,43 @@ function asBool(value: unknown): boolean {
return value === true 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([ const CHANNEL_COMMON_CONFIG_KEYS = new Set([
"allow_from", "allow_from",
"group_trigger", "group_trigger",
@ -82,12 +124,20 @@ function buildSavePayload(
if (key.startsWith("_")) continue if (key.startsWith("_")) continue
if (key === "enabled") continue if (key === "enabled") continue
if (CHANNEL_COMMON_CONFIG_KEYS.has(key)) { 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 continue
} }
if (isSecretField(key)) continue if (isSecretField(key)) continue
settings[key] = value settings[key] = serializeStringArrayForSubmit(value)
} }
for (const [secretKey, editKey] of Object.entries(SECRET_FIELD_MAP)) { 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 [editConfig, setEditConfig] = useState<ChannelConfig>({})
const [configuredSecrets, setConfiguredSecrets] = useState<string[]>([]) const [configuredSecrets, setConfiguredSecrets] = useState<string[]>([])
const [enabled, setEnabled] = useState(false) const [enabled, setEnabled] = useState(false)
const [arrayFieldResetVersion, setArrayFieldResetVersion] = useState(0)
const arrayFieldFlushersRef = useRef(new Map<string, ArrayFieldFlusher>())
const loadData = useCallback( const loadData = useCallback(
async (silent = false) => { async (silent = false) => {
@ -302,11 +354,6 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
previousGatewayStatusRef.current = gatewayState previousGatewayStatusRef.current = gatewayState
}, [gatewayState, loadData]) }, [gatewayState, loadData])
const savePayload = useMemo(() => {
if (!channel) return null
return buildSavePayload(channel, editConfig, enabled)
}, [channel, editConfig, enabled])
const configured = useMemo(() => { const configured = useMemo(() => {
if (!channel) return false if (!channel) return false
return isConfigured(channel, editConfig, configuredSecrets) 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 = () => { const handleReset = () => {
if (!channel) return if (!channel) return
setEditConfig(buildEditConfig(channel.name, baseConfig)) setEditConfig(buildEditConfig(channel.name, baseConfig))
setEnabled(asBool(baseConfig.enabled)) setEnabled(asBool(baseConfig.enabled))
setServerError("") setServerError("")
setFieldErrors({}) setFieldErrors({})
setArrayFieldResetVersion((version) => version + 1)
} }
const handleSave = async () => { const handleSave = async () => {
if (!channel || !savePayload) return if (!channel) return
const preparedEditConfig = flushPendingArrayFieldDrafts(editConfig)
if (preparedEditConfig !== editConfig) {
setEditConfig(preparedEditConfig)
}
const missingRequiredFields = requiredKeys.filter((key) => const missingRequiredFields = requiredKeys.filter((key) =>
isMissingRequiredValue( isMissingRequiredValue(
getFieldValueForValidation(editConfig, configuredSecrets, key), getFieldValueForValidation(preparedEditConfig, configuredSecrets, key),
), ),
) )
if (missingRequiredFields.length > 0) { if (missingRequiredFields.length > 0) {
@ -393,6 +472,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
setServerError("") setServerError("")
setFieldErrors({}) setFieldErrors({})
try { try {
const savePayload = buildSavePayload(channel, preparedEditConfig, enabled)
await patchAppConfig({ await patchAppConfig({
channel_list: { channel_list: {
[channel.config_key]: savePayload, [channel.config_key]: savePayload,
@ -462,6 +542,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
onChange={handleChange} onChange={handleChange}
configuredSecrets={configuredSecrets} configuredSecrets={configuredSecrets}
fieldErrors={fieldErrors} fieldErrors={fieldErrors}
registerArrayFieldFlusher={registerArrayFieldFlusher}
arrayFieldResetVersion={arrayFieldResetVersion}
/> />
) )
case "discord": case "discord":
@ -471,6 +553,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
onChange={handleChange} onChange={handleChange}
configuredSecrets={configuredSecrets} configuredSecrets={configuredSecrets}
fieldErrors={fieldErrors} fieldErrors={fieldErrors}
registerArrayFieldFlusher={registerArrayFieldFlusher}
arrayFieldResetVersion={arrayFieldResetVersion}
/> />
) )
case "slack": case "slack":
@ -480,6 +564,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
onChange={handleChange} onChange={handleChange}
configuredSecrets={configuredSecrets} configuredSecrets={configuredSecrets}
fieldErrors={fieldErrors} fieldErrors={fieldErrors}
registerArrayFieldFlusher={registerArrayFieldFlusher}
arrayFieldResetVersion={arrayFieldResetVersion}
/> />
) )
case "feishu": case "feishu":
@ -489,6 +575,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
onChange={handleChange} onChange={handleChange}
configuredSecrets={configuredSecrets} configuredSecrets={configuredSecrets}
fieldErrors={fieldErrors} fieldErrors={fieldErrors}
registerArrayFieldFlusher={registerArrayFieldFlusher}
arrayFieldResetVersion={arrayFieldResetVersion}
/> />
) )
case "weixin": case "weixin":
@ -498,6 +586,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
onChange={handleChange} onChange={handleChange}
isEdit={isEdit} isEdit={isEdit}
onBindSuccess={() => void handleWeixinBindSuccess()} onBindSuccess={() => void handleWeixinBindSuccess()}
registerArrayFieldFlusher={registerArrayFieldFlusher}
arrayFieldResetVersion={arrayFieldResetVersion}
/> />
) )
case "wecom": case "wecom":
@ -518,6 +608,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
hiddenKeys={[...hiddenKeys, "bot_id"]} hiddenKeys={[...hiddenKeys, "bot_id"]}
requiredKeys={requiredKeys} requiredKeys={requiredKeys}
fieldErrors={fieldErrors} fieldErrors={fieldErrors}
registerArrayFieldFlusher={registerArrayFieldFlusher}
arrayFieldResetVersion={arrayFieldResetVersion}
/> />
</> </>
) )
@ -530,6 +622,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
hiddenKeys={hiddenKeys} hiddenKeys={hiddenKeys}
requiredKeys={requiredKeys} requiredKeys={requiredKeys}
fieldErrors={fieldErrors} fieldErrors={fieldErrors}
registerArrayFieldFlusher={registerArrayFieldFlusher}
arrayFieldResetVersion={arrayFieldResetVersion}
/> />
) )
} }

View file

@ -1,6 +1,14 @@
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import type { ChannelConfig } from "@/api/channels" 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 { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields"
import { Field, KeyInput, SwitchCardField } from "@/components/shared-form" import { Field, KeyInput, SwitchCardField } from "@/components/shared-form"
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
@ -11,17 +19,17 @@ interface DiscordFormProps {
onChange: (key: string, value: unknown) => void onChange: (key: string, value: unknown) => void
configuredSecrets: string[] configuredSecrets: string[]
fieldErrors?: Record<string, string> fieldErrors?: Record<string, string>
registerArrayFieldFlusher?: (
fieldPath: string,
flusher: ArrayFieldFlusher | null,
) => void
arrayFieldResetVersion?: number
} }
function asString(value: unknown): string { function asString(value: unknown): string {
return typeof value === "string" ? value : "" 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 { function asBool(value: unknown): boolean {
return value === true return value === true
} }
@ -38,6 +46,8 @@ export function DiscordForm({
onChange, onChange,
configuredSecrets, configuredSecrets,
fieldErrors = {}, fieldErrors = {},
registerArrayFieldFlusher,
arrayFieldResetVersion,
}: DiscordFormProps) { }: DiscordFormProps) {
const { t } = useTranslation() const { t } = useTranslation()
const groupTriggerConfig = asRecord(config.group_trigger) const groupTriggerConfig = asRecord(config.group_trigger)
@ -78,24 +88,17 @@ export function DiscordForm({
placeholder="http://127.0.0.1:7890" placeholder="http://127.0.0.1:7890"
/> />
</Field> </Field>
<Field <ChannelArrayListField
label={t("channels.field.allowFrom")} label={t("channels.field.allowFrom")}
hint={t("channels.form.desc.allowFrom")} hint={t("channels.form.desc.allowFrom")}
> value={asStringArray(config.allow_from)}
<Input onChange={(value) => onChange("allow_from", value)}
value={asStringArray(config.allow_from).join(", ")} placeholder={t("channels.field.allowFromPlaceholder")}
onChange={(e) => parser={parseAllowFromInput}
onChange( fieldPath="allow_from"
"allow_from", registerFlusher={registerArrayFieldFlusher}
e.target.value resetVersion={arrayFieldResetVersion}
.split(",") />
.map((s: string) => s.trim())
.filter(Boolean),
)
}
placeholder={t("channels.field.allowFromPlaceholder")}
/>
</Field>
<div> <div>
<SwitchCardField <SwitchCardField

View file

@ -1,6 +1,15 @@
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import type { ChannelConfig } from "@/api/channels" 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 { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields"
import { Field, KeyInput, SwitchCardField } from "@/components/shared-form" import { Field, KeyInput, SwitchCardField } from "@/components/shared-form"
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
@ -11,6 +20,11 @@ interface FeishuFormProps {
onChange: (key: string, value: unknown) => void onChange: (key: string, value: unknown) => void
configuredSecrets: string[] configuredSecrets: string[]
fieldErrors?: Record<string, string> fieldErrors?: Record<string, string>
registerArrayFieldFlusher?: (
fieldPath: string,
flusher: ArrayFieldFlusher | null,
) => void
arrayFieldResetVersion?: number
} }
function asString(value: unknown): string { function asString(value: unknown): string {
@ -21,9 +35,11 @@ function asBool(value: unknown): boolean {
return typeof value === "boolean" ? value : false return typeof value === "boolean" ? value : false
} }
function asStringArray(value: unknown): string[] { function asRecord(value: unknown): Record<string, unknown> {
if (!Array.isArray(value)) return [] if (value && typeof value === "object" && !Array.isArray(value)) {
return value.filter((item): item is string => typeof item === "string") return value as Record<string, unknown>
}
return {}
} }
export function FeishuForm({ export function FeishuForm({
@ -31,8 +47,11 @@ export function FeishuForm({
onChange, onChange,
configuredSecrets, configuredSecrets,
fieldErrors = {}, fieldErrors = {},
registerArrayFieldFlusher,
arrayFieldResetVersion,
}: FeishuFormProps) { }: FeishuFormProps) {
const { t } = useTranslation() const { t } = useTranslation()
const groupTriggerConfig = asRecord(config.group_trigger)
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@ -104,24 +123,17 @@ export function FeishuForm({
/> />
</Field> </Field>
<Field <ChannelArrayListField
label={t("channels.field.allowFrom")} label={t("channels.field.allowFrom")}
hint={t("channels.form.desc.allowFrom")} hint={t("channels.form.desc.allowFrom")}
> value={asStringArray(config.allow_from)}
<Input onChange={(value) => onChange("allow_from", value)}
value={asStringArray(config.allow_from).join(", ")} placeholder={t("channels.field.allowFromPlaceholder")}
onChange={(e) => parser={parseAllowFromInput}
onChange( fieldPath="allow_from"
"allow_from", registerFlusher={registerArrayFieldFlusher}
e.target.value resetVersion={arrayFieldResetVersion}
.split(",") />
.map((s: string) => s.trim())
.filter(Boolean),
)
}
placeholder={t("channels.field.allowFromPlaceholder")}
/>
</Field>
<div> <div>
<SwitchCardField <SwitchCardField
@ -134,6 +146,37 @@ export function FeishuForm({
</div> </div>
</CardContent> </CardContent>
</Card> </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> </div>
) )
} }

View file

@ -1,6 +1,14 @@
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import type { ChannelConfig } from "@/api/channels" 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 { import {
getSecretInputPlaceholder, getSecretInputPlaceholder,
isSecretField, isSecretField,
@ -16,6 +24,11 @@ interface GenericFormProps {
hiddenKeys?: string[] hiddenKeys?: string[]
requiredKeys?: string[] requiredKeys?: string[]
fieldErrors?: Record<string, 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). // 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 : "" 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> { function asRecord(value: unknown): Record<string, unknown> {
if (value && typeof value === "object" && !Array.isArray(value)) { if (value && typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, unknown> return value as Record<string, unknown>
@ -71,6 +79,8 @@ export function GenericForm({
hiddenKeys = [], hiddenKeys = [],
requiredKeys = [], requiredKeys = [],
fieldErrors = {}, fieldErrors = {},
registerArrayFieldFlusher,
arrayFieldResetVersion,
}: GenericFormProps) { }: GenericFormProps) {
const { t } = useTranslation() const { t } = useTranslation()
const hiddenFieldSet = new Set(hiddenKeys) const hiddenFieldSet = new Set(hiddenKeys)
@ -187,26 +197,18 @@ export function GenericForm({
if (Array.isArray(value)) { if (Array.isArray(value)) {
return ( return (
<Field <ChannelArrayListField
key={key} key={key}
label={formatLabel(key)} label={formatLabel(key)}
required={isRequired} required={isRequired}
hint={buildHint(key)} hint={buildHint(key)}
error={fieldErrors[key]} error={fieldErrors[key]}
> value={asStringArray(value)}
<Input onChange={(nextValue) => onChange(key, nextValue)}
value={asStringArray(value).join(", ")} fieldPath={key}
onChange={(e) => registerFlusher={registerArrayFieldFlusher}
onChange( resetVersion={arrayFieldResetVersion}
key, />
e.target.value
.split(",")
.map((s: string) => s.trim())
.filter(Boolean),
)
}
/>
</Field>
) )
} }
@ -281,46 +283,31 @@ export function GenericForm({
{config.allow_from !== undefined && {config.allow_from !== undefined &&
!hiddenFieldSet.has("allow_from") && ( !hiddenFieldSet.has("allow_from") && (
<Field <ChannelArrayListField
label={t("channels.field.allowFrom")} label={t("channels.field.allowFrom")}
hint={t("channels.form.desc.allowFrom")} hint={t("channels.form.desc.allowFrom")}
> value={asStringArray(config.allow_from)}
<Input onChange={(value) => onChange("allow_from", value)}
value={asStringArray(config.allow_from).join(", ")} placeholder={t("channels.field.allowFromPlaceholder")}
onChange={(e) => parser={parseAllowFromInput}
onChange( fieldPath="allow_from"
"allow_from", registerFlusher={registerArrayFieldFlusher}
e.target.value resetVersion={arrayFieldResetVersion}
.split(",") />
.map((s: string) => s.trim())
.filter(Boolean),
)
}
placeholder={t("channels.field.allowFromPlaceholder")}
/>
</Field>
)} )}
{config.allow_origins !== undefined && {config.allow_origins !== undefined &&
!hiddenFieldSet.has("allow_origins") && ( !hiddenFieldSet.has("allow_origins") && (
<Field <ChannelArrayListField
label={t("channels.field.allowOrigins")} label={t("channels.field.allowOrigins")}
hint={t("channels.form.desc.allowOrigins")} hint={t("channels.form.desc.allowOrigins")}
> value={asStringArray(config.allow_origins)}
<Input onChange={(value) => onChange("allow_origins", value)}
value={asStringArray(config.allow_origins).join(", ")} placeholder={t("channels.field.allowOriginsPlaceholder")}
onChange={(e) => fieldPath="allow_origins"
onChange( registerFlusher={registerArrayFieldFlusher}
"allow_origins", resetVersion={arrayFieldResetVersion}
e.target.value />
.split(",")
.map((s: string) => s.trim())
.filter(Boolean),
)
}
placeholder={t("channels.field.allowOriginsPlaceholder")}
/>
</Field>
)} )}
{config.allow_token_query !== undefined && {config.allow_token_query !== undefined &&
@ -356,26 +343,21 @@ export function GenericForm({
/> />
</div> </div>
<Field <ChannelArrayListField
label={t("channels.field.groupTriggerPrefixes")} label={t("channels.field.groupTriggerPrefixes")}
hint={t("channels.form.desc.groupTriggerPrefixes")} hint={t("channels.form.desc.groupTriggerPrefixes")}
> value={asStringArray(groupTriggerConfig.prefixes)}
<Input onChange={(value) =>
value={asStringArray(groupTriggerConfig.prefixes).join( onChange("group_trigger", {
", ", ...groupTriggerConfig,
)} prefixes: value,
onChange={(e) => })
onChange("group_trigger", { }
...groupTriggerConfig, placeholder={t("channels.field.groupTriggerPrefixes")}
prefixes: e.target.value fieldPath="group_trigger.prefixes"
.split(",") registerFlusher={registerArrayFieldFlusher}
.map((s: string) => s.trim()) resetVersion={arrayFieldResetVersion}
.filter(Boolean), />
})
}
placeholder={t("channels.field.groupTriggerPrefixes")}
/>
</Field>
</> </>
)} )}

View file

@ -1,32 +1,41 @@
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import type { ChannelConfig } from "@/api/channels" 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 { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields"
import { Field, KeyInput } from "@/components/shared-form" import { Field, KeyInput } from "@/components/shared-form"
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
interface SlackFormProps { interface SlackFormProps {
config: ChannelConfig config: ChannelConfig
onChange: (key: string, value: unknown) => void onChange: (key: string, value: unknown) => void
configuredSecrets: string[] configuredSecrets: string[]
fieldErrors?: Record<string, string> fieldErrors?: Record<string, string>
registerArrayFieldFlusher?: (
fieldPath: string,
flusher: ArrayFieldFlusher | null,
) => void
arrayFieldResetVersion?: number
} }
function asString(value: unknown): string { function asString(value: unknown): string {
return typeof value === "string" ? value : "" 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({ export function SlackForm({
config, config,
onChange, onChange,
configuredSecrets, configuredSecrets,
fieldErrors = {}, fieldErrors = {},
registerArrayFieldFlusher,
arrayFieldResetVersion,
}: SlackFormProps) { }: SlackFormProps) {
const { t } = useTranslation() const { t } = useTranslation()
@ -72,24 +81,17 @@ export function SlackForm({
<Card className="shadow-sm"> <Card className="shadow-sm">
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5"> <CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
<Field <ChannelArrayListField
label={t("channels.field.allowFrom")} label={t("channels.field.allowFrom")}
hint={t("channels.form.desc.allowFrom")} hint={t("channels.form.desc.allowFrom")}
> value={asStringArray(config.allow_from)}
<Input onChange={(value) => onChange("allow_from", value)}
value={asStringArray(config.allow_from).join(", ")} placeholder={t("channels.field.allowFromPlaceholder")}
onChange={(e) => parser={parseAllowFromInput}
onChange( fieldPath="allow_from"
"allow_from", registerFlusher={registerArrayFieldFlusher}
e.target.value resetVersion={arrayFieldResetVersion}
.split(",") />
.map((s: string) => s.trim())
.filter(Boolean),
)
}
placeholder={t("channels.field.allowFromPlaceholder")}
/>
</Field>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View file

@ -1,6 +1,14 @@
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import type { ChannelConfig } from "@/api/channels" 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 { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields"
import { Field, KeyInput, SwitchCardField } from "@/components/shared-form" import { Field, KeyInput, SwitchCardField } from "@/components/shared-form"
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
@ -11,17 +19,17 @@ interface TelegramFormProps {
onChange: (key: string, value: unknown) => void onChange: (key: string, value: unknown) => void
configuredSecrets: string[] configuredSecrets: string[]
fieldErrors?: Record<string, string> fieldErrors?: Record<string, string>
registerArrayFieldFlusher?: (
fieldPath: string,
flusher: ArrayFieldFlusher | null,
) => void
arrayFieldResetVersion?: number
} }
function asString(value: unknown): string { function asString(value: unknown): string {
return typeof value === "string" ? value : "" 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> { function asRecord(value: unknown): Record<string, unknown> {
if (value && typeof value === "object" && !Array.isArray(value)) { if (value && typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, unknown> return value as Record<string, unknown>
@ -38,6 +46,8 @@ export function TelegramForm({
onChange, onChange,
configuredSecrets, configuredSecrets,
fieldErrors = {}, fieldErrors = {},
registerArrayFieldFlusher,
arrayFieldResetVersion,
}: TelegramFormProps) { }: TelegramFormProps) {
const { t } = useTranslation() const { t } = useTranslation()
const typingConfig = asRecord(config.typing) const typingConfig = asRecord(config.typing)
@ -91,24 +101,17 @@ export function TelegramForm({
placeholder="http://127.0.0.1:7890" placeholder="http://127.0.0.1:7890"
/> />
</Field> </Field>
<Field <ChannelArrayListField
label={t("channels.field.allowFrom")} label={t("channels.field.allowFrom")}
hint={t("channels.form.desc.allowFrom")} hint={t("channels.form.desc.allowFrom")}
> value={asStringArray(config.allow_from)}
<Input onChange={(value) => onChange("allow_from", value)}
value={asStringArray(config.allow_from).join(", ")} placeholder={t("channels.field.allowFromPlaceholder")}
onChange={(e) => parser={parseAllowFromInput}
onChange( fieldPath="allow_from"
"allow_from", registerFlusher={registerArrayFieldFlusher}
e.target.value resetVersion={arrayFieldResetVersion}
.split(",") />
.map((s: string) => s.trim())
.filter(Boolean),
)
}
placeholder={t("channels.field.allowFromPlaceholder")}
/>
</Field>
<div> <div>
<SwitchCardField <SwitchCardField

View file

@ -10,6 +10,14 @@ import { useTranslation } from "react-i18next"
import type { ChannelConfig } from "@/api/channels" import type { ChannelConfig } from "@/api/channels"
import { pollWeixinFlow, startWeixinFlow } 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 { Field } from "@/components/shared-form"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
@ -35,22 +43,24 @@ interface WeixinFormProps {
onChange: (key: string, value: unknown) => void onChange: (key: string, value: unknown) => void
isEdit: boolean isEdit: boolean
onBindSuccess?: () => void onBindSuccess?: () => void
registerArrayFieldFlusher?: (
fieldPath: string,
flusher: ArrayFieldFlusher | null,
) => void
arrayFieldResetVersion?: number
} }
function asString(value: unknown): string { function asString(value: unknown): string {
return typeof value === "string" ? value : "" 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({ export function WeixinForm({
config, config,
onChange, onChange,
isEdit, isEdit,
onBindSuccess, onBindSuccess,
registerArrayFieldFlusher,
arrayFieldResetVersion,
}: WeixinFormProps) { }: WeixinFormProps) {
const { t } = useTranslation() const { t } = useTranslation()
@ -321,24 +331,17 @@ export function WeixinForm({
<Card className="shadow-sm"> <Card className="shadow-sm">
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5"> <CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
<Field <ChannelArrayListField
label={t("channels.field.allowFrom")} label={t("channels.field.allowFrom")}
hint={t("channels.form.desc.allowFrom")} hint={t("channels.form.desc.allowFrom")}
> value={asStringArray(config.allow_from)}
<Input onChange={(value) => onChange("allow_from", value)}
value={asStringArray(config.allow_from).join(", ")} placeholder={t("channels.field.allowFromPlaceholder")}
onChange={(e) => parser={parseAllowFromInput}
onChange( fieldPath="allow_from"
"allow_from", registerFlusher={registerArrayFieldFlusher}
e.target.value resetVersion={arrayFieldResetVersion}
.split(",") />
.map((s: string) => s.trim())
.filter(Boolean),
)
}
placeholder={t("channels.field.allowFromPlaceholder")}
/>
</Field>
<Field <Field
label={t("channels.field.proxy")} label={t("channels.field.proxy")}

View file

@ -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 { useState } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import ReactMarkdown from "react-markdown" import ReactMarkdown from "react-markdown"
@ -10,6 +16,7 @@ import remarkGfm from "remark-gfm"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { formatMessageTime } from "@/hooks/use-pico-chat" import { formatMessageTime } from "@/hooks/use-pico-chat"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { showThoughtsAtom } from "@/store/chat"
interface AssistantMessageProps { interface AssistantMessageProps {
content: string content: string
@ -24,6 +31,7 @@ export function AssistantMessage({
}: AssistantMessageProps) { }: AssistantMessageProps) {
const { t } = useTranslation() const { t } = useTranslation()
const [isCopied, setIsCopied] = useState(false) const [isCopied, setIsCopied] = useState(false)
const [isExpanded, setIsExpanded] = useAtom(showThoughtsAtom)
const formattedTimestamp = const formattedTimestamp =
timestamp !== "" ? formatMessageTime(timestamp) : "" timestamp !== "" ? formatMessageTime(timestamp) : ""
@ -36,64 +44,76 @@ export function AssistantMessage({
return ( return (
<div className="group flex w-full flex-col gap-1.5"> <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"> {!isThought && (
<div className="flex items-center gap-2"> <div className="text-muted-foreground/60 flex items-center justify-between gap-2 px-1 text-xs opacity-70">
<span>PicoClaw</span> <div className="flex items-center gap-2">
{isThought && ( <span>PicoClaw</span>
<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"> {formattedTimestamp && (
<IconBrain className="size-3" /> <>
<span>{t("chat.reasoningLabel")}</span> <span className="opacity-50"></span>
</span> <span>{formattedTimestamp}</span>
)} </>
{formattedTimestamp && ( )}
<> </div>
<span className="opacity-50"></span>
<span>{formattedTimestamp}</span>
</>
)}
</div> </div>
</div> )}
<div <div
className={cn( className={cn(
"relative overflow-hidden rounded-xl border", "relative overflow-hidden rounded-xl border",
isThought 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" ? "border-border/30 bg-muted/20 text-muted-foreground dark:border-border/20 dark:bg-muted/10"
: "bg-card text-card-foreground", : "bg-card text-card-foreground border-border/60",
)} )}
> >
<div {isThought && (
className={cn( <div
"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", 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"
isThought onClick={() => setIsExpanded(!isExpanded)}
? "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]}
> >
{content} <div className="flex items-center gap-1.5">
</ReactMarkdown> <IconBrain className="size-3.5" />
</div> <span>{t("chat.reasoningLabel")}</span>
<Button </div>
variant="ghost" <IconChevronDown
size="icon" className={cn(
className={cn( "size-3.5 opacity-0 transition-all duration-200 group-hover:opacity-100",
"absolute top-2 right-2 h-7 w-7 opacity-0 transition-opacity group-hover:opacity-100", isExpanded ? "rotate-180" : "",
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", </div>
)} )}
onClick={handleCopy} {(!isThought || isExpanded) && (
> <div
{isCopied ? ( className={cn(
<IconCheck className="h-4 w-4 text-green-500" /> "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
<IconCopy className="text-muted-foreground h-4 w-4" /> ? "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",
</Button> )}
>
<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>
</div> </div>
) )

View file

@ -3,9 +3,15 @@ import type { KeyboardEvent } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import TextareaAutosize from "react-textarea-autosize" import TextareaAutosize from "react-textarea-autosize"
import { ContextUsageRing } from "@/components/chat/context-usage-ring"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import type { ChatAttachment } from "@/store/chat" import type { ChatAttachment, ContextUsage } from "@/store/chat"
export type ChatInputDisabledReason = export type ChatInputDisabledReason =
| "gatewayUnknown" | "gatewayUnknown"
@ -26,8 +32,10 @@ interface ChatComposerProps {
onAddImages: () => void onAddImages: () => void
onRemoveAttachment: (index: number) => void onRemoveAttachment: (index: number) => void
onSend: () => void onSend: () => void
onContextDetail?: () => void
inputDisabledReason: ChatInputDisabledReason | null inputDisabledReason: ChatInputDisabledReason | null
canSend: boolean canSend: boolean
contextUsage?: ContextUsage
} }
export function ChatComposer({ export function ChatComposer({
@ -37,8 +45,10 @@ export function ChatComposer({
onAddImages, onAddImages,
onRemoveAttachment, onRemoveAttachment,
onSend, onSend,
onContextDetail,
inputDisabledReason, inputDisabledReason,
canSend, canSend,
contextUsage,
}: ChatComposerProps) { }: ChatComposerProps) {
const { t } = useTranslation() const { t } = useTranslation()
const canInput = inputDisabledReason === null const canInput = inputDisabledReason === null
@ -57,8 +67,8 @@ export function ChatComposer({
} }
return ( 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="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/80 mx-auto flex max-w-[1000px] flex-col rounded-2xl border p-3 shadow-md"> <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 && ( {attachments.length > 0 && (
<div className="mb-3 flex flex-wrap gap-2 px-2"> <div className="mb-3 flex flex-wrap gap-2 px-2">
{attachments.map((attachment, index) => ( {attachments.map((attachment, index) => (
@ -93,17 +103,12 @@ export function ChatComposer({
disabled={!canInput} disabled={!canInput}
title={disabledMessage || undefined} title={disabledMessage || undefined}
className={cn( 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", !canInput && "cursor-not-allowed",
)} )}
minRows={1} minRows={1}
maxRows={8} 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="mt-2 flex items-center justify-between px-1">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@ -121,17 +126,35 @@ export function ChatComposer({
</Button> </Button>
</div> </div>
{canInput ? ( <div className="flex items-center gap-1.5">
<Button {contextUsage && (
type="button" <ContextUsageRing usage={contextUsage} onDetailClick={onContextDetail} />
size="icon" )}
className="size-8 rounded-full bg-violet-500 text-white transition-transform hover:bg-violet-600 active:scale-95" {canInput ? (
onClick={onSend} <Tooltip delayDuration={700}>
disabled={!canSend} <TooltipTrigger asChild>
> <span tabIndex={!canSend ? 0 : undefined}>
<IconArrowUp className="size-4" /> <Button
</Button> type="button"
) : null} 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> </div>
</div> </div>

View file

@ -115,6 +115,7 @@ export function ChatPage() {
connectionState, connectionState,
isTyping, isTyping,
activeSessionId, activeSessionId,
contextUsage,
sendMessage, sendMessage,
switchSession, switchSession,
newChat, newChat,
@ -153,7 +154,7 @@ export function ChatPage() {
}) })
const syncScrollState = (element: HTMLDivElement) => { const syncScrollState = (element: HTMLDivElement) => {
const { scrollTop, scrollHeight, clientHeight } = element const { clientHeight, scrollHeight, scrollTop } = element
setHasScrolled(scrollTop > 0) setHasScrolled(scrollTop > 0)
setIsAtBottom(scrollHeight - scrollTop <= clientHeight + 10) setIsAtBottom(scrollHeight - scrollTop <= clientHeight + 10)
} }
@ -294,7 +295,7 @@ export function ChatPage() {
<div <div
ref={scrollRef} ref={scrollRef}
onScroll={handleScroll} 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"> <div className="mx-auto flex w-full max-w-250 flex-col gap-8 pb-8">
{messages.length === 0 && !isTyping && ( {messages.length === 0 && !isTyping && (
@ -341,8 +342,14 @@ export function ChatPage() {
onAddImages={handleAddImages} onAddImages={handleAddImages}
onRemoveAttachment={handleRemoveAttachment} onRemoveAttachment={handleRemoveAttachment}
onSend={handleSend} onSend={handleSend}
onContextDetail={() => {
if (sendMessage({ content: "/context", attachments: [] })) {
setInput("")
}
}}
inputDisabledReason={inputDisabledReason} inputDisabledReason={inputDisabledReason}
canSend={canSubmit} canSend={canSubmit}
contextUsage={contextUsage}
/> />
</div> </div>
) )

View 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>
)
}

View file

@ -21,10 +21,7 @@ export function TypingIndicator() {
return ( return (
<div className="flex w-full flex-col gap-1.5"> <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"> <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">
<span>PicoClaw</span>
</div>
<div className="bg-card inline-flex w-fit max-w-xs flex-col gap-3 rounded-xl border px-5 py-4">
<div className="flex items-center gap-1.5"> <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.3s]" />
<span className="size-2 animate-bounce rounded-full bg-violet-400/70 [animation-delay:-0.15s]" /> <span className="size-2 animate-bounce rounded-full bg-violet-400/70 [animation-delay:-0.15s]" />

View file

@ -1,3 +1,4 @@
import { cn } from "@/lib/utils"
import type { ChatAttachment } from "@/store/chat" import type { ChatAttachment } from "@/store/chat"
interface UserMessageProps { interface UserMessageProps {
@ -7,6 +8,7 @@ interface UserMessageProps {
export function UserMessage({ content, attachments = [] }: UserMessageProps) { export function UserMessage({ content, attachments = [] }: UserMessageProps) {
const hasText = content.trim().length > 0 const hasText = content.trim().length > 0
const isCommand = content.trim().startsWith("/")
const imageAttachments = attachments.filter( const imageAttachments = attachments.filter(
(attachment) => attachment.type === "image", (attachment) => attachment.type === "image",
) )
@ -27,8 +29,24 @@ export function UserMessage({ content, attachments = [] }: UserMessageProps) {
)} )}
{hasText && ( {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"> <div
{content} 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>
)} )}
</div> </div>

View file

@ -7,6 +7,7 @@ import { toast } from "sonner"
import { patchAppConfig } from "@/api/channels" import { patchAppConfig } from "@/api/channels"
import { launcherFetch } from "@/api/http" import { launcherFetch } from "@/api/http"
import { postLauncherDashboardSetup } from "@/api/launcher-auth"
import { import {
getAutoStartStatus, getAutoStartStatus,
getLauncherConfig, getLauncherConfig,
@ -94,7 +95,8 @@ export function ConfigPage() {
port: String(launcherConfig.port), port: String(launcherConfig.port),
publicAccess: launcherConfig.public, publicAccess: launcherConfig.public,
allowedCIDRsText: (launcherConfig.allowed_cidrs ?? []).join("\n"), allowedCIDRsText: (launcherConfig.allowed_cidrs ?? []).join("\n"),
launcherToken: launcherConfig.launcher_token ?? "", dashboardPassword: "",
dashboardPasswordConfirm: "",
} }
setLauncherForm(parsed) setLauncherForm(parsed)
setLauncherBaseline(parsed) setLauncherBaseline(parsed)
@ -107,8 +109,14 @@ export function ConfigPage() {
}, [autoStartStatus]) }, [autoStartStatus])
const configDirty = JSON.stringify(form) !== JSON.stringify(baseline) const configDirty = JSON.stringify(form) !== JSON.stringify(baseline)
const launcherDirty = const launcherSettingsDirty =
JSON.stringify(launcherForm) !== JSON.stringify(launcherBaseline) 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 autoStartDirty = autoStartEnabled !== autoStartBaseline
const isDirty = configDirty || launcherDirty || autoStartDirty const isDirty = configDirty || launcherDirty || autoStartDirty
@ -143,6 +151,19 @@ export function ConfigPage() {
const handleSave = async () => { const handleSave = async () => {
try { try {
setSaving(true) 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) { if (configDirty) {
const workspace = form.workspace.trim() const workspace = form.workspace.trim()
@ -255,7 +276,8 @@ export function ConfigPage() {
queryClient.invalidateQueries({ queryKey: ["config"] }) queryClient.invalidateQueries({ queryKey: ["config"] })
} }
if (launcherDirty) { let savedLauncherForm: LauncherForm | null = null
if (launcherSettingsDirty) {
const port = parseIntField(launcherForm.port, "Service port", { const port = parseIntField(launcherForm.port, "Service port", {
min: 1, min: 1,
max: 65535, max: 65535,
@ -265,7 +287,6 @@ export function ConfigPage() {
port, port,
public: launcherForm.publicAccess, public: launcherForm.publicAccess,
allowed_cidrs: allowedCIDRs, allowed_cidrs: allowedCIDRs,
launcher_token: launcherForm.launcherToken.trim(),
}) })
const parsedLauncher: LauncherForm = { const parsedLauncher: LauncherForm = {
port: String(savedLauncherConfig.port), port: String(savedLauncherConfig.port),
@ -273,8 +294,10 @@ export function ConfigPage() {
allowedCIDRsText: (savedLauncherConfig.allowed_cidrs ?? []).join( allowedCIDRsText: (savedLauncherConfig.allowed_cidrs ?? []).join(
"\n", "\n",
), ),
launcherToken: savedLauncherConfig.launcher_token ?? "", dashboardPassword: "",
dashboardPasswordConfirm: "",
} }
savedLauncherForm = parsedLauncher
setLauncherForm(parsedLauncher) setLauncherForm(parsedLauncher)
setLauncherBaseline(parsedLauncher) setLauncherBaseline(parsedLauncher)
queryClient.setQueryData( 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 (autoStartDirty) {
if (!autoStartSupported) { if (!autoStartSupported) {
throw new Error(t("pages.config.autostart_unsupported")) 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 ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<PageHeader <PageHeader
@ -340,12 +396,6 @@ export function ConfigPage() {
</div> </div>
) : ( ) : (
<div className="space-y-6"> <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 <LauncherSection
launcherForm={launcherForm} launcherForm={launcherForm}
onFieldChange={updateLauncherField} onFieldChange={updateLauncherField}
@ -374,23 +424,21 @@ export function ConfigPage() {
onAutoStartChange={setAutoStartEnabled} onAutoStartChange={setAutoStartEnabled}
/> />
<div className="flex justify-end gap-2"> {!isDirty && actionButtons}
<Button
variant="outline"
onClick={handleReset}
disabled={!isDirty || saving}
>
{t("common.reset")}
</Button>
<Button onClick={handleSave} disabled={!isDirty || saving}>
<IconDeviceFloppy className="size-4" />
{saving ? t("common.saving") : t("common.save")}
</Button>
</div>
</div> </div>
)} )}
</div> </div>
</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> </div>
) )
} }

View file

@ -519,23 +519,48 @@ export function LauncherSection({
return ( return (
<ConfigSectionCard <ConfigSectionCard
title={t("pages.config.sections.launcher")} title={t("pages.config.sections.launcher")}
description={t("pages.config.launcher_token_section_hint")} description={t("pages.config.launcher_section_hint")}
> >
<Field <Field
label={t("pages.config.launcher_token")} label={t("pages.config.dashboard_password")}
hint={t("pages.config.launcher_token_hint")} hint={t("pages.config.dashboard_password_hint")}
layout="setting-row" layout="setting-row"
controlClassName="md:max-w-md"
> >
<Input <Input
type="password" type="password"
value={launcherForm.launcherToken} value={launcherForm.dashboardPassword}
disabled={disabled} disabled={disabled}
autoComplete="off" autoComplete="new-password"
placeholder={t("pages.config.launcher_token_placeholder")} placeholder={t("pages.config.dashboard_password_placeholder")}
onChange={(e) => onFieldChange("launcherToken", e.target.value)} onChange={(e) =>
onFieldChange("dashboardPassword", e.target.value)
}
/> />
</Field> </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 <SwitchCardField
label={t("pages.config.lan_access")} label={t("pages.config.lan_access")}
hint={t("pages.config.lan_access_hint")} hint={t("pages.config.lan_access_hint")}

View file

@ -30,7 +30,8 @@ export interface LauncherForm {
port: string port: string
publicAccess: boolean publicAccess: boolean
allowedCIDRsText: string allowedCIDRsText: string
launcherToken: string dashboardPassword: string
dashboardPasswordConfirm: string
} }
export const DM_SCOPE_OPTIONS = [ export const DM_SCOPE_OPTIONS = [
@ -94,7 +95,8 @@ export const EMPTY_LAUNCHER_FORM: LauncherForm = {
port: "18800", port: "18800",
publicAccess: false, publicAccess: false,
allowedCIDRsText: "", allowedCIDRsText: "",
launcherToken: "", dashboardPassword: "",
dashboardPasswordConfirm: "",
} }
function asRecord(value: unknown): JsonRecord { function asRecord(value: unknown): JsonRecord {

View file

@ -30,10 +30,13 @@ function TooltipTrigger({
function TooltipContent({ function TooltipContent({
className, className,
arrowClassName,
sideOffset = 0, sideOffset = 0,
children, children,
...props ...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) { }: React.ComponentProps<typeof TooltipPrimitive.Content> & {
arrowClassName?: string
}) {
return ( return (
<TooltipPrimitive.Portal> <TooltipPrimitive.Portal>
<TooltipPrimitive.Content <TooltipPrimitive.Content
@ -46,7 +49,12 @@ function TooltipContent({
{...props} {...props}
> >
{children} {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.Content>
</TooltipPrimitive.Portal> </TooltipPrimitive.Portal>
) )

View file

@ -392,6 +392,7 @@ export async function switchChatSession(sessionId: string) {
messages: historyMessages, messages: historyMessages,
isTyping: false, isTyping: false,
hasHydratedActiveSession: true, hasHydratedActiveSession: true,
contextUsage: undefined,
}) })
if (store.get(gatewayAtom).status === "running") { if (store.get(gatewayAtom).status === "running") {
@ -415,6 +416,7 @@ export async function newChatSession() {
messages: [], messages: [],
isTyping: false, isTyping: false,
hasHydratedActiveSession: true, hasHydratedActiveSession: true,
contextUsage: undefined,
}) })
if (store.get(gatewayAtom).status === "running") { if (store.get(gatewayAtom).status === "running") {

View file

@ -1,7 +1,11 @@
import { toast } from "sonner" import { toast } from "sonner"
import { normalizeUnixTimestamp } from "@/features/chat/state" 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 { export interface PicoMessage {
type: string type: string
@ -21,6 +25,24 @@ function hasAssistantKindPayload(payload: Record<string, unknown>): boolean {
return typeof payload.thought === "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( export function handlePicoMessage(
message: PicoMessage, message: PicoMessage,
expectedSessionId: string, expectedSessionId: string,
@ -36,6 +58,7 @@ export function handlePicoMessage(
const content = (payload.content as string) || "" const content = (payload.content as string) || ""
const messageId = (payload.message_id as string) || `pico-${Date.now()}` const messageId = (payload.message_id as string) || `pico-${Date.now()}`
const kind = parseAssistantMessageKind(payload) const kind = parseAssistantMessageKind(payload)
const contextUsage = parseContextUsage(payload)
const timestamp = const timestamp =
message.timestamp !== undefined && message.timestamp !== undefined &&
Number.isFinite(Number(message.timestamp)) Number.isFinite(Number(message.timestamp))
@ -54,6 +77,7 @@ export function handlePicoMessage(
}, },
], ],
isTyping: false, isTyping: false,
...(contextUsage ? { contextUsage } : {}),
})) }))
break break
} }

View file

@ -55,7 +55,7 @@ export function formatMessageTime(dateRaw: number | string | Date): string {
} }
export function usePicoChat() { export function usePicoChat() {
const { messages, connectionState, isTyping, activeSessionId } = const { messages, connectionState, isTyping, activeSessionId, contextUsage } =
useAtomValue(chatAtom) useAtomValue(chatAtom)
return { return {
@ -63,6 +63,7 @@ export function usePicoChat() {
connectionState, connectionState,
isTyping, isTyping,
activeSessionId, activeSessionId,
contextUsage,
sendMessage: sendChatMessage, sendMessage: sendChatMessage,
switchSession: switchChatSession, switchSession: switchChatSession,
newChat: newChatSession, newChat: newChatSession,

View file

@ -38,7 +38,7 @@
"chat": { "chat": {
"welcome": "How can I help you today?", "welcome": "How can I help you today?",
"welcomeDesc": "Ask me about weather, settings, or any other tasks. I'm here to assist you.", "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": { "disabledPlaceholder": {
"gatewayUnknown": "Unable to chat: Gateway status is still being checked. Please wait, then refresh the page or restart Launcher if needed.", "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.", "gatewayStarting": "Unable to chat: Gateway is starting. Wait for startup to complete, then try again.",
@ -60,6 +60,7 @@
"step4": "Almost there..." "step4": "Almost there..."
}, },
"reasoningLabel": "Reasoning", "reasoningLabel": "Reasoning",
"toolLabel": "Tool",
"history": "History", "history": "History",
"noHistory": "No chat history yet", "noHistory": "No chat history yet",
"historyLoadFailed": "Failed to load chat history", "historyLoadFailed": "Failed to load chat history",
@ -72,6 +73,10 @@
"notConnected": "Gateway is not running. Start it to chat.", "notConnected": "Gateway is not running. Start it to chat.",
"noModel": "No default model configured. Go to Models page to set one." "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", "attachImage": "Add images",
"removeImage": "Remove image", "removeImage": "Remove image",
"uploadedImage": "Uploaded image", "uploadedImage": "Uploaded image",
@ -354,11 +359,15 @@
"placeholderText": "Placeholder Text", "placeholderText": "Placeholder Text",
"groupTriggerMentionOnly": "Group Mention Only", "groupTriggerMentionOnly": "Group Mention Only",
"groupTriggerPrefixes": "Group Trigger Prefixes", "groupTriggerPrefixes": "Group Trigger Prefixes",
"groupTriggerPrefixesPlaceholder": "e.g. /, !, ?",
"randomReactionEmoji": "Random Reaction Emoji",
"randomReactionEmojiPlaceholder": "e.g. THUMBSUP, HEART, SMILE",
"isLark": "Lark (International)", "isLark": "Lark (International)",
"allowFrom": "Allow From", "allowFrom": "Allow From",
"allowFromPlaceholder": "e.g. 123456, 789012", "allowFromPlaceholder": "e.g. 123456, 789012",
"allowOrigins": "Allow Origins", "allowOrigins": "Allow Origins",
"allowOriginsPlaceholder": "e.g. https://example.com, http://localhost:5173", "allowOriginsPlaceholder": "e.g. https://example.com, http://localhost:5173",
"removeListItem": "Remove {{value}}",
"secretPlaceholder": "Enter secret", "secretPlaceholder": "Enter secret",
"secretHintSet": "A value is already set. Leave blank to keep it unchanged." "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.", "typingEnabled": "Display typing status while the assistant is generating a response.",
"placeholderEnabled": "Enable temporary placeholder messages before the final reply is sent.", "placeholderEnabled": "Enable temporary placeholder messages before the final reply is sent.",
"groupTriggerMentionOnly": "In group chats, respond only when the bot is mentioned.", "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).", "isLark": "Use Lark international domain (open.larksuite.com) instead of Feishu domain (open.feishu.cn).",
"allowFrom": "Allowed user or group IDs, separated by commas.", "allowFrom": "Allowed user or group IDs. Add items one by one, or paste multiple values at once.",
"allowOrigins": "Allowed origin domains, separated by commas.", "allowOrigins": "Allowed origin domains. Add items one by one, or paste multiple values at once.",
"wsUrl": "WebSocket service URL.", "wsUrl": "WebSocket service URL.",
"reconnectInterval": "Reconnect interval after disconnection (seconds).", "reconnectInterval": "Reconnect interval after disconnection (seconds).",
"bridgeUrl": "Bridge service URL.", "bridgeUrl": "Bridge service URL.",
@ -653,10 +663,16 @@
"autostart_load_error": "Failed to load launch-at-login status.", "autostart_load_error": "Failed to load launch-at-login status.",
"server_port": "Service Port", "server_port": "Service Port",
"server_port_hint": "HTTP port used by PicoClaw Web.", "server_port_hint": "HTTP port used by PicoClaw Web.",
"launcher_token": "Login Token", "launcher_section_hint": "Changes in this section take effect after the launcher restarts.",
"launcher_token_section_hint": "Changes in this section take effect after the launcher restarts.", "dashboard_password": "Login Password",
"launcher_token_hint": "Used to sign in on the launcher login page.", "dashboard_password_hint": "Set a new login password.",
"launcher_token_placeholder": "Enter login token", "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": "Enable LAN Access",
"lan_access_hint": "Allow access from other devices on your local network.", "lan_access_hint": "Allow access from other devices on your local network.",
"allowed_cidrs": "Allowed Network CIDRs", "allowed_cidrs": "Allowed Network CIDRs",

View file

@ -38,7 +38,7 @@
"chat": { "chat": {
"welcome": "今天我能为您做些什么?", "welcome": "今天我能为您做些什么?",
"welcomeDesc": "您可以询问我天气、设置或其他任何任务,我随时为您效劳。", "welcomeDesc": "您可以询问我天气、设置或其他任何任务,我随时为您效劳。",
"placeholder": "输入新消息...\n按 Enter 发送Shift + Enter 换行", "placeholder": "输入新消息...",
"disabledPlaceholder": { "disabledPlaceholder": {
"gatewayUnknown": "无法对话:网关状态仍在检测中。请稍候重试,如仍无效请刷新页面或重启 Launcher。", "gatewayUnknown": "无法对话:网关状态仍在检测中。请稍候重试,如仍无效请刷新页面或重启 Launcher。",
"gatewayStarting": "无法对话:网关正在启动。请等待启动完成后重试。", "gatewayStarting": "无法对话:网关正在启动。请等待启动完成后重试。",
@ -60,6 +60,7 @@
"step4": "马上就好..." "step4": "马上就好..."
}, },
"reasoningLabel": "思考", "reasoningLabel": "思考",
"toolLabel": "工具",
"history": "历史记录", "history": "历史记录",
"noHistory": "暂无对话历史", "noHistory": "暂无对话历史",
"historyLoadFailed": "加载历史记录失败", "historyLoadFailed": "加载历史记录失败",
@ -72,6 +73,10 @@
"notConnected": "服务未运行,请先启动以进行对话。", "notConnected": "服务未运行,请先启动以进行对话。",
"noModel": "未设置默认模型,请前往模型页面进行配置。" "noModel": "未设置默认模型,请前往模型页面进行配置。"
}, },
"sendMessage": "发送消息",
"sendHint": "按 Enter 发送\nShift + Enter 换行",
"contextTitle": "上下文",
"contextDetail": "查看详情",
"attachImage": "添加图片", "attachImage": "添加图片",
"removeImage": "移除图片", "removeImage": "移除图片",
"uploadedImage": "已上传图片", "uploadedImage": "已上传图片",
@ -354,11 +359,15 @@
"placeholderText": "占位文案", "placeholderText": "占位文案",
"groupTriggerMentionOnly": "群聊仅提及时响应", "groupTriggerMentionOnly": "群聊仅提及时响应",
"groupTriggerPrefixes": "群聊触发前缀", "groupTriggerPrefixes": "群聊触发前缀",
"groupTriggerPrefixesPlaceholder": "例如 /, !, ?",
"randomReactionEmoji": "随机表情回应",
"randomReactionEmojiPlaceholder": "例如 THUMBSUP, HEART, SMILE",
"isLark": "Lark国际版", "isLark": "Lark国际版",
"allowFrom": "允许来源", "allowFrom": "允许来源",
"allowFromPlaceholder": "例如 123456, 789012", "allowFromPlaceholder": "例如 123456, 789012",
"allowOrigins": "允许来源域名", "allowOrigins": "允许来源域名",
"allowOriginsPlaceholder": "例如 https://example.com, http://localhost:5173", "allowOriginsPlaceholder": "例如 https://example.com, http://localhost:5173",
"removeListItem": "删除 {{value}}",
"secretPlaceholder": "输入密钥", "secretPlaceholder": "输入密钥",
"secretHintSet": "配置已保存,留空表示不修改" "secretHintSet": "配置已保存,留空表示不修改"
}, },
@ -386,10 +395,11 @@
"typingEnabled": "在生成回复时显示“正在输入”状态", "typingEnabled": "在生成回复时显示“正在输入”状态",
"placeholderEnabled": "在最终回复发送前,先发送临时占位消息", "placeholderEnabled": "在最终回复发送前,先发送临时占位消息",
"groupTriggerMentionOnly": "在群聊中仅当提及机器人时才响应", "groupTriggerMentionOnly": "在群聊中仅当提及机器人时才响应",
"groupTriggerPrefixes": "群聊触发前缀,多个值用逗号分隔", "groupTriggerPrefixes": "群聊触发前缀。可逐项添加,也支持一次粘贴多个值。",
"randomReactionEmoji": "PicoClaw 会对用户消息添加表情回复以确认已收到。例如:\"THUMBSUP\", \"HEART\", \"SMILE\"。留空则使用默认的 \"Pin\" 表情。",
"isLark": "使用 Lark 国际版域名open.larksuite.com替代飞书域名open.feishu.cn", "isLark": "使用 Lark 国际版域名open.larksuite.com替代飞书域名open.feishu.cn",
"allowFrom": "允许访问的用户或群组 ID,多个值用逗号分隔", "allowFrom": "允许访问的用户或群组 ID。可逐项添加,也支持一次粘贴多个值。",
"allowOrigins": "允许访问的来源域名,多个值用逗号分隔", "allowOrigins": "允许访问的来源域名。可逐项添加,也支持一次粘贴多个值。",
"wsUrl": "WebSocket 服务地址", "wsUrl": "WebSocket 服务地址",
"reconnectInterval": "断线后的重连间隔(秒)", "reconnectInterval": "断线后的重连间隔(秒)",
"bridgeUrl": "桥接服务地址", "bridgeUrl": "桥接服务地址",
@ -653,10 +663,16 @@
"autostart_load_error": "加载开机自启状态失败", "autostart_load_error": "加载开机自启状态失败",
"server_port": "服务端口", "server_port": "服务端口",
"server_port_hint": "PicoClaw Web 的 HTTP 监听端口", "server_port_hint": "PicoClaw Web 的 HTTP 监听端口",
"launcher_token": "登录令牌", "launcher_section_hint": "此分组中的改动需要在重启 launcher 后生效",
"launcher_token_section_hint": "此分组中的改动需要在重启 launcher 后生效", "dashboard_password": "登录密码",
"launcher_token_hint": "用于在 launcher 登录页进行登录", "dashboard_password_hint": "设置新的登录密码",
"launcher_token_placeholder": "输入登录令牌", "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": "启用局域网访问",
"lan_access_hint": "允许局域网中的其他设备访问当前服务", "lan_access_hint": "允许局域网中的其他设备访问当前服务",
"allowed_cidrs": "允许访问网段", "allowed_cidrs": "允许访问网段",

View file

@ -33,7 +33,6 @@ const RootLayout = () => {
const [authError, setAuthError] = useState<string | null>(null) const [authError, setAuthError] = useState<string | null>(null)
// Session guard: proactively check auth status on every page load. // Session guard: proactively check auth status on every page load.
// This catches the case where ?token= auto-login bypassed the login/setup UI.
useEffect(() => { useEffect(() => {
if (isAuthPage) return if (isAuthPage) return
void getLauncherAuthStatus() void getLauncherAuthStatus()
@ -55,7 +54,7 @@ const RootLayout = () => {
setAuthError( setAuthError(
err instanceof Error err instanceof Error
? err.message ? 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.",
) )
} }
}) })

View file

@ -28,7 +28,7 @@ import { useTheme } from "@/hooks/use-theme"
function LauncherLoginPage() { function LauncherLoginPage() {
const { t, i18n } = useTranslation() const { t, i18n } = useTranslation()
const { theme, toggleTheme } = useTheme() const { theme, toggleTheme } = useTheme()
const [token, setToken] = React.useState("") const [password, setPassword] = React.useState("")
const [submitting, setSubmitting] = React.useState(false) const [submitting, setSubmitting] = React.useState(false)
const [error, setError] = React.useState("") const [error, setError] = React.useState("")
@ -45,17 +45,25 @@ function LauncherLoginPage() {
}) })
}, []) }, [])
const loginWithToken = React.useCallback( const loginWithPassword = React.useCallback(
async (tokenValue: string) => { async (passwordValue: string) => {
setError("") setError("")
setSubmitting(true) setSubmitting(true)
try { try {
const ok = await postLauncherDashboardLogin(tokenValue) const result = await postLauncherDashboardLogin(passwordValue)
if (ok) { if (result.ok) {
globalThis.location.assign("/") globalThis.location.assign("/")
return 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 { } catch {
setError(t("launcherLogin.errorNetwork")) setError(t("launcherLogin.errorNetwork"))
} finally { } finally {
@ -67,7 +75,7 @@ function LauncherLoginPage() {
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => { const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault() e.preventDefault()
await loginWithToken(token) await loginWithPassword(password)
} }
return ( return (
@ -112,17 +120,17 @@ function LauncherLoginPage() {
<CardContent> <CardContent>
<form className="flex flex-col gap-4" onSubmit={onSubmit}> <form className="flex flex-col gap-4" onSubmit={onSubmit}>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="launcher-token"> <Label htmlFor="launcher-password">
{t("launcherLogin.passwordLabel")} {t("launcherLogin.passwordLabel")}
</Label> </Label>
<Input <Input
id="launcher-token" id="launcher-password"
name="password" name="password"
type="password" type="password"
autoComplete="current-password" autoComplete="current-password"
required required
value={token} value={password}
onChange={(e) => setToken(e.target.value)} onChange={(e) => setPassword(e.target.value)}
placeholder={t("launcherLogin.passwordPlaceholder")} placeholder={t("launcherLogin.passwordPlaceholder")}
/> />
</div> </div>

View file

@ -22,6 +22,13 @@ export interface ChatMessage {
attachments?: ChatAttachment[] attachments?: ChatAttachment[]
} }
export interface ContextUsage {
used_tokens: number
total_tokens: number
compress_at_tokens: number
used_percent: number
}
export type ConnectionState = export type ConnectionState =
| "disconnected" | "disconnected"
| "connecting" | "connecting"
@ -34,6 +41,7 @@ export interface ChatStoreState {
isTyping: boolean isTyping: boolean
activeSessionId: string activeSessionId: string
hasHydratedActiveSession: boolean hasHydratedActiveSession: boolean
contextUsage?: ContextUsage
} }
type ChatStorePatch = Partial<ChatStoreState> type ChatStorePatch = Partial<ChatStoreState>
@ -48,6 +56,8 @@ const DEFAULT_CHAT_STATE: ChatStoreState = {
export const chatAtom = atom<ChatStoreState>(DEFAULT_CHAT_STATE) export const chatAtom = atom<ChatStoreState>(DEFAULT_CHAT_STATE)
export const showThoughtsAtom = atom<boolean>(true)
const store = getDefaultStore() const store = getDefaultStore()
export function getChatState() { export function getChatState() {