Merge branch 'main' into t3
This commit is contained in:
commit
50b8d9bf83
66 changed files with 3895 additions and 1617 deletions
13
Makefile
13
Makefile
|
|
@ -130,14 +130,17 @@ build: generate
|
|||
build-launcher:
|
||||
@echo "Building picoclaw-launcher for $(PLATFORM)/$(ARCH)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
@if [ ! -f web/backend/dist/index.html ]; then \
|
||||
echo "Building frontend..."; \
|
||||
cd web/frontend && pnpm install && pnpm build:backend; \
|
||||
fi
|
||||
@$(WEB_GO) build $(GOFLAGS) -o $(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH) ./web/backend
|
||||
@$(MAKE) -C web build \
|
||||
OUTPUT="$(CURDIR)/$(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH)" \
|
||||
WEB_GO='$(WEB_GO)' \
|
||||
GO_BUILD_TAGS='$(GO_BUILD_TAGS)' \
|
||||
LDFLAGS='$(LDFLAGS)'
|
||||
@ln -sf picoclaw-launcher-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/picoclaw-launcher
|
||||
@echo "Build complete: $(BUILD_DIR)/picoclaw-launcher"
|
||||
|
||||
build-launcher-frontend:
|
||||
@$(MAKE) -C web build-frontend
|
||||
|
||||
## build-launcher-tui: Build the picoclaw-launcher TUI binary
|
||||
build-launcher-tui:
|
||||
@echo "Building picoclaw-launcher-tui for $(PLATFORM)/$(ARCH)..."
|
||||
|
|
|
|||
|
|
@ -462,6 +462,8 @@ Parlez à votre PicoClaw via plus de 17 plateformes de messagerie :
|
|||
|
||||
> Tous les channels basés sur webhook partagent un seul serveur HTTP Gateway (`gateway.host`:`gateway.port`, par défaut `127.0.0.1:18790`). Feishu utilise le mode WebSocket/SDK et n'utilise pas le serveur HTTP partagé.
|
||||
|
||||
> La verbosité des logs est contrôlée par `gateway.log_level` (par défaut : `warn`). Valeurs supportées : `debug`, `info`, `warn`, `error`, `fatal`. Peut aussi être défini via `PICOCLAW_LOG_LEVEL`. Voir [Configuration](docs/fr/configuration.md#niveau-de-log-du-gateway) pour plus de détails.
|
||||
|
||||
Pour les instructions détaillées de configuration des channels, voir [Configuration des applications de chat](docs/fr/chat-apps.md).
|
||||
|
||||
## 🔧 Outils
|
||||
|
|
|
|||
|
|
@ -458,6 +458,8 @@ Bicara dengan PicoClaw Anda melalui 17+ platform pesan:
|
|||
|
||||
> Semua channel berbasis webhook berbagi satu server HTTP Gateway (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Feishu menggunakan mode WebSocket/SDK dan tidak menggunakan server HTTP bersama.
|
||||
|
||||
> Verbositas log dikontrol oleh `gateway.log_level` (default: `warn`). Nilai yang didukung: `debug`, `info`, `warn`, `error`, `fatal`. Juga dapat diatur melalui `PICOCLAW_LOG_LEVEL`. Lihat [Konfigurasi](docs/configuration.md#gateway-log-level) untuk detail.
|
||||
|
||||
Untuk instruksi pengaturan channel lengkap, lihat [Konfigurasi Aplikasi Chat](docs/chat-apps.md).
|
||||
|
||||
## 🔧 Tools
|
||||
|
|
|
|||
|
|
@ -458,6 +458,8 @@ Parla con il tuo PicoClaw attraverso 17+ piattaforme di messaggistica:
|
|||
|
||||
> Tutti i channel basati su webhook condividono un singolo server HTTP Gateway (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Feishu usa la modalità WebSocket/SDK e non usa il server HTTP condiviso.
|
||||
|
||||
> La verbosità dei log è controllata da `gateway.log_level` (default: `warn`). Valori supportati: `debug`, `info`, `warn`, `error`, `fatal`. Può essere impostato anche tramite `PICOCLAW_LOG_LEVEL`. Vedi [Configurazione](docs/configuration.md#gateway-log-level) per i dettagli.
|
||||
|
||||
Per istruzioni dettagliate sulla configurazione dei channel, vedi [Configurazione App di Chat](docs/chat-apps.md).
|
||||
|
||||
## 🔧 Strumenti
|
||||
|
|
|
|||
|
|
@ -458,6 +458,8 @@ Provider の完全な設定詳細は [Provider とモデル](docs/ja/providers.m
|
|||
|
||||
> webhook ベースのすべての Channel は単一の Gateway HTTP サーバー(`gateway.host`:`gateway.port`、デフォルト `127.0.0.1:18790`)を共有します。Feishu は WebSocket/SDK モードを使用し、共有 HTTP サーバーを使用しません。
|
||||
|
||||
> ログの詳細度は `gateway.log_level` で制御します(デフォルト:`warn`)。サポートされる値:`debug`、`info`、`warn`、`error`、`fatal`。`PICOCLAW_LOG_LEVEL` 環境変数でも設定可能です。詳細は[設定ガイド](docs/ja/configuration.md#gateway-ログレベル)を参照してください。
|
||||
|
||||
Channel の詳細なセットアップ手順は [チャットアプリ設定](docs/ja/chat-apps.md) を参照してください。
|
||||
|
||||
## 🔧 ツール
|
||||
|
|
|
|||
|
|
@ -464,6 +464,8 @@ Talk to your PicoClaw through 17+ messaging platforms:
|
|||
|
||||
> All webhook-based channels share a single Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Feishu uses WebSocket/SDK mode and does not use the shared HTTP server.
|
||||
|
||||
> Log verbosity is controlled by `gateway.log_level` (default: `warn`). Supported values: `debug`, `info`, `warn`, `error`, `fatal`. Can also be set via `PICOCLAW_LOG_LEVEL`. See [Configuration](docs/configuration.md#gateway-log-level) for details.
|
||||
|
||||
For detailed channel setup instructions, see [Chat Apps Configuration](docs/chat-apps.md).
|
||||
|
||||
## 🔧 Tools
|
||||
|
|
|
|||
|
|
@ -458,6 +458,8 @@ Bercakap dengan PicoClaw anda melalui 17+ platform pemesejan:
|
|||
|
||||
> Semua saluran berasaskan webhook berkongsi satu pelayan HTTP Gateway (`gateway.host`:`gateway.port`, lalai `127.0.0.1:18790`). Feishu menggunakan mod WebSocket/SDK dan tidak menggunakan pelayan HTTP yang dikongsi.
|
||||
|
||||
> Tahap perincian log dikawal oleh `gateway.log_level` (lalai: `warn`). Nilai yang disokong: `debug`, `info`, `warn`, `error`, `fatal`. Boleh juga ditetapkan melalui `PICOCLAW_LOG_LEVEL`. Lihat [Konfigurasi](docs/configuration.md#gateway-log-level) untuk butiran.
|
||||
|
||||
Untuk arahan persediaan saluran terperinci, lihat [Konfigurasi Aplikasi Sembang](docs/my/chat-apps.md).
|
||||
|
||||
## 🔧 Alat
|
||||
|
|
|
|||
|
|
@ -458,6 +458,8 @@ Converse com seu PicoClaw por meio de mais de 17 plataformas de mensagens:
|
|||
|
||||
> Todos os channels baseados em webhook compartilham um único servidor HTTP do Gateway (`gateway.host`:`gateway.port`, padrão `127.0.0.1:18790`). O Feishu usa modo WebSocket/SDK e não utiliza o servidor HTTP compartilhado.
|
||||
|
||||
> A verbosidade dos logs é controlada por `gateway.log_level` (padrão: `warn`). Valores suportados: `debug`, `info`, `warn`, `error`, `fatal`. Também pode ser definido via `PICOCLAW_LOG_LEVEL`. Veja [Configuração](docs/pt-br/configuration.md#nível-de-log-do-gateway) para detalhes.
|
||||
|
||||
Para instruções detalhadas de configuração de channels, veja [Configuração de Apps de Chat](docs/pt-br/chat-apps.md).
|
||||
|
||||
## 🔧 Ferramentas
|
||||
|
|
|
|||
|
|
@ -458,6 +458,8 @@ Trò chuyện với PicoClaw của bạn qua 17+ nền tảng nhắn tin:
|
|||
|
||||
> Tất cả các Channel dựa trên webhook dùng chung một Gateway HTTP server (`gateway.host`:`gateway.port`, mặc định `127.0.0.1:18790`). Feishu sử dụng chế độ WebSocket/SDK và không dùng HTTP server chung.
|
||||
|
||||
> Mức độ chi tiết log được kiểm soát bởi `gateway.log_level` (mặc định: `warn`). Các giá trị được hỗ trợ: `debug`, `info`, `warn`, `error`, `fatal`. Cũng có thể đặt qua `PICOCLAW_LOG_LEVEL`. Xem [Cấu hình](docs/vi/configuration.md#mức-log-của-gateway) để biết thêm chi tiết.
|
||||
|
||||
Để biết hướng dẫn thiết lập Channel chi tiết, xem [Cấu hình Ứng dụng Chat](docs/vi/chat-apps.md).
|
||||
|
||||
## 🔧 Tools
|
||||
|
|
|
|||
|
|
@ -458,6 +458,8 @@ PicoClaw 通过 `model_list` 配置支持 30+ LLM Provider,使用 `协议/模
|
|||
|
||||
> 所有基于 Webhook 的 Channel 共用同一个 Gateway HTTP 服务器(`gateway.host`:`gateway.port`,默认 `127.0.0.1:18790`)。飞书使用 WebSocket/SDK 模式,不使用共享 HTTP 服务器。
|
||||
|
||||
> 日志详细程度通过 `gateway.log_level` 控制(默认:`warn`)。支持的值:`debug`、`info`、`warn`、`error`、`fatal`。也可通过 `PICOCLAW_LOG_LEVEL` 环境变量设置。详见[配置指南](docs/zh/configuration.md#gateway-日志等级)。
|
||||
|
||||
详细 Channel 配置说明请参阅 [聊天应用配置](docs/zh/chat-apps.md)。
|
||||
|
||||
## 🔧 Tools
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ func newAddCommand(storePath func() string) *cobra.Command {
|
|||
message string
|
||||
every int64
|
||||
cronExp string
|
||||
deliver bool
|
||||
channel string
|
||||
to string
|
||||
)
|
||||
|
|
@ -37,7 +36,7 @@ func newAddCommand(storePath func() string) *cobra.Command {
|
|||
}
|
||||
|
||||
cs := cron.NewCronService(storePath(), nil)
|
||||
job, err := cs.AddJob(name, schedule, message, deliver, channel, to)
|
||||
job, err := cs.AddJob(name, schedule, message, channel, to)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error adding job: %w", err)
|
||||
}
|
||||
|
|
@ -52,7 +51,6 @@ func newAddCommand(storePath func() string) *cobra.Command {
|
|||
cmd.Flags().StringVarP(&message, "message", "m", "", "Message for agent")
|
||||
cmd.Flags().Int64VarP(&every, "every", "e", 0, "Run every N seconds")
|
||||
cmd.Flags().StringVarP(&cronExp, "cron", "c", "", "Cron expression (e.g. '0 9 * * *')")
|
||||
cmd.Flags().BoolVarP(&deliver, "deliver", "d", false, "Deliver response to channel")
|
||||
cmd.Flags().StringVar(&to, "to", "", "Recipient for delivery")
|
||||
cmd.Flags().StringVar(&channel, "channel", "", "Channel for delivery")
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ func TestNewAddSubcommand(t *testing.T) {
|
|||
|
||||
assert.NotNil(t, cmd.Flags().Lookup("every"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("cron"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("deliver"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("to"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("channel"))
|
||||
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ func listAvailableModels(cfg *config.Config) {
|
|||
if model.ModelName == defaultModel {
|
||||
marker = "> "
|
||||
}
|
||||
if model.APIKey() == "" {
|
||||
if !model.Enabled {
|
||||
continue
|
||||
}
|
||||
fmt.Printf("%s- %s (%s)\n", marker, model.ModelName, model.Model)
|
||||
|
|
@ -92,7 +92,7 @@ func setDefaultModel(configPath string, cfg *config.Config, modelName string) er
|
|||
// Validate that the model exists in model_list
|
||||
modelFound := false
|
||||
for _, model := range cfg.ModelList {
|
||||
if model.APIKey() != "" && model.ModelName == modelName {
|
||||
if model.Enabled && model.ModelName == modelName {
|
||||
modelFound = true
|
||||
break
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,11 +65,17 @@ func TestShowCurrentModel_WithDefaultModel(t *testing.T) {
|
|||
},
|
||||
},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{ModelName: "gpt-4", Model: "openai/gpt-4", APIKeys: config.SecureStrings{config.NewSecureString("test")}},
|
||||
{
|
||||
ModelName: "gpt-4",
|
||||
Model: "openai/gpt-4",
|
||||
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||
Enabled: true,
|
||||
},
|
||||
{
|
||||
ModelName: "claude-3",
|
||||
Model: "anthropic/claude-3",
|
||||
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||
Enabled: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -92,7 +98,12 @@ func TestShowCurrentModel_NoDefaultModel(t *testing.T) {
|
|||
},
|
||||
},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{ModelName: "gpt-4", Model: "openai/gpt-4", APIKeys: config.SecureStrings{config.NewSecureString("test")}},
|
||||
{
|
||||
ModelName: "gpt-4",
|
||||
Model: "openai/gpt-4",
|
||||
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||
Enabled: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -124,11 +135,17 @@ func TestListAvailableModels_WithModels(t *testing.T) {
|
|||
},
|
||||
},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{ModelName: "gpt-4", Model: "openai/gpt-4", APIKeys: config.SecureStrings{config.NewSecureString("test")}},
|
||||
{
|
||||
ModelName: "gpt-4",
|
||||
Model: "openai/gpt-4",
|
||||
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||
Enabled: true,
|
||||
},
|
||||
{
|
||||
ModelName: "claude-3",
|
||||
Model: "anthropic/claude-3",
|
||||
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||
Enabled: true,
|
||||
},
|
||||
{ModelName: "no-key-model", Model: "openai/test"},
|
||||
},
|
||||
|
|
@ -158,11 +175,13 @@ func TestSetDefaultModel_ValidModel(t *testing.T) {
|
|||
ModelName: "new-model",
|
||||
Model: "openai/new-model",
|
||||
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||
Enabled: true,
|
||||
},
|
||||
{
|
||||
ModelName: "old-model",
|
||||
Model: "openai/old-model",
|
||||
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||
Enabled: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -194,6 +213,7 @@ func TestSetDefaultModel_InvalidModel(t *testing.T) {
|
|||
ModelName: "existing-model",
|
||||
Model: "openai/existing",
|
||||
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||
Enabled: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -215,6 +235,7 @@ func TestSetDefaultModel_ModelWithoutAPIKey(t *testing.T) {
|
|||
ModelName: "existing-model",
|
||||
Model: "openai/existing",
|
||||
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||
Enabled: true,
|
||||
},
|
||||
{ModelName: "no-key-model", Model: "openai/nokey"},
|
||||
},
|
||||
|
|
@ -238,6 +259,7 @@ func TestSetDefaultModel_SaveConfigError(t *testing.T) {
|
|||
ModelName: "new-model",
|
||||
Model: "openai/new-model",
|
||||
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||
Enabled: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -283,6 +305,7 @@ func TestModelCommandExecution_Show(t *testing.T) {
|
|||
ModelName: "test-model",
|
||||
Model: "openai/test",
|
||||
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||
Enabled: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -314,11 +337,13 @@ func TestModelCommandExecution_Set(t *testing.T) {
|
|||
ModelName: "old-model",
|
||||
Model: "openai/old",
|
||||
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||
Enabled: true,
|
||||
},
|
||||
{
|
||||
ModelName: "new-model",
|
||||
Model: "openai/new",
|
||||
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||
Enabled: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -356,16 +381,19 @@ func TestListAvailableModels_MarkerLogic(t *testing.T) {
|
|||
ModelName: "first-model",
|
||||
Model: "openai/first",
|
||||
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||
Enabled: true,
|
||||
},
|
||||
{
|
||||
ModelName: "middle-model",
|
||||
Model: "openai/middle",
|
||||
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||
Enabled: true,
|
||||
},
|
||||
{
|
||||
ModelName: "last-model",
|
||||
Model: "openai/last",
|
||||
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||
Enabled: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,12 +38,12 @@ PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gat
|
|||
```json
|
||||
{
|
||||
"gateway": {
|
||||
"log_level": "fatal"
|
||||
"log_level": "warn"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When omitted, the default is `fatal`. Supported values: `debug`, `info`, `warn`, `error`, `fatal`.
|
||||
When omitted, the default is `warn`. Supported values: `debug`, `info`, `warn`, `error`, `fatal`.
|
||||
|
||||
You can also override this with the environment variable `PICOCLAW_LOG_LEVEL`.
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,22 @@ PICOCLAW_HOME=/opt/picoclaw picoclaw agent
|
|||
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
|
||||
```
|
||||
|
||||
### Niveau de Log du Gateway
|
||||
|
||||
`gateway.log_level` contrôle la verbosité des logs du Gateway, configurable dans `config.json` :
|
||||
|
||||
```json
|
||||
{
|
||||
"gateway": {
|
||||
"log_level": "warn"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
La valeur par défaut est `warn`. Valeurs supportées : `debug`, `info`, `warn`, `error`, `fatal`.
|
||||
|
||||
Peut également être surchargé via la variable d'environnement : `PICOCLAW_LOG_LEVEL=info`
|
||||
|
||||
### Structure du Workspace
|
||||
|
||||
PicoClaw stocke les données dans votre workspace configuré (par défaut : `~/.picoclaw/workspace`) :
|
||||
|
|
|
|||
|
|
@ -31,6 +31,22 @@ PICOCLAW_HOME=/opt/picoclaw picoclaw agent
|
|||
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
|
||||
```
|
||||
|
||||
### Gateway ログレベル
|
||||
|
||||
`gateway.log_level` は Gateway のログ詳細度を制御します。`config.json` で設定できます:
|
||||
|
||||
```json
|
||||
{
|
||||
"gateway": {
|
||||
"log_level": "warn"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
デフォルト値は `warn` です。サポートされる値:`debug`、`info`、`warn`、`error`、`fatal`。
|
||||
|
||||
環境変数でも上書き可能です:`PICOCLAW_LOG_LEVEL=info`
|
||||
|
||||
### ワークスペースレイアウト
|
||||
|
||||
PicoClaw は設定されたワークスペース(デフォルト: `~/.picoclaw/workspace`)にデータを保存します:
|
||||
|
|
|
|||
|
|
@ -31,6 +31,22 @@ PICOCLAW_HOME=/opt/picoclaw picoclaw agent
|
|||
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
|
||||
```
|
||||
|
||||
### Nível de Log do Gateway
|
||||
|
||||
`gateway.log_level` controla a verbosidade dos logs do Gateway, configurável em `config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"gateway": {
|
||||
"log_level": "warn"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
O valor padrão é `warn`. Valores suportados: `debug`, `info`, `warn`, `error`, `fatal`.
|
||||
|
||||
Também pode ser substituído pela variável de ambiente: `PICOCLAW_LOG_LEVEL=info`
|
||||
|
||||
### Layout do Workspace
|
||||
|
||||
O PicoClaw armazena dados no seu workspace configurado (padrão: `~/.picoclaw/workspace`):
|
||||
|
|
|
|||
|
|
@ -31,6 +31,22 @@ PICOCLAW_HOME=/opt/picoclaw picoclaw agent
|
|||
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
|
||||
```
|
||||
|
||||
### Mức Log của Gateway
|
||||
|
||||
`gateway.log_level` kiểm soát mức độ chi tiết của log Gateway, có thể cấu hình trong `config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"gateway": {
|
||||
"log_level": "warn"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Giá trị mặc định là `warn`. Các giá trị được hỗ trợ: `debug`, `info`, `warn`, `error`, `fatal`.
|
||||
|
||||
Cũng có thể ghi đè bằng biến môi trường: `PICOCLAW_LOG_LEVEL=info`
|
||||
|
||||
### Bố Cục Workspace
|
||||
|
||||
PicoClaw lưu trữ dữ liệu trong workspace đã cấu hình (mặc định: `~/.picoclaw/workspace`):
|
||||
|
|
|
|||
|
|
@ -31,6 +31,22 @@ PICOCLAW_HOME=/opt/picoclaw picoclaw agent
|
|||
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
|
||||
```
|
||||
|
||||
### Gateway 日志等级
|
||||
|
||||
`gateway.log_level` 控制 Gateway 的日志详细程度,可在 `config.json` 中配置:
|
||||
|
||||
```json
|
||||
{
|
||||
"gateway": {
|
||||
"log_level": "warn"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
默认值为 `warn`。支持的值:`debug`、`info`、`warn`、`error`、`fatal`。
|
||||
|
||||
也可通过环境变量覆盖:`PICOCLAW_LOG_LEVEL=info`
|
||||
|
||||
### 工作区布局 (Workspace Layout)
|
||||
|
||||
PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/workspace`):
|
||||
|
|
|
|||
26
go.mod
26
go.mod
|
|
@ -7,9 +7,10 @@ require (
|
|||
github.com/BurntSushi/toml v1.6.0
|
||||
github.com/adhocore/gronx v1.19.6
|
||||
github.com/anthropics/anthropic-sdk-go v1.26.0
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.4
|
||||
github.com/atotto/clipboard v0.1.4
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.12
|
||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.2
|
||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4
|
||||
github.com/bwmarrin/discordgo v0.29.0
|
||||
github.com/caarlos0/env/v11 v11.4.0
|
||||
github.com/creack/pty v1.1.24
|
||||
|
|
@ -36,22 +37,21 @@ require (
|
|||
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/term v0.41.0
|
||||
golang.org/x/time v0.14.0
|
||||
golang.org/x/time v0.15.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
maunium.net/go/mautrix v0.26.4
|
||||
modernc.org/sqlite v1.46.1
|
||||
modernc.org/sqlite v1.47.0
|
||||
rsc.io/qr v0.2.0
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect
|
||||
|
|
@ -66,6 +66,8 @@ require (
|
|||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
|
||||
github.com/gdamore/encoding v1.0.1 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
|
|
@ -82,9 +84,13 @@ require (
|
|||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/vektah/gqlparser/v2 v2.5.27 // indirect
|
||||
go.mau.fi/libsignal v0.2.1 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/otel v1.35.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.35.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.35.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
modernc.org/libc v1.67.6 // indirect
|
||||
modernc.org/libc v1.70.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
|
@ -95,7 +101,7 @@ require (
|
|||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/github/copilot-sdk/go v0.1.32
|
||||
github.com/github/copilot-sdk/go v0.2.0
|
||||
github.com/go-resty/resty/v2 v2.17.1 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/google/jsonschema-go v0.4.2 // indirect
|
||||
|
|
|
|||
67
go.sum
67
go.sum
|
|
@ -19,24 +19,24 @@ github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAf
|
|||
github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 h1:3kGOqnh1pPeddVa/E37XNTaWJ8W6vrbYV9lJEkCnhuY=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.12 h1:O3csC7HUGn2895eNrLytOJQdoL2xyJy0iYXhoZ1OmP0=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.12/go.mod h1:96zTvoOFR4FURjI+/5wY1vc1ABceROO4lWgWJuxgy0g=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.12 h1:oqtA6v+y5fZg//tcTWahyN9PEn5eDU/Wpvc2+kJ4aY8=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.12/go.mod h1:U3R1RtSHx6NB0DvEQFGyf/0sbrpJrluENHdPy1j/3TE=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 h1:zOgq3uezl5nznfoK3ODuqbhVg1JzAGDUhXOsU0IDCAo=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20/go.mod h1:z/MVwUARehy6GAg/yQ1GO2IMl0k++cu1ohP9zo887wE=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 h1:CNXO7mvgThFGqOFgbNAP2nol2qAWBOGfqR/7tQlvLmc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 h1:tN6W/hg+pkM+tf9XDkWUbDEjGLb+raoBMFsTodcoYKw=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20/go.mod h1:YJ898MhD067hSHA6xYCx5ts/jEd8BSOLtQDL3iZsvbc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
|
||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.2 h1:x0eGAWpd1B5I/vMtrB4Q4Zuc3CXWI8wjHfPPqBSrKmM=
|
||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.2/go.mod h1:V9oTWSDC2MtS1DR71hbNET/bZ8psQp022amEBe1grJc=
|
||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4 h1:W6tKfa/s37faUnwJ71pGqsBO7/wfUX1L7tVprupQGo4=
|
||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4/go.mod h1:BZ+9thH0QOTDUwE8KAv/ZwUzsNC7CSMJXj/wtnZMs5k=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 h1:2HvVAIq+YqgGotK6EkMf+KIEqTISmTYh5zLpYyeTo1Y=
|
||||
|
|
@ -94,8 +94,13 @@ github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uh
|
|||
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
|
||||
github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3RlfU=
|
||||
github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
|
||||
github.com/github/copilot-sdk/go v0.1.32 h1:wc9SFWwxXhJts6vyzzboPLJqcEJGnHE8rMCAY1RrUgo=
|
||||
github.com/github/copilot-sdk/go v0.1.32/go.mod h1:qc2iEF7hdO8kzSvbyGvrcGhuk2fzdW4xTtT0+1EH2ts=
|
||||
github.com/github/copilot-sdk/go v0.2.0 h1:RnrIIirmtp4wGgqSQFJ2k9phbeveIxOtYZqDogoNEa0=
|
||||
github.com/github/copilot-sdk/go v0.2.0/go.mod h1:uGWkjVYcp2DV9DgtqYihh5tEoJjNqxIFaUNnrwY4FxM=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w=
|
||||
github.com/go-resty/resty/v2 v2.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q=
|
||||
github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4=
|
||||
|
|
@ -157,8 +162,9 @@ github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzh
|
|||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
|
|
@ -209,8 +215,9 @@ github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoX
|
|||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||
|
|
@ -277,6 +284,14 @@ go.mau.fi/util v0.9.7 h1:AWGNbJfz1zRcQOKeOEYhKUG2fT+/26Gy6kyqcH8tnBg=
|
|||
go.mau.fi/util v0.9.7/go.mod h1:5T2f3ZWZFAGgmFwg3dGw7YK6kIsb9lryDzvynoR98pE=
|
||||
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4 h1:hsmlwsM+VqfF70cpdZEeIUKer2XWCQmQPK0u0tHy3ZQ=
|
||||
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4/go.mod h1:mXCRFyPEPn4jqWz6Afirn8vY7DpHCPnlKq6I2cWwFHM=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
|
||||
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
|
||||
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
|
||||
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
|
||||
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
|
||||
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
|
|
@ -363,8 +378,8 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
|||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
|
|
@ -407,18 +422,18 @@ maunium.net/go/mautrix v0.26.4 h1:enHSnkf0L2V9+VnfJfNhKSReSW6pBKS/x3Su+v+Vovs=
|
|||
maunium.net/go/mautrix v0.26.4/go.mod h1:YWw8NWTszsbyFAznboicBObwHPgTSLcuTbVX2kY7U2M=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
|
||||
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
|
||||
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
|
||||
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
|
||||
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
|
||||
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
|
|
@ -427,8 +442,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
|||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
|
||||
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||
modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
|
||||
modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
|
|
|||
|
|
@ -75,6 +75,8 @@ type processOptions struct {
|
|||
SessionKey string // Session identifier for history/context
|
||||
Channel string // Target channel for tool execution
|
||||
ChatID string // Target chat ID for tool execution
|
||||
MessageID string // Current inbound platform message ID
|
||||
ReplyToMessageID string // Current inbound reply target message ID
|
||||
SenderID string // Current sender ID for dynamic context
|
||||
SenderDisplayName string // Current sender display name for dynamic context
|
||||
UserMessage string // User message content (may include prefix)
|
||||
|
|
@ -104,6 +106,7 @@ const (
|
|||
metadataKeyAccountID = "account_id"
|
||||
metadataKeyGuildID = "guild_id"
|
||||
metadataKeyTeamID = "team_id"
|
||||
metadataKeyReplyToMessage = "reply_to_message_id"
|
||||
metadataKeyParentPeerKind = "parent_peer_kind"
|
||||
metadataKeyParentPeerID = "parent_peer_id"
|
||||
)
|
||||
|
|
@ -222,17 +225,37 @@ func registerSharedTools(
|
|||
// Message tool
|
||||
if cfg.Tools.IsToolEnabled("message") {
|
||||
messageTool := tools.NewMessageTool()
|
||||
messageTool.SetSendCallback(func(channel, chatID, content string) error {
|
||||
messageTool.SetSendCallback(func(channel, chatID, content, replyToMessageID string) error {
|
||||
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer pubCancel()
|
||||
return msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
|
||||
Channel: channel,
|
||||
ChatID: chatID,
|
||||
Content: content,
|
||||
Channel: channel,
|
||||
ChatID: chatID,
|
||||
Content: content,
|
||||
ReplyToMessageID: replyToMessageID,
|
||||
})
|
||||
})
|
||||
agent.Tools.Register(messageTool)
|
||||
}
|
||||
if cfg.Tools.IsToolEnabled("reaction") {
|
||||
reactionTool := tools.NewReactionTool()
|
||||
reactionTool.SetReactionCallback(func(ctx context.Context, channel, chatID, messageID string) error {
|
||||
if al.channelManager == nil {
|
||||
return fmt.Errorf("channel manager not configured")
|
||||
}
|
||||
ch, ok := al.channelManager.GetChannel(channel)
|
||||
if !ok {
|
||||
return fmt.Errorf("channel %s not found", channel)
|
||||
}
|
||||
rc, ok := ch.(channels.ReactionCapable)
|
||||
if !ok {
|
||||
return fmt.Errorf("channel %s does not support reactions", channel)
|
||||
}
|
||||
_, err := rc.ReactToMessage(ctx, chatID, messageID)
|
||||
return err
|
||||
})
|
||||
agent.Tools.Register(reactionTool)
|
||||
}
|
||||
|
||||
// Send file tool (outbound media via MediaStore — store injected later by SetMediaStore)
|
||||
if cfg.Tools.IsToolEnabled("send_file") {
|
||||
|
|
@ -1315,6 +1338,8 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
|||
SessionKey: sessionKey,
|
||||
Channel: msg.Channel,
|
||||
ChatID: msg.ChatID,
|
||||
MessageID: msg.MessageID,
|
||||
ReplyToMessageID: inboundMetadata(msg, metadataKeyReplyToMessage),
|
||||
SenderID: msg.SenderID,
|
||||
SenderDisplayName: msg.Sender.DisplayName,
|
||||
UserMessage: msg.Content,
|
||||
|
|
@ -2384,8 +2409,15 @@ turnLoop:
|
|||
}
|
||||
|
||||
toolStart := time.Now()
|
||||
toolResult := ts.agent.Tools.ExecuteWithContext(
|
||||
execCtx := tools.WithToolInboundContext(
|
||||
turnCtx,
|
||||
ts.channel,
|
||||
ts.chatID,
|
||||
ts.opts.MessageID,
|
||||
ts.opts.ReplyToMessageID,
|
||||
)
|
||||
toolResult := ts.agent.Tools.ExecuteWithContext(
|
||||
execCtx,
|
||||
toolName,
|
||||
toolArgs,
|
||||
ts.channel,
|
||||
|
|
|
|||
|
|
@ -531,6 +531,20 @@ func TestToolContext_Updates(t *testing.T) {
|
|||
if got := tools.ToolChannel(context.Background()); got != "" {
|
||||
t.Errorf("expected empty channel from bare context, got %q", got)
|
||||
}
|
||||
|
||||
inboundCtx := tools.WithToolInboundContext(
|
||||
context.Background(),
|
||||
"telegram",
|
||||
"chat-42",
|
||||
"msg-123",
|
||||
"msg-100",
|
||||
)
|
||||
if got := tools.ToolMessageID(inboundCtx); got != "msg-123" {
|
||||
t.Errorf("expected messageID 'msg-123', got %q", got)
|
||||
}
|
||||
if got := tools.ToolReplyToMessageID(inboundCtx); got != "msg-100" {
|
||||
t.Errorf("expected replyToMessageID 'msg-100', got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestToolRegistry_GetDefinitions verifies tool definitions can be retrieved
|
||||
|
|
|
|||
|
|
@ -245,15 +245,18 @@ func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (str
|
|||
// ReactToMessage implements channels.ReactionCapable.
|
||||
// Adds a reaction (randomly chosen from config) and returns an undo function to remove it.
|
||||
func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) {
|
||||
// Get emoji list from config
|
||||
emojiList := c.config.RandomReactionEmoji
|
||||
var chosenEmoji string
|
||||
if len(emojiList) == 0 {
|
||||
// Default to "Pin" if no config
|
||||
chosenEmoji = "Pin"
|
||||
} else {
|
||||
idx := rand.Intn(len(emojiList))
|
||||
chosenEmoji = emojiList[idx]
|
||||
// Get emoji list from config (Feishu emoji_type keys, e.g. Pin, THUMBSUP).
|
||||
// Ignore empty entries so a list like ["", "Pin"] does not randomly pick "" (API 231001).
|
||||
var candidates []string
|
||||
for _, e := range c.config.RandomReactionEmoji {
|
||||
e = strings.TrimSpace(e)
|
||||
if e != "" {
|
||||
candidates = append(candidates, e)
|
||||
}
|
||||
}
|
||||
chosenEmoji := "Pin"
|
||||
if len(candidates) > 0 {
|
||||
chosenEmoji = candidates[rand.Intn(len(candidates))]
|
||||
}
|
||||
|
||||
req := larkim.NewCreateMessageReactionReqBuilder().
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ import (
|
|||
"math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/caarlos0/env/v11"
|
||||
|
||||
|
|
@ -20,89 +20,8 @@ import (
|
|||
// rrCounter is a global counter for round-robin load balancing across models.
|
||||
var rrCounter atomic.Uint64
|
||||
|
||||
// FlexibleStringSlice is a []string that also accepts JSON numbers,
|
||||
// so allow_from can contain both "123" and 123.
|
||||
// It also supports parsing comma-separated strings from environment variables,
|
||||
// including both English (,) and Chinese (,) commas.
|
||||
type FlexibleStringSlice []string
|
||||
|
||||
func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
|
||||
// Accept a single JSON string for convenience, e.g.:
|
||||
// "text": "Thinking..."
|
||||
var singleString string
|
||||
if err := json.Unmarshal(data, &singleString); err == nil {
|
||||
*f = FlexibleStringSlice{singleString}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Accept a single JSON number too, to keep symmetry with mixed allow_from
|
||||
// payloads that may contain numeric identifiers.
|
||||
var singleNumber float64
|
||||
if err := json.Unmarshal(data, &singleNumber); err == nil {
|
||||
*f = FlexibleStringSlice{fmt.Sprintf("%.0f", singleNumber)}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try []string first
|
||||
var ss []string
|
||||
if err := json.Unmarshal(data, &ss); err == nil {
|
||||
*f = ss
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try []interface{} to handle mixed types
|
||||
var raw []any
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
var s string
|
||||
// fail over to compatible to old format string
|
||||
if err = json.Unmarshal(data, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
*f = []string{s}
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(raw))
|
||||
for _, v := range raw {
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
result = append(result, val)
|
||||
case float64:
|
||||
result = append(result, fmt.Sprintf("%.0f", val))
|
||||
default:
|
||||
result = append(result, fmt.Sprintf("%v", val))
|
||||
}
|
||||
}
|
||||
*f = result
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnmarshalText implements encoding.TextUnmarshaler to support env variable parsing.
|
||||
// It handles comma-separated values with both English (,) and Chinese (,) commas.
|
||||
func (f *FlexibleStringSlice) UnmarshalText(text []byte) error {
|
||||
if len(text) == 0 {
|
||||
*f = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
s := string(text)
|
||||
// Replace Chinese comma with English comma, then split
|
||||
s = strings.ReplaceAll(s, ",", ",")
|
||||
parts := strings.Split(s, ",")
|
||||
|
||||
result := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
result = append(result, part)
|
||||
}
|
||||
}
|
||||
*f = result
|
||||
return nil
|
||||
}
|
||||
|
||||
// CurrentVersion is the latest config schema version
|
||||
const CurrentVersion = 1
|
||||
const CurrentVersion = 2
|
||||
|
||||
// Config is the current config structure with version support
|
||||
type Config struct {
|
||||
|
|
@ -675,6 +594,11 @@ type ModelConfig struct {
|
|||
|
||||
APIKeys SecureStrings `json:"api_keys,omitzero" yaml:"api_keys,omitempty"` // API authentication keys (multiple keys for failover)
|
||||
|
||||
// Enabled indicates whether this model entry is active. When omitted in
|
||||
// existing configs, the field is inferred during load: models with API keys
|
||||
// or the reserved "local-model" name are auto-enabled.
|
||||
Enabled bool `json:"enabled,omitempty" yaml:"enabled,omitempty"`
|
||||
|
||||
// isVirtual marks this model as a virtual model generated from multi-key expansion.
|
||||
// Virtual models should not be persisted to config files.
|
||||
isVirtual bool
|
||||
|
|
@ -1047,6 +971,35 @@ func LoadConfig(path string) (*Config, error) {
|
|||
defer func(cfg *Config) {
|
||||
_ = SaveConfig(path, cfg)
|
||||
}(cfg)
|
||||
case 1:
|
||||
// V1→V2 migration: infer Enabled and migrate channel config fields
|
||||
logger.InfoF("config migrate start", map[string]any{"from": versionInfo.Version, "to": CurrentVersion})
|
||||
cfg, err = loadConfig(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
secPath := securityPath(path)
|
||||
err = loadSecurityConfig(cfg, secPath)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, fmt.Errorf("failed to load security config: %w", err)
|
||||
}
|
||||
|
||||
oldCfg := &configV1{Config: *cfg}
|
||||
cfg, err = oldCfg.Migrate()
|
||||
if err != nil {
|
||||
logger.ErrorF("config migrate fail", map[string]any{"from": versionInfo.Version, "to": CurrentVersion})
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = makeBackup(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer func(cfg *Config) {
|
||||
_ = SaveConfig(path, cfg)
|
||||
}(cfg)
|
||||
logger.InfoF("config migrate success", map[string]any{"from": versionInfo.Version, "to": CurrentVersion})
|
||||
case CurrentVersion:
|
||||
// Current version
|
||||
cfg, err = loadConfig(data)
|
||||
|
|
@ -1064,18 +1017,15 @@ func LoadConfig(path string) (*Config, error) {
|
|||
return nil, fmt.Errorf("unsupported config version: %d", versionInfo.Version)
|
||||
}
|
||||
|
||||
if err := env.Parse(cfg); err != nil {
|
||||
if err = env.Parse(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Expand multi-key configs into separate entries for key-level failover
|
||||
cfg.ModelList = expandMultiKeyModels(cfg.ModelList)
|
||||
|
||||
// Migrate legacy channel config fields to new unified structures
|
||||
cfg.migrateChannelConfigs()
|
||||
|
||||
// Validate model_list for uniqueness and required fields
|
||||
if err := cfg.ValidateModelList(); err != nil {
|
||||
if err = cfg.ValidateModelList(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
|
@ -1092,12 +1042,22 @@ func makeBackup(path string) error {
|
|||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
// Create backup of the config file before migration
|
||||
bakPath := path + ".bak"
|
||||
dateSuffix := time.Now().Format(".20060102.bak")
|
||||
// Backup config file
|
||||
bakPath := path + dateSuffix
|
||||
if err := fileutil.CopyFile(path, bakPath, 0o600); err != nil {
|
||||
logger.ErrorF("failed to create config backup", map[string]any{"error": err})
|
||||
return fmt.Errorf("failed to create config backup: %w", err)
|
||||
}
|
||||
// Backup security config file
|
||||
secPath := securityPath(path)
|
||||
if _, err := os.Stat(secPath); err == nil {
|
||||
secBakPath := secPath + dateSuffix
|
||||
if secErr := fileutil.CopyFile(secPath, secBakPath, 0o600); secErr != nil {
|
||||
logger.ErrorF("failed to create security backup", map[string]any{"error": secErr})
|
||||
return fmt.Errorf("failed to create security backup: %w", secErr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -1113,19 +1073,6 @@ func toNameIndex(list []*ModelConfig) []string {
|
|||
return nameList
|
||||
}
|
||||
|
||||
func (c *Config) migrateChannelConfigs() {
|
||||
// Discord: mention_only -> group_trigger.mention_only
|
||||
if c.Channels.Discord.MentionOnly && !c.Channels.Discord.GroupTrigger.MentionOnly {
|
||||
c.Channels.Discord.GroupTrigger.MentionOnly = true
|
||||
}
|
||||
|
||||
// OneBot: group_trigger_prefix -> group_trigger.prefixes
|
||||
if len(c.Channels.OneBot.GroupTriggerPrefix) > 0 &&
|
||||
len(c.Channels.OneBot.GroupTrigger.Prefixes) == 0 {
|
||||
c.Channels.OneBot.GroupTrigger.Prefixes = c.Channels.OneBot.GroupTriggerPrefix
|
||||
}
|
||||
}
|
||||
|
||||
func SaveConfig(path string, cfg *Config) error {
|
||||
if cfg.Version < CurrentVersion {
|
||||
cfg.Version = CurrentVersion
|
||||
|
|
@ -1139,6 +1086,10 @@ func SaveConfig(path string, cfg *Config) error {
|
|||
}
|
||||
// Temporarily replace ModelList with filtered version for serialization
|
||||
originalModelList := cfg.ModelList
|
||||
defer func() {
|
||||
// Restore original ModelList after serialization
|
||||
cfg.ModelList = originalModelList
|
||||
}()
|
||||
cfg.ModelList = nonVirtualModels
|
||||
|
||||
if err := saveSecurityConfig(securityPath(path), cfg); err != nil {
|
||||
|
|
@ -1147,8 +1098,6 @@ func SaveConfig(path string, cfg *Config) error {
|
|||
}
|
||||
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
// Restore original ModelList after serialization
|
||||
cfg.ModelList = originalModelList
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -1218,29 +1167,6 @@ func (c *Config) SecurityCopyFrom(path string) error {
|
|||
return loadSecurityConfig(c, securityPath(path))
|
||||
}
|
||||
|
||||
func MergeAPIKeys(apiKey string, apiKeys []string) []string {
|
||||
seen := make(map[string]struct{})
|
||||
var all []string
|
||||
|
||||
if k := strings.TrimSpace(apiKey); k != "" {
|
||||
if _, exists := seen[k]; !exists {
|
||||
seen[k] = struct{}{}
|
||||
all = append(all, k)
|
||||
}
|
||||
}
|
||||
|
||||
for _, k := range apiKeys {
|
||||
if trimmed := strings.TrimSpace(k); trimmed != "" {
|
||||
if _, exists := seen[trimmed]; !exists {
|
||||
seen[trimmed] = struct{}{}
|
||||
all = append(all, trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return all
|
||||
}
|
||||
|
||||
// expandMultiKeyModels expands ModelConfig entries with multiple API keys into
|
||||
// separate entries for key-level failover. Each key gets its own ModelConfig entry,
|
||||
// and the original entry's fallbacks are set up to chain through the expanded entries.
|
||||
|
|
|
|||
|
|
@ -734,7 +734,8 @@ func (c *configV0) Migrate() (*Config, error) {
|
|||
// Convert []modelConfigV0 to []ModelConfig
|
||||
cfg.ModelList = make([]*ModelConfig, len(c.ModelList))
|
||||
for i, m := range c.ModelList {
|
||||
cfg.ModelList[i] = &ModelConfig{
|
||||
mergedKeys := toSecureStrings(mergeAPIKeys(m.APIKey, m.APIKeys))
|
||||
mc := &ModelConfig{
|
||||
ModelName: m.ModelName,
|
||||
Model: m.Model,
|
||||
APIBase: m.APIBase,
|
||||
|
|
@ -747,8 +748,13 @@ func (c *configV0) Migrate() (*Config, error) {
|
|||
MaxTokensField: m.MaxTokensField,
|
||||
RequestTimeout: m.RequestTimeout,
|
||||
ThinkingLevel: m.ThinkingLevel,
|
||||
APIKeys: toSecureStrings(MergeAPIKeys(m.APIKey, m.APIKeys)),
|
||||
APIKeys: mergedKeys,
|
||||
}
|
||||
// Infer Enabled during V0→V1 migration
|
||||
if len(mergedKeys) > 0 || m.ModelName == "local-model" {
|
||||
mc.Enabled = true
|
||||
}
|
||||
cfg.ModelList[i] = mc
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -756,6 +762,52 @@ func (c *configV0) Migrate() (*Config, error) {
|
|||
return cfg, nil
|
||||
}
|
||||
|
||||
type configV1 struct {
|
||||
Config
|
||||
}
|
||||
|
||||
// Migrate applies V1→Current Version migrations to an already-loaded Config.
|
||||
//
|
||||
// It must be called AFTER loadSecurityConfig so that API keys (which live in
|
||||
// the security file) are available for the Enabled inference.
|
||||
func (c *configV1) Migrate() (*Config, error) {
|
||||
c.migrateModelEnabled()
|
||||
c.migrateChannelConfigs()
|
||||
return &c.Config, nil
|
||||
}
|
||||
|
||||
// migrateModelEnabled infers the Enabled field for models loaded from V1 configs
|
||||
// that predate the field (JSON where "enabled" is absent).
|
||||
//
|
||||
// Rules (only applied when Enabled has not been explicitly set by the user):
|
||||
// - Models with API keys are considered enabled.
|
||||
// - The reserved "local-model" entry is considered enabled.
|
||||
func (cfg *configV1) migrateModelEnabled() {
|
||||
for _, m := range cfg.ModelList {
|
||||
if m.Enabled {
|
||||
continue
|
||||
}
|
||||
if len(m.APIKeys) > 0 || m.ModelName == "local-model" {
|
||||
m.Enabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// migrateChannelConfigs migrates legacy channel config fields in a V1 Config
|
||||
// to the new unified structures.
|
||||
func (cfg *configV1) migrateChannelConfigs() {
|
||||
// Discord: mention_only -> group_trigger.mention_only
|
||||
if cfg.Channels.Discord.MentionOnly && !cfg.Channels.Discord.GroupTrigger.MentionOnly {
|
||||
cfg.Channels.Discord.GroupTrigger.MentionOnly = true
|
||||
}
|
||||
|
||||
// OneBot: group_trigger_prefix -> group_trigger.prefixes
|
||||
if len(cfg.Channels.OneBot.GroupTriggerPrefix) > 0 &&
|
||||
len(cfg.Channels.OneBot.GroupTrigger.Prefixes) == 0 {
|
||||
cfg.Channels.OneBot.GroupTrigger.Prefixes = cfg.Channels.OneBot.GroupTriggerPrefix
|
||||
}
|
||||
}
|
||||
|
||||
type webToolsConfigV0 struct {
|
||||
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_WEB_"`
|
||||
Brave braveConfigV0 ` json:"brave"`
|
||||
|
|
@ -791,7 +843,7 @@ func (v *braveConfigV0) ToBraveConfig() BraveConfig {
|
|||
return BraveConfig{
|
||||
Enabled: v.Enabled,
|
||||
MaxResults: v.MaxResults,
|
||||
APIKeys: toSecureStrings(MergeAPIKeys(v.APIKey, v.APIKeys)),
|
||||
APIKeys: toSecureStrings(mergeAPIKeys(v.APIKey, v.APIKeys)),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -808,7 +860,7 @@ func (v *tavilyConfigV0) ToTavilyConfig() TavilyConfig {
|
|||
Enabled: v.Enabled,
|
||||
BaseURL: v.BaseURL,
|
||||
MaxResults: v.MaxResults,
|
||||
APIKeys: toSecureStrings(MergeAPIKeys(v.APIKey, v.APIKeys)),
|
||||
APIKeys: toSecureStrings(mergeAPIKeys(v.APIKey, v.APIKeys)),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -823,7 +875,7 @@ func (v *perplexityConfigV0) ToPerplexityConfig() PerplexityConfig {
|
|||
return PerplexityConfig{
|
||||
Enabled: v.Enabled,
|
||||
MaxResults: v.MaxResults,
|
||||
APIKeys: toSecureStrings(MergeAPIKeys(v.APIKey, v.APIKeys)),
|
||||
APIKeys: toSecureStrings(mergeAPIKeys(v.APIKey, v.APIKeys)),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
327
pkg/config/config_struct.go
Normal file
327
pkg/config/config_struct.go
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/credential"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
)
|
||||
|
||||
// FlexibleStringSlice is a []string that also accepts JSON numbers,
|
||||
// so allow_from can contain both "123" and 123.
|
||||
// It also supports parsing comma-separated strings from environment variables,
|
||||
// including both English (,) and Chinese (,) commas.
|
||||
type FlexibleStringSlice []string
|
||||
|
||||
func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
|
||||
// Accept a single JSON string for convenience, e.g.:
|
||||
// "text": "Thinking..."
|
||||
var singleString string
|
||||
if err := json.Unmarshal(data, &singleString); err == nil {
|
||||
*f = FlexibleStringSlice{singleString}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Accept a single JSON number too, to keep symmetry with mixed allow_from
|
||||
// payloads that may contain numeric identifiers.
|
||||
var singleNumber float64
|
||||
if err := json.Unmarshal(data, &singleNumber); err == nil {
|
||||
*f = FlexibleStringSlice{fmt.Sprintf("%.0f", singleNumber)}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try []string first
|
||||
var ss []string
|
||||
if err := json.Unmarshal(data, &ss); err == nil {
|
||||
*f = ss
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try []interface{} to handle mixed types
|
||||
var raw []any
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
var s string
|
||||
// fail over to compatible to old format string
|
||||
if err = json.Unmarshal(data, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
*f = []string{s}
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(raw))
|
||||
for _, v := range raw {
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
result = append(result, val)
|
||||
case float64:
|
||||
result = append(result, fmt.Sprintf("%.0f", val))
|
||||
default:
|
||||
result = append(result, fmt.Sprintf("%v", val))
|
||||
}
|
||||
}
|
||||
*f = result
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnmarshalText implements encoding.TextUnmarshaler to support env variable parsing.
|
||||
// It handles comma-separated values with both English (,) and Chinese (,) commas.
|
||||
func (f *FlexibleStringSlice) UnmarshalText(text []byte) error {
|
||||
if len(text) == 0 {
|
||||
*f = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
s := string(text)
|
||||
// Replace Chinese comma with English comma, then split
|
||||
s = strings.ReplaceAll(s, ",", ",")
|
||||
parts := strings.Split(s, ",")
|
||||
|
||||
result := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
result = append(result, part)
|
||||
}
|
||||
}
|
||||
*f = result
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
notHere = `"[NOT_HERE]"`
|
||||
)
|
||||
|
||||
// SecureStrings is a slice of SecureString
|
||||
type SecureStrings []*SecureString
|
||||
|
||||
// Values returns the decrypted/resolved values
|
||||
func (s *SecureStrings) Values() []string {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
keys := make([]string, len(*s))
|
||||
for i, k := range *s {
|
||||
keys[i] = k.String()
|
||||
}
|
||||
return unique(keys)
|
||||
}
|
||||
|
||||
func SimpleSecureStrings(val ...string) SecureStrings {
|
||||
val = unique(val)
|
||||
vv := make(SecureStrings, len(val))
|
||||
for i, s := range val {
|
||||
vv[i] = NewSecureString(s)
|
||||
}
|
||||
return vv
|
||||
}
|
||||
|
||||
// unique returns a new slice with duplicate elements removed.
|
||||
func unique[T comparable](input []T) []T {
|
||||
m := make(map[T]struct{})
|
||||
var result []T
|
||||
for _, v := range input {
|
||||
if _, ok := m[v]; !ok {
|
||||
m[v] = struct{}{}
|
||||
result = append(result, v)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s SecureStrings) MarshalJSON() ([]byte, error) {
|
||||
return []byte(notHere), nil
|
||||
}
|
||||
|
||||
func (s *SecureStrings) UnmarshalJSON(value []byte) error {
|
||||
if string(value) == notHere {
|
||||
return nil
|
||||
}
|
||||
var v []*SecureString
|
||||
err := json.Unmarshal(value, &v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*s = v
|
||||
return nil
|
||||
}
|
||||
|
||||
// SecureString the string value that can be decrypted or resolved
|
||||
//
|
||||
//nolint:recvcheck
|
||||
type SecureString struct {
|
||||
resolved string // Decrypted/resolved value returned by String()
|
||||
raw string // Persisted raw value (enc://, file://, or plaintext)
|
||||
}
|
||||
|
||||
func callerFromYaml() bool {
|
||||
_, file, _, ok := runtime.Caller(2)
|
||||
if ok {
|
||||
d := filepath.Dir(file)
|
||||
// check the caller is from yaml.v
|
||||
if !strings.Contains(d, "yaml.v") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsZero returns true if the SecureString is empty
|
||||
// if caller not yaml, just return true for prevent marshal this field
|
||||
func (s SecureString) IsZero() bool {
|
||||
if callerFromYaml() {
|
||||
return true
|
||||
}
|
||||
return s.resolved == ""
|
||||
}
|
||||
|
||||
func NewSecureString(value string) *SecureString {
|
||||
s := &SecureString{}
|
||||
if err := s.fromRaw(value); err != nil {
|
||||
logger.Warn(fmt.Sprintf("NewSecureString.fromRaw error: %s", err))
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *SecureString) String() string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return s.resolved
|
||||
}
|
||||
|
||||
func (s *SecureString) Set(value string) *SecureString {
|
||||
s.resolved = value
|
||||
s.raw = ""
|
||||
return s
|
||||
}
|
||||
|
||||
func (s SecureString) MarshalJSON() ([]byte, error) {
|
||||
return []byte(notHere), nil
|
||||
}
|
||||
|
||||
func (s *SecureString) UnmarshalJSON(value []byte) error {
|
||||
if string(value) == notHere {
|
||||
return nil
|
||||
}
|
||||
var v string
|
||||
if err := json.Unmarshal(value, &v); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.fromRaw(v)
|
||||
}
|
||||
|
||||
func (s SecureString) MarshalYAML() (any, error) {
|
||||
// Preserve raw value if it is already a reference (enc:// or file://)
|
||||
if strings.HasPrefix(s.raw, credential.EncScheme) || strings.HasPrefix(s.raw, credential.FileScheme) {
|
||||
return s.raw, nil
|
||||
}
|
||||
// If resolved is a reference format (e.g. set via Set), copy back to raw
|
||||
if strings.HasPrefix(s.resolved, credential.EncScheme) || strings.HasPrefix(s.resolved, credential.FileScheme) {
|
||||
s.raw = s.resolved
|
||||
return s.raw, nil
|
||||
}
|
||||
// Try to encrypt the resolved value
|
||||
if passphrase := credential.PassphraseProvider(); passphrase != "" {
|
||||
encrypted, err := credential.Encrypt(passphrase, "", s.resolved)
|
||||
if err != nil {
|
||||
logger.Errorf("Encrypt error: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
s.raw = encrypted
|
||||
} else {
|
||||
s.raw = s.resolved
|
||||
}
|
||||
return s.raw, nil
|
||||
}
|
||||
|
||||
func (s *SecureString) UnmarshalYAML(value *yaml.Node) error {
|
||||
return s.fromRaw(value.Value)
|
||||
}
|
||||
|
||||
func (s *SecureString) fromRaw(v string) error {
|
||||
s.raw = v
|
||||
vv, err := resolveKey(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.resolved = vv
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
secResolverMu sync.RWMutex
|
||||
secResolver *credential.Resolver
|
||||
)
|
||||
|
||||
func updateResolver(path string) {
|
||||
secResolverMu.Lock()
|
||||
defer secResolverMu.Unlock()
|
||||
secResolver = credential.NewResolver(path)
|
||||
}
|
||||
|
||||
func resolveKey(v string) (string, error) {
|
||||
secResolverMu.RLock()
|
||||
resolver := secResolver
|
||||
secResolverMu.RUnlock()
|
||||
if resolver == nil {
|
||||
resolver = credential.NewResolver("")
|
||||
}
|
||||
if strings.HasPrefix(v, "enc://") || strings.HasPrefix(v, "file://") {
|
||||
decrypted, err := resolver.Resolve(v)
|
||||
if err != nil {
|
||||
logger.Errorf("Resolve error: %v", err)
|
||||
return "", err
|
||||
}
|
||||
return decrypted, nil
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (s *SecureString) UnmarshalText(text []byte) error {
|
||||
v := string(text)
|
||||
return s.fromRaw(v)
|
||||
}
|
||||
|
||||
type SecureModelList []*ModelConfig
|
||||
|
||||
func (v *SecureModelList) UnmarshalYAML(value *yaml.Node) error {
|
||||
mm := make(map[string]*ModelConfig)
|
||||
if err := value.Decode(&mm); err != nil {
|
||||
logger.Errorf("Decode error: %v", err)
|
||||
return err
|
||||
}
|
||||
nameList := toNameIndex(*v)
|
||||
for i, m := range *v {
|
||||
sec := mm[nameList[i]]
|
||||
if sec == nil {
|
||||
sec = mm[m.ModelName]
|
||||
}
|
||||
if sec != nil {
|
||||
m.APIKeys = sec.APIKeys
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v SecureModelList) MarshalYAML() (any, error) {
|
||||
type onlySecureData struct {
|
||||
APIKeys SecureStrings `yaml:"api_keys,omitempty"`
|
||||
}
|
||||
mm := make(map[string]onlySecureData)
|
||||
nameList := toNameIndex(v)
|
||||
for i, m := range v {
|
||||
mm[nameList[i]] = onlySecureData{
|
||||
APIKeys: m.APIKeys,
|
||||
}
|
||||
}
|
||||
|
||||
return mm, nil
|
||||
}
|
||||
145
pkg/config/config_struct_test.go
Normal file
145
pkg/config/config_struct_test.go
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/caarlos0/env/v11"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/credential"
|
||||
)
|
||||
|
||||
func TestLoadSecurityValue(t *testing.T) {
|
||||
type valueStruct struct {
|
||||
Url string `json:"url,omitempty" yaml:"-"`
|
||||
Token *SecureString `json:"token,omitempty" yaml:"token,omitempty" env:"PICO_TOKEN"`
|
||||
ApiKeys SecureStrings `json:"api_keys,omitempty" yaml:"api_keys,omitempty" env:"PICO_API_KEYS"`
|
||||
}
|
||||
|
||||
type testStruct struct {
|
||||
Pico *valueStruct `json:"pico,omitempty" yaml:"pico,omitempty"`
|
||||
}
|
||||
|
||||
v1 := &testStruct{
|
||||
Pico: &valueStruct{
|
||||
Url: "https://example.com",
|
||||
Token: NewSecureString("token1"),
|
||||
ApiKeys: SecureStrings{NewSecureString("api-key1"), NewSecureString("api-key2")},
|
||||
},
|
||||
}
|
||||
bytes, err := yaml.Marshal(v1)
|
||||
assert.NoError(t, err)
|
||||
jsonBytes, err := json.Marshal(v1)
|
||||
assert.NoError(t, err)
|
||||
const want = `pico:
|
||||
token: token1
|
||||
api_keys:
|
||||
- api-key1
|
||||
- api-key2
|
||||
`
|
||||
const jsonPost = `{"pico":{"url":"https://example.com","token":"token0"}}`
|
||||
v0 := &testStruct{}
|
||||
err = json.Unmarshal([]byte(jsonPost), v0)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "https://example.com", v0.Pico.Url)
|
||||
assert.Equal(t, "token0", v0.Pico.Token.String())
|
||||
|
||||
const jsonWant = `{"pico":{"url":"https://example.com","token":"[NOT_HERE]","api_keys":"[NOT_HERE]"}}`
|
||||
assert.Equal(t, want, string(bytes))
|
||||
assert.Equal(t, jsonWant, string(jsonBytes))
|
||||
|
||||
v2 := &testStruct{}
|
||||
err = json.Unmarshal(jsonBytes, v2)
|
||||
assert.NoError(t, err)
|
||||
err = yaml.Unmarshal(bytes, v2)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "https://example.com", v2.Pico.Url)
|
||||
if v2.Pico.Token != nil {
|
||||
assert.Equal(t, "token1", v2.Pico.Token.String())
|
||||
assert.Equal(t, "token1", v2.Pico.Token.raw)
|
||||
}
|
||||
|
||||
v2.Pico.Token = NewSecureString("token1")
|
||||
v2.Pico.Token.raw = "abc"
|
||||
err = yaml.Unmarshal(bytes, v2)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "token1", v2.Pico.Token.raw)
|
||||
|
||||
os.Setenv("PICO_TOKEN", "token_env")
|
||||
err = env.Parse(v2)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, v2.Pico.Token)
|
||||
assert.Equal(t, "token1", v2.Pico.Token.String())
|
||||
|
||||
v3 := &testStruct{Pico: &valueStruct{}}
|
||||
err = env.Parse(v3)
|
||||
assert.NoError(t, err)
|
||||
if v3.Pico.Token != nil {
|
||||
assert.Equal(t, "token_env", v3.Pico.Token.String())
|
||||
}
|
||||
|
||||
type toolsStruct struct {
|
||||
Pico valueStruct `json:"pico,omitempty" yaml:"pico,omitempty"`
|
||||
}
|
||||
|
||||
type testStruct2 struct {
|
||||
Tools toolsStruct `json:"tools,omitempty" yaml:",inline"`
|
||||
}
|
||||
|
||||
v4 := &testStruct2{
|
||||
Tools: toolsStruct{
|
||||
Pico: valueStruct{
|
||||
Url: "https://example.com",
|
||||
Token: NewSecureString("token1"),
|
||||
ApiKeys: SecureStrings{NewSecureString("api-key1"), NewSecureString("api-key2")},
|
||||
},
|
||||
},
|
||||
}
|
||||
bytes, err = yaml.Marshal(v4)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, want, string(bytes))
|
||||
jsonBytes, err = json.Marshal(v4)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(
|
||||
t,
|
||||
`{"tools":{"pico":{"url":"https://example.com","token":"[NOT_HERE]","api_keys":"[NOT_HERE]"}}}`,
|
||||
string(jsonBytes),
|
||||
)
|
||||
|
||||
v5 := &testStruct2{}
|
||||
err = json.Unmarshal(jsonBytes, v5)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "https://example.com", v5.Tools.Pico.Url)
|
||||
err = yaml.Unmarshal(bytes, v5)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, v5.Tools.Pico.Token)
|
||||
assert.Equal(t, "token1", v5.Tools.Pico.Token.raw)
|
||||
|
||||
dir := t.TempDir()
|
||||
sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key")
|
||||
if err = os.WriteFile(sshKeyPath, []byte("fake-ssh-key-material\n"), 0o600); err != nil {
|
||||
t.Fatalf("setup: %v", err)
|
||||
}
|
||||
|
||||
const passphrase = "test-passphrase-32bytes-long-ok!"
|
||||
|
||||
t.Setenv(credential.SSHKeyPathEnvVar, sshKeyPath)
|
||||
|
||||
t.Setenv(credential.PassphraseEnvVar, passphrase)
|
||||
|
||||
v5.Tools.Pico.Token.Set("newtoken1")
|
||||
v5.Tools.Pico.ApiKeys[0].Set("newapi-key1")
|
||||
bytes, err = yaml.Marshal(v5)
|
||||
assert.NoError(t, err)
|
||||
t.Logf("yaml: %s", string(bytes))
|
||||
|
||||
v6 := &testStruct2{}
|
||||
err = yaml.Unmarshal(bytes, v6)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, v6.Tools.Pico.Token)
|
||||
assert.Equal(t, "newtoken1", v6.Tools.Pico.Token.String())
|
||||
}
|
||||
|
|
@ -1673,3 +1673,163 @@ func TestFilterSensitiveData_AllTokenTypes(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// makeBackup tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestMakeBackup_WithDateSuffix verifies backup files include a date suffix.
|
||||
func TestMakeBackup_WithDateSuffix(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.json")
|
||||
if err := os.WriteFile(configPath, []byte(`{"version":2}`), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
if err := makeBackup(configPath); err != nil {
|
||||
t.Fatalf("makeBackup: %v", err)
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDir: %v", err)
|
||||
}
|
||||
|
||||
var hasDatedBackup bool
|
||||
for _, e := range entries {
|
||||
if matched, _ := filepath.Match("config.json.20*.bak", e.Name()); matched {
|
||||
hasDatedBackup = true
|
||||
// Verify backup content matches original
|
||||
bakPath := filepath.Join(dir, e.Name())
|
||||
data, err := os.ReadFile(bakPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile backup: %v", err)
|
||||
}
|
||||
if string(data) != `{"version":2}` {
|
||||
t.Errorf("backup content = %q, want original content", string(data))
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasDatedBackup {
|
||||
t.Error("expected backup file with date suffix pattern config.json.20*.bak")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMakeBackup_AlsoBacksSecurityFile verifies that the security config file
|
||||
// is also backed up with the same date suffix.
|
||||
func TestMakeBackup_AlsoBacksSecurityFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.json")
|
||||
secPath := securityPath(configPath)
|
||||
|
||||
os.WriteFile(configPath, []byte(`{"version":2}`), 0o600)
|
||||
os.WriteFile(secPath, []byte(`model_list:\n test:0:\n api_keys:\n - "sk-test"\n`), 0o600)
|
||||
|
||||
if err := makeBackup(configPath); err != nil {
|
||||
t.Fatalf("makeBackup: %v", err)
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDir: %v", err)
|
||||
}
|
||||
|
||||
configBackups := 0
|
||||
secBackups := 0
|
||||
for _, e := range entries {
|
||||
if matched, _ := filepath.Match("config.json.20*.bak", e.Name()); matched {
|
||||
configBackups++
|
||||
}
|
||||
if matched, _ := filepath.Match(".security.yml.20*.bak", e.Name()); matched {
|
||||
secBackups++
|
||||
}
|
||||
}
|
||||
if configBackups != 1 {
|
||||
t.Errorf("expected 1 config backup, got %d", configBackups)
|
||||
}
|
||||
if secBackups != 1 {
|
||||
t.Errorf("expected 1 security backup, got %d", secBackups)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMakeBackup_NonexistentFileSkipsBackup verifies that makeBackup returns nil
|
||||
// when the config file does not exist (no error, no panic).
|
||||
func TestMakeBackup_NonexistentFileSkipsBackup(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "nonexistent.json")
|
||||
|
||||
if err := makeBackup(configPath); err != nil {
|
||||
t.Fatalf("makeBackup on nonexistent file should return nil, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMakeBackup_OnlyConfigNoSecurity verifies backup succeeds when only
|
||||
// the config file exists and no security file.
|
||||
func TestMakeBackup_OnlyConfigNoSecurity(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.json")
|
||||
os.WriteFile(configPath, []byte(`{"version":2}`), 0o600)
|
||||
|
||||
if err := makeBackup(configPath); err != nil {
|
||||
t.Fatalf("makeBackup: %v", err)
|
||||
}
|
||||
|
||||
entries, _ := os.ReadDir(dir)
|
||||
configBackups := 0
|
||||
secBackups := 0
|
||||
for _, e := range entries {
|
||||
if matched, _ := filepath.Match("config.json.20*.bak", e.Name()); matched {
|
||||
configBackups++
|
||||
}
|
||||
if matched, _ := filepath.Match(".security.yml.20*.bak", e.Name()); matched {
|
||||
secBackups++
|
||||
}
|
||||
}
|
||||
if configBackups != 1 {
|
||||
t.Errorf("expected 1 config backup, got %d", configBackups)
|
||||
}
|
||||
if secBackups != 0 {
|
||||
t.Errorf("expected 0 security backups when no security file exists, got %d", secBackups)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMakeBackup_SameDateSuffix verifies that config and security backups
|
||||
// share the same date suffix (they are created in the same makeBackup call).
|
||||
func TestMakeBackup_SameDateSuffix(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.json")
|
||||
secPath := securityPath(configPath)
|
||||
|
||||
os.WriteFile(configPath, []byte(`{"version":2}`), 0o600)
|
||||
os.WriteFile(secPath, []byte(`key: value`), 0o600)
|
||||
|
||||
if err := makeBackup(configPath); err != nil {
|
||||
t.Fatalf("makeBackup: %v", err)
|
||||
}
|
||||
|
||||
entries, _ := os.ReadDir(dir)
|
||||
var configDate, secDate string
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
// Extract date part: after the last . before .bak
|
||||
// e.g. config.json.20260330.bak → 20260330
|
||||
if strings.HasPrefix(name, "config.json.") && strings.HasSuffix(name, ".bak") {
|
||||
configDate = strings.TrimPrefix(name, "config.json.")
|
||||
configDate = strings.TrimSuffix(configDate, ".bak")
|
||||
}
|
||||
if strings.HasPrefix(name, ".security.yml.") && strings.HasSuffix(name, ".bak") {
|
||||
secDate = strings.TrimPrefix(name, ".security.yml.")
|
||||
secDate = strings.TrimSuffix(secDate, ".bak")
|
||||
}
|
||||
}
|
||||
if configDate == "" {
|
||||
t.Fatal("config backup file not found")
|
||||
}
|
||||
if secDate == "" {
|
||||
t.Fatal("security backup file not found")
|
||||
}
|
||||
if configDate != secDate {
|
||||
t.Errorf("config backup date = %q, security backup date = %q, should match", configDate, secDate)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -534,3 +534,26 @@ func loadConfig(data []byte) (*Config, error) {
|
|||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func mergeAPIKeys(apiKey string, apiKeys []string) []string {
|
||||
seen := make(map[string]struct{})
|
||||
var all []string
|
||||
|
||||
if k := strings.TrimSpace(apiKey); k != "" {
|
||||
if _, exists := seen[k]; !exists {
|
||||
seen[k] = struct{}{}
|
||||
all = append(all, k)
|
||||
}
|
||||
}
|
||||
|
||||
for _, k := range apiKeys {
|
||||
if trimmed := strings.TrimSpace(k); trimmed != "" {
|
||||
if _, exists := seen[trimmed]; !exists {
|
||||
seen[trimmed] = struct{}{}
|
||||
all = append(all, trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return all
|
||||
}
|
||||
|
|
|
|||
|
|
@ -681,3 +681,473 @@ web:
|
|||
t.Error("Discord token not preserved in .security.yml file")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// V1 → V2 migration tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestMigrateModelEnabled_APIKeysInferredEnabled verifies that models with API keys
|
||||
// are marked as enabled during V1→V2 migration.
|
||||
func TestMigrateModelEnabled_APIKeysInferredEnabled(t *testing.T) {
|
||||
v1 := &configV1{Config: Config{
|
||||
ModelList: []*ModelConfig{
|
||||
{ModelName: "gpt-4", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-test")},
|
||||
{ModelName: "claude", Model: "anthropic/claude", APIKeys: SimpleSecureStrings("sk-ant")},
|
||||
},
|
||||
}}
|
||||
v1.migrateModelEnabled()
|
||||
for _, m := range v1.ModelList {
|
||||
if !m.Enabled {
|
||||
t.Errorf("model %q with API key should be enabled", m.ModelName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateModelEnabled_LocalModelInferredEnabled verifies that the reserved
|
||||
// "local-model" entry is enabled even without API keys.
|
||||
func TestMigrateModelEnabled_LocalModelInferredEnabled(t *testing.T) {
|
||||
v1 := &configV1{Config: Config{
|
||||
ModelList: []*ModelConfig{
|
||||
{ModelName: "local-model", Model: "vllm/custom-model", APIBase: "http://localhost:8000/v1"},
|
||||
},
|
||||
}}
|
||||
v1.migrateModelEnabled()
|
||||
if !v1.ModelList[0].Enabled {
|
||||
t.Error("local-model should be enabled")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateModelEnabled_NoKeyStaysDisabled verifies that models without API keys
|
||||
// and not named "local-model" remain disabled.
|
||||
func TestMigrateModelEnabled_NoKeyStaysDisabled(t *testing.T) {
|
||||
v1 := &configV1{Config: Config{
|
||||
ModelList: []*ModelConfig{
|
||||
{ModelName: "gpt-4", Model: "openai/gpt-4"},
|
||||
{ModelName: "claude", Model: "anthropic/claude"},
|
||||
},
|
||||
}}
|
||||
v1.migrateModelEnabled()
|
||||
for _, m := range v1.ModelList {
|
||||
if m.Enabled {
|
||||
t.Errorf("model %q without API key should stay disabled", m.ModelName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateModelEnabled_ExplicitEnabledPreserved verifies that a model with
|
||||
// explicitly enabled=true is NOT overridden by the migration.
|
||||
func TestMigrateModelEnabled_ExplicitEnabledPreserved(t *testing.T) {
|
||||
v1 := &configV1{Config: Config{
|
||||
ModelList: []*ModelConfig{
|
||||
{ModelName: "gpt-4", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-test"), Enabled: true},
|
||||
},
|
||||
}}
|
||||
v1.migrateModelEnabled()
|
||||
if !v1.ModelList[0].Enabled {
|
||||
t.Error("explicitly enabled model should remain enabled")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateModelEnabled_ExplicitDisabledNotOverridden verifies that a model with
|
||||
// explicitly enabled=false and API keys gets enabled during migration.
|
||||
// Note: since Go's zero value for bool is false and JSON omitempty omits false,
|
||||
// migration cannot distinguish "explicitly false" from "field absent". Both cases
|
||||
// get the same inference treatment.
|
||||
func TestMigrateModelEnabled_ExplicitDisabledNotOverridden(t *testing.T) {
|
||||
v1 := &configV1{Config: Config{
|
||||
ModelList: []*ModelConfig{
|
||||
{ModelName: "gpt-4", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-test"), Enabled: false},
|
||||
},
|
||||
}}
|
||||
v1.migrateModelEnabled()
|
||||
// Even though Enabled was set to false, migration infers it as true because
|
||||
// the migration cannot distinguish from a missing field (both are zero value).
|
||||
if !v1.ModelList[0].Enabled {
|
||||
t.Error("model with API key should be enabled by migration inference")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateModelEnabled_Mixed verifies a mix of models.
|
||||
func TestMigrateModelEnabled_Mixed(t *testing.T) {
|
||||
v1 := &configV1{Config: Config{
|
||||
ModelList: []*ModelConfig{
|
||||
{ModelName: "with-key", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-test")},
|
||||
{ModelName: "no-key", Model: "openai/gpt-4"},
|
||||
{ModelName: "local-model", Model: "vllm/custom"},
|
||||
{
|
||||
ModelName: "disabled-explicit",
|
||||
Model: "openai/gpt-4",
|
||||
APIKeys: SimpleSecureStrings("sk-test"),
|
||||
Enabled: false,
|
||||
},
|
||||
},
|
||||
}}
|
||||
v1.migrateModelEnabled()
|
||||
|
||||
assertEnabled := func(name string, want bool) {
|
||||
for _, m := range v1.ModelList {
|
||||
if m.ModelName == name {
|
||||
if m.Enabled != want {
|
||||
t.Errorf("model %q: Enabled=%v, want %v", name, m.Enabled, want)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Errorf("model %q not found", name)
|
||||
}
|
||||
|
||||
assertEnabled("with-key", true)
|
||||
assertEnabled("no-key", false)
|
||||
assertEnabled("local-model", true)
|
||||
assertEnabled("disabled-explicit", true) // false is zero value, migration infers from API key
|
||||
}
|
||||
|
||||
// TestMigrateChannelConfigs_DiscordMentionOnly verifies Discord mention_only migration.
|
||||
func TestMigrateChannelConfigs_DiscordMentionOnly(t *testing.T) {
|
||||
v1 := &configV1{Config: Config{
|
||||
Channels: ChannelsConfig{
|
||||
Discord: DiscordConfig{
|
||||
MentionOnly: true,
|
||||
},
|
||||
},
|
||||
}}
|
||||
v1.migrateChannelConfigs()
|
||||
if !v1.Channels.Discord.GroupTrigger.MentionOnly {
|
||||
t.Error("Discord GroupTrigger.MentionOnly should be set to true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateChannelConfigs_DiscordAlreadyMigrated is a no-op test.
|
||||
func TestMigrateChannelConfigs_DiscordAlreadyMigrated(t *testing.T) {
|
||||
v1 := &configV1{Config: Config{
|
||||
Channels: ChannelsConfig{
|
||||
Discord: DiscordConfig{
|
||||
GroupTrigger: GroupTriggerConfig{MentionOnly: true},
|
||||
},
|
||||
},
|
||||
}}
|
||||
v1.migrateChannelConfigs()
|
||||
}
|
||||
|
||||
// TestMigrateChannelConfigs_OneBotPrefix verifies OneBot prefix migration.
|
||||
func TestMigrateChannelConfigs_OneBotPrefix(t *testing.T) {
|
||||
v1 := &configV1{Config: Config{
|
||||
Channels: ChannelsConfig{
|
||||
OneBot: OneBotConfig{
|
||||
GroupTriggerPrefix: []string{"/"},
|
||||
},
|
||||
},
|
||||
}}
|
||||
v1.migrateChannelConfigs()
|
||||
if len(v1.Channels.OneBot.GroupTrigger.Prefixes) != 1 || v1.Channels.OneBot.GroupTrigger.Prefixes[0] != "/" {
|
||||
t.Errorf("OneBot GroupTrigger.Prefixes = %v, want [\"/\"]", v1.Channels.OneBot.GroupTrigger.Prefixes)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateConfigV1_Combined verifies that configV1.Migrate applies both migrations.
|
||||
func TestMigrateConfigV1_Combined(t *testing.T) {
|
||||
v1 := &configV1{Config: Config{
|
||||
ModelList: []*ModelConfig{
|
||||
{ModelName: "gpt-4", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-test")},
|
||||
},
|
||||
Channels: ChannelsConfig{
|
||||
Discord: DiscordConfig{MentionOnly: true},
|
||||
},
|
||||
}}
|
||||
result, err := v1.Migrate()
|
||||
if err != nil {
|
||||
t.Fatalf("Migrate: %v", err)
|
||||
}
|
||||
|
||||
if !result.ModelList[0].Enabled {
|
||||
t.Error("model with API key should be enabled after V1→V2 migration")
|
||||
}
|
||||
if !result.Channels.Discord.GroupTrigger.MentionOnly {
|
||||
t.Error("Discord mention_only should be migrated after V1→V2 migration")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConfig_V1ToV2Migration verifies end-to-end V1→V2 config migration
|
||||
// through LoadConfig, including Enabled field inference and version bump.
|
||||
func TestLoadConfig_V1ToV2Migration(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.json")
|
||||
|
||||
// Write a V1 config with model_list but no "enabled" field
|
||||
v1Config := `{
|
||||
"version": 1,
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-4",
|
||||
"model": "openai/gpt-4"
|
||||
},
|
||||
{
|
||||
"model_name": "local-model",
|
||||
"model": "vllm/custom-model",
|
||||
"api_base": "http://localhost:8000/v1"
|
||||
}
|
||||
],
|
||||
"channels": {
|
||||
"discord": {
|
||||
"mention_only": true
|
||||
}
|
||||
},
|
||||
"gateway": {"host": "127.0.0.1", "port": 18790}
|
||||
}`
|
||||
|
||||
if err := os.WriteFile(configPath, []byte(v1Config), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig: %v", err)
|
||||
}
|
||||
|
||||
// Version should be bumped to 2
|
||||
if cfg.Version != CurrentVersion {
|
||||
t.Errorf("Version = %d, want %d", cfg.Version, CurrentVersion)
|
||||
}
|
||||
|
||||
// gpt-4 has no API key → disabled
|
||||
gpt4, err := cfg.GetModelConfig("gpt-4")
|
||||
if err != nil {
|
||||
t.Fatalf("GetModelConfig(gpt-4): %v", err)
|
||||
}
|
||||
if gpt4.Enabled {
|
||||
t.Error("gpt-4 without API key should be disabled after migration")
|
||||
}
|
||||
|
||||
// local-model → enabled
|
||||
local, err := cfg.GetModelConfig("local-model")
|
||||
if err != nil {
|
||||
t.Fatalf("GetModelConfig(local-model): %v", err)
|
||||
}
|
||||
if !local.Enabled {
|
||||
t.Error("local-model should be enabled after migration")
|
||||
}
|
||||
|
||||
// Discord channel config should be migrated
|
||||
if !cfg.Channels.Discord.GroupTrigger.MentionOnly {
|
||||
t.Error("Discord mention_only should be migrated to group_trigger.mention_only")
|
||||
}
|
||||
|
||||
// Verify backup was created with date suffix
|
||||
entries, err := os.ReadDir(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDir: %v", err)
|
||||
}
|
||||
var hasBackup bool
|
||||
for _, e := range entries {
|
||||
if matched, _ := filepath.Match("config.json.20*.bak", e.Name()); matched {
|
||||
hasBackup = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasBackup {
|
||||
t.Error("expected backup file with date suffix to be created")
|
||||
}
|
||||
|
||||
// Verify the saved config on disk now has version 2
|
||||
saved, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile saved config: %v", err)
|
||||
}
|
||||
var versionCheck struct {
|
||||
Version int `json:"version"`
|
||||
}
|
||||
if err := json.Unmarshal(saved, &versionCheck); err != nil {
|
||||
t.Fatalf("Unmarshal saved config: %v", err)
|
||||
}
|
||||
if versionCheck.Version != 2 {
|
||||
t.Errorf("saved config version = %d, want 2", versionCheck.Version)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConfig_V1WithAPIKeysInferredEnabled verifies that V1 configs with
|
||||
// API keys in the security file get Enabled=true after migration.
|
||||
func TestLoadConfig_V1WithAPIKeysInferredEnabled(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.json")
|
||||
secPath := securityPath(configPath)
|
||||
|
||||
v1Config := `{
|
||||
"version": 1,
|
||||
"model_list": [
|
||||
{"model_name": "gpt-4", "model": "openai/gpt-4"},
|
||||
{"model_name": "claude", "model": "anthropic/claude"}
|
||||
],
|
||||
"gateway": {"host": "127.0.0.1", "port": 18790}
|
||||
}`
|
||||
|
||||
securityConfig := `model_list:
|
||||
gpt-4:0:
|
||||
api_keys:
|
||||
- "sk-gpt-key"
|
||||
claude:0:
|
||||
api_keys:
|
||||
- "sk-claude-key"
|
||||
`
|
||||
|
||||
if err := os.WriteFile(configPath, []byte(v1Config), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(secPath, []byte(securityConfig), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile security: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig: %v", err)
|
||||
}
|
||||
|
||||
for _, m := range cfg.ModelList {
|
||||
if !m.Enabled {
|
||||
t.Errorf("model %q with API key in security file should be enabled", m.ModelName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConfig_V2DirectLoad verifies that V2 configs load directly without
|
||||
// running any migration.
|
||||
func TestLoadConfig_V2DirectLoad(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.json")
|
||||
|
||||
v2Config := `{
|
||||
"version": 2,
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-4",
|
||||
"model": "openai/gpt-4",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"model_name": "claude",
|
||||
"model": "anthropic/claude"
|
||||
}
|
||||
],
|
||||
"gateway": {"host": "127.0.0.1", "port": 18790}
|
||||
}`
|
||||
|
||||
if err := os.WriteFile(configPath, []byte(v2Config), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Version != 2 {
|
||||
t.Errorf("Version = %d, want 2", cfg.Version)
|
||||
}
|
||||
|
||||
gpt4, _ := cfg.GetModelConfig("gpt-4")
|
||||
if !gpt4.Enabled {
|
||||
t.Error("gpt-4 with explicit enabled=true should remain enabled")
|
||||
}
|
||||
|
||||
claude, _ := cfg.GetModelConfig("claude")
|
||||
if claude.Enabled {
|
||||
t.Error("claude without enabled field should be false (no migration for V2)")
|
||||
}
|
||||
|
||||
// No backup should be created for V2 load
|
||||
entries, _ := os.ReadDir(tmpDir)
|
||||
for _, e := range entries {
|
||||
if matched, _ := filepath.Match("config.json.*.bak", e.Name()); matched {
|
||||
t.Errorf("V2 load should not create backup, but found %q", e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConfig_V0MigrateProducesV2 verifies that V0→V2 migration produces
|
||||
// correct Enabled fields and version.
|
||||
func TestLoadConfig_V0MigrateProducesV2(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.json")
|
||||
|
||||
v0Config := `{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-4",
|
||||
"model": "openai/gpt-4",
|
||||
"api_key": "sk-test"
|
||||
},
|
||||
{
|
||||
"model_name": "claude",
|
||||
"model": "anthropic/claude"
|
||||
},
|
||||
{
|
||||
"model_name": "local-model",
|
||||
"model": "vllm/custom-model"
|
||||
}
|
||||
],
|
||||
"gateway": {"host": "127.0.0.1", "port": 18790}
|
||||
}`
|
||||
|
||||
if err := os.WriteFile(configPath, []byte(v0Config), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Version != CurrentVersion {
|
||||
t.Errorf("Version = %d, want %d", cfg.Version, CurrentVersion)
|
||||
}
|
||||
|
||||
// Check enabled status
|
||||
modelEnabled := func(name string) bool {
|
||||
m, err := cfg.GetModelConfig(name)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return m.Enabled
|
||||
}
|
||||
|
||||
if !modelEnabled("gpt-4") {
|
||||
t.Error("gpt-4 with API key from V0 should be enabled")
|
||||
}
|
||||
if modelEnabled("claude") {
|
||||
t.Error("claude without API key from V0 should be disabled")
|
||||
}
|
||||
if !modelEnabled("local-model") {
|
||||
t.Error("local-model from V0 should be enabled")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConfig_UnsupportedVersion verifies that unsupported versions return an error.
|
||||
func TestLoadConfig_UnsupportedVersion(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.json")
|
||||
|
||||
badConfig := `{"version": 99, "gateway": {"host": "127.0.0.1", "port": 18790}}`
|
||||
if err := os.WriteFile(configPath, []byte(badConfig), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
_, err := LoadConfig(configPath)
|
||||
if err == nil {
|
||||
t.Fatal("LoadConfig should return error for unsupported version")
|
||||
}
|
||||
if !containsString(err.Error(), "unsupported config version") {
|
||||
t.Errorf("error = %q, want 'unsupported config version'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func containsString(s, substr string) bool {
|
||||
return len(s) >= len(substr) && searchString(s, substr)
|
||||
}
|
||||
|
||||
func searchString(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -345,7 +345,7 @@ func TestMergeAPIKeys(t *testing.T) {
|
|||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := MergeAPIKeys(tt.apiKey, tt.apiKeys)
|
||||
result := mergeAPIKeys(tt.apiKey, tt.apiKeys)
|
||||
if len(result) != len(tt.expected) {
|
||||
t.Fatalf("expected %d keys, got %d", len(tt.expected), len(result))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,20 +7,16 @@ package config
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/credential"
|
||||
"github.com/sipeed/picoclaw/pkg/fileutil"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -66,7 +62,6 @@ func saveSecurityConfig(securityPath string, sec *Config) error {
|
|||
return fileutil.WriteFileAtomic(securityPath, buf.Bytes(), 0o600)
|
||||
}
|
||||
|
||||
// SensitiveDataCache caches the compiled regex for filtering sensitive data.
|
||||
// SensitiveDataCache caches the strings.Replacer for filtering sensitive data.
|
||||
// Computed once on first access via sync.Once.
|
||||
type SensitiveDataCache struct {
|
||||
|
|
@ -178,234 +173,3 @@ func collectSensitive(v reflect.Value, values *[]string) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
notHere = `"[NOT_HERE]"`
|
||||
)
|
||||
|
||||
// SecureStrings is a slice of SecureString
|
||||
type SecureStrings []*SecureString
|
||||
|
||||
// Values returns the decrypted/resolved values
|
||||
func (s *SecureStrings) Values() []string {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
keys := make([]string, len(*s))
|
||||
for i, k := range *s {
|
||||
keys[i] = k.String()
|
||||
}
|
||||
return unique(keys)
|
||||
}
|
||||
|
||||
func SimpleSecureStrings(val ...string) SecureStrings {
|
||||
val = unique(val)
|
||||
vv := make(SecureStrings, len(val))
|
||||
for i, s := range val {
|
||||
vv[i] = NewSecureString(s)
|
||||
}
|
||||
return vv
|
||||
}
|
||||
|
||||
// unique returns a new slice with duplicate elements removed.
|
||||
func unique[T comparable](input []T) []T {
|
||||
m := make(map[T]struct{})
|
||||
var result []T
|
||||
for _, v := range input {
|
||||
if _, ok := m[v]; !ok {
|
||||
m[v] = struct{}{}
|
||||
result = append(result, v)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s SecureStrings) MarshalJSON() ([]byte, error) {
|
||||
return []byte(notHere), nil
|
||||
}
|
||||
|
||||
func (s *SecureStrings) UnmarshalJSON(value []byte) error {
|
||||
if string(value) == notHere {
|
||||
return nil
|
||||
}
|
||||
var v []*SecureString
|
||||
err := json.Unmarshal(value, &v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*s = v
|
||||
return nil
|
||||
}
|
||||
|
||||
// SecureString the string value that can be decrypted or resolved
|
||||
//
|
||||
//nolint:recvcheck
|
||||
type SecureString struct {
|
||||
resolved string // Decrypted/resolved value returned by String()
|
||||
raw string // Persisted raw value (enc://, file://, or plaintext)
|
||||
}
|
||||
|
||||
func callerFromYaml() bool {
|
||||
_, file, _, ok := runtime.Caller(2)
|
||||
if ok {
|
||||
d := filepath.Dir(file)
|
||||
// check the caller is from yaml.v
|
||||
if !strings.Contains(d, "yaml.v") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsZero returns true if the SecureString is empty
|
||||
// if caller not yaml, just return true for prevent marshal this field
|
||||
func (s SecureString) IsZero() bool {
|
||||
if callerFromYaml() {
|
||||
return true
|
||||
}
|
||||
return s.resolved == ""
|
||||
}
|
||||
|
||||
func NewSecureString(value string) *SecureString {
|
||||
s := &SecureString{}
|
||||
if err := s.fromRaw(value); err != nil {
|
||||
logger.Warn(fmt.Sprintf("NewSecureString.fromRaw error: %s", err))
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *SecureString) String() string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return s.resolved
|
||||
}
|
||||
|
||||
func (s *SecureString) Set(value string) *SecureString {
|
||||
s.resolved = value
|
||||
s.raw = ""
|
||||
return s
|
||||
}
|
||||
|
||||
func (s SecureString) MarshalJSON() ([]byte, error) {
|
||||
return []byte(notHere), nil
|
||||
}
|
||||
|
||||
func (s *SecureString) UnmarshalJSON(value []byte) error {
|
||||
if string(value) == notHere {
|
||||
return nil
|
||||
}
|
||||
var v string
|
||||
if err := json.Unmarshal(value, &v); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.fromRaw(v)
|
||||
}
|
||||
|
||||
func (s SecureString) MarshalYAML() (any, error) {
|
||||
// Preserve raw value if it is already a reference (enc:// or file://)
|
||||
if strings.HasPrefix(s.raw, credential.EncScheme) || strings.HasPrefix(s.raw, credential.FileScheme) {
|
||||
return s.raw, nil
|
||||
}
|
||||
// If resolved is a reference format (e.g. set via Set), copy back to raw
|
||||
if strings.HasPrefix(s.resolved, credential.EncScheme) || strings.HasPrefix(s.resolved, credential.FileScheme) {
|
||||
s.raw = s.resolved
|
||||
return s.raw, nil
|
||||
}
|
||||
// Try to encrypt the resolved value
|
||||
if passphrase := credential.PassphraseProvider(); passphrase != "" {
|
||||
encrypted, err := credential.Encrypt(passphrase, "", s.resolved)
|
||||
if err != nil {
|
||||
logger.Errorf("Encrypt error: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
s.raw = encrypted
|
||||
} else {
|
||||
s.raw = s.resolved
|
||||
}
|
||||
return s.raw, nil
|
||||
}
|
||||
|
||||
func (s *SecureString) UnmarshalYAML(value *yaml.Node) error {
|
||||
return s.fromRaw(value.Value)
|
||||
}
|
||||
|
||||
func (s *SecureString) fromRaw(v string) error {
|
||||
s.raw = v
|
||||
vv, err := resolveKey(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.resolved = vv
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
secResolverMu sync.RWMutex
|
||||
secResolver *credential.Resolver
|
||||
)
|
||||
|
||||
func updateResolver(path string) {
|
||||
secResolverMu.Lock()
|
||||
defer secResolverMu.Unlock()
|
||||
secResolver = credential.NewResolver(path)
|
||||
}
|
||||
|
||||
func resolveKey(v string) (string, error) {
|
||||
secResolverMu.RLock()
|
||||
resolver := secResolver
|
||||
secResolverMu.RUnlock()
|
||||
if resolver == nil {
|
||||
resolver = credential.NewResolver("")
|
||||
}
|
||||
if strings.HasPrefix(v, "enc://") || strings.HasPrefix(v, "file://") {
|
||||
decrypted, err := resolver.Resolve(v)
|
||||
if err != nil {
|
||||
logger.Errorf("Resolve error: %v", err)
|
||||
return "", err
|
||||
}
|
||||
return decrypted, nil
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (s *SecureString) UnmarshalText(text []byte) error {
|
||||
v := string(text)
|
||||
return s.fromRaw(v)
|
||||
}
|
||||
|
||||
type SecureModelList []*ModelConfig
|
||||
|
||||
func (v *SecureModelList) UnmarshalYAML(value *yaml.Node) error {
|
||||
mm := make(map[string]*ModelConfig)
|
||||
if err := value.Decode(&mm); err != nil {
|
||||
logger.Errorf("Decode error: %v", err)
|
||||
return err
|
||||
}
|
||||
nameList := toNameIndex(*v)
|
||||
for i, m := range *v {
|
||||
sec := mm[nameList[i]]
|
||||
if sec == nil {
|
||||
sec = mm[m.ModelName]
|
||||
}
|
||||
if sec != nil {
|
||||
m.APIKeys = sec.APIKeys
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v SecureModelList) MarshalYAML() (any, error) {
|
||||
type onlySecureData struct {
|
||||
APIKeys SecureStrings `yaml:"api_keys,omitempty"`
|
||||
}
|
||||
mm := make(map[string]onlySecureData)
|
||||
nameList := toNameIndex(v)
|
||||
for i, m := range v {
|
||||
mm[nameList[i]] = onlySecureData{
|
||||
APIKeys: m.APIKeys,
|
||||
}
|
||||
}
|
||||
|
||||
return mm, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,8 +15,6 @@ import (
|
|||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/credential"
|
||||
)
|
||||
|
||||
func TestSecurityConfig(t *testing.T) {
|
||||
|
|
@ -227,134 +225,3 @@ skills:
|
|||
assert.Equal(t, "abc", cfg2.Tools.Web.Brave.APIKeys[1].raw)
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoadSecurityValue(t *testing.T) {
|
||||
type valueStruct struct {
|
||||
Url string `json:"url,omitempty" yaml:"-"`
|
||||
Token *SecureString `json:"token,omitempty" yaml:"token,omitempty" env:"PICO_TOKEN"`
|
||||
ApiKeys SecureStrings `json:"api_keys,omitempty" yaml:"api_keys,omitempty" env:"PICO_API_KEYS"`
|
||||
}
|
||||
|
||||
type testStruct struct {
|
||||
Pico *valueStruct `json:"pico,omitempty" yaml:"pico,omitempty"`
|
||||
}
|
||||
|
||||
v1 := &testStruct{
|
||||
Pico: &valueStruct{
|
||||
Url: "https://example.com",
|
||||
Token: NewSecureString("token1"),
|
||||
ApiKeys: SecureStrings{NewSecureString("api-key1"), NewSecureString("api-key2")},
|
||||
},
|
||||
}
|
||||
bytes, err := yaml.Marshal(v1)
|
||||
assert.NoError(t, err)
|
||||
jsonBytes, err := json.Marshal(v1)
|
||||
assert.NoError(t, err)
|
||||
const want = `pico:
|
||||
token: token1
|
||||
api_keys:
|
||||
- api-key1
|
||||
- api-key2
|
||||
`
|
||||
const jsonPost = `{"pico":{"url":"https://example.com","token":"token0"}}`
|
||||
v0 := &testStruct{}
|
||||
err = json.Unmarshal([]byte(jsonPost), v0)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "https://example.com", v0.Pico.Url)
|
||||
assert.Equal(t, "token0", v0.Pico.Token.String())
|
||||
|
||||
const jsonWant = `{"pico":{"url":"https://example.com","token":"[NOT_HERE]","api_keys":"[NOT_HERE]"}}`
|
||||
assert.Equal(t, want, string(bytes))
|
||||
assert.Equal(t, jsonWant, string(jsonBytes))
|
||||
|
||||
v2 := &testStruct{}
|
||||
err = json.Unmarshal(jsonBytes, v2)
|
||||
assert.NoError(t, err)
|
||||
err = yaml.Unmarshal(bytes, v2)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "https://example.com", v2.Pico.Url)
|
||||
if v2.Pico.Token != nil {
|
||||
assert.Equal(t, "token1", v2.Pico.Token.String())
|
||||
assert.Equal(t, "token1", v2.Pico.Token.raw)
|
||||
}
|
||||
|
||||
v2.Pico.Token = NewSecureString("token1")
|
||||
v2.Pico.Token.raw = "abc"
|
||||
err = yaml.Unmarshal(bytes, v2)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "token1", v2.Pico.Token.raw)
|
||||
|
||||
os.Setenv("PICO_TOKEN", "token_env")
|
||||
err = env.Parse(v2)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, v2.Pico.Token)
|
||||
assert.Equal(t, "token1", v2.Pico.Token.String())
|
||||
|
||||
v3 := &testStruct{Pico: &valueStruct{}}
|
||||
err = env.Parse(v3)
|
||||
assert.NoError(t, err)
|
||||
if v3.Pico.Token != nil {
|
||||
assert.Equal(t, "token_env", v3.Pico.Token.String())
|
||||
}
|
||||
|
||||
type toolsStruct struct {
|
||||
Pico valueStruct `json:"pico,omitempty" yaml:"pico,omitempty"`
|
||||
}
|
||||
|
||||
type testStruct2 struct {
|
||||
Tools toolsStruct `json:"tools,omitempty" yaml:",inline"`
|
||||
}
|
||||
|
||||
v4 := &testStruct2{
|
||||
Tools: toolsStruct{
|
||||
Pico: valueStruct{
|
||||
Url: "https://example.com",
|
||||
Token: NewSecureString("token1"),
|
||||
ApiKeys: SecureStrings{NewSecureString("api-key1"), NewSecureString("api-key2")},
|
||||
},
|
||||
},
|
||||
}
|
||||
bytes, err = yaml.Marshal(v4)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, want, string(bytes))
|
||||
jsonBytes, err = json.Marshal(v4)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(
|
||||
t,
|
||||
`{"tools":{"pico":{"url":"https://example.com","token":"[NOT_HERE]","api_keys":"[NOT_HERE]"}}}`,
|
||||
string(jsonBytes),
|
||||
)
|
||||
|
||||
v5 := &testStruct2{}
|
||||
err = json.Unmarshal(jsonBytes, v5)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "https://example.com", v5.Tools.Pico.Url)
|
||||
err = yaml.Unmarshal(bytes, v5)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, v5.Tools.Pico.Token)
|
||||
assert.Equal(t, "token1", v5.Tools.Pico.Token.raw)
|
||||
|
||||
dir := t.TempDir()
|
||||
sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key")
|
||||
if err = os.WriteFile(sshKeyPath, []byte("fake-ssh-key-material\n"), 0o600); err != nil {
|
||||
t.Fatalf("setup: %v", err)
|
||||
}
|
||||
|
||||
const passphrase = "test-passphrase-32bytes-long-ok!"
|
||||
|
||||
t.Setenv(credential.SSHKeyPathEnvVar, sshKeyPath)
|
||||
|
||||
t.Setenv(credential.PassphraseEnvVar, passphrase)
|
||||
|
||||
v5.Tools.Pico.Token.Set("newtoken1")
|
||||
v5.Tools.Pico.ApiKeys[0].Set("newapi-key1")
|
||||
bytes, err = yaml.Marshal(v5)
|
||||
assert.NoError(t, err)
|
||||
t.Logf("yaml: %s", string(bytes))
|
||||
|
||||
v6 := &testStruct2{}
|
||||
err = yaml.Unmarshal(bytes, v6)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, v6.Tools.Pico.Token)
|
||||
assert.Equal(t, "newtoken1", v6.Tools.Pico.Token.String())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,10 +25,8 @@ type CronSchedule struct {
|
|||
|
||||
type CronPayload struct {
|
||||
Kind string `json:"kind"`
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
Command string `json:"command,omitempty"`
|
||||
Deliver bool `json:"deliver"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
To string `json:"to,omitempty"`
|
||||
}
|
||||
|
|
@ -410,7 +408,6 @@ func (cs *CronService) AddJob(
|
|||
name string,
|
||||
schedule CronSchedule,
|
||||
message string,
|
||||
deliver bool,
|
||||
channel, to string,
|
||||
) (*CronJob, error) {
|
||||
cs.mu.Lock()
|
||||
|
|
@ -429,7 +426,6 @@ func (cs *CronService) AddJob(
|
|||
Payload: CronPayload{
|
||||
Kind: "agent_turn",
|
||||
Message: message,
|
||||
Deliver: deliver,
|
||||
Channel: channel,
|
||||
To: to,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ func TestSaveStore_FilePermissions(t *testing.T) {
|
|||
|
||||
cs := NewCronService(storePath, nil)
|
||||
|
||||
_, err := cs.AddJob("test", CronSchedule{Kind: "every", EveryMS: int64Ptr(60000)}, "hello", false, "cli", "direct")
|
||||
_, err := cs.AddJob("test", CronSchedule{Kind: "every", EveryMS: int64Ptr(60000)}, "hello", "cli", "direct")
|
||||
if err != nil {
|
||||
t.Fatalf("AddJob failed: %v", err)
|
||||
}
|
||||
|
|
@ -52,7 +52,7 @@ func TestCronService_CRUD(t *testing.T) {
|
|||
|
||||
// Test AddJob
|
||||
at := time.Now().Add(time.Hour).UnixMilli()
|
||||
job, err := cs.AddJob("Task1", CronSchedule{Kind: "at", AtMS: &at}, "msg", true, "ch", "to")
|
||||
job, err := cs.AddJob("Task1", CronSchedule{Kind: "at", AtMS: &at}, "msg", "ch", "to")
|
||||
if err != nil || job.ID == "" {
|
||||
t.Fatalf("AddJob failed: %v", err)
|
||||
}
|
||||
|
|
@ -134,7 +134,7 @@ func TestCronService_ExecutionFlow(t *testing.T) {
|
|||
|
||||
// Add a job then runs 100ms from now
|
||||
target := time.Now().Add(100 * time.Millisecond).UnixMilli()
|
||||
job, _ := cs.AddJob("FastJob", CronSchedule{Kind: "at", AtMS: &target}, "", false, "", "")
|
||||
job, _ := cs.AddJob("FastJob", CronSchedule{Kind: "at", AtMS: &target}, "", "", "")
|
||||
|
||||
// Check for job execution with a timeout
|
||||
success := false
|
||||
|
|
@ -167,7 +167,7 @@ func TestCronService_PersistenceIntegrity(t *testing.T) {
|
|||
// write a job and persist
|
||||
cs1 := NewCronService(tmpFile, nil)
|
||||
at := int64(2000000000000)
|
||||
cs1.AddJob("PersistMe", CronSchedule{Kind: "at", AtMS: &at}, "payload", true, "ch1", "")
|
||||
cs1.AddJob("PersistMe", CronSchedule{Kind: "at", AtMS: &at}, "payload", "ch1", "")
|
||||
|
||||
// check file exists
|
||||
if _, err := os.Stat(tmpFile); os.IsNotExist(err) {
|
||||
|
|
@ -213,7 +213,7 @@ func TestCronService_ConcurrentAccess(t *testing.T) {
|
|||
defer wg.Done()
|
||||
for j := range iterations {
|
||||
at := time.Now().Add(time.Hour).UnixMilli()
|
||||
cs.AddJob(fmt.Sprintf("Job-%d-%d", id, j), CronSchedule{Kind: "at", AtMS: &at}, "", false, "", "")
|
||||
cs.AddJob(fmt.Sprintf("Job-%d-%d", id, j), CronSchedule{Kind: "at", AtMS: &at}, "", "", "")
|
||||
time.Sleep(100 * time.Microsecond)
|
||||
}
|
||||
}(i)
|
||||
|
|
|
|||
|
|
@ -94,13 +94,17 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error
|
|||
defer panicFunc()
|
||||
|
||||
if err = logger.EnableFileLogging(filepath.Join(homePath, logPath, logFile)); err != nil {
|
||||
panic(fmt.Sprintf("error enabling file logging: %v", err))
|
||||
logger.Fatal(fmt.Sprintf("error enabling file logging: %v", err))
|
||||
}
|
||||
defer logger.DisableFileLogging()
|
||||
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error loading config: %w", err)
|
||||
logger.Fatalf("error loading config: %v", err)
|
||||
}
|
||||
|
||||
if err = preCheckConfig(cfg); err != nil {
|
||||
logger.Fatalf("config pre-check failed: %v", err)
|
||||
}
|
||||
|
||||
logger.SetLevelFromString(cfg.Gateway.LogLevel)
|
||||
|
|
@ -224,6 +228,13 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error
|
|||
}
|
||||
}
|
||||
|
||||
func preCheckConfig(cfg *config.Config) error {
|
||||
if cfg.Gateway.Port <= 0 || cfg.Gateway.Port > 65535 {
|
||||
return fmt.Errorf("invalid gateway port: %d, port must be between 1 and 65535", cfg.Gateway.Port)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeReload(
|
||||
ctx context.Context,
|
||||
agentLoop *agent.AgentLoop,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
"sync"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
type LogLevel = zerolog.Level
|
||||
|
|
@ -21,6 +22,8 @@ const (
|
|||
WARN = zerolog.WarnLevel
|
||||
ERROR = zerolog.ErrorLevel
|
||||
FATAL = zerolog.FatalLevel
|
||||
|
||||
Component = "component"
|
||||
)
|
||||
|
||||
var (
|
||||
|
|
@ -32,28 +35,47 @@ var (
|
|||
FATAL: "FATAL",
|
||||
}
|
||||
|
||||
currentLevel = INFO
|
||||
logger zerolog.Logger
|
||||
fileLogger zerolog.Logger
|
||||
logFile *os.File
|
||||
once sync.Once
|
||||
mu sync.RWMutex
|
||||
currentLevel = INFO
|
||||
logger zerolog.Logger
|
||||
logFile *os.File
|
||||
once sync.Once
|
||||
mu sync.RWMutex
|
||||
writers []io.Writer
|
||||
consoleWriter zerolog.ConsoleWriter
|
||||
)
|
||||
|
||||
func init() {
|
||||
once.Do(func() {
|
||||
zerolog.SetGlobalLevel(zerolog.InfoLevel)
|
||||
|
||||
consoleWriter := zerolog.ConsoleWriter{
|
||||
isTTY := term.IsTerminal(int(os.Stdout.Fd()))
|
||||
|
||||
consoleWriter = zerolog.ConsoleWriter{
|
||||
Out: os.Stdout,
|
||||
TimeFormat: "15:04:05", // TODO: make it configurable???
|
||||
|
||||
// Custom formatter to handle multiline strings and JSON objects
|
||||
FormatFieldValue: formatFieldValue,
|
||||
PartsOrder: []string{
|
||||
zerolog.TimestampFieldName,
|
||||
zerolog.LevelFieldName,
|
||||
Component,
|
||||
zerolog.CallerFieldName,
|
||||
zerolog.MessageFieldName,
|
||||
},
|
||||
FieldsExclude: []string{Component},
|
||||
FormatPrepare: func(fields map[string]any) error {
|
||||
if isTTY {
|
||||
fields[Component] = fmt.Sprintf("\x1b[33m%v\x1b[0m", fields[Component])
|
||||
}
|
||||
return nil
|
||||
},
|
||||
NoColor: !isTTY,
|
||||
}
|
||||
|
||||
logger = zerolog.New(consoleWriter).With().Timestamp().Caller().Logger()
|
||||
fileLogger = zerolog.Logger{}
|
||||
writers = append(writers, consoleWriter)
|
||||
|
||||
logger = zerolog.New(io.MultiWriter(writers...)).With().Timestamp().Caller().Logger()
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -104,7 +126,15 @@ func SetConsoleLevel(level LogLevel) {
|
|||
func DisableConsole() {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
logger = zerolog.New(io.Discard).With().Timestamp().Caller().Logger()
|
||||
writers[0] = io.Discard
|
||||
logger = logger.Output(io.MultiWriter(writers...))
|
||||
}
|
||||
|
||||
func EnableConsole() {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
writers[0] = consoleWriter
|
||||
logger = logger.Output(io.MultiWriter(writers...))
|
||||
}
|
||||
|
||||
func GetLevel() LogLevel {
|
||||
|
|
@ -162,7 +192,14 @@ func EnableFileLogging(filePath string) error {
|
|||
}
|
||||
|
||||
logFile = newFile
|
||||
fileLogger = zerolog.New(logFile).With().Timestamp().Caller().Logger()
|
||||
|
||||
if len(writers) != 1 {
|
||||
return fmt.Errorf("failed to configure file logging: %w", err)
|
||||
}
|
||||
|
||||
writers = append(writers, logFile)
|
||||
logger = logger.Output(io.MultiWriter(writers...))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -174,7 +211,10 @@ func DisableFileLogging() {
|
|||
logFile.Close()
|
||||
logFile = nil
|
||||
}
|
||||
fileLogger = zerolog.Logger{}
|
||||
if len(writers) > 1 {
|
||||
writers = writers[:1]
|
||||
logger = logger.Output(io.MultiWriter(writers...))
|
||||
}
|
||||
}
|
||||
|
||||
func ConfigureFromEnv() {
|
||||
|
|
@ -193,7 +233,28 @@ func ConfigureFromEnv() {
|
|||
}
|
||||
}
|
||||
|
||||
func getCallerSkip() int {
|
||||
const (
|
||||
locUnknown = "<unknown>"
|
||||
)
|
||||
|
||||
func getPackageNameFromFile(filePath string) string {
|
||||
dir := filepath.Dir(filePath)
|
||||
importPath := filepath.ToSlash(dir)
|
||||
|
||||
parts := strings.Split(importPath, "/")
|
||||
if len(parts) == 0 {
|
||||
return locUnknown
|
||||
}
|
||||
|
||||
pkg := parts[len(parts)-1]
|
||||
if pkg == "." {
|
||||
return "<main>"
|
||||
}
|
||||
|
||||
return pkg
|
||||
}
|
||||
|
||||
func getCallerSkip() (int, string) {
|
||||
for i := 2; i < 15; i++ {
|
||||
pc, file, _, ok := runtime.Caller(i)
|
||||
if !ok {
|
||||
|
|
@ -217,10 +278,10 @@ func getCallerSkip() int {
|
|||
continue
|
||||
}
|
||||
|
||||
return i - 1
|
||||
return i - 1, getPackageNameFromFile(file)
|
||||
}
|
||||
|
||||
return 3
|
||||
return 3, locUnknown
|
||||
}
|
||||
|
||||
//nolint:zerologlint
|
||||
|
|
@ -246,33 +307,19 @@ func logMessage(level LogLevel, component string, message string, fields map[str
|
|||
return
|
||||
}
|
||||
|
||||
skip := getCallerSkip()
|
||||
skip, pkg := getCallerSkip()
|
||||
|
||||
event := getEvent(logger, level)
|
||||
|
||||
if component != "" {
|
||||
event.Str("component", component)
|
||||
if component == "" {
|
||||
component = pkg
|
||||
}
|
||||
|
||||
event.Str(Component, component)
|
||||
|
||||
appendFields(event, fields)
|
||||
|
||||
event.CallerSkipFrame(skip).Msg(message)
|
||||
|
||||
// Also log to file if enabled
|
||||
if fileLogger.GetLevel() != zerolog.NoLevel {
|
||||
fileEvent := getEvent(fileLogger, level)
|
||||
|
||||
if component != "" {
|
||||
fileEvent.Str("component", component)
|
||||
}
|
||||
// fileEvent.Str("caller", fmt.Sprintf("%s:%d (%s)", callerFile, callerLine, callerFunc))
|
||||
|
||||
appendFields(fileEvent, fields)
|
||||
fileEvent.CallerSkipFrame(skip).Msg(message)
|
||||
}
|
||||
|
||||
if level == FATAL {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func appendFields(event *zerolog.Event, fields map[string]any) {
|
||||
|
|
|
|||
|
|
@ -406,3 +406,28 @@ func TestConfigureFromEnvNoEnv(t *testing.T) {
|
|||
os.Unsetenv("PICOCLAW_LOG_FILE")
|
||||
ConfigureFromEnv()
|
||||
}
|
||||
|
||||
func TestGetPackageNameFromFile(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{"normal package path", "/home/user/project/pkg/logger/logger.go", "logger"},
|
||||
{"nested package", "/home/user/project/internal/service/auth/handler.go", "auth"},
|
||||
{"cmd package", "/home/user/project/cmd/server/main.go", "server"},
|
||||
{"project root returns main", "./main.go", "<main>"},
|
||||
{"single dot returns main", ".", "<main>"},
|
||||
{"single directory", "mypkg/file.go", "mypkg"},
|
||||
{"deep nesting", "/a/b/c/d/e/f.go", "e"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := getPackageNameFromFile(tt.path)
|
||||
if got != tt.want {
|
||||
t.Errorf("getPackageNameFromFile(%q) = %q, want %q", tt.path, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,8 +21,10 @@ type Tool interface {
|
|||
type toolCtxKey struct{ name string }
|
||||
|
||||
var (
|
||||
ctxKeyChannel = &toolCtxKey{"channel"}
|
||||
ctxKeyChatID = &toolCtxKey{"chatID"}
|
||||
ctxKeyChannel = &toolCtxKey{"channel"}
|
||||
ctxKeyChatID = &toolCtxKey{"chatID"}
|
||||
ctxKeyMessageID = &toolCtxKey{"messageID"}
|
||||
ctxKeyReplyToMessageID = &toolCtxKey{"replyToMessageID"}
|
||||
)
|
||||
|
||||
// WithToolContext returns a child context carrying channel and chatID.
|
||||
|
|
@ -32,6 +34,23 @@ func WithToolContext(ctx context.Context, channel, chatID string) context.Contex
|
|||
return ctx
|
||||
}
|
||||
|
||||
// WithToolMessageContext returns a child context carrying inbound message IDs.
|
||||
func WithToolMessageContext(ctx context.Context, messageID, replyToMessageID string) context.Context {
|
||||
ctx = context.WithValue(ctx, ctxKeyMessageID, messageID)
|
||||
ctx = context.WithValue(ctx, ctxKeyReplyToMessageID, replyToMessageID)
|
||||
return ctx
|
||||
}
|
||||
|
||||
// WithToolInboundContext returns a child context carrying channel/chat and inbound IDs.
|
||||
func WithToolInboundContext(
|
||||
ctx context.Context,
|
||||
channel, chatID, messageID, replyToMessageID string,
|
||||
) context.Context {
|
||||
ctx = WithToolContext(ctx, channel, chatID)
|
||||
ctx = WithToolMessageContext(ctx, messageID, replyToMessageID)
|
||||
return ctx
|
||||
}
|
||||
|
||||
// ToolChannel extracts the channel from ctx, or "" if unset.
|
||||
func ToolChannel(ctx context.Context) string {
|
||||
v, _ := ctx.Value(ctxKeyChannel).(string)
|
||||
|
|
@ -44,6 +63,18 @@ func ToolChatID(ctx context.Context) string {
|
|||
return v
|
||||
}
|
||||
|
||||
// ToolMessageID extracts the current inbound message ID from ctx, or "" if unset.
|
||||
func ToolMessageID(ctx context.Context) string {
|
||||
v, _ := ctx.Value(ctxKeyMessageID).(string)
|
||||
return v
|
||||
}
|
||||
|
||||
// ToolReplyToMessageID extracts the current inbound reply target from ctx, or "" if unset.
|
||||
func ToolReplyToMessageID(ctx context.Context) string {
|
||||
v, _ := ctx.Value(ctxKeyReplyToMessageID).(string)
|
||||
return v
|
||||
}
|
||||
|
||||
// AsyncCallback is a function type that async tools use to notify completion.
|
||||
// When an async tool finishes its work, it calls this callback with the result.
|
||||
//
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ func (t *CronTool) Parameters() map[string]any {
|
|||
},
|
||||
"command": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Optional: Shell command to execute directly (e.g., 'df -h'). If set, the agent will run this command and report output instead of just showing the message. 'deliver' will be forced to false for commands.",
|
||||
"description": "Optional: Shell command to execute directly (e.g., 'df -h'). If set, the agent will run this command and report output instead of just showing the message.",
|
||||
},
|
||||
"command_confirm": map[string]any{
|
||||
"type": "boolean",
|
||||
|
|
@ -114,15 +114,6 @@ func (t *CronTool) Parameters() map[string]any {
|
|||
"type": "string",
|
||||
"description": "Job ID (for remove/enable/disable)",
|
||||
},
|
||||
"type": map[string]any{
|
||||
"type": "string",
|
||||
"enum": []string{"message", "directive"},
|
||||
"description": "Message generation strategy. 'message' (default): content is sent directly as-is. 'directive': content is treated as instructions for an AI agent to execute before delivery.",
|
||||
},
|
||||
"deliver": map[string]any{
|
||||
"type": "boolean",
|
||||
"description": "If true, send message directly to channel. If false, let agent process message (for complex tasks). Default: false",
|
||||
},
|
||||
},
|
||||
"required": []string{"action"},
|
||||
}
|
||||
|
|
@ -199,18 +190,6 @@ func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult
|
|||
return ErrorResult("one of at_seconds, every_seconds, or cron_expr is required")
|
||||
}
|
||||
|
||||
// Read deliver parameter, default to false so scheduled tasks execute through the agent
|
||||
deliver := false
|
||||
if d, ok := args["deliver"].(bool); ok {
|
||||
deliver = d
|
||||
}
|
||||
|
||||
// Validate type parameter (server-side whitelist, not just LLM schema hint)
|
||||
msgType, _ := args["type"].(string)
|
||||
if msgType != "" && msgType != "message" && msgType != "directive" {
|
||||
return ErrorResult(fmt.Sprintf("invalid type %q, must be 'message' or 'directive'", msgType))
|
||||
}
|
||||
|
||||
// GHSA-pv8c-p6jf-3fpp: command scheduling requires internal channel. When
|
||||
// allow_command is disabled, explicit confirmation is required as an override.
|
||||
// Non-command reminders remain open to all channels.
|
||||
|
|
@ -226,7 +205,6 @@ func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult
|
|||
if !t.allowCommand && !commandConfirm {
|
||||
return ErrorResult("command_confirm=true is required when allow_command is disabled")
|
||||
}
|
||||
deliver = false
|
||||
}
|
||||
|
||||
// Truncate message for job name (max 30 chars)
|
||||
|
|
@ -236,7 +214,6 @@ func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult
|
|||
messagePreview,
|
||||
schedule,
|
||||
message,
|
||||
deliver,
|
||||
channel,
|
||||
chatID,
|
||||
)
|
||||
|
|
@ -250,10 +227,6 @@ func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult
|
|||
job.Payload.Command = command
|
||||
needsUpdate = true
|
||||
}
|
||||
if msgType != "" {
|
||||
job.Payload.Type = msgType
|
||||
needsUpdate = true
|
||||
}
|
||||
if needsUpdate {
|
||||
t.cronService.UpdateJob(job)
|
||||
}
|
||||
|
|
@ -369,40 +342,12 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string {
|
|||
return "ok"
|
||||
}
|
||||
|
||||
// Determine message generation strategy
|
||||
// Type="directive": treat message as instructions for AI agent to execute
|
||||
// Type="" or "message" (default): static message content
|
||||
isDirective := job.Payload.Type == "directive"
|
||||
|
||||
// If deliver=true and not directive, send message directly without agent processing
|
||||
if job.Payload.Deliver && !isDirective {
|
||||
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer pubCancel()
|
||||
t.msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
|
||||
Channel: channel,
|
||||
ChatID: chatID,
|
||||
Content: job.Payload.Message,
|
||||
})
|
||||
return "ok"
|
||||
}
|
||||
|
||||
// For deliver=false OR directive mode, process through agent
|
||||
sessionKey := fmt.Sprintf("cron-%s", job.ID)
|
||||
|
||||
// Prepare the prompt based on type
|
||||
prompt := job.Payload.Message
|
||||
if isDirective {
|
||||
// For directive type, prefix to clarify this is an instruction
|
||||
prompt = fmt.Sprintf(
|
||||
"Please execute the following directive and provide the result:\n\n%s",
|
||||
job.Payload.Message,
|
||||
)
|
||||
}
|
||||
|
||||
// Call agent with the prepared prompt
|
||||
// Call agent with the job message
|
||||
response, err := t.executor.ProcessDirectWithChannel(
|
||||
ctx,
|
||||
prompt,
|
||||
job.Payload.Message,
|
||||
sessionKey,
|
||||
channel,
|
||||
chatID,
|
||||
|
|
|
|||
|
|
@ -229,28 +229,6 @@ func TestCronTool_NonCommandJobAllowedFromRemoteChannel(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCronTool_NonCommandJobDefaultsDeliverToFalse(t *testing.T) {
|
||||
tool := newTestCronTool(t)
|
||||
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
|
||||
result := tool.Execute(ctx, map[string]any{
|
||||
"action": "add",
|
||||
"message": "send me a poem",
|
||||
"at_seconds": float64(600),
|
||||
})
|
||||
|
||||
if result.IsError {
|
||||
t.Fatalf("expected non-command reminder to succeed, got: %s", result.ForLLM)
|
||||
}
|
||||
|
||||
jobs := tool.cronService.ListJobs(false)
|
||||
if len(jobs) != 1 {
|
||||
t.Fatalf("expected 1 job, got %d", len(jobs))
|
||||
}
|
||||
if jobs[0].Payload.Deliver {
|
||||
t.Fatal("expected deliver=false by default for non-command jobs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronTool_ExecuteJobPublishesErrorWhenExecDisabled(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Tools.Exec.Enabled = false
|
||||
|
|
@ -346,93 +324,6 @@ func TestCronTool_ExecuteJobSkipsWhenMessageToolAlreadySent(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCronTool_ExecuteJobDirectiveAddsPromptPrefix(t *testing.T) {
|
||||
executor := &stubJobExecutor{response: "directive result"}
|
||||
tool := newTestCronToolWithExecutorAndConfig(t, executor, config.DefaultConfig())
|
||||
|
||||
originalMsg := "check the weather and summarize"
|
||||
job := &cron.CronJob{ID: "job-dir-1"}
|
||||
job.Payload.Channel = "telegram"
|
||||
job.Payload.To = "chat-1"
|
||||
job.Payload.Message = originalMsg
|
||||
job.Payload.Type = "directive"
|
||||
|
||||
if got := tool.ExecuteJob(context.Background(), job); got != "ok" {
|
||||
t.Fatalf("ExecuteJob() = %q, want ok", got)
|
||||
}
|
||||
|
||||
wantPrompt := "Please execute the following directive and provide the result:\n\n" + originalMsg
|
||||
if executor.lastPrompt != wantPrompt {
|
||||
t.Fatalf("prompt = %q, want exact %q", executor.lastPrompt, wantPrompt)
|
||||
}
|
||||
if executor.publishedResp != "directive result" {
|
||||
t.Fatalf("published response = %q, want %q", executor.publishedResp, "directive result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronTool_ExecuteJobDirectiveWithDeliverRoutesToAgent(t *testing.T) {
|
||||
executor := &stubJobExecutor{response: "agent processed"}
|
||||
tool := newTestCronToolWithExecutorAndConfig(t, executor, config.DefaultConfig())
|
||||
|
||||
job := &cron.CronJob{ID: "job-dir-deliver"}
|
||||
job.Payload.Channel = "telegram"
|
||||
job.Payload.To = "chat-1"
|
||||
job.Payload.Message = "generate daily report"
|
||||
job.Payload.Type = "directive"
|
||||
job.Payload.Deliver = true
|
||||
|
||||
if got := tool.ExecuteJob(context.Background(), job); got != "ok" {
|
||||
t.Fatalf("ExecuteJob() = %q, want ok", got)
|
||||
}
|
||||
|
||||
if executor.lastPrompt == "" {
|
||||
t.Fatal("expected agent to be called for directive+deliver, but ProcessDirectWithChannel was not invoked")
|
||||
}
|
||||
if executor.publishedResp != "agent processed" {
|
||||
t.Fatalf("published response = %q, want %q", executor.publishedResp, "agent processed")
|
||||
}
|
||||
|
||||
// Verify no direct publish happened on the bus (agent path, not direct path)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
select {
|
||||
case msg := <-tool.msgBus.OutboundChan():
|
||||
t.Fatalf("unexpected direct bus message: %+v", msg)
|
||||
case <-ctx.Done():
|
||||
// expected: no direct bus message
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronTool_ExecuteJobDeliverMessageDirectlyToBus(t *testing.T) {
|
||||
executor := &stubJobExecutor{response: "should not be called"}
|
||||
tool := newTestCronToolWithExecutorAndConfig(t, executor, config.DefaultConfig())
|
||||
|
||||
job := &cron.CronJob{ID: "job-deliver"}
|
||||
job.Payload.Channel = "telegram"
|
||||
job.Payload.To = "chat-1"
|
||||
job.Payload.Message = "hello world"
|
||||
job.Payload.Deliver = true
|
||||
|
||||
if got := tool.ExecuteJob(context.Background(), job); got != "ok" {
|
||||
t.Fatalf("ExecuteJob() = %q, want ok", got)
|
||||
}
|
||||
|
||||
if executor.lastPrompt != "" {
|
||||
t.Fatal("expected agent NOT to be invoked for deliver=true message type")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
select {
|
||||
case msg := <-tool.msgBus.OutboundChan():
|
||||
if msg.Content != "hello world" {
|
||||
t.Fatalf("bus content = %q, want %q", msg.Content, "hello world")
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("timeout waiting for direct bus message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronTool_ExecuteJobReturnsErrorWithoutPublish(t *testing.T) {
|
||||
executor := &stubJobExecutor{
|
||||
response: "this response must not be published",
|
||||
|
|
@ -454,43 +345,3 @@ func TestCronTool_ExecuteJobReturnsErrorWithoutPublish(t *testing.T) {
|
|||
t.Fatalf("unexpected publish on error path: %q", executor.publishedResp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronTool_AddJobRejectsInvalidType(t *testing.T) {
|
||||
tool := newTestCronTool(t)
|
||||
ctx := WithToolContext(context.Background(), "cli", "direct")
|
||||
result := tool.Execute(ctx, map[string]any{
|
||||
"action": "add",
|
||||
"message": "test",
|
||||
"at_seconds": float64(60),
|
||||
"type": "invalid_type",
|
||||
})
|
||||
|
||||
if !result.IsError {
|
||||
t.Fatal("expected error for invalid type parameter")
|
||||
}
|
||||
if !strings.Contains(result.ForLLM, "invalid type") {
|
||||
t.Errorf("expected 'invalid type' error, got: %s", result.ForLLM)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronTool_AddJobAcceptsValidTypes(t *testing.T) {
|
||||
for _, msgType := range []string{"", "message", "directive"} {
|
||||
t.Run("type="+msgType, func(t *testing.T) {
|
||||
tool := newTestCronTool(t)
|
||||
ctx := WithToolContext(context.Background(), "cli", "direct")
|
||||
args := map[string]any{
|
||||
"action": "add",
|
||||
"message": "test",
|
||||
"at_seconds": float64(60),
|
||||
}
|
||||
if msgType != "" {
|
||||
args["type"] = msgType
|
||||
}
|
||||
|
||||
result := tool.Execute(ctx, args)
|
||||
if result.IsError {
|
||||
t.Fatalf("expected valid type %q to succeed, got: %s", msgType, result.ForLLM)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import (
|
|||
"sync/atomic"
|
||||
)
|
||||
|
||||
type SendCallback func(channel, chatID, content string) error
|
||||
type SendCallback func(channel, chatID, content, replyToMessageID string) error
|
||||
|
||||
type MessageTool struct {
|
||||
sendCallback SendCallback
|
||||
|
|
@ -41,6 +41,10 @@ func (t *MessageTool) Parameters() map[string]any {
|
|||
"type": "string",
|
||||
"description": "Optional: target chat/user ID",
|
||||
},
|
||||
"reply_to_message_id": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Optional: reply target message ID for channels that support threaded replies",
|
||||
},
|
||||
},
|
||||
"required": []string{"content"},
|
||||
}
|
||||
|
|
@ -69,6 +73,7 @@ func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolRes
|
|||
|
||||
channel, _ := args["channel"].(string)
|
||||
chatID, _ := args["chat_id"].(string)
|
||||
replyToMessageID, _ := args["reply_to_message_id"].(string)
|
||||
|
||||
if channel == "" {
|
||||
channel = ToolChannel(ctx)
|
||||
|
|
@ -85,7 +90,7 @@ func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolRes
|
|||
return &ToolResult{ForLLM: "Message sending not configured", IsError: true}
|
||||
}
|
||||
|
||||
if err := t.sendCallback(channel, chatID, content); err != nil {
|
||||
if err := t.sendCallback(channel, chatID, content, replyToMessageID); err != nil {
|
||||
return &ToolResult{
|
||||
ForLLM: fmt.Sprintf("sending message: %v", err),
|
||||
IsError: true,
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ func TestMessageTool_Execute_Success(t *testing.T) {
|
|||
tool := NewMessageTool()
|
||||
|
||||
var sentChannel, sentChatID, sentContent string
|
||||
tool.SetSendCallback(func(channel, chatID, content string) error {
|
||||
tool.SetSendCallback(func(channel, chatID, content, replyToMessageID string) error {
|
||||
sentChannel = channel
|
||||
sentChatID = chatID
|
||||
sentContent = content
|
||||
|
|
@ -61,7 +61,7 @@ func TestMessageTool_Execute_WithCustomChannel(t *testing.T) {
|
|||
tool := NewMessageTool()
|
||||
|
||||
var sentChannel, sentChatID string
|
||||
tool.SetSendCallback(func(channel, chatID, content string) error {
|
||||
tool.SetSendCallback(func(channel, chatID, content, replyToMessageID string) error {
|
||||
sentChannel = channel
|
||||
sentChatID = chatID
|
||||
return nil
|
||||
|
|
@ -96,7 +96,7 @@ func TestMessageTool_Execute_SendFailure(t *testing.T) {
|
|||
tool := NewMessageTool()
|
||||
|
||||
sendErr := errors.New("network error")
|
||||
tool.SetSendCallback(func(channel, chatID, content string) error {
|
||||
tool.SetSendCallback(func(channel, chatID, content, replyToMessageID string) error {
|
||||
return sendErr
|
||||
})
|
||||
|
||||
|
|
@ -149,7 +149,7 @@ func TestMessageTool_Execute_NoTargetChannel(t *testing.T) {
|
|||
tool := NewMessageTool()
|
||||
// No WithToolContext — channel/chatID are empty
|
||||
|
||||
tool.SetSendCallback(func(channel, chatID, content string) error {
|
||||
tool.SetSendCallback(func(channel, chatID, content, replyToMessageID string) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
|
|
@ -251,4 +251,37 @@ func TestMessageTool_Parameters(t *testing.T) {
|
|||
if chatIDProp["type"] != "string" {
|
||||
t.Error("Expected chat_id type to be 'string'")
|
||||
}
|
||||
|
||||
// Check reply_to_message_id property (optional)
|
||||
replyToProp, ok := props["reply_to_message_id"].(map[string]any)
|
||||
if !ok {
|
||||
t.Error("Expected 'reply_to_message_id' property")
|
||||
}
|
||||
if replyToProp["type"] != "string" {
|
||||
t.Error("Expected reply_to_message_id type to be 'string'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageTool_Execute_WithReplyToMessageID(t *testing.T) {
|
||||
tool := NewMessageTool()
|
||||
|
||||
var sentReplyTo string
|
||||
tool.SetSendCallback(func(channel, chatID, content, replyToMessageID string) error {
|
||||
sentReplyTo = replyToMessageID
|
||||
return nil
|
||||
})
|
||||
|
||||
ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id")
|
||||
args := map[string]any{
|
||||
"content": "Reply test",
|
||||
"reply_to_message_id": "msg-123",
|
||||
}
|
||||
|
||||
result := tool.Execute(ctx, args)
|
||||
if result.IsError {
|
||||
t.Fatalf("expected success, got error: %s", result.ForLLM)
|
||||
}
|
||||
if sentReplyTo != "msg-123" {
|
||||
t.Fatalf("expected reply_to_message_id msg-123, got %q", sentReplyTo)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
87
pkg/tools/reaction.go
Normal file
87
pkg/tools/reaction.go
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type ReactionCallback func(ctx context.Context, channel, chatID, messageID string) error
|
||||
|
||||
type ReactionTool struct {
|
||||
reactionCallback ReactionCallback
|
||||
}
|
||||
|
||||
func NewReactionTool() *ReactionTool {
|
||||
return &ReactionTool{}
|
||||
}
|
||||
|
||||
func (t *ReactionTool) Name() string {
|
||||
return "reaction"
|
||||
}
|
||||
|
||||
func (t *ReactionTool) Description() string {
|
||||
return "Add a reaction to a message. Defaults to the current inbound message when message_id is omitted."
|
||||
}
|
||||
|
||||
func (t *ReactionTool) Parameters() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"message_id": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Optional: target message ID; defaults to the current inbound message",
|
||||
},
|
||||
"channel": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Optional: target channel (telegram, whatsapp, etc.)",
|
||||
},
|
||||
"chat_id": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Optional: target chat/user ID",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *ReactionTool) SetReactionCallback(callback ReactionCallback) {
|
||||
t.reactionCallback = callback
|
||||
}
|
||||
|
||||
func (t *ReactionTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
||||
channel, _ := args["channel"].(string)
|
||||
chatID, _ := args["chat_id"].(string)
|
||||
messageID, _ := args["message_id"].(string)
|
||||
|
||||
if channel == "" {
|
||||
channel = ToolChannel(ctx)
|
||||
}
|
||||
if chatID == "" {
|
||||
chatID = ToolChatID(ctx)
|
||||
}
|
||||
if messageID == "" {
|
||||
messageID = ToolMessageID(ctx)
|
||||
}
|
||||
|
||||
if channel == "" || chatID == "" {
|
||||
return &ToolResult{ForLLM: "No target channel/chat specified", IsError: true}
|
||||
}
|
||||
if messageID == "" {
|
||||
return &ToolResult{ForLLM: "message_id is required", IsError: true}
|
||||
}
|
||||
if t.reactionCallback == nil {
|
||||
return &ToolResult{ForLLM: "Reaction not configured", IsError: true}
|
||||
}
|
||||
|
||||
if err := t.reactionCallback(ctx, channel, chatID, messageID); err != nil {
|
||||
return &ToolResult{
|
||||
ForLLM: fmt.Sprintf("adding reaction: %v", err),
|
||||
IsError: true,
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
return &ToolResult{
|
||||
ForLLM: fmt.Sprintf("Reaction added to %s:%s message %s", channel, chatID, messageID),
|
||||
Silent: true,
|
||||
}
|
||||
}
|
||||
96
pkg/tools/reaction_test.go
Normal file
96
pkg/tools/reaction_test.go
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReactionTool_Execute_UsesContextMessageIDByDefault(t *testing.T) {
|
||||
tool := NewReactionTool()
|
||||
|
||||
var gotChannel, gotChatID, gotMessageID string
|
||||
tool.SetReactionCallback(func(ctx context.Context, channel, chatID, messageID string) error {
|
||||
gotChannel = channel
|
||||
gotChatID = chatID
|
||||
gotMessageID = messageID
|
||||
return nil
|
||||
})
|
||||
|
||||
ctx := WithToolInboundContext(context.Background(), "telegram", "chat-1", "msg-100", "")
|
||||
result := tool.Execute(ctx, map[string]any{})
|
||||
if result.IsError {
|
||||
t.Fatalf("expected success, got error: %s", result.ForLLM)
|
||||
}
|
||||
if gotChannel != "telegram" || gotChatID != "chat-1" || gotMessageID != "msg-100" {
|
||||
t.Fatalf("unexpected callback args: channel=%q chatID=%q messageID=%q", gotChannel, gotChatID, gotMessageID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReactionTool_Execute_AllowsExplicitMessageIDOverride(t *testing.T) {
|
||||
tool := NewReactionTool()
|
||||
|
||||
var gotMessageID string
|
||||
tool.SetReactionCallback(func(ctx context.Context, channel, chatID, messageID string) error {
|
||||
gotMessageID = messageID
|
||||
return nil
|
||||
})
|
||||
|
||||
ctx := WithToolInboundContext(context.Background(), "telegram", "chat-1", "msg-context", "")
|
||||
result := tool.Execute(ctx, map[string]any{"message_id": "msg-explicit"})
|
||||
if result.IsError {
|
||||
t.Fatalf("expected success, got error: %s", result.ForLLM)
|
||||
}
|
||||
if gotMessageID != "msg-explicit" {
|
||||
t.Fatalf("expected explicit message id, got %q", gotMessageID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReactionTool_Execute_MissingMessageID(t *testing.T) {
|
||||
tool := NewReactionTool()
|
||||
tool.SetReactionCallback(func(ctx context.Context, channel, chatID, messageID string) error { return nil })
|
||||
|
||||
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
|
||||
result := tool.Execute(ctx, map[string]any{})
|
||||
if !result.IsError {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if result.ForLLM != "message_id is required" {
|
||||
t.Fatalf("unexpected error message: %q", result.ForLLM)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReactionTool_Execute_CallbackError(t *testing.T) {
|
||||
tool := NewReactionTool()
|
||||
tool.SetReactionCallback(func(ctx context.Context, channel, chatID, messageID string) error {
|
||||
return errors.New("unsupported")
|
||||
})
|
||||
|
||||
ctx := WithToolInboundContext(context.Background(), "telegram", "chat-1", "msg-100", "")
|
||||
result := tool.Execute(ctx, map[string]any{})
|
||||
if !result.IsError {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if result.Err == nil {
|
||||
t.Fatal("expected wrapped error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReactionTool_Parameters(t *testing.T) {
|
||||
tool := NewReactionTool()
|
||||
params := tool.Parameters()
|
||||
|
||||
props, ok := params["properties"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("expected properties map")
|
||||
}
|
||||
if _, ok := props["message_id"]; !ok {
|
||||
t.Fatal("expected message_id parameter")
|
||||
}
|
||||
if _, ok := props["channel"]; !ok {
|
||||
t.Fatal("expected channel parameter")
|
||||
}
|
||||
if _, ok := props["chat_id"]; !ok {
|
||||
t.Fatal("expected chat_id parameter")
|
||||
}
|
||||
}
|
||||
|
|
@ -190,6 +190,33 @@ func TestToolRegistry_ExecuteWithContext_EmptyContext(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestToolRegistry_ExecuteWithContext_PreservesMessageContext(t *testing.T) {
|
||||
r := NewToolRegistry()
|
||||
ct := &mockContextAwareTool{
|
||||
mockRegistryTool: *newMockTool("ctx_tool", "needs context"),
|
||||
}
|
||||
r.Register(ct)
|
||||
|
||||
baseCtx := WithToolMessageContext(context.Background(), "msg-123", "msg-100")
|
||||
r.ExecuteWithContext(baseCtx, "ctx_tool", nil, "telegram", "chat-42", nil)
|
||||
|
||||
if ct.lastCtx == nil {
|
||||
t.Fatal("expected Execute to be called")
|
||||
}
|
||||
if got := ToolChannel(ct.lastCtx); got != "telegram" {
|
||||
t.Errorf("expected channel 'telegram', got %q", got)
|
||||
}
|
||||
if got := ToolChatID(ct.lastCtx); got != "chat-42" {
|
||||
t.Errorf("expected chatID 'chat-42', got %q", got)
|
||||
}
|
||||
if got := ToolMessageID(ct.lastCtx); got != "msg-123" {
|
||||
t.Errorf("expected messageID 'msg-123', got %q", got)
|
||||
}
|
||||
if got := ToolReplyToMessageID(ct.lastCtx); got != "msg-100" {
|
||||
t.Errorf("expected replyToMessageID 'msg-100', got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolRegistry_ExecuteWithContext_AsyncCallback(t *testing.T) {
|
||||
r := NewToolRegistry()
|
||||
at := &mockAsyncRegistryTool{
|
||||
|
|
|
|||
|
|
@ -29,18 +29,18 @@ const (
|
|||
DefaultBM25B = 0.75
|
||||
)
|
||||
|
||||
// BM25Engine is a query-time BM25 search engine over a generic corpus.
|
||||
// BM25Engine is a BM25 search engine over a generic corpus.
|
||||
// T is the document type; the caller supplies a TextFunc that extracts the
|
||||
// searchable text from each document.
|
||||
//
|
||||
// The engine is stateless between queries: no caching, no invalidation logic.
|
||||
// All indexing work is performed inside Search() on every call, making it
|
||||
// safe to use on corpora that change frequently.
|
||||
// The engine precomputes its index once at construction time and reuses it for
|
||||
// subsequent searches. If the corpus content changes, construct a new engine.
|
||||
type BM25Engine[T any] struct {
|
||||
corpus []T
|
||||
textFunc func(T) string
|
||||
k1 float64
|
||||
b float64
|
||||
index *bm25Index
|
||||
}
|
||||
|
||||
// BM25Option is a functional option to configure a BM25Engine.
|
||||
|
|
@ -51,6 +51,17 @@ type bm25Config struct {
|
|||
b float64
|
||||
}
|
||||
|
||||
type bm25Index struct {
|
||||
entries []bm25DocEntry
|
||||
idf map[string]float32
|
||||
docLenNorm []float32
|
||||
posting map[string][]int32
|
||||
}
|
||||
|
||||
type bm25DocEntry struct {
|
||||
tf map[string]uint32
|
||||
}
|
||||
|
||||
// WithK1 overrides the term-frequency saturation constant (default 1.2).
|
||||
func WithK1(k1 float64) BM25Option {
|
||||
return func(c *bm25Config) { c.k1 = k1 }
|
||||
|
|
@ -74,12 +85,14 @@ func NewBM25Engine[T any](corpus []T, textFunc func(T) string, opts ...BM25Optio
|
|||
for _, o := range opts {
|
||||
o(&cfg)
|
||||
}
|
||||
return &BM25Engine[T]{
|
||||
engine := &BM25Engine[T]{
|
||||
corpus: corpus,
|
||||
textFunc: textFunc,
|
||||
k1: cfg.k1,
|
||||
b: cfg.b,
|
||||
}
|
||||
engine.index = buildBM25Index(corpus, textFunc, cfg.k1, cfg.b)
|
||||
return engine
|
||||
}
|
||||
|
||||
// BM25Result is a single ranked result from a Search call.
|
||||
|
|
@ -91,9 +104,8 @@ type BM25Result[T any] struct {
|
|||
// Search ranks the corpus against query and returns the top-k results.
|
||||
// Returns an empty slice (not nil) when there are no matches.
|
||||
//
|
||||
// Complexity: O(N×L) for indexing + O(|Q|×avgPostingLen) for scoring,
|
||||
// where N = corpus size, L = average document length, Q = query terms.
|
||||
// Top-k extraction uses a fixed-size min-heap: O(candidates × log k).
|
||||
// Complexity: O(|Q|×avgPostingLen + candidates × log k) per search after the
|
||||
// one-time indexing work performed by NewBM25Engine.
|
||||
func (e *BM25Engine[T]) Search(query string, topK int) []BM25Result[T] {
|
||||
if topK <= 0 {
|
||||
return []BM25Result[T]{}
|
||||
|
|
@ -104,78 +116,24 @@ func (e *BM25Engine[T]) Search(query string, topK int) []BM25Result[T] {
|
|||
return []BM25Result[T]{}
|
||||
}
|
||||
|
||||
N := len(e.corpus)
|
||||
if N == 0 {
|
||||
if len(e.corpus) == 0 || e.index == nil {
|
||||
return []BM25Result[T]{}
|
||||
}
|
||||
|
||||
// Step 1: build per-document tf + raw doc lengths
|
||||
type docEntry struct {
|
||||
tf map[string]uint32
|
||||
rawLen int
|
||||
}
|
||||
|
||||
entries := make([]docEntry, N)
|
||||
df := make(map[string]int, 64)
|
||||
totalLen := 0
|
||||
|
||||
for i, doc := range e.corpus {
|
||||
tokens := bm25Tokenize(e.textFunc(doc))
|
||||
totalLen += len(tokens)
|
||||
|
||||
tf := make(map[string]uint32, len(tokens))
|
||||
for _, t := range tokens {
|
||||
tf[t]++
|
||||
}
|
||||
// df: each term counts once per document (iterate the map, keys are unique)
|
||||
for t := range tf {
|
||||
df[t]++
|
||||
}
|
||||
|
||||
entries[i] = docEntry{tf: tf, rawLen: len(tokens)}
|
||||
}
|
||||
|
||||
avgDocLen := float64(totalLen) / float64(N)
|
||||
|
||||
// Step 2: pre-compute IDF and per-doc length normalization
|
||||
// IDF (Robertson smoothing): log( (N - df(t) + 0.5) / (df(t) + 0.5) + 1 )
|
||||
idf := make(map[string]float32, len(df))
|
||||
for term, freq := range df {
|
||||
idf[term] = float32(math.Log(
|
||||
(float64(N)-float64(freq)+0.5)/(float64(freq)+0.5) + 1,
|
||||
))
|
||||
}
|
||||
|
||||
// docLenNorm[i] = k1 * (1 - b + b * |doc_i| / avgDocLen)
|
||||
// Stored as float32 — sufficient precision for ranking.
|
||||
docLenNorm := make([]float32, N)
|
||||
for i, entry := range entries {
|
||||
docLenNorm[i] = float32(e.k1 * (1 - e.b + e.b*float64(entry.rawLen)/avgDocLen))
|
||||
}
|
||||
|
||||
// Step 3: build inverted index (posting lists)
|
||||
// Iterate the tf map directly — map keys are already unique, no seen-set needed.
|
||||
posting := make(map[string][]int32, len(df))
|
||||
for i, entry := range entries {
|
||||
for term := range entry.tf {
|
||||
posting[term] = append(posting[term], int32(i))
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: score via posting lists
|
||||
// Deduplicate query terms to avoid double-weighting the same term.
|
||||
unique := bm25Dedupe(queryTerms)
|
||||
|
||||
scores := make(map[int32]float32)
|
||||
for _, term := range unique {
|
||||
termIDF, ok := idf[term]
|
||||
termIDF, ok := e.index.idf[term]
|
||||
if !ok {
|
||||
continue // term not in vocabulary → zero contribution
|
||||
}
|
||||
for _, docID := range posting[term] {
|
||||
freq := float32(entries[docID].tf[term])
|
||||
for _, docID := range e.index.posting[term] {
|
||||
freq := float32(e.index.entries[docID].tf[term])
|
||||
// TF_norm = freq * (k1+1) / (freq + docLenNorm)
|
||||
tfNorm := freq * float32(e.k1+1) / (freq + docLenNorm[docID])
|
||||
tfNorm := freq * float32(e.k1+1) / (freq + e.index.docLenNorm[docID])
|
||||
scores[docID] += termIDF * tfNorm
|
||||
}
|
||||
}
|
||||
|
|
@ -212,6 +170,65 @@ func (e *BM25Engine[T]) Search(query string, topK int) []BM25Result[T] {
|
|||
return out
|
||||
}
|
||||
|
||||
func buildBM25Index[T any](corpus []T, textFunc func(T) string, k1, b float64) *bm25Index {
|
||||
N := len(corpus)
|
||||
if N == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
entries := make([]bm25DocEntry, N)
|
||||
rawLens := make([]int, N)
|
||||
df := make(map[string]int, 64)
|
||||
totalLen := 0
|
||||
|
||||
for i, doc := range corpus {
|
||||
tokens := bm25Tokenize(textFunc(doc))
|
||||
totalLen += len(tokens)
|
||||
rawLens[i] = len(tokens)
|
||||
|
||||
tf := make(map[string]uint32, len(tokens))
|
||||
for _, t := range tokens {
|
||||
tf[t]++
|
||||
}
|
||||
for term := range tf {
|
||||
df[term]++
|
||||
}
|
||||
|
||||
entries[i] = bm25DocEntry{tf: tf}
|
||||
}
|
||||
|
||||
avgDocLen := float64(totalLen) / float64(N)
|
||||
if avgDocLen == 0 {
|
||||
avgDocLen = 1
|
||||
}
|
||||
|
||||
idf := make(map[string]float32, len(df))
|
||||
for term, freq := range df {
|
||||
idf[term] = float32(math.Log(
|
||||
(float64(N)-float64(freq)+0.5)/(float64(freq)+0.5) + 1,
|
||||
))
|
||||
}
|
||||
|
||||
docLenNorm := make([]float32, N)
|
||||
for i, rawLen := range rawLens {
|
||||
docLenNorm[i] = float32(k1 * (1 - b + b*float64(rawLen)/avgDocLen))
|
||||
}
|
||||
|
||||
posting := make(map[string][]int32, len(df))
|
||||
for i, entry := range entries {
|
||||
for term := range entry.tf {
|
||||
posting[term] = append(posting[term], int32(i))
|
||||
}
|
||||
}
|
||||
|
||||
return &bm25Index{
|
||||
entries: entries,
|
||||
idf: idf,
|
||||
docLenNorm: docLenNorm,
|
||||
posting: posting,
|
||||
}
|
||||
}
|
||||
|
||||
// bm25Tokenize splits s into lowercase tokens, stripping edge punctuation.
|
||||
func bm25Tokenize(s string) []string {
|
||||
raw := strings.Fields(strings.ToLower(s))
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
|
|
@ -173,3 +175,61 @@ func TestBM25Search_SortingStability(t *testing.T) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkBM25Search_ReusedIndex(b *testing.B) {
|
||||
corpus := benchmarkBM25Corpus(2000)
|
||||
engine := NewBM25Engine(corpus, extractText)
|
||||
query := "hardware gpio i2c sensor controller latency"
|
||||
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
results := engine.Search(query, 10)
|
||||
if len(results) == 0 {
|
||||
b.Fatal("expected non-empty results")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkBM25Search_RebuildEachTime(b *testing.B) {
|
||||
corpus := benchmarkBM25Corpus(2000)
|
||||
query := "hardware gpio i2c sensor controller latency"
|
||||
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
engine := NewBM25Engine(corpus, extractText)
|
||||
results := engine.Search(query, 10)
|
||||
if len(results) == 0 {
|
||||
b.Fatal("expected non-empty results")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func benchmarkBM25Corpus(size int) []testDoc {
|
||||
corpus := make([]testDoc, size)
|
||||
topics := []string{
|
||||
"hardware gpio pwm adc sensor controller latency throughput",
|
||||
"telegram markdown parser message escape formatting bot command",
|
||||
"jsonl memory session history storage append compact recovery",
|
||||
"openai provider routing agent tool search registry hidden tools",
|
||||
"i2c spi uart serial device bus address transfer clock",
|
||||
}
|
||||
|
||||
for i := range corpus {
|
||||
topic := topics[i%len(topics)]
|
||||
corpus[i] = testDoc{
|
||||
ID: i,
|
||||
Text: fmt.Sprintf(
|
||||
"doc %d %s repeated repeated %s variant-%d %s",
|
||||
i,
|
||||
topic,
|
||||
topic,
|
||||
i%17,
|
||||
strings.Repeat("token ", (i%7)+1),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
return corpus
|
||||
}
|
||||
|
|
|
|||
64
web/Makefile
64
web/Makefile
|
|
@ -1,12 +1,20 @@
|
|||
.PHONY: dev dev-frontend dev-backend build test lint clean
|
||||
.PHONY: dev dev-frontend dev-backend build build-frontend build-dev-picoclaw test lint clean
|
||||
|
||||
# Go variables
|
||||
GO?=CGO_ENABLED=0 go
|
||||
WEB_GO?=$(GO)
|
||||
GOFLAGS?=-v -tags stdjson
|
||||
GO_BUILD_TAGS?=goolm,stdjson
|
||||
GOFLAGS?=-v -tags $(GO_BUILD_TAGS)
|
||||
|
||||
# Build variables
|
||||
BUILD_DIR=build
|
||||
OUTPUT?=$(BUILD_DIR)/picoclaw-launcher
|
||||
FRONTEND_DIR=frontend
|
||||
BACKEND_DIR=backend
|
||||
BACKEND_DIST=$(BACKEND_DIR)/dist
|
||||
PICOCLAW_BINARY_NAME=picoclaw
|
||||
PICOCLAW_BINARY?=$(abspath ../build/$(PICOCLAW_BINARY_NAME))
|
||||
LAUNCHER_GUI_LDFLAG=
|
||||
|
||||
# Version
|
||||
VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||
|
|
@ -52,45 +60,63 @@ else ifeq ($(UNAME_S),Darwin)
|
|||
else ifeq ($(UNAME_S),Windows)
|
||||
PLATFORM=windows
|
||||
ARCH=$(UNAME_M)
|
||||
LDFLAGS=-H=windowsgui $(LDFLAGS)
|
||||
PICOCLAW_BINARY_NAME=picoclaw.exe
|
||||
LAUNCHER_GUI_LDFLAG=-H=windowsgui
|
||||
else
|
||||
PLATFORM=$(UNAME_S)
|
||||
ARCH=$(UNAME_M)
|
||||
endif
|
||||
|
||||
LAUNCHER_LDFLAGS=$(strip $(LAUNCHER_GUI_LDFLAG) $(LDFLAGS))
|
||||
|
||||
# Run both frontend and backend dev servers
|
||||
dev:
|
||||
@if [ ! -f $(BUILD_DIR)/picoclaw-launcher ] || [ ! -d backend/dist ]; then \
|
||||
echo "Build artifacts not found, building..."; \
|
||||
$(MAKE) build; \
|
||||
dev: build-dev-picoclaw
|
||||
@if [ ! -f "$(BACKEND_DIST)/index.html" ]; then \
|
||||
echo "Embedded frontend not found, building..."; \
|
||||
$(MAKE) build-frontend; \
|
||||
fi
|
||||
@echo "Starting backend and frontend dev servers..."
|
||||
@$(MAKE) dev-backend & $(MAKE) dev-frontend
|
||||
@$(MAKE) dev-backend BACKEND_ARGS='-no-browser' & $(MAKE) dev-frontend
|
||||
|
||||
# Start frontend dev server (Vite, with proxy to backend)
|
||||
dev-frontend:
|
||||
cd frontend && pnpm dev
|
||||
cd $(FRONTEND_DIR) && pnpm dev
|
||||
|
||||
# Start backend dev server
|
||||
dev-backend:
|
||||
cd backend && ${WEB_GO} run -ldflags "$(LDFLAGS)" .
|
||||
cd $(BACKEND_DIR) && PICOCLAW_BINARY="$(PICOCLAW_BINARY)" ${WEB_GO} run -ldflags "$(LAUNCHER_LDFLAGS)" . $(BACKEND_ARGS)
|
||||
|
||||
# Build frontend and embed into Go binary
|
||||
build:
|
||||
cd frontend && pnpm build:backend
|
||||
${WEB_GO} build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/picoclaw-launcher ./backend/
|
||||
build: build-frontend
|
||||
@mkdir -p "$$(dirname "$(OUTPUT)")"
|
||||
${WEB_GO} build $(GOFLAGS) -ldflags "$(LAUNCHER_LDFLAGS)" -o "$(OUTPUT)" ./$(BACKEND_DIR)/
|
||||
|
||||
build-frontend:
|
||||
@if [ ! -d $(FRONTEND_DIR)/node_modules ] || \
|
||||
[ $(FRONTEND_DIR)/package.json -nt $(FRONTEND_DIR)/node_modules ] || \
|
||||
[ $(FRONTEND_DIR)/pnpm-lock.yaml -nt $(FRONTEND_DIR)/node_modules ]; then \
|
||||
echo "Installing frontend dependencies..."; \
|
||||
cd $(FRONTEND_DIR) && pnpm install --frozen-lockfile; \
|
||||
fi
|
||||
@echo "Building frontend..."
|
||||
@cd $(FRONTEND_DIR) && pnpm build:backend
|
||||
|
||||
build-dev-picoclaw:
|
||||
@echo "Building picoclaw for launcher development..."
|
||||
@mkdir -p "$$(dirname "$(PICOCLAW_BINARY)")"
|
||||
@$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o "$(PICOCLAW_BINARY)" ../cmd/picoclaw
|
||||
|
||||
# Run all tests
|
||||
test:
|
||||
cd backend && ${WEB_GO} test ./...
|
||||
cd frontend && pnpm lint
|
||||
cd $(BACKEND_DIR) && ${WEB_GO} test ./...
|
||||
cd $(FRONTEND_DIR) && pnpm lint
|
||||
|
||||
# Lint and format
|
||||
lint:
|
||||
cd backend && ${WEB_GO} vet ./...
|
||||
cd frontend && pnpm check
|
||||
cd $(BACKEND_DIR) && ${WEB_GO} vet ./...
|
||||
cd $(FRONTEND_DIR) && pnpm check
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
rm -rf frontend/dist backend/dist $(BUILD_DIR)
|
||||
mkdir -p backend/dist && touch backend/dist/.gitkeep
|
||||
rm -rf $(FRONTEND_DIR)/dist $(BACKEND_DIST) $(BUILD_DIR)
|
||||
node $(FRONTEND_DIR)/scripts/ensure-backend-gitkeep.cjs
|
||||
|
|
|
|||
374
web/README.md
374
web/README.md
|
|
@ -1,51 +1,383 @@
|
|||
# Picoclaw Web
|
||||
# PicoClaw Web
|
||||
|
||||
This directory contains the standalone web service for `picoclaw`.
|
||||
It provides a complete unified web interface, acting as a dashboard, configuration center, and interactive console (channel client) for the core `picoclaw` engine.
|
||||
`web/` contains the standalone WebUI launcher for PicoClaw.
|
||||
It is not just a frontend: it is a small launcher service that bundles a React dashboard, exposes a backend API, manages launcher authentication, and starts or attaches to the `picoclaw gateway` process.
|
||||
|
||||

|
||||
|
||||
## What This Directory Provides
|
||||
|
||||
- A browser-based chat UI backed by the Pico channel WebSocket proxy.
|
||||
- A dashboard for models, credentials, channels, agent tools, skills, logs, and runtime settings.
|
||||
- A launcher process that can auto-open the browser, show a system tray menu, and persist launcher-specific settings.
|
||||
- A controlled way to start, stop, restart, and inspect the `picoclaw gateway` subprocess.
|
||||
- A single-binary deployment target where the frontend is embedded into the Go backend.
|
||||
|
||||
## Architecture
|
||||
|
||||
The service is structured as a monorepo containing both the backend and frontend code to ensure high cohesion and simplify deployment.
|
||||
This directory is a small monorepo:
|
||||
|
||||
* **`backend/`**: The Go-based web server. It provides RESTful APIs, manages WebSocket connections for chat, and handles the lifecycle of the `picoclaw` process. It eventually embeds the compiled frontend assets into a single executable.
|
||||
* **`frontend/`**: The Vite + React + TanStack Router single-page application (SPA). It provides the interactive user interface.
|
||||
- `backend/`
|
||||
- Go HTTP server and launcher runtime.
|
||||
- Serves REST APIs, authentication endpoints, channel helper flows, and the Pico WebSocket reverse proxy.
|
||||
- Embeds compiled frontend assets from `backend/dist`.
|
||||
- `frontend/`
|
||||
- Vite + React 19 + TanStack Router SPA.
|
||||
- Provides the launcher dashboard and chat UI.
|
||||
|
||||
## Getting Started
|
||||
At runtime the launcher and the main PicoClaw engine are separate processes:
|
||||
|
||||
1. The launcher starts the web backend on port `18800` by default.
|
||||
2. The launcher serves the dashboard and handles dashboard authentication.
|
||||
3. When allowed, it starts or attaches to `picoclaw gateway -E`.
|
||||
4. The frontend talks only to the launcher backend.
|
||||
5. The launcher proxies chat traffic to the gateway through `/pico/ws`.
|
||||
|
||||
## Dashboard Capabilities
|
||||
|
||||
The current frontend exposes these major pages and flows:
|
||||
|
||||
- `/`
|
||||
- Chat UI with session history, default model selection, and Pico channel messaging.
|
||||
- `/models`
|
||||
- Add, edit, delete, and set the default model.
|
||||
- Supports API-key models, OAuth-backed models, and local/CLI-backed models.
|
||||
- `/credentials`
|
||||
- Manage provider credentials.
|
||||
- Current built-in flows: OpenAI, Anthropic, and Google Antigravity.
|
||||
- `/channels/*`
|
||||
- Configure supported channels from a shared catalog.
|
||||
- Current catalog: `weixin`, `telegram`, `discord`, `slack`, `feishu`, `dingtalk`, `line`, `qq`, `onebot`, `wecom`, `whatsapp`, `whatsapp_native`, `pico`, `maixcam`, `matrix`, `irc`.
|
||||
- Includes QR-based binding helpers for WeChat and WeCom.
|
||||
- `/agent/skills`
|
||||
- Browse built-in, global, and workspace skills.
|
||||
- Import Markdown skills into the workspace and delete workspace-owned skills.
|
||||
- `/agent/tools`
|
||||
- View tool availability and enable or disable tool switches through config-backed APIs.
|
||||
- `/config`
|
||||
- Edit agent defaults, exec controls, cron controls, heartbeat, device monitoring, launcher networking, and launch-at-login settings.
|
||||
- `/logs`
|
||||
- View the in-memory gateway log buffer and clear it.
|
||||
|
||||
The UI currently supports English and Simplified Chinese, plus light and dark themes.
|
||||
|
||||
## Runtime Behavior
|
||||
|
||||
### Config Resolution
|
||||
|
||||
The launcher uses the same PicoClaw config file as the main binary.
|
||||
|
||||
- Default app config path: `~/.picoclaw/config.json`
|
||||
- Override with environment variable: `PICOCLAW_CONFIG`
|
||||
- Override with a positional CLI argument: `picoclaw-launcher /path/to/config.json`
|
||||
|
||||
Launcher-only settings are stored beside that app config:
|
||||
|
||||
- File name: `launcher-config.json`
|
||||
- Default location: `~/.picoclaw/launcher-config.json`
|
||||
|
||||
That file currently stores:
|
||||
|
||||
- `port`
|
||||
- `public`
|
||||
- `allowed_cidrs`
|
||||
|
||||
If `-port` or `-public` are passed explicitly, the CLI flag wins for that run.
|
||||
If they are omitted, stored launcher settings are used.
|
||||
|
||||
### First-Run Onboarding
|
||||
|
||||
If the target config file does not exist, the launcher tries to bootstrap it automatically by running:
|
||||
|
||||
```bash
|
||||
picoclaw onboard
|
||||
```
|
||||
|
||||
The launcher looks for the main PicoClaw binary in this order:
|
||||
|
||||
1. `PICOCLAW_BINARY`
|
||||
2. A `picoclaw` binary in the same directory as the launcher
|
||||
3. `picoclaw` from `PATH`
|
||||
|
||||
If onboarding or gateway startup cannot find the main binary, set `PICOCLAW_BINARY` explicitly.
|
||||
|
||||
### Gateway Management
|
||||
|
||||
The launcher manages `picoclaw gateway -E`.
|
||||
|
||||
On startup it tries to auto-start or attach to the gateway, but only when startup preconditions pass. In the current code, the main checks are:
|
||||
|
||||
- a default model is configured
|
||||
- the default model entry is valid
|
||||
- the default model has usable credentials
|
||||
- local/runtime-probed models are reachable
|
||||
|
||||
When a gateway process is started by the launcher, the launcher:
|
||||
|
||||
- captures stdout and stderr into an in-memory ring buffer
|
||||
- tracks transient states such as `starting`, `restarting`, and `stopping`
|
||||
- marks restart-required when the default model or enabled tool set changed since boot
|
||||
- ensures the Pico channel is configured before startup
|
||||
|
||||
### Launcher Authentication
|
||||
|
||||
The dashboard is protected by a launcher access token.
|
||||
|
||||
- If `PICOCLAW_LAUNCHER_TOKEN` is set, that token is used.
|
||||
- Otherwise a random token is generated for each launcher process.
|
||||
- The browser auto-open URL includes `?token=...` so local launches can sign in automatically.
|
||||
- Manual login uses `/launcher-login`.
|
||||
- API clients may also authenticate with `Authorization: Bearer <token>`.
|
||||
|
||||
Where users can retrieve the token depends on launch mode:
|
||||
|
||||
- Console mode: printed to stdout
|
||||
- GUI mode: available through the tray menu on supported builds
|
||||
- GUI mode without stdout:
|
||||
- random per-run tokens are written to the launcher log
|
||||
- default log path: `~/.picoclaw/logs/launcher.log`
|
||||
- if `PICOCLAW_HOME` is set, use `$PICOCLAW_HOME/logs/launcher.log`
|
||||
- env-pinned tokens are not reprinted there; the log only notes that `PICOCLAW_LAUNCHER_TOKEN` is in use
|
||||
|
||||
### Network Exposure
|
||||
|
||||
By default the launcher listens on:
|
||||
|
||||
```text
|
||||
127.0.0.1:18800
|
||||
```
|
||||
|
||||
With `-public` or `public: true`, it listens on all interfaces:
|
||||
|
||||
```text
|
||||
0.0.0.0:18800
|
||||
```
|
||||
|
||||
When public access is enabled:
|
||||
|
||||
- the launcher can still protect the dashboard with the access token
|
||||
- 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
|
||||
|
||||
## Build And Run
|
||||
|
||||
### Prerequisites
|
||||
|
||||
* Go 1.25+
|
||||
* Node.js 20+ with pnpm
|
||||
- Go `1.25+`
|
||||
- Node.js 20.19+ or 22.13+
|
||||
- `pnpm`
|
||||
|
||||
### Development
|
||||
On macOS, the `web` Makefile enables `CGO_ENABLED=1` so tray-enabled launcher builds work as expected.
|
||||
On Darwin or FreeBSD without cgo, the launcher falls back to headless mode without a tray.
|
||||
|
||||
Run both the frontend dev server and the Go backend simultaneously:
|
||||
If you want to prepare the frontend workspace manually, you can still install dependencies yourself:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### Recommended Development Workflow
|
||||
|
||||
From the `web/` directory:
|
||||
|
||||
```bash
|
||||
make dev
|
||||
```
|
||||
|
||||
Or run them separately:
|
||||
This does three things:
|
||||
|
||||
1. Builds `../build/picoclaw` for launcher development.
|
||||
2. Starts the Go backend with `PICOCLAW_BINARY` pointing at that binary.
|
||||
3. Starts the Vite frontend dev server.
|
||||
|
||||
Use this when you want the full launcher flow during development.
|
||||
|
||||
### Run Frontend And Backend Separately
|
||||
|
||||
```bash
|
||||
make dev-frontend # Vite dev server
|
||||
make dev-backend # Go backend
|
||||
make dev-frontend
|
||||
make dev-backend
|
||||
```
|
||||
|
||||
### Build
|
||||
Notes:
|
||||
|
||||
Build the frontend and embed it into a single Go binary:
|
||||
- `dev-frontend` runs the Vite server.
|
||||
- `dev-backend` runs the Go backend only.
|
||||
- The Vite dev server proxies `/api` to `http://localhost:18800`.
|
||||
- Chat WebSocket URLs are generated by the backend, so the frontend does not hardcode gateway addresses.
|
||||
- Running `dev-backend` alone is mainly useful for backend work or when `backend/dist` already contains a built frontend.
|
||||
|
||||
### Build The Standalone Launcher Binary
|
||||
|
||||
From `web/`:
|
||||
|
||||
```bash
|
||||
make build
|
||||
```
|
||||
|
||||
The output binary is `backend/picoclaw-web`.
|
||||
This:
|
||||
|
||||
### Other Commands
|
||||
1. Installs frontend dependencies when needed.
|
||||
2. Builds the frontend into `backend/dist`.
|
||||
3. Embeds those assets into the Go backend.
|
||||
4. Produces `build/picoclaw-launcher`.
|
||||
|
||||
Override the output path if needed:
|
||||
|
||||
```bash
|
||||
make test # Run backend tests and frontend lint
|
||||
make lint # Run go vet and prettier/eslint
|
||||
make clean # Remove all build artifacts
|
||||
make build OUTPUT=/tmp/picoclaw-launcher
|
||||
```
|
||||
|
||||
From the repository root you can also use:
|
||||
|
||||
```bash
|
||||
make build-launcher
|
||||
```
|
||||
|
||||
That writes the platform-specific launcher to:
|
||||
|
||||
```text
|
||||
build/picoclaw-launcher-<platform>-<arch>
|
||||
```
|
||||
|
||||
and refreshes the `build/picoclaw-launcher` symlink.
|
||||
|
||||
### Frontend-Only Builds
|
||||
|
||||
For frontend work there are two useful package scripts:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
pnpm build
|
||||
pnpm build:backend
|
||||
```
|
||||
|
||||
- `pnpm build` writes a normal Vite build to `frontend/dist`
|
||||
- `pnpm build:backend` writes the embeddable build to `../backend/dist`
|
||||
|
||||
### Run The Built Launcher
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
./build/picoclaw-launcher
|
||||
./build/picoclaw-launcher -console
|
||||
./build/picoclaw-launcher -public
|
||||
./build/picoclaw-launcher -port 19999 /path/to/config.json
|
||||
```
|
||||
|
||||
Current launcher flags:
|
||||
|
||||
- `-port`
|
||||
- `-public`
|
||||
- `-no-browser`
|
||||
- `-lang`
|
||||
- `-console`
|
||||
|
||||
## Make Targets
|
||||
|
||||
From `web/`:
|
||||
|
||||
```bash
|
||||
make dev
|
||||
make dev-frontend
|
||||
make dev-backend
|
||||
make build
|
||||
make build-frontend
|
||||
make test
|
||||
make lint
|
||||
make clean
|
||||
```
|
||||
|
||||
What they do today:
|
||||
|
||||
- `make build-frontend`
|
||||
- Runs `pnpm install --frozen-lockfile` when dependencies are missing or stale.
|
||||
- Builds the embeddable frontend into `backend/dist`.
|
||||
- `make test`
|
||||
- Runs backend Go tests.
|
||||
- Runs frontend `pnpm lint`.
|
||||
- `make lint`
|
||||
- Runs backend `go vet`.
|
||||
- Runs frontend `pnpm check`.
|
||||
- `pnpm check` currently formats files with Prettier and fixes lint issues with ESLint, so this target can modify your working tree.
|
||||
- `make clean`
|
||||
- Removes `frontend/dist`, `backend/dist`, and `build/`, then recreates `backend/dist/.gitkeep`.
|
||||
|
||||
## Directory Layout
|
||||
|
||||
```text
|
||||
web/
|
||||
├── backend/
|
||||
│ ├── api/ # REST API handlers and launcher runtime endpoints
|
||||
│ ├── launcherconfig/ # launcher-config.json load/save/validation
|
||||
│ ├── middleware/ # auth, content type, logging, CIDR allowlist
|
||||
│ ├── model/ # Go data structures and logic wrappers
|
||||
│ ├── utils/ # runtime helpers, onboarding, browser launch
|
||||
│ ├── winres/ # Windows application resources
|
||||
│ └── dist/ # embedded frontend build output
|
||||
├── frontend/
|
||||
│ ├── src/api/ # browser API clients
|
||||
│ ├── src/components/ # UI pages and shared components
|
||||
│ ├── src/features/ # feature-specific state, controllers, and protocol helpers
|
||||
│ ├── src/hooks/ # shared React hooks
|
||||
│ ├── src/i18n/ # internationalization language packs
|
||||
│ ├── src/lib/ # generic library utilities
|
||||
│ ├── src/routes/ # TanStack file routes
|
||||
│ ├── src/store/ # global state management
|
||||
│ └── vite.config.ts # dev server and build config
|
||||
├── Makefile
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### You have to sign in again after the launcher restarts
|
||||
|
||||
Existing dashboard sessions do not survive launcher restarts.
|
||||
That is expected: each launcher process generates a new signed session value, so old cookies become invalid.
|
||||
|
||||
To make re-login easier, set a stable token:
|
||||
|
||||
```bash
|
||||
export PICOCLAW_LAUNCHER_TOKEN="replace-with-a-long-random-token"
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- a stable token does not preserve the old cookie-based session by itself
|
||||
- when the launcher opens the browser automatically, it appends `?token=...` and signs in again automatically
|
||||
- if you reopen the dashboard manually, use the same stable token on `/launcher-login`
|
||||
|
||||
### "Start Gateway" stays disabled
|
||||
|
||||
The launcher only allows gateway startup when the configured default model is usable.
|
||||
Check these in the dashboard:
|
||||
|
||||
- a default model is selected
|
||||
- the model has credentials or OAuth state
|
||||
- local models such as Ollama or vLLM are reachable
|
||||
|
||||
### The launcher cannot find `picoclaw`
|
||||
|
||||
Set the main binary explicitly:
|
||||
|
||||
```bash
|
||||
export PICOCLAW_BINARY=/absolute/path/to/picoclaw
|
||||
```
|
||||
|
||||
This affects onboarding and gateway subprocess startup.
|
||||
|
||||
### The backend starts but the UI is blank in development
|
||||
|
||||
Use `make dev` for the normal workflow.
|
||||
If you run only `make dev-backend`, either run `make dev-frontend` alongside it or build the embedded frontend first with `make build-frontend`.
|
||||
|
||||
## Related Docs
|
||||
|
||||
- Main project overview: [`../README.md`](../README.md)
|
||||
- Configuration guide: [`../docs/configuration.md`](../docs/configuration.md)
|
||||
- Providers: [`../docs/providers.md`](../docs/providers.md)
|
||||
- Troubleshooting: [`../docs/troubleshooting.md`](../docs/troubleshooting.md)
|
||||
- Official docs site: [docs.picoclaw.io](https://docs.picoclaw.io)
|
||||
|
|
|
|||
|
|
@ -52,6 +52,11 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
|
|||
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var raw map[string]any
|
||||
if err = json.Unmarshal(body, &raw); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if execAllowRemoteOmitted(body) {
|
||||
cfg.Tools.Exec.AllowRemote = config.DefaultConfig().Tools.Exec.AllowRemote
|
||||
}
|
||||
|
|
@ -63,6 +68,7 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
|
|||
http.Error(w, fmt.Sprintf("Failed to apply security config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
applyConfigSecretsFromMap(&cfg, raw)
|
||||
|
||||
if errs := validateConfig(&cfg); len(errs) > 0 {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
|
@ -162,6 +168,7 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) {
|
|||
http.Error(w, fmt.Sprintf("Failed to apply security config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
applyConfigSecretsFromMap(&newCfg, base)
|
||||
|
||||
if errs := validateConfig(&newCfg); len(errs) > 0 {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
|
@ -331,3 +338,146 @@ func mergeMap(dst, src map[string]any) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
func asMapField(value map[string]any, key string) (map[string]any, bool) {
|
||||
raw, exists := value[key]
|
||||
if !exists {
|
||||
return nil, false
|
||||
}
|
||||
m, isMap := raw.(map[string]any)
|
||||
return m, isMap
|
||||
}
|
||||
|
||||
func getSecretString(m map[string]any, key string) (string, bool) {
|
||||
if raw, exists := m[key]; exists {
|
||||
s, isString := raw.(string)
|
||||
if isString {
|
||||
return s, true
|
||||
}
|
||||
}
|
||||
if raw, exists := m["_"+key]; exists {
|
||||
s, isString := raw.(string)
|
||||
if isString {
|
||||
return s, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func applyConfigSecretsFromMap(cfg *config.Config, raw map[string]any) {
|
||||
channels, hasChannels := asMapField(raw, "channels")
|
||||
if hasChannels {
|
||||
if telegram, hasTelegram := asMapField(channels, "telegram"); hasTelegram {
|
||||
if token, hasToken := getSecretString(telegram, "token"); hasToken {
|
||||
cfg.Channels.Telegram.SetToken(token)
|
||||
}
|
||||
}
|
||||
if feishu, hasFeishu := asMapField(channels, "feishu"); hasFeishu {
|
||||
if appSecret, hasAppSecret := getSecretString(feishu, "app_secret"); hasAppSecret {
|
||||
cfg.Channels.Feishu.AppSecret.Set(appSecret)
|
||||
}
|
||||
if encryptKey, hasEncryptKey := getSecretString(feishu, "encrypt_key"); hasEncryptKey {
|
||||
cfg.Channels.Feishu.EncryptKey.Set(encryptKey)
|
||||
}
|
||||
if verificationToken, hasVerificationToken := getSecretString(
|
||||
feishu,
|
||||
"verification_token",
|
||||
); hasVerificationToken {
|
||||
cfg.Channels.Feishu.VerificationToken.Set(verificationToken)
|
||||
}
|
||||
}
|
||||
if discord, hasDiscord := asMapField(channels, "discord"); hasDiscord {
|
||||
if token, hasToken := getSecretString(discord, "token"); hasToken {
|
||||
cfg.Channels.Discord.Token.Set(token)
|
||||
}
|
||||
}
|
||||
if weixin, hasWeixin := asMapField(channels, "weixin"); hasWeixin {
|
||||
if token, hasToken := getSecretString(weixin, "token"); hasToken {
|
||||
cfg.Channels.Weixin.SetToken(token)
|
||||
}
|
||||
}
|
||||
if qq, hasQQ := asMapField(channels, "qq"); hasQQ {
|
||||
if appSecret, hasAppSecret := getSecretString(qq, "app_secret"); hasAppSecret {
|
||||
cfg.Channels.QQ.AppSecret.Set(appSecret)
|
||||
}
|
||||
}
|
||||
if dingtalk, hasDingTalk := asMapField(channels, "dingtalk"); hasDingTalk {
|
||||
if clientSecret, hasClientSecret := getSecretString(dingtalk, "client_secret"); hasClientSecret {
|
||||
cfg.Channels.DingTalk.ClientSecret.Set(clientSecret)
|
||||
}
|
||||
}
|
||||
if slack, hasSlack := asMapField(channels, "slack"); hasSlack {
|
||||
if botToken, hasBotToken := getSecretString(slack, "bot_token"); hasBotToken {
|
||||
cfg.Channels.Slack.BotToken.Set(botToken)
|
||||
}
|
||||
if appToken, hasAppToken := getSecretString(slack, "app_token"); hasAppToken {
|
||||
cfg.Channels.Slack.AppToken.Set(appToken)
|
||||
}
|
||||
}
|
||||
if matrix, hasMatrix := asMapField(channels, "matrix"); hasMatrix {
|
||||
if accessToken, hasAccessToken := getSecretString(matrix, "access_token"); hasAccessToken {
|
||||
cfg.Channels.Matrix.AccessToken.Set(accessToken)
|
||||
}
|
||||
}
|
||||
if line, hasLine := asMapField(channels, "line"); hasLine {
|
||||
if channelSecret, hasChannelSecret := getSecretString(line, "channel_secret"); hasChannelSecret {
|
||||
cfg.Channels.LINE.ChannelSecret.Set(channelSecret)
|
||||
}
|
||||
if channelAccessToken, hasChannelAccessToken := getSecretString(
|
||||
line,
|
||||
"channel_access_token",
|
||||
); hasChannelAccessToken {
|
||||
cfg.Channels.LINE.ChannelAccessToken.Set(channelAccessToken)
|
||||
}
|
||||
}
|
||||
if onebot, hasOneBot := asMapField(channels, "onebot"); hasOneBot {
|
||||
if accessToken, hasAccessToken := getSecretString(onebot, "access_token"); hasAccessToken {
|
||||
cfg.Channels.OneBot.AccessToken.Set(accessToken)
|
||||
}
|
||||
}
|
||||
if wecom, hasWeCom := asMapField(channels, "wecom"); hasWeCom {
|
||||
if secret, hasSecret := getSecretString(wecom, "secret"); hasSecret {
|
||||
cfg.Channels.WeCom.SetSecret(secret)
|
||||
}
|
||||
}
|
||||
if pico, hasPico := asMapField(channels, "pico"); hasPico {
|
||||
if token, hasToken := getSecretString(pico, "token"); hasToken {
|
||||
cfg.Channels.Pico.SetToken(token)
|
||||
}
|
||||
}
|
||||
if irc, hasIRC := asMapField(channels, "irc"); hasIRC {
|
||||
if password, hasPassword := getSecretString(irc, "password"); hasPassword {
|
||||
cfg.Channels.IRC.Password.Set(password)
|
||||
}
|
||||
if nickservPassword, hasNickservPassword := getSecretString(irc, "nickserv_password"); hasNickservPassword {
|
||||
cfg.Channels.IRC.NickServPassword.Set(nickservPassword)
|
||||
}
|
||||
if saslPassword, hasSASLPassword := getSecretString(irc, "sasl_password"); hasSASLPassword {
|
||||
cfg.Channels.IRC.SASLPassword.Set(saslPassword)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tools, hasTools := asMapField(raw, "tools")
|
||||
if !hasTools {
|
||||
return
|
||||
}
|
||||
skills, hasSkills := asMapField(tools, "skills")
|
||||
if !hasSkills {
|
||||
return
|
||||
}
|
||||
if github, hasGithub := asMapField(skills, "github"); hasGithub {
|
||||
if token, hasToken := getSecretString(github, "token"); hasToken {
|
||||
cfg.Tools.Skills.Github.Token.Set(token)
|
||||
}
|
||||
}
|
||||
registries, hasRegistries := asMapField(skills, "registries")
|
||||
if !hasRegistries {
|
||||
return
|
||||
}
|
||||
if clawHub, hasClawHub := asMapField(registries, "clawhub"); hasClawHub {
|
||||
if authToken, hasAuthToken := getSecretString(clawHub, "auth_token"); hasAuthToken {
|
||||
cfg.Tools.Skills.Registries.ClawHub.AuthToken.Set(authToken)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -251,6 +251,42 @@ func TestHandlePatchConfig_SucceedsWhenPicoTokenInSecurityOnly(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestHandlePatchConfig_SavesDiscordTokenFromPayload(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(`{
|
||||
"channels": {
|
||||
"discord": {
|
||||
"enabled": true,
|
||||
"token": "discord-test-token"
|
||||
}
|
||||
}
|
||||
}`))
|
||||
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)
|
||||
}
|
||||
if !cfg.Channels.Discord.Enabled {
|
||||
t.Fatal("discord should be enabled after PATCH")
|
||||
}
|
||||
if got := cfg.Channels.Discord.Token.String(); got != "discord-test-token" {
|
||||
t.Fatalf("discord token = %q, want %q", got, "discord-test-token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePatchConfig_AllowsInvalidDenyRegexPatternsWhenDenyPatternsDisabled(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ type modelResponse struct {
|
|||
ThinkingLevel string `json:"thinking_level,omitempty"`
|
||||
ExtraBody map[string]any `json:"extra_body,omitempty"`
|
||||
// Meta
|
||||
Enabled bool `json:"enabled"`
|
||||
Configured bool `json:"configured"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
IsVirtual bool `json:"is_virtual"`
|
||||
|
|
@ -85,6 +86,7 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) {
|
|||
RequestTimeout: m.RequestTimeout,
|
||||
ThinkingLevel: m.ThinkingLevel,
|
||||
ExtraBody: m.ExtraBody,
|
||||
Enabled: m.Enabled,
|
||||
Configured: configured[i],
|
||||
IsDefault: m.ModelName == defaultModel,
|
||||
IsVirtual: m.IsVirtual(),
|
||||
|
|
|
|||
|
|
@ -76,6 +76,9 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
|||
// Launcher service parameters (port/public)
|
||||
h.registerLauncherConfigRoutes(mux)
|
||||
|
||||
// Runtime build/version metadata
|
||||
h.registerVersionRoutes(mux)
|
||||
|
||||
// WeChat QR login flow
|
||||
h.registerWeixinRoutes(mux)
|
||||
|
||||
|
|
|
|||
345
web/backend/api/version.go
Normal file
345
web/backend/api/version.go
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/web/backend/utils"
|
||||
)
|
||||
|
||||
type systemVersionResponse struct {
|
||||
Version string `json:"version"`
|
||||
GitCommit string `json:"git_commit,omitempty"`
|
||||
BuildTime string `json:"build_time,omitempty"`
|
||||
GoVersion string `json:"go_version"`
|
||||
}
|
||||
|
||||
type cachedSystemVersion struct {
|
||||
value systemVersionResponse
|
||||
gatewayPID int
|
||||
}
|
||||
|
||||
type systemVersionCache struct {
|
||||
mu sync.Mutex
|
||||
current cachedSystemVersion
|
||||
hasCurrent bool
|
||||
inflightCh chan struct{}
|
||||
}
|
||||
|
||||
func newSystemVersionCache() *systemVersionCache {
|
||||
return &systemVersionCache{}
|
||||
}
|
||||
|
||||
var (
|
||||
// 15 seconds matches the gateway startup window used elsewhere in launcher flow,
|
||||
// giving slow/embedded hosts enough time for first command invocation while
|
||||
// staying independent from cross-file init ordering.
|
||||
versionCmdTimeout = 15 * time.Second
|
||||
maxVersionResolveAttempts = 3
|
||||
findPicoclawBinaryForInfo = resolveGatewayBinaryForVersionInfo
|
||||
runPicoclawVersionOutput = executePicoclawVersion
|
||||
currentGatewayVersionState = gatewayVersionState
|
||||
launcherBuildInfoForVersion = fallbackSystemVersionInfoFromConfig
|
||||
versionInfoCache = newSystemVersionCache()
|
||||
ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;]*m`)
|
||||
versionLinePattern = regexp.MustCompile(
|
||||
`^(?:[^A-Za-z0-9]*\s*)?picoclaw(?:\.exe)?\s+([^\s(]+)` +
|
||||
`(?:\s+\(git:\s*([^)]+)\))?\s*$`,
|
||||
)
|
||||
)
|
||||
|
||||
func (h *Handler) registerVersionRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/system/version", h.handleGetVersion)
|
||||
}
|
||||
|
||||
// handleGetVersion returns runtime version information for web clients.
|
||||
func (h *Handler) handleGetVersion(w http.ResponseWriter, r *http.Request) {
|
||||
versionInfo := h.resolveSystemVersionInfo(r.Context())
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(versionInfo); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// resolveSystemVersionInfo prefers the actual picoclaw binary version output,
|
||||
// and falls back to launcher build metadata when command execution fails.
|
||||
func (h *Handler) resolveSystemVersionInfo(ctx context.Context) systemVersionResponse {
|
||||
for range maxVersionResolveAttempts {
|
||||
gatewayPID, gatewayAlive := currentGatewayVersionState()
|
||||
if cached, ok := versionInfoCache.get(gatewayPID, gatewayAlive); ok {
|
||||
return cached
|
||||
}
|
||||
|
||||
leader, ok := versionInfoCache.waitOrStart(ctx)
|
||||
if !ok {
|
||||
return fallbackSystemVersionInfo()
|
||||
}
|
||||
if !leader {
|
||||
continue
|
||||
}
|
||||
|
||||
resolved := h.resolveSystemVersionInfoUncached(ctx)
|
||||
gatewayPID, gatewayAlive = currentGatewayVersionState()
|
||||
versionInfoCache.finishResolve(resolved, gatewayPID, gatewayAlive)
|
||||
return resolved
|
||||
}
|
||||
|
||||
return fallbackSystemVersionInfo()
|
||||
}
|
||||
|
||||
func (h *Handler) resolveSystemVersionInfoUncached(ctx context.Context) systemVersionResponse {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
fallback := fallbackSystemVersionInfo()
|
||||
|
||||
execPath := strings.TrimSpace(findPicoclawBinaryForInfo())
|
||||
if execPath == "" {
|
||||
return fallback
|
||||
}
|
||||
|
||||
cmdCtx, cancel := context.WithTimeout(ctx, versionCmdTimeout)
|
||||
defer cancel()
|
||||
|
||||
output, err := runPicoclawVersionOutput(cmdCtx, execPath)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
|
||||
parsed, ok := parsePicoclawVersionOutput(output)
|
||||
if !ok {
|
||||
return fallback
|
||||
}
|
||||
|
||||
if parsed.GoVersion == "" {
|
||||
parsed.GoVersion = fallback.GoVersion
|
||||
if parsed.GoVersion == "" {
|
||||
parsed.GoVersion = runtime.Version()
|
||||
}
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
func fallbackSystemVersionInfo() systemVersionResponse {
|
||||
return launcherBuildInfoForVersion()
|
||||
}
|
||||
|
||||
func fallbackSystemVersionInfoFromConfig() systemVersionResponse {
|
||||
buildTime, goVer := config.FormatBuildInfo()
|
||||
return systemVersionResponse{
|
||||
Version: config.GetVersion(),
|
||||
GitCommit: config.GitCommit,
|
||||
BuildTime: buildTime,
|
||||
GoVersion: goVer,
|
||||
}
|
||||
}
|
||||
|
||||
// resolveGatewayBinaryForVersionInfo uses the same executable as the launcher
|
||||
// gateway start path when available, then falls back to launcher binary lookup.
|
||||
// This keeps version probing aligned with the actual gateway startup behavior,
|
||||
// so web and gateway do not drift onto different binaries.
|
||||
func resolveGatewayBinaryForVersionInfo() string {
|
||||
gateway.mu.Lock()
|
||||
cmd := gateway.cmd
|
||||
gateway.mu.Unlock()
|
||||
|
||||
if cmd != nil {
|
||||
if execPath := strings.TrimSpace(cmd.Path); execPath != "" {
|
||||
return execPath
|
||||
}
|
||||
}
|
||||
|
||||
return utils.FindPicoclawBinary()
|
||||
}
|
||||
|
||||
func gatewayVersionState() (int, bool) {
|
||||
gateway.mu.Lock()
|
||||
defer gateway.mu.Unlock()
|
||||
|
||||
if gateway.cmd == nil || gateway.cmd.Process == nil {
|
||||
return 0, false
|
||||
}
|
||||
pid := gateway.cmd.Process.Pid
|
||||
if pid <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return pid, isCmdProcessAliveLocked(gateway.cmd)
|
||||
}
|
||||
|
||||
func (c *systemVersionCache) get(gatewayPID int, gatewayAlive bool) (systemVersionResponse, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.hasCurrent && (!gatewayAlive || gatewayPID <= 0 || gatewayPID != c.current.gatewayPID) {
|
||||
c.clearCurrentLocked()
|
||||
}
|
||||
|
||||
if c.hasCurrent {
|
||||
return c.current.value, true
|
||||
}
|
||||
|
||||
return systemVersionResponse{}, false
|
||||
}
|
||||
|
||||
func (c *systemVersionCache) waitOrStart(ctx context.Context) (bool, bool) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return false, false
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
if c.inflightCh == nil {
|
||||
c.inflightCh = make(chan struct{})
|
||||
c.mu.Unlock()
|
||||
return true, true
|
||||
}
|
||||
waitCh := c.inflightCh
|
||||
c.mu.Unlock()
|
||||
|
||||
select {
|
||||
case <-waitCh:
|
||||
return false, true
|
||||
case <-ctx.Done():
|
||||
return false, false
|
||||
}
|
||||
}
|
||||
|
||||
func (c *systemVersionCache) finishResolve(value systemVersionResponse, gatewayPID int, gatewayAlive bool) {
|
||||
c.mu.Lock()
|
||||
if gatewayAlive && gatewayPID > 0 {
|
||||
c.current = cachedSystemVersion{value: value, gatewayPID: gatewayPID}
|
||||
c.hasCurrent = true
|
||||
} else {
|
||||
c.clearCurrentLocked()
|
||||
}
|
||||
|
||||
inflightCh := c.inflightCh
|
||||
c.inflightCh = nil
|
||||
c.mu.Unlock()
|
||||
|
||||
if inflightCh != nil {
|
||||
close(inflightCh)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *systemVersionCache) clearCurrentLocked() {
|
||||
c.hasCurrent = false
|
||||
c.current = cachedSystemVersion{}
|
||||
}
|
||||
|
||||
func (c *systemVersionCache) resetForTest() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.current = cachedSystemVersion{}
|
||||
c.hasCurrent = false
|
||||
if c.inflightCh != nil {
|
||||
close(c.inflightCh)
|
||||
c.inflightCh = nil
|
||||
}
|
||||
}
|
||||
|
||||
// executePicoclawVersion runs the version subcommand against the
|
||||
// discovered picoclaw executable.
|
||||
func executePicoclawVersion(ctx context.Context, execPath string) (string, error) {
|
||||
out, err := exec.CommandContext(ctx, execPath, "version").CombinedOutput()
|
||||
if err == nil {
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
return string(out), fmt.Errorf("failed to execute version command: %w", err)
|
||||
}
|
||||
|
||||
// parsePicoclawVersionOutput extracts version/build/go fields from CLI output.
|
||||
// It accepts banner/ANSI-decorated output and only requires the version line.
|
||||
func parsePicoclawVersionOutput(raw string) (systemVersionResponse, bool) {
|
||||
var result systemVersionResponse
|
||||
|
||||
scanner := bufio.NewScanner(strings.NewReader(raw))
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(ansiEscapePattern.ReplaceAllString(scanner.Text(), ""))
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if match := versionLinePattern.FindStringSubmatch(line); len(match) > 0 {
|
||||
candidateVersion := strings.TrimSpace(match[1])
|
||||
if !isLikelyVersionValue(candidateVersion) {
|
||||
continue
|
||||
}
|
||||
result.Version = candidateVersion
|
||||
if len(match) > 2 {
|
||||
result.GitCommit = strings.TrimSpace(match[2])
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if buildValue, ok := strings.CutPrefix(line, "Build:"); ok {
|
||||
result.BuildTime = strings.TrimSpace(buildValue)
|
||||
continue
|
||||
}
|
||||
|
||||
if goValue, ok := strings.CutPrefix(line, "Go:"); ok {
|
||||
result.GoVersion = strings.TrimSpace(goValue)
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return systemVersionResponse{}, false
|
||||
}
|
||||
|
||||
if result.Version == "" {
|
||||
return systemVersionResponse{}, false
|
||||
}
|
||||
|
||||
return result, true
|
||||
}
|
||||
|
||||
func isLikelyVersionValue(value string) bool {
|
||||
v := strings.TrimSpace(strings.ToLower(value))
|
||||
if v == "" {
|
||||
return false
|
||||
}
|
||||
if v == "dev" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Accept git-like short/long hashes even when they contain only letters (a-f).
|
||||
if len(v) >= 7 && len(v) <= 40 {
|
||||
allHex := true
|
||||
for _, ch := range v {
|
||||
if (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') {
|
||||
continue
|
||||
}
|
||||
allHex = false
|
||||
break
|
||||
}
|
||||
if allHex {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
for _, ch := range v {
|
||||
if ch >= '0' && ch <= '9' {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
317
web/backend/api/version_test.go
Normal file
317
web/backend/api/version_test.go
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func setupVersionTestIsolation(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
originalGatewayState := currentGatewayVersionState
|
||||
originalFinder := findPicoclawBinaryForInfo
|
||||
originalRunner := runPicoclawVersionOutput
|
||||
originalFallback := launcherBuildInfoForVersion
|
||||
t.Cleanup(func() {
|
||||
currentGatewayVersionState = originalGatewayState
|
||||
findPicoclawBinaryForInfo = originalFinder
|
||||
runPicoclawVersionOutput = originalRunner
|
||||
launcherBuildInfoForVersion = originalFallback
|
||||
versionInfoCache.resetForTest()
|
||||
})
|
||||
|
||||
currentGatewayVersionState = func() (int, bool) { return 0, false }
|
||||
versionInfoCache.resetForTest()
|
||||
}
|
||||
|
||||
func TestGetSystemVersionUsesPicoclawBinaryInfo(t *testing.T) {
|
||||
setupVersionTestIsolation(t)
|
||||
|
||||
launcherBuildInfoForVersion = func() systemVersionResponse {
|
||||
return systemVersionResponse{Version: "fallback", GoVersion: "go-fallback"}
|
||||
}
|
||||
|
||||
findPicoclawBinaryForInfo = func() string { return "picoclaw" }
|
||||
runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) {
|
||||
return "🦞 picoclaw v1.2.3 (git: deadbeef)\n Build: 2026-03-27T12:34:56Z\n Go: go1.25.8\n", nil
|
||||
}
|
||||
|
||||
h := NewHandler("")
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/system/version", nil)
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
var got systemVersionResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
if got.Version != "v1.2.3" {
|
||||
t.Fatalf("version = %q, want %q", got.Version, "v1.2.3")
|
||||
}
|
||||
if got.GitCommit != "deadbeef" {
|
||||
t.Fatalf("git_commit = %q, want %q", got.GitCommit, "deadbeef")
|
||||
}
|
||||
if got.BuildTime != "2026-03-27T12:34:56Z" {
|
||||
t.Fatalf("build_time = %q, want %q", got.BuildTime, "2026-03-27T12:34:56Z")
|
||||
}
|
||||
if got.GoVersion != "go1.25.8" {
|
||||
t.Fatalf("go_version = %q, want %q", got.GoVersion, "go1.25.8")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSystemVersionFallsBackToLauncherInfoWhenCommandFails(t *testing.T) {
|
||||
setupVersionTestIsolation(t)
|
||||
|
||||
expected := systemVersionResponse{
|
||||
Version: "v9.9.9",
|
||||
GitCommit: "cafebabe",
|
||||
BuildTime: "2026-03-27T10:43:34+0000",
|
||||
GoVersion: "go1.25.8",
|
||||
}
|
||||
launcherBuildInfoForVersion = func() systemVersionResponse { return expected }
|
||||
|
||||
findPicoclawBinaryForInfo = func() string { return "picoclaw" }
|
||||
runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) {
|
||||
return "", errors.New("binary unavailable")
|
||||
}
|
||||
|
||||
h := NewHandler("")
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/system/version", nil)
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
var got systemVersionResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
if got.Version != expected.Version {
|
||||
t.Fatalf("version = %q, want %q", got.Version, expected.Version)
|
||||
}
|
||||
if got.GitCommit != expected.GitCommit {
|
||||
t.Fatalf("git_commit = %q, want %q", got.GitCommit, expected.GitCommit)
|
||||
}
|
||||
if got.BuildTime != expected.BuildTime {
|
||||
t.Fatalf("build_time = %q, want %q", got.BuildTime, expected.BuildTime)
|
||||
}
|
||||
if got.GoVersion != expected.GoVersion {
|
||||
t.Fatalf("go_version = %q, want %q", got.GoVersion, expected.GoVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePicoclawVersionOutput(t *testing.T) {
|
||||
setupVersionTestIsolation(t)
|
||||
|
||||
raw := "\u001b[1;31m████\u001b[0m\n🦞 picoclaw 18ec263 (git: 18ec2631)\n Build: 2026-03-27T10:43:34+0000\n Go: go1.25.8\n"
|
||||
got, ok := parsePicoclawVersionOutput(raw)
|
||||
if !ok {
|
||||
t.Fatal("parsePicoclawVersionOutput() should parse valid output")
|
||||
}
|
||||
if got.Version != "18ec263" {
|
||||
t.Fatalf("version = %q, want %q", got.Version, "18ec263")
|
||||
}
|
||||
if got.GitCommit != "18ec2631" {
|
||||
t.Fatalf("git_commit = %q, want %q", got.GitCommit, "18ec2631")
|
||||
}
|
||||
if got.BuildTime != "2026-03-27T10:43:34+0000" {
|
||||
t.Fatalf("build_time = %q, want %q", got.BuildTime, "2026-03-27T10:43:34+0000")
|
||||
}
|
||||
if got.GoVersion != "go1.25.8" {
|
||||
t.Fatalf("go_version = %q, want %q", got.GoVersion, "go1.25.8")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePicoclawVersionOutputIgnoresUsageLine(t *testing.T) {
|
||||
setupVersionTestIsolation(t)
|
||||
|
||||
raw := "Usage: picoclaw version [flags]\n"
|
||||
got, ok := parsePicoclawVersionOutput(raw)
|
||||
if ok {
|
||||
t.Fatalf("parsePicoclawVersionOutput() parsed usage line unexpectedly: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePicoclawVersionOutputAcceptsLetterOnlyHashVersion(t *testing.T) {
|
||||
setupVersionTestIsolation(t)
|
||||
|
||||
raw := "picoclaw abcdefa (git: abcdefabcdefabcdefabcdefabcdefabcdefabcd)\n"
|
||||
got, ok := parsePicoclawVersionOutput(raw)
|
||||
if !ok {
|
||||
t.Fatal("parsePicoclawVersionOutput() should parse letter-only hash version")
|
||||
}
|
||||
if got.Version != "abcdefa" {
|
||||
t.Fatalf("version = %q, want %q", got.Version, "abcdefa")
|
||||
}
|
||||
if got.GitCommit != "abcdefabcdefabcdefabcdefabcdefabcdefabcd" {
|
||||
t.Fatalf("git_commit = %q, want %q", got.GitCommit, "abcdefabcdefabcdefabcdefabcdefabcdefabcd")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSystemVersionInfoFallsBackRuntimeGoVersion(t *testing.T) {
|
||||
setupVersionTestIsolation(t)
|
||||
|
||||
launcherBuildInfoForVersion = func() systemVersionResponse {
|
||||
return systemVersionResponse{Version: "dev", GoVersion: ""}
|
||||
}
|
||||
|
||||
findPicoclawBinaryForInfo = func() string { return "picoclaw" }
|
||||
runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) {
|
||||
return "picoclaw v1.0.0\n", nil
|
||||
}
|
||||
|
||||
h := NewHandler("")
|
||||
got := h.resolveSystemVersionInfo(context.Background())
|
||||
if got.GoVersion != runtime.Version() {
|
||||
t.Fatalf("go_version = %q, want runtime version %q", got.GoVersion, runtime.Version())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSystemVersionInfoCachesWhileGatewayAlive(t *testing.T) {
|
||||
setupVersionTestIsolation(t)
|
||||
|
||||
launcherBuildInfoForVersion = func() systemVersionResponse {
|
||||
return systemVersionResponse{Version: "dev", GoVersion: "go-fallback"}
|
||||
}
|
||||
findPicoclawBinaryForInfo = func() string { return "picoclaw" }
|
||||
|
||||
pid := 4321
|
||||
currentGatewayVersionState = func() (int, bool) { return pid, true }
|
||||
|
||||
runCount := 0
|
||||
runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) {
|
||||
runCount++
|
||||
return fmt.Sprintf("picoclaw v1.2.%d\n", runCount), nil
|
||||
}
|
||||
|
||||
h := NewHandler("")
|
||||
first := h.resolveSystemVersionInfo(context.Background())
|
||||
second := h.resolveSystemVersionInfo(context.Background())
|
||||
|
||||
if first.Version != "v1.2.1" {
|
||||
t.Fatalf("first version = %q, want %q", first.Version, "v1.2.1")
|
||||
}
|
||||
if second.Version != "v1.2.1" {
|
||||
t.Fatalf("second version = %q, want cached %q", second.Version, "v1.2.1")
|
||||
}
|
||||
if runCount != 1 {
|
||||
t.Fatalf("run count = %d, want %d", runCount, 1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSystemVersionInfoInvalidatesCacheWhenGatewayStops(t *testing.T) {
|
||||
setupVersionTestIsolation(t)
|
||||
|
||||
launcherBuildInfoForVersion = func() systemVersionResponse {
|
||||
return systemVersionResponse{Version: "dev", GoVersion: "go-fallback"}
|
||||
}
|
||||
findPicoclawBinaryForInfo = func() string { return "picoclaw" }
|
||||
|
||||
alive := true
|
||||
pid := 9876
|
||||
currentGatewayVersionState = func() (int, bool) {
|
||||
if !alive {
|
||||
return 0, false
|
||||
}
|
||||
return pid, true
|
||||
}
|
||||
|
||||
runCount := 0
|
||||
runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) {
|
||||
runCount++
|
||||
return fmt.Sprintf("picoclaw v2.0.%d\n", runCount), nil
|
||||
}
|
||||
|
||||
h := NewHandler("")
|
||||
first := h.resolveSystemVersionInfo(context.Background())
|
||||
second := h.resolveSystemVersionInfo(context.Background())
|
||||
|
||||
if first.Version != "v2.0.1" || second.Version != "v2.0.1" {
|
||||
t.Fatalf("expected cached version v2.0.1, got first=%q second=%q", first.Version, second.Version)
|
||||
}
|
||||
if runCount != 1 {
|
||||
t.Fatalf("run count after cache hit = %d, want %d", runCount, 1)
|
||||
}
|
||||
|
||||
alive = false
|
||||
third := h.resolveSystemVersionInfo(context.Background())
|
||||
if third.Version != "v2.0.2" {
|
||||
t.Fatalf("third version = %q, want refreshed %q", third.Version, "v2.0.2")
|
||||
}
|
||||
if runCount != 2 {
|
||||
t.Fatalf("run count after invalidation = %d, want %d", runCount, 2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSystemVersionInfoSkipsCommandWhenContextCanceled(t *testing.T) {
|
||||
setupVersionTestIsolation(t)
|
||||
|
||||
launcherBuildInfoForVersion = func() systemVersionResponse {
|
||||
return systemVersionResponse{Version: "v3.0.0", GoVersion: "go-fallback"}
|
||||
}
|
||||
findPicoclawBinaryForInfo = func() string { return "picoclaw" }
|
||||
|
||||
runCount := 0
|
||||
runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) {
|
||||
runCount++
|
||||
return "picoclaw v9.9.9\n", nil
|
||||
}
|
||||
|
||||
canceledCtx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
h := NewHandler("")
|
||||
got := h.resolveSystemVersionInfo(canceledCtx)
|
||||
|
||||
if runCount != 0 {
|
||||
t.Fatalf("run count = %d, want %d", runCount, 0)
|
||||
}
|
||||
if got.Version != "v3.0.0" {
|
||||
t.Fatalf("version = %q, want fallback %q", got.Version, "v3.0.0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveGatewayBinaryForVersionInfoPrefersGatewayCommandPath(t *testing.T) {
|
||||
setupVersionTestIsolation(t)
|
||||
|
||||
originalFinder := findPicoclawBinaryForInfo
|
||||
t.Cleanup(func() {
|
||||
findPicoclawBinaryForInfo = originalFinder
|
||||
})
|
||||
|
||||
gateway.mu.Lock()
|
||||
originalCmd := gateway.cmd
|
||||
gateway.cmd = &exec.Cmd{Path: "/tmp/picoclaw-from-gateway"}
|
||||
gateway.mu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
gateway.mu.Lock()
|
||||
gateway.cmd = originalCmd
|
||||
gateway.mu.Unlock()
|
||||
})
|
||||
|
||||
got := resolveGatewayBinaryForVersionInfo()
|
||||
if got != "/tmp/picoclaw-from-gateway" {
|
||||
t.Fatalf("exec path = %q, want %q", got, "/tmp/picoclaw-from-gateway")
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,9 @@
|
|||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
|
|
@ -22,7 +25,7 @@
|
|||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.20",
|
||||
"i18next": "^25.8.14",
|
||||
"i18next": "^26.0.1",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"jotai": "^2.18.1",
|
||||
"radix-ui": "^1.4.3",
|
||||
|
|
@ -42,7 +45,7 @@
|
|||
"wrap-ansi": "^10.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tanstack/router-plugin": "^1.164.0",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||
|
|
@ -50,16 +53,16 @@
|
|||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.57.1",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"eslint": "^9.39.4",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^10.1.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.26",
|
||||
"globals": "^16.5.0",
|
||||
"globals": "^17.4.0",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.57.1",
|
||||
"vite": "^7.3.1"
|
||||
"vite": "^8.0.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1169
web/frontend/pnpm-lock.yaml
generated
1169
web/frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -13,6 +13,13 @@ export interface LauncherConfig {
|
|||
allowed_cidrs: string[]
|
||||
}
|
||||
|
||||
export interface SystemVersionInfo {
|
||||
version: string
|
||||
git_commit?: string
|
||||
build_time?: string
|
||||
go_version: string
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await launcherFetch(path, options)
|
||||
if (!res.ok) {
|
||||
|
|
@ -62,3 +69,7 @@ export async function setLauncherConfig(
|
|||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export async function getSystemVersionInfo(): Promise<SystemVersionInfo> {
|
||||
return request<SystemVersionInfo>("/api/system/version")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,10 +10,12 @@ import {
|
|||
IconSparkles,
|
||||
IconTools,
|
||||
} from "@tabler/icons-react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { Link, useRouterState } from "@tanstack/react-router"
|
||||
import * as React from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { getSystemVersionInfo } from "@/api/system"
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
|
|
@ -27,6 +29,7 @@ import {
|
|||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarFooter,
|
||||
SidebarMenuItem,
|
||||
SidebarRail,
|
||||
} from "@/components/ui/sidebar"
|
||||
|
|
@ -78,6 +81,13 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||
language: (i18n.resolvedLanguage ?? i18n.language ?? "").toLowerCase(),
|
||||
t,
|
||||
})
|
||||
const { data: versionInfo } = useQuery({
|
||||
queryKey: ["system", "version"],
|
||||
queryFn: getSystemVersionInfo,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
const versionText = versionInfo?.version ?? t("footer.version_unknown")
|
||||
|
||||
const navGroups: NavGroup[] = React.useMemo(() => {
|
||||
return [
|
||||
|
|
@ -235,6 +245,26 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||
</Collapsible>
|
||||
))}
|
||||
</SidebarContent>
|
||||
<SidebarFooter className="border-t-border/30 group-data-[collapsible=icon]:hidden border-t px-3 py-2">
|
||||
<div className="text-muted-foreground flex flex-col gap-0.5 text-[11px] leading-4">
|
||||
<div className="truncate" title={versionText}>
|
||||
<span className="text-foreground/80">{t("footer.version")}:</span>{" "}
|
||||
{versionText}
|
||||
</div>
|
||||
{versionInfo?.git_commit && (
|
||||
<div className="truncate" title={versionInfo.git_commit}>
|
||||
<span className="text-foreground/80">{t("footer.commit")}:</span>{" "}
|
||||
{versionInfo.git_commit}
|
||||
</div>
|
||||
)}
|
||||
{versionInfo?.build_time && (
|
||||
<div className="truncate" title={versionInfo.build_time}>
|
||||
<span className="text-foreground/80">{t("footer.build")}:</span>{" "}
|
||||
{versionInfo.build_time}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SidebarFooter>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -62,10 +62,8 @@ function asBool(value: unknown): boolean {
|
|||
|
||||
function buildEditConfig(config: ChannelConfig): ChannelConfig {
|
||||
const edit: ChannelConfig = { ...config }
|
||||
for (const secretKey of Object.keys(SECRET_FIELD_MAP)) {
|
||||
if (secretKey in config) {
|
||||
edit[SECRET_FIELD_MAP[secretKey]] = ""
|
||||
}
|
||||
for (const editKey of Object.values(SECRET_FIELD_MAP)) {
|
||||
edit[editKey] = ""
|
||||
}
|
||||
return edit
|
||||
}
|
||||
|
|
@ -94,17 +92,22 @@ function buildSavePayload(
|
|||
for (const [key, value] of Object.entries(editConfig)) {
|
||||
if (key.startsWith("_")) continue
|
||||
if (key === "enabled") continue
|
||||
|
||||
if (key in SECRET_FIELD_MAP) {
|
||||
const editKey = SECRET_FIELD_MAP[key]
|
||||
const incoming = asString(editConfig[editKey])
|
||||
payload[key] = incoming !== "" ? incoming : value
|
||||
continue
|
||||
}
|
||||
if (key in SECRET_FIELD_MAP) continue
|
||||
|
||||
payload[key] = value
|
||||
}
|
||||
|
||||
for (const [secretKey, editKey] of Object.entries(SECRET_FIELD_MAP)) {
|
||||
const incoming = asString(editConfig[editKey])
|
||||
if (incoming !== "") {
|
||||
payload[secretKey] = incoming
|
||||
continue
|
||||
}
|
||||
if (secretKey in editConfig) {
|
||||
payload[secretKey] = editConfig[secretKey]
|
||||
}
|
||||
}
|
||||
|
||||
if (channel.name === "whatsapp_native") {
|
||||
payload.use_native = true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -169,7 +169,7 @@ export function SkillsPage() {
|
|||
{data.skills.map((skill) => (
|
||||
<Card
|
||||
key={`${skill.source}:${skill.name}`}
|
||||
className="border-border/60 gap-4 bg-white/80"
|
||||
className="border-border/60 gap-4"
|
||||
size="sm"
|
||||
>
|
||||
<CardHeader>
|
||||
|
|
@ -211,7 +211,7 @@ export function SkillsPage() {
|
|||
<div className="text-muted-foreground text-[11px] tracking-[0.18em] uppercase">
|
||||
{t("pages.agent.skills.path")}
|
||||
</div>
|
||||
<div className="bg-muted/60 overflow-x-auto rounded-lg px-3 py-2 font-mono text-xs leading-relaxed">
|
||||
<div className="bg-muted text-foreground overflow-x-auto rounded-lg px-3 py-2 font-mono text-xs leading-relaxed">
|
||||
{skill.path}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -93,6 +93,12 @@
|
|||
"labels": {
|
||||
"loading": "Loading..."
|
||||
},
|
||||
"footer": {
|
||||
"version": "Version",
|
||||
"commit": "Commit",
|
||||
"build": "Build",
|
||||
"version_unknown": "Unknown"
|
||||
},
|
||||
"credentials": {
|
||||
"description": "Manage OAuth and token-based credentials for supported providers.",
|
||||
"loading": "Loading credentials...",
|
||||
|
|
|
|||
|
|
@ -93,6 +93,12 @@
|
|||
"labels": {
|
||||
"loading": "加载中..."
|
||||
},
|
||||
"footer": {
|
||||
"version": "版本",
|
||||
"commit": "提交",
|
||||
"build": "构建",
|
||||
"version_unknown": "未知"
|
||||
},
|
||||
"credentials": {
|
||||
"description": "管理已支持服务商的 OAuth 与 Token 凭据。",
|
||||
"loading": "正在加载凭据...",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue