Merge branch 'main' into feat/channel-tool-feedback-animation
# Conflicts: # pkg/channels/pico/pico.go # web/backend/api/session.go
This commit is contained in:
commit
c731ecdafc
87 changed files with 3614 additions and 991 deletions
60
.github/workflows/create-tag.yml
vendored
Normal file
60
.github/workflows/create-tag.yml
vendored
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
name: Create Tag
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: "Tag name (required, e.g. v0.2.0)"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
commit:
|
||||||
|
description: "Target commit SHA (leave empty for latest main)"
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
default: ""
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
create-tag:
|
||||||
|
name: Create Git Tag
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
ref: main
|
||||||
|
|
||||||
|
- name: Validate commit exists
|
||||||
|
if: ${{ inputs.commit != '' }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
if ! git cat-file -t "${{ inputs.commit }}" &>/dev/null; then
|
||||||
|
echo "::error::Commit '${{ inputs.commit }}' does not exist."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Check tag does not already exist
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
if gh api "repos/${{ github.repository }}/git/ref/tags/${{ inputs.tag }}" --silent 2>/dev/null; then
|
||||||
|
echo "::error::Tag '${{ inputs.tag }}' already exists."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Create and push tag
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
TARGET="${{ inputs.commit || 'HEAD' }}"
|
||||||
|
COMMIT_SHA=$(git rev-parse "$TARGET")
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git tag -a "${{ inputs.tag }}" "$COMMIT_SHA" -m "Release ${{ inputs.tag }}"
|
||||||
|
git push origin "${{ inputs.tag }}"
|
||||||
|
echo "### Tag Created" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
echo "- **Tag:** \`${{ inputs.tag }}\`" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
echo "- **Commit:** \`${COMMIT_SHA}\`" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
echo "- **Branch:** \`$(git branch -r --contains "$COMMIT_SHA" | head -1 | xargs)\`" >> "$GITHUB_STEP_SUMMARY"
|
||||||
36
.github/workflows/release.yml
vendored
36
.github/workflows/release.yml
vendored
|
|
@ -1,10 +1,10 @@
|
||||||
name: Create Tag and Release
|
name: Release
|
||||||
|
|
||||||
on:
|
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:
|
||||||
|
|
|
||||||
|
|
@ -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"))
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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 スタイルのスケジュールタスクをサポートします。
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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`.
|
||||||
|
|
|
||||||
|
|
@ -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` và `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`.
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 モード (ワンショット)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 模式 (一次性运行)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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[] | はい* | 認証キー。複数キーでリクエストごとのローテーションが可能。ローカル provider(Ollama、LM Studio、VLLM)には不要 |
|
| `api_keys` | string[] | はい* | 認証キー。複数キーでリクエストごとのローテーションが可能。ローカル provider(Ollama、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 認証ルート。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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` và `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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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[] | 是* | 认证密钥。多个密钥可按请求轮换。本地 provider(Ollama、LM Studio、VLLM)不需要 |
|
| `api_keys` | string[] | 是* | 认证密钥。多个密钥可按请求轮换。本地 provider(Ollama、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>
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
78
pkg/agent/context_usage.go
Normal file
78
pkg/agent/context_usage.go
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// computeContextUsage estimates current context window consumption for the
|
||||||
|
// given agent and session. Includes history, system prompt (with dynamic context,
|
||||||
|
// summary, and skills — mirroring BuildMessages composition), and tool definitions.
|
||||||
|
// The output reserve (MaxTokens) is not counted as "used" but reduces the
|
||||||
|
// effective budget, matching isOverContextBudget's compression trigger:
|
||||||
|
//
|
||||||
|
// compress when: history + system + tools + maxTokens > contextWindow
|
||||||
|
// equivalent to: history + system + tools > contextWindow - maxTokens
|
||||||
|
//
|
||||||
|
// Returns nil when the agent or session is unavailable.
|
||||||
|
func computeContextUsage(agent *AgentInstance, sessionKey string) *bus.ContextUsage {
|
||||||
|
if agent == nil || agent.Sessions == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
contextWindow := agent.ContextWindow
|
||||||
|
if contextWindow <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// History tokens
|
||||||
|
history := agent.Sessions.GetHistory(sessionKey)
|
||||||
|
historyTokens := 0
|
||||||
|
for _, m := range history {
|
||||||
|
historyTokens += EstimateMessageTokens(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// System message tokens: uses EstimateSystemTokens which mirrors
|
||||||
|
// the full system message composition in BuildMessages (static prompt,
|
||||||
|
// dynamic context, active skills, summary with wrapping prefix).
|
||||||
|
systemTokens := 0
|
||||||
|
if agent.ContextBuilder != nil {
|
||||||
|
summary := agent.Sessions.GetSummary(sessionKey)
|
||||||
|
// Pass nil for active skills: skills are only injected when the user
|
||||||
|
// explicitly activates them via /use, which is rare. Using nil matches
|
||||||
|
// the common case and avoids over-counting all installed skills.
|
||||||
|
systemTokens = agent.ContextBuilder.EstimateSystemTokens(summary, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tool definition tokens
|
||||||
|
toolTokens := 0
|
||||||
|
if agent.Tools != nil {
|
||||||
|
toolTokens = EstimateToolDefsTokens(agent.Tools.ToProviderDefs())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Used = history + system (includes summary) + tools
|
||||||
|
usedTokens := historyTokens + systemTokens + toolTokens
|
||||||
|
|
||||||
|
// Effective budget = contextWindow minus output reserve (maxTokens)
|
||||||
|
effectiveWindow := contextWindow - agent.MaxTokens
|
||||||
|
if effectiveWindow < 0 {
|
||||||
|
effectiveWindow = contextWindow
|
||||||
|
}
|
||||||
|
|
||||||
|
// compressAt = effectiveWindow: aligns with isOverContextBudget's
|
||||||
|
// proactive trigger (msgTokens + toolTokens + maxTokens > contextWindow).
|
||||||
|
compressAt := effectiveWindow
|
||||||
|
|
||||||
|
usedPercent := 0
|
||||||
|
if compressAt > 0 {
|
||||||
|
usedPercent = usedTokens * 100 / compressAt
|
||||||
|
}
|
||||||
|
if usedPercent > 100 {
|
||||||
|
usedPercent = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
return &bus.ContextUsage{
|
||||||
|
UsedTokens: usedTokens,
|
||||||
|
TotalTokens: contextWindow,
|
||||||
|
CompressAtTokens: compressAt,
|
||||||
|
UsedPercent: usedPercent,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"encoding/json"
|
"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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ func BuiltinDefinitions() []Definition {
|
||||||
switchCommand(),
|
switchCommand(),
|
||||||
checkCommand(),
|
checkCommand(),
|
||||||
clearCommand(),
|
clearCommand(),
|
||||||
|
contextCommand(),
|
||||||
subagentsCommand(),
|
subagentsCommand(),
|
||||||
reloadCommand(),
|
reloadCommand(),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
42
pkg/commands/cmd_context.go
Normal file
42
pkg/commands/cmd_context.go
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func contextCommand() Definition {
|
||||||
|
return Definition{
|
||||||
|
Name: "context",
|
||||||
|
Description: "Show current session context and token usage",
|
||||||
|
Usage: "/context",
|
||||||
|
Handler: func(_ context.Context, req Request, rt *Runtime) error {
|
||||||
|
if rt == nil || rt.GetContextStats == nil {
|
||||||
|
return req.Reply(unavailableMsg)
|
||||||
|
}
|
||||||
|
stats := rt.GetContextStats()
|
||||||
|
if stats == nil {
|
||||||
|
return req.Reply("No active session context.")
|
||||||
|
}
|
||||||
|
return req.Reply(formatContextStats(stats))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatContextStats(s *ContextStats) string {
|
||||||
|
remaining := s.CompressAtTokens - s.UsedTokens
|
||||||
|
if remaining < 0 {
|
||||||
|
remaining = 0
|
||||||
|
}
|
||||||
|
usedWindowPercent := s.UsedTokens * 100 / max(s.TotalTokens, 1)
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"Context usage \nMessages: %d \nUsed: ~%d / %d tokens (%d%%) \nCompress at: %d tokens \nCompression progress: %d%% \nRemaining: ~%d tokens",
|
||||||
|
s.MessageCount,
|
||||||
|
s.UsedTokens,
|
||||||
|
s.TotalTokens,
|
||||||
|
usedWindowPercent,
|
||||||
|
s.CompressAtTokens,
|
||||||
|
s.UsedPercent,
|
||||||
|
remaining,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,15 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(`{}{}`))
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -230,6 +230,285 @@ func TestHandlePatchConfig_SavesChannelListSettingsPatch(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandlePatchConfig_NormalizesStringChannelArrayFields(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
|
||||||
|
"channel_list": {
|
||||||
|
"pico": {
|
||||||
|
"type": "pico",
|
||||||
|
"allow_from": " ou_a\u200b,\u2060ou_b\tou_c\u202e,ou_a ",
|
||||||
|
"group_trigger": {
|
||||||
|
"prefixes": "/,!;\n?,/"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"allow_origins": "https://a.example.com,http://localhost:5173,https://a.example.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"irc": {
|
||||||
|
"type": "irc",
|
||||||
|
"settings": {
|
||||||
|
"channels": "#ops,\n#dev,\n#ops",
|
||||||
|
"request_caps": "multi-prefix,echo-message\tbatch,multi-prefix"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
picoChannel := cfg.Channels[config.ChannelPico]
|
||||||
|
if len(picoChannel.AllowFrom) != 3 ||
|
||||||
|
picoChannel.AllowFrom[0] != "ou_a" ||
|
||||||
|
picoChannel.AllowFrom[1] != "ou_b" ||
|
||||||
|
picoChannel.AllowFrom[2] != "ou_c" {
|
||||||
|
t.Fatalf("pico allow_from = %#v, want [\"ou_a\", \"ou_b\", \"ou_c\"]", picoChannel.AllowFrom)
|
||||||
|
}
|
||||||
|
if len(picoChannel.GroupTrigger.Prefixes) != 3 ||
|
||||||
|
picoChannel.GroupTrigger.Prefixes[0] != "/" ||
|
||||||
|
picoChannel.GroupTrigger.Prefixes[1] != "!;" ||
|
||||||
|
picoChannel.GroupTrigger.Prefixes[2] != "?" {
|
||||||
|
t.Fatalf(
|
||||||
|
"pico group_trigger.prefixes = %#v, want [\"/\", \"!;\", \"?\"]",
|
||||||
|
picoChannel.GroupTrigger.Prefixes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded, err := picoChannel.GetDecoded()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetDecoded() pico error = %v", err)
|
||||||
|
}
|
||||||
|
picoCfg := decoded.(*config.PicoSettings)
|
||||||
|
if len(picoCfg.AllowOrigins) != 2 ||
|
||||||
|
picoCfg.AllowOrigins[0] != "https://a.example.com" ||
|
||||||
|
picoCfg.AllowOrigins[1] != "http://localhost:5173" {
|
||||||
|
t.Fatalf(
|
||||||
|
"pico allow_origins = %#v, want [\"https://a.example.com\", \"http://localhost:5173\"]",
|
||||||
|
picoCfg.AllowOrigins,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ircChannel := cfg.Channels[config.ChannelIRC]
|
||||||
|
decoded, err = ircChannel.GetDecoded()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetDecoded() irc error = %v", err)
|
||||||
|
}
|
||||||
|
ircCfg := decoded.(*config.IRCSettings)
|
||||||
|
if len(ircCfg.Channels) != 2 ||
|
||||||
|
ircCfg.Channels[0] != "#ops" ||
|
||||||
|
ircCfg.Channels[1] != "#dev" {
|
||||||
|
t.Fatalf("irc channels = %#v, want [\"#ops\", \"#dev\"]", ircCfg.Channels)
|
||||||
|
}
|
||||||
|
if len(ircCfg.RequestCaps) != 3 ||
|
||||||
|
ircCfg.RequestCaps[0] != "multi-prefix" ||
|
||||||
|
ircCfg.RequestCaps[1] != "echo-message" ||
|
||||||
|
ircCfg.RequestCaps[2] != "batch" {
|
||||||
|
t.Fatalf(
|
||||||
|
"irc request_caps = %#v, want [\"multi-prefix\", \"echo-message\", \"batch\"]",
|
||||||
|
ircCfg.RequestCaps,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlePatchConfig_NormalizesSingleNumericAllowFrom(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
|
||||||
|
"channel_list": {
|
||||||
|
"telegram": {
|
||||||
|
"type": "telegram",
|
||||||
|
"allow_from": 123456
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
telegramChannel := cfg.Channels[config.ChannelTelegram]
|
||||||
|
if len(telegramChannel.AllowFrom) != 1 || telegramChannel.AllowFrom[0] != "123456" {
|
||||||
|
t.Fatalf("telegram allow_from = %#v, want [\"123456\"]", telegramChannel.AllowFrom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlePatchConfig_RejectsInvalidChannelArrayFields(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
telegramChannel := cfg.Channels[config.ChannelTelegram]
|
||||||
|
telegramChannel.AllowFrom = config.FlexibleStringSlice{"existing-user"}
|
||||||
|
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||||
|
t.Fatalf("SaveConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "object allow_from",
|
||||||
|
body: `{
|
||||||
|
"channel_list": {
|
||||||
|
"telegram": {
|
||||||
|
"type": "telegram",
|
||||||
|
"allow_from": {"id": "bad"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "boolean allow_from",
|
||||||
|
body: `{
|
||||||
|
"channel_list": {
|
||||||
|
"telegram": {
|
||||||
|
"type": "telegram",
|
||||||
|
"allow_from": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "object settings array",
|
||||||
|
body: `{
|
||||||
|
"channel_list": {
|
||||||
|
"irc": {
|
||||||
|
"type": "irc",
|
||||||
|
"settings": {
|
||||||
|
"channels": {"name": "#ops"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(tt.body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf(
|
||||||
|
"PATCH /api/config status = %d, want %d, body=%s",
|
||||||
|
rec.Code,
|
||||||
|
http.StatusBadRequest,
|
||||||
|
rec.Body.String(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
telegramChannel := cfg.Channels[config.ChannelTelegram]
|
||||||
|
if len(telegramChannel.AllowFrom) != 1 || telegramChannel.AllowFrom[0] != "existing-user" {
|
||||||
|
t.Fatalf("telegram allow_from = %#v, want unchanged [\"existing-user\"]", telegramChannel.AllowFrom)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlePatchConfig_ClearingAllowFromDoesNotLeaveEmptyStringItem(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
feishuChannel := cfg.Channels[config.ChannelFeishu]
|
||||||
|
feishuChannel.Enabled = true
|
||||||
|
feishuChannel.AllowFrom = config.FlexibleStringSlice{"ou_existing_user"}
|
||||||
|
decoded, err := feishuChannel.GetDecoded()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetDecoded() error = %v", err)
|
||||||
|
}
|
||||||
|
feishuCfg := decoded.(*config.FeishuSettings)
|
||||||
|
feishuCfg.AppID = "cli_existing_app"
|
||||||
|
feishuCfg.AppSecret = *config.NewSecureString("existing-secret")
|
||||||
|
if err = config.SaveConfig(configPath, cfg); err != nil {
|
||||||
|
t.Fatalf("SaveConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
|
||||||
|
"channel_list": {
|
||||||
|
"feishu": {
|
||||||
|
"enabled": true,
|
||||||
|
"allow_from": "",
|
||||||
|
"settings": {
|
||||||
|
"app_id": "cli_existing_app"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err = config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
feishuChannel = cfg.Channels[config.ChannelFeishu]
|
||||||
|
if len(feishuChannel.AllowFrom) != 0 {
|
||||||
|
t.Fatalf("feishu allow_from = %#v, want empty slice", feishuChannel.AllowFrom)
|
||||||
|
}
|
||||||
|
|
||||||
|
configData, err := os.ReadFile(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile(configPath) error = %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(configData), `"allow_from": [""]`) {
|
||||||
|
t.Fatalf("config file should not contain empty-string allow_from item: %s", string(configData))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandlePatchConfig_CreatesMissingChannelWithTypeAndSecret(t *testing.T) {
|
func TestHandlePatchConfig_CreatesMissingChannelWithTypeAndSecret(t *testing.T) {
|
||||||
configPath, cleanup := setupOAuthTestEnv(t)
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
62
web/backend/launcherconfig/migration.go
Normal file
62
web/backend/launcherconfig/migration.go
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
package launcherconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
loadConfigForMigration = Load
|
||||||
|
saveConfigForMigration = Save
|
||||||
|
)
|
||||||
|
|
||||||
|
type dashboardPasswordStore interface {
|
||||||
|
IsInitialized(ctx context.Context) (bool, error)
|
||||||
|
SetPassword(ctx context.Context, plain string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// LegacyLauncherTokenMigrationResult reports the outcome of converting a
|
||||||
|
// removed launcher_token value into the current password-based auth flow.
|
||||||
|
type LegacyLauncherTokenMigrationResult struct {
|
||||||
|
Migrated bool
|
||||||
|
// CleanupErr is non-nil when password migration succeeded (or was already in
|
||||||
|
// place) but removing launcher_token from launcher-config.json failed.
|
||||||
|
CleanupErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
// MigrateLegacyLauncherToken converts the removed launcher_token setting into
|
||||||
|
// the current password-login store, then removes launcher_token from config.
|
||||||
|
func MigrateLegacyLauncherToken(
|
||||||
|
ctx context.Context,
|
||||||
|
store dashboardPasswordStore,
|
||||||
|
launcherPath string,
|
||||||
|
fallback Config,
|
||||||
|
) (LegacyLauncherTokenMigrationResult, error) {
|
||||||
|
legacyToken := strings.TrimSpace(fallback.LegacyLauncherToken)
|
||||||
|
if legacyToken == "" || store == nil {
|
||||||
|
return LegacyLauncherTokenMigrationResult{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result := LegacyLauncherTokenMigrationResult{}
|
||||||
|
initialized, err := store.IsInitialized(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
if !initialized {
|
||||||
|
if err = store.SetPassword(ctx, legacyToken); err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
result.Migrated = true
|
||||||
|
}
|
||||||
|
result.CleanupErr = cleanupLegacyLauncherTokenConfig(launcherPath, fallback)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanupLegacyLauncherTokenConfig(launcherPath string, fallback Config) error {
|
||||||
|
cfg, err := loadConfigForMigration(launcherPath, fallback)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cfg.LegacyLauncherToken = ""
|
||||||
|
return saveConfigForMigration(launcherPath, cfg)
|
||||||
|
}
|
||||||
135
web/backend/launcherconfig/migration_test.go
Normal file
135
web/backend/launcherconfig/migration_test.go
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
package launcherconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type stubMigrationPasswordStore struct {
|
||||||
|
initialized bool
|
||||||
|
password string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubMigrationPasswordStore) IsInitialized(context.Context) (bool, error) {
|
||||||
|
return s.initialized, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubMigrationPasswordStore) SetPassword(_ context.Context, plain string) error {
|
||||||
|
s.password = plain
|
||||||
|
s.initialized = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrateLegacyLauncherToken(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
launcherPath := filepath.Join(dir, FileName)
|
||||||
|
cfg := Config{
|
||||||
|
Port: DefaultPort,
|
||||||
|
LegacyLauncherToken: "legacy-password",
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(
|
||||||
|
launcherPath,
|
||||||
|
[]byte("{\n \"port\": 18800,\n \"launcher_token\": \"legacy-password\"\n}\n"),
|
||||||
|
0o600,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("WriteFile() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
store := NewPasswordStore(launcherPath, Default())
|
||||||
|
result, err := MigrateLegacyLauncherToken(context.Background(), store, launcherPath, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MigrateLegacyLauncherToken() error = %v", err)
|
||||||
|
}
|
||||||
|
if !result.Migrated {
|
||||||
|
t.Fatal("MigrateLegacyLauncherToken().Migrated = false, want true")
|
||||||
|
}
|
||||||
|
if result.CleanupErr != nil {
|
||||||
|
t.Fatalf("MigrateLegacyLauncherToken().CleanupErr = %v, want nil", result.CleanupErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
loaded, err := Load(launcherPath, Default())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() error = %v", err)
|
||||||
|
}
|
||||||
|
if loaded.LegacyLauncherToken != "" {
|
||||||
|
t.Fatalf("legacy launcher token = %q, want empty", loaded.LegacyLauncherToken)
|
||||||
|
}
|
||||||
|
if loaded.DashboardPasswordHash == "" {
|
||||||
|
t.Fatal("dashboard password hash should be set after migration")
|
||||||
|
}
|
||||||
|
ok, err := store.VerifyPassword(context.Background(), "legacy-password")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VerifyPassword() error = %v", err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("VerifyPassword() = false, want true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrateLegacyLauncherTokenCleanupFailureIsNonFatal(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
launcherPath := filepath.Join(dir, FileName)
|
||||||
|
cfg := Config{
|
||||||
|
Port: DefaultPort,
|
||||||
|
LegacyLauncherToken: "legacy-password",
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(
|
||||||
|
launcherPath,
|
||||||
|
[]byte("{\n \"port\": 18800,\n \"launcher_token\": \"legacy-password\"\n}\n"),
|
||||||
|
0o600,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("WriteFile() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
store := &stubMigrationPasswordStore{}
|
||||||
|
origSave := saveConfigForMigration
|
||||||
|
saveConfigForMigration = func(string, Config) error {
|
||||||
|
return errors.New("write launcher config")
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
saveConfigForMigration = origSave
|
||||||
|
})
|
||||||
|
|
||||||
|
result, err := MigrateLegacyLauncherToken(context.Background(), store, launcherPath, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MigrateLegacyLauncherToken() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
if !result.Migrated {
|
||||||
|
t.Fatal("MigrateLegacyLauncherToken().Migrated = false, want true")
|
||||||
|
}
|
||||||
|
if result.CleanupErr == nil {
|
||||||
|
t.Fatal("MigrateLegacyLauncherToken().CleanupErr = nil, want non-nil")
|
||||||
|
}
|
||||||
|
if store.password != "legacy-password" {
|
||||||
|
t.Fatalf("password = %q, want legacy-password", store.password)
|
||||||
|
}
|
||||||
|
|
||||||
|
loaded, err := Load(launcherPath, Default())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() error = %v", err)
|
||||||
|
}
|
||||||
|
if loaded.LegacyLauncherToken != "legacy-password" {
|
||||||
|
t.Fatalf(
|
||||||
|
"legacy launcher token = %q, want legacy-password after cleanup failure",
|
||||||
|
loaded.LegacyLauncherToken,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrateLegacyLauncherTokenNoopWithoutToken(t *testing.T) {
|
||||||
|
launcherPath := filepath.Join(t.TempDir(), FileName)
|
||||||
|
store := NewPasswordStore(launcherPath, Default())
|
||||||
|
result, err := MigrateLegacyLauncherToken(context.Background(), store, launcherPath, Default())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MigrateLegacyLauncherToken() error = %v", err)
|
||||||
|
}
|
||||||
|
if result.Migrated {
|
||||||
|
t.Fatal("MigrateLegacyLauncherToken().Migrated = true, want false")
|
||||||
|
}
|
||||||
|
if result.CleanupErr != nil {
|
||||||
|
t.Fatalf("MigrateLegacyLauncherToken().CleanupErr = %v, want nil", result.CleanupErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
92
web/backend/launcherconfig/password_store.go
Normal file
92
web/backend/launcherconfig/password_store.go
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
package launcherconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
const passwordBcryptCost = 12
|
||||||
|
|
||||||
|
// PasswordStore keeps the dashboard bcrypt hash in launcher-config.json.
|
||||||
|
// It is used on platforms where the SQLite-backed dashboard auth store is not
|
||||||
|
// available.
|
||||||
|
type PasswordStore struct {
|
||||||
|
path string
|
||||||
|
fallback Config
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPasswordStore returns a config-backed password store.
|
||||||
|
func NewPasswordStore(path string, fallback Config) *PasswordStore {
|
||||||
|
return &PasswordStore{
|
||||||
|
path: path,
|
||||||
|
fallback: fallback,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsInitialized reports whether a dashboard password hash exists in config.
|
||||||
|
func (s *PasswordStore) IsInitialized(ctx context.Context) (bool, error) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
cfg, err := s.load()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(cfg.DashboardPasswordHash) != "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPassword hashes plain with bcrypt and writes it to launcher-config.json.
|
||||||
|
func (s *PasswordStore) SetPassword(ctx context.Context, plain string) error {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len([]rune(plain)) == 0 {
|
||||||
|
return errors.New("password must not be empty")
|
||||||
|
}
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(plain), passwordBcryptCost)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
cfg, err := Load(s.path, s.fallback)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cfg.DashboardPasswordHash = string(hash)
|
||||||
|
cfg.LegacyLauncherToken = ""
|
||||||
|
return Save(s.path, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyPassword returns true iff plain matches the stored bcrypt hash.
|
||||||
|
func (s *PasswordStore) VerifyPassword(ctx context.Context, plain string) (bool, error) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
cfg, err := s.load()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
hash := strings.TrimSpace(cfg.DashboardPasswordHash)
|
||||||
|
if hash == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
err = bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain))
|
||||||
|
if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return err == nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PasswordStore) load() (Config, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return Load(s.path, s.fallback)
|
||||||
|
}
|
||||||
|
|
@ -12,12 +12,12 @@
|
||||||
package main
|
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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,180 @@
|
||||||
|
import { IconX } from "@tabler/icons-react"
|
||||||
|
import {
|
||||||
|
type KeyboardEvent,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
|
import {
|
||||||
|
mergeUniqueStringItems,
|
||||||
|
parseConservativeStringListInput,
|
||||||
|
} from "@/components/channels/channel-array-utils"
|
||||||
|
import { Field } from "@/components/shared-form"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
|
||||||
|
type StringListParser = (raw: string) => string[]
|
||||||
|
export type ArrayFieldFlusher = () => string[] | null
|
||||||
|
|
||||||
|
type RegisterArrayFieldFlusher = (
|
||||||
|
fieldPath: string,
|
||||||
|
flusher: ArrayFieldFlusher | null,
|
||||||
|
) => void
|
||||||
|
|
||||||
|
function areStringArraysEqual(left: string[], right: string[]): boolean {
|
||||||
|
if (left.length !== right.length) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return left.every((item, index) => item === right[index])
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChannelArrayListFieldProps {
|
||||||
|
label: string
|
||||||
|
hint?: string
|
||||||
|
error?: string
|
||||||
|
required?: boolean
|
||||||
|
value: string[]
|
||||||
|
onChange: (value: string[]) => void
|
||||||
|
placeholder?: string
|
||||||
|
parser?: StringListParser
|
||||||
|
fieldPath?: string
|
||||||
|
registerFlusher?: RegisterArrayFieldFlusher
|
||||||
|
resetVersion?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChannelArrayListField({
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
error,
|
||||||
|
required,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
parser = parseConservativeStringListInput,
|
||||||
|
fieldPath,
|
||||||
|
registerFlusher,
|
||||||
|
resetVersion,
|
||||||
|
}: ChannelArrayListFieldProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [draft, setDraft] = useState("")
|
||||||
|
const draftRef = useRef("")
|
||||||
|
const valueRef = useRef(value)
|
||||||
|
const localValueRef = useRef(value)
|
||||||
|
const parserRef = useRef(parser)
|
||||||
|
const onChangeRef = useRef(onChange)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
valueRef.current = value
|
||||||
|
localValueRef.current = value
|
||||||
|
}, [value])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
draftRef.current = ""
|
||||||
|
setDraft("")
|
||||||
|
}, [resetVersion])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
parserRef.current = parser
|
||||||
|
}, [parser])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onChangeRef.current = onChange
|
||||||
|
}, [onChange])
|
||||||
|
|
||||||
|
const commitDraft = useCallback(() => {
|
||||||
|
const rawDraft = draftRef.current
|
||||||
|
if (rawDraft.trim() === "") {
|
||||||
|
if (!areStringArraysEqual(localValueRef.current, valueRef.current)) {
|
||||||
|
return localValueRef.current
|
||||||
|
}
|
||||||
|
draftRef.current = ""
|
||||||
|
setDraft("")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
draftRef.current = ""
|
||||||
|
setDraft("")
|
||||||
|
const nextItems = parserRef.current(rawDraft)
|
||||||
|
if (nextItems.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const mergedItems = mergeUniqueStringItems(localValueRef.current, nextItems)
|
||||||
|
localValueRef.current = mergedItems
|
||||||
|
onChangeRef.current(mergedItems)
|
||||||
|
return mergedItems
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fieldPath || !registerFlusher) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
registerFlusher(fieldPath, commitDraft)
|
||||||
|
return () => registerFlusher(fieldPath, null)
|
||||||
|
}, [commitDraft, fieldPath, registerFlusher])
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (event.key !== "Enter") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
event.preventDefault()
|
||||||
|
commitDraft()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemove = (index: number) => {
|
||||||
|
const nextValue = value.filter((_, itemIndex) => itemIndex !== index)
|
||||||
|
localValueRef.current = nextValue
|
||||||
|
onChangeRef.current(nextValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Field label={label} hint={hint} error={error} required={required}>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{value.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{value.map((item, index) => (
|
||||||
|
<span
|
||||||
|
key={`${item}-${index}`}
|
||||||
|
className="bg-muted text-foreground inline-flex max-w-full items-center gap-1 rounded-md border px-2 py-1 text-xs"
|
||||||
|
>
|
||||||
|
<span className="break-all">{item}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemove(index)}
|
||||||
|
className="text-muted-foreground hover:text-foreground shrink-0 transition-colors"
|
||||||
|
aria-label={t("channels.field.removeListItem", {
|
||||||
|
value: item,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<IconX className="size-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={draft}
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextDraft = event.target.value
|
||||||
|
draftRef.current = nextDraft
|
||||||
|
setDraft(nextDraft)
|
||||||
|
}}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
onClick={commitDraft}
|
||||||
|
disabled={draft.trim() === ""}
|
||||||
|
>
|
||||||
|
{t("common.confirm")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
)
|
||||||
|
}
|
||||||
72
web/frontend/src/components/channels/channel-array-utils.ts
Normal file
72
web/frontend/src/components/channels/channel-array-utils.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
const ALLOW_FROM_HIDDEN_CHARS_RE =
|
||||||
|
/\u200b|\u200c|\u200d|\u200e|\u200f|\u202a|\u202b|\u202c|\u202d|\u202e|\u2060|\u2061|\u2062|\u2063|\u2064|\u2066|\u2067|\u2068|\u2069|\ufeff/g
|
||||||
|
|
||||||
|
function normalizeStringListItems(
|
||||||
|
items: string[],
|
||||||
|
options: { stripHiddenChars?: boolean } = {},
|
||||||
|
): string[] {
|
||||||
|
const result: string[] = []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const normalized = options.stripHiddenChars
|
||||||
|
? item.replace(ALLOW_FROM_HIDDEN_CHARS_RE, "")
|
||||||
|
: item
|
||||||
|
const trimmed = normalized.trim()
|
||||||
|
if (trimmed.length === 0 || seen.has(trimmed)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen.add(trimmed)
|
||||||
|
result.push(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitStringList(
|
||||||
|
raw: string,
|
||||||
|
separators: RegExp,
|
||||||
|
options: { stripHiddenChars?: boolean } = {},
|
||||||
|
): string[] {
|
||||||
|
if (raw.trim() === "") {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return normalizeStringListItems(raw.split(separators), options)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asStringArray(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return value.filter((item): item is string => typeof item === "string")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAllowFromInput(raw: string): string[] {
|
||||||
|
return splitStringList(raw, /[,\uFF0C、;;\n\r\t]+/, {
|
||||||
|
stripHiddenChars: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseConservativeStringListInput(raw: string): string[] {
|
||||||
|
return splitStringList(raw, /[,\uFF0C\n\r\t]+/)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeAllowFromValues(value: unknown): string[] {
|
||||||
|
return normalizeStringListItems(asStringArray(value), {
|
||||||
|
stripHiddenChars: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeUniqueStringItems(
|
||||||
|
currentItems: string[],
|
||||||
|
nextItems: string[],
|
||||||
|
): string[] {
|
||||||
|
return normalizeStringListItems([...currentItems, ...nextItems])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeStringArrayForSubmit(value: unknown): unknown {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return normalizeStringListItems(asStringArray(value)).join("\n")
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,11 @@ import {
|
||||||
getChannelsCatalog,
|
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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
161
web/frontend/src/components/chat/context-usage-ring.tsx
Normal file
161
web/frontend/src/components/chat/context-usage-ring.tsx
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
import { IconArrowRight } from "@tabler/icons-react"
|
||||||
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
|
import type { ContextUsage } from "@/store/chat"
|
||||||
|
|
||||||
|
interface ContextUsageRingProps {
|
||||||
|
usage: ContextUsage
|
||||||
|
onDetailClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTokens(n: number): string {
|
||||||
|
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
|
||||||
|
return String(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContextUsageRing({
|
||||||
|
usage,
|
||||||
|
onDetailClick,
|
||||||
|
}: ContextUsageRingProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [intent, setIntent] = useState(false) // user wants open
|
||||||
|
const [visible, setVisible] = useState(false) // DOM mounted
|
||||||
|
const [animated, setAnimated] = useState(false) // CSS target state
|
||||||
|
const [cooldown, setCooldown] = useState(false)
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout>>(null)
|
||||||
|
const hoverIntent = useRef<ReturnType<typeof setTimeout>>(null)
|
||||||
|
const closeTimer = useRef<ReturnType<typeof setTimeout>>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (intent) {
|
||||||
|
// Mount first, animate in on next frame
|
||||||
|
if (closeTimer.current) clearTimeout(closeTimer.current)
|
||||||
|
setVisible(true)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => setAnimated(true))
|
||||||
|
})
|
||||||
|
} else if (visible) {
|
||||||
|
// Animate out, then unmount
|
||||||
|
setAnimated(false)
|
||||||
|
closeTimer.current = setTimeout(() => setVisible(false), 150)
|
||||||
|
}
|
||||||
|
}, [intent, visible])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current)
|
||||||
|
if (hoverIntent.current) clearTimeout(hoverIntent.current)
|
||||||
|
if (closeTimer.current) clearTimeout(closeTimer.current)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const percent = Math.min(usage.used_percent, 100)
|
||||||
|
const radius = 8
|
||||||
|
const circumference = 2 * Math.PI * radius
|
||||||
|
const offset = circumference - (percent / 100) * circumference
|
||||||
|
const barPercent = Math.min(percent, 100)
|
||||||
|
|
||||||
|
const handleDetail = () => {
|
||||||
|
if (cooldown || !onDetailClick) return
|
||||||
|
setCooldown(true)
|
||||||
|
onDetailClick()
|
||||||
|
setIntent(false)
|
||||||
|
timerRef.current = setTimeout(() => setCooldown(false), 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desktop: hover to open, mouse leave to close (with small delay)
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
if (hoverIntent.current) clearTimeout(hoverIntent.current)
|
||||||
|
setIntent(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
hoverIntent.current = setTimeout(() => setIntent(false), 150)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mobile: tap to toggle (preventDefault suppresses synthetic mouseenter)
|
||||||
|
const handleTouchStart = (e: React.TouchEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setIntent((v) => !v)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="relative"
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
className="relative flex h-6 w-6 cursor-pointer items-center justify-center transition-opacity hover:opacity-70"
|
||||||
|
>
|
||||||
|
<svg className="h-6 w-6 -rotate-90" viewBox="0 0 20 20">
|
||||||
|
<circle
|
||||||
|
cx="10"
|
||||||
|
cy="10"
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
className="stroke-muted-foreground/30"
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx="10"
|
||||||
|
cy="10"
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
className="stroke-muted-foreground"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={circumference}
|
||||||
|
strokeDashoffset={offset}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="text-muted-foreground absolute text-[8px] font-medium tabular-nums">
|
||||||
|
{percent}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{visible && (
|
||||||
|
<div
|
||||||
|
className={`bg-popover text-popover-foreground absolute right-0 bottom-full z-50 mb-3 w-[220px] rounded-xl border p-4 shadow-lg transition-all duration-150 ${
|
||||||
|
animated
|
||||||
|
? "scale-100 opacity-100"
|
||||||
|
: "pointer-events-none scale-95 opacity-0"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="bg-popover absolute -bottom-1.5 right-3 h-3 w-3 rotate-45 border-r border-b" />
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{t("chat.contextTitle")}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-medium">
|
||||||
|
{formatTokens(usage.used_tokens)} /{" "}
|
||||||
|
{formatTokens(usage.compress_at_tokens)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-muted mt-1.5 h-1.5 w-full overflow-hidden rounded-full">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-violet-500 transition-all"
|
||||||
|
style={{ width: `${barPercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDetail}
|
||||||
|
disabled={cooldown}
|
||||||
|
className="mt-3 inline-flex items-center gap-1 text-xs font-medium text-violet-600 transition-opacity hover:opacity-70 disabled:opacity-40 dark:text-violet-400"
|
||||||
|
>
|
||||||
|
{t("chat.contextDetail")}
|
||||||
|
<IconArrowRight className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -21,10 +21,7 @@ export function TypingIndicator() {
|
||||||
|
|
||||||
return (
|
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]" />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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") {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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": "允许访问网段",
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue