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:
|
build-launcher:
|
||||||
@echo "Building picoclaw-launcher for $(PLATFORM)/$(ARCH)..."
|
@echo "Building picoclaw-launcher for $(PLATFORM)/$(ARCH)..."
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
@if [ ! -f web/backend/dist/index.html ]; then \
|
@$(MAKE) -C web build \
|
||||||
echo "Building frontend..."; \
|
OUTPUT="$(CURDIR)/$(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH)" \
|
||||||
cd web/frontend && pnpm install && pnpm build:backend; \
|
WEB_GO='$(WEB_GO)' \
|
||||||
fi
|
GO_BUILD_TAGS='$(GO_BUILD_TAGS)' \
|
||||||
@$(WEB_GO) build $(GOFLAGS) -o $(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH) ./web/backend
|
LDFLAGS='$(LDFLAGS)'
|
||||||
@ln -sf picoclaw-launcher-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/picoclaw-launcher
|
@ln -sf picoclaw-launcher-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/picoclaw-launcher
|
||||||
@echo "Build complete: $(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: Build the picoclaw-launcher TUI binary
|
||||||
build-launcher-tui:
|
build-launcher-tui:
|
||||||
@echo "Building picoclaw-launcher-tui for $(PLATFORM)/$(ARCH)..."
|
@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é.
|
> 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).
|
Pour les instructions détaillées de configuration des channels, voir [Configuration des applications de chat](docs/fr/chat-apps.md).
|
||||||
|
|
||||||
## 🔧 Outils
|
## 🔧 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.
|
> 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).
|
Untuk instruksi pengaturan channel lengkap, lihat [Konfigurasi Aplikasi Chat](docs/chat-apps.md).
|
||||||
|
|
||||||
## 🔧 Tools
|
## 🔧 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.
|
> 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).
|
Per istruzioni dettagliate sulla configurazione dei channel, vedi [Configurazione App di Chat](docs/chat-apps.md).
|
||||||
|
|
||||||
## 🔧 Strumenti
|
## 🔧 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 サーバーを使用しません。
|
> 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) を参照してください。
|
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.
|
> 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).
|
For detailed channel setup instructions, see [Chat Apps Configuration](docs/chat-apps.md).
|
||||||
|
|
||||||
## 🔧 Tools
|
## 🔧 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.
|
> 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).
|
Untuk arahan persediaan saluran terperinci, lihat [Konfigurasi Aplikasi Sembang](docs/my/chat-apps.md).
|
||||||
|
|
||||||
## 🔧 Alat
|
## 🔧 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.
|
> 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).
|
Para instruções detalhadas de configuração de channels, veja [Configuração de Apps de Chat](docs/pt-br/chat-apps.md).
|
||||||
|
|
||||||
## 🔧 Ferramentas
|
## 🔧 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.
|
> 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).
|
Để 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
|
## 🔧 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 服务器。
|
> 所有基于 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)。
|
详细 Channel 配置说明请参阅 [聊天应用配置](docs/zh/chat-apps.md)。
|
||||||
|
|
||||||
## 🔧 Tools
|
## 🔧 Tools
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ func newAddCommand(storePath func() string) *cobra.Command {
|
||||||
message string
|
message string
|
||||||
every int64
|
every int64
|
||||||
cronExp string
|
cronExp string
|
||||||
deliver bool
|
|
||||||
channel string
|
channel string
|
||||||
to string
|
to string
|
||||||
)
|
)
|
||||||
|
|
@ -37,7 +36,7 @@ func newAddCommand(storePath func() string) *cobra.Command {
|
||||||
}
|
}
|
||||||
|
|
||||||
cs := cron.NewCronService(storePath(), nil)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("error adding job: %w", err)
|
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().StringVarP(&message, "message", "m", "", "Message for agent")
|
||||||
cmd.Flags().Int64VarP(&every, "every", "e", 0, "Run every N seconds")
|
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().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(&to, "to", "", "Recipient for delivery")
|
||||||
cmd.Flags().StringVar(&channel, "channel", "", "Channel 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("every"))
|
||||||
assert.NotNil(t, cmd.Flags().Lookup("cron"))
|
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("to"))
|
||||||
assert.NotNil(t, cmd.Flags().Lookup("channel"))
|
assert.NotNil(t, cmd.Flags().Lookup("channel"))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,7 @@ func listAvailableModels(cfg *config.Config) {
|
||||||
if model.ModelName == defaultModel {
|
if model.ModelName == defaultModel {
|
||||||
marker = "> "
|
marker = "> "
|
||||||
}
|
}
|
||||||
if model.APIKey() == "" {
|
if !model.Enabled {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
fmt.Printf("%s- %s (%s)\n", marker, model.ModelName, model.Model)
|
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
|
// Validate that the model exists in model_list
|
||||||
modelFound := false
|
modelFound := false
|
||||||
for _, model := range cfg.ModelList {
|
for _, model := range cfg.ModelList {
|
||||||
if model.APIKey() != "" && model.ModelName == modelName {
|
if model.Enabled && model.ModelName == modelName {
|
||||||
modelFound = true
|
modelFound = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -65,11 +65,17 @@ func TestShowCurrentModel_WithDefaultModel(t *testing.T) {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ModelList: []*config.ModelConfig{
|
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",
|
ModelName: "claude-3",
|
||||||
Model: "anthropic/claude-3",
|
Model: "anthropic/claude-3",
|
||||||
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||||
|
Enabled: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -92,7 +98,12 @@ func TestShowCurrentModel_NoDefaultModel(t *testing.T) {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ModelList: []*config.ModelConfig{
|
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{
|
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",
|
ModelName: "claude-3",
|
||||||
Model: "anthropic/claude-3",
|
Model: "anthropic/claude-3",
|
||||||
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||||
|
Enabled: true,
|
||||||
},
|
},
|
||||||
{ModelName: "no-key-model", Model: "openai/test"},
|
{ModelName: "no-key-model", Model: "openai/test"},
|
||||||
},
|
},
|
||||||
|
|
@ -158,11 +175,13 @@ func TestSetDefaultModel_ValidModel(t *testing.T) {
|
||||||
ModelName: "new-model",
|
ModelName: "new-model",
|
||||||
Model: "openai/new-model",
|
Model: "openai/new-model",
|
||||||
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||||
|
Enabled: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ModelName: "old-model",
|
ModelName: "old-model",
|
||||||
Model: "openai/old-model",
|
Model: "openai/old-model",
|
||||||
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||||
|
Enabled: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -194,6 +213,7 @@ func TestSetDefaultModel_InvalidModel(t *testing.T) {
|
||||||
ModelName: "existing-model",
|
ModelName: "existing-model",
|
||||||
Model: "openai/existing",
|
Model: "openai/existing",
|
||||||
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||||
|
Enabled: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -215,6 +235,7 @@ func TestSetDefaultModel_ModelWithoutAPIKey(t *testing.T) {
|
||||||
ModelName: "existing-model",
|
ModelName: "existing-model",
|
||||||
Model: "openai/existing",
|
Model: "openai/existing",
|
||||||
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||||
|
Enabled: true,
|
||||||
},
|
},
|
||||||
{ModelName: "no-key-model", Model: "openai/nokey"},
|
{ModelName: "no-key-model", Model: "openai/nokey"},
|
||||||
},
|
},
|
||||||
|
|
@ -238,6 +259,7 @@ func TestSetDefaultModel_SaveConfigError(t *testing.T) {
|
||||||
ModelName: "new-model",
|
ModelName: "new-model",
|
||||||
Model: "openai/new-model",
|
Model: "openai/new-model",
|
||||||
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||||
|
Enabled: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -283,6 +305,7 @@ func TestModelCommandExecution_Show(t *testing.T) {
|
||||||
ModelName: "test-model",
|
ModelName: "test-model",
|
||||||
Model: "openai/test",
|
Model: "openai/test",
|
||||||
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||||
|
Enabled: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -314,11 +337,13 @@ func TestModelCommandExecution_Set(t *testing.T) {
|
||||||
ModelName: "old-model",
|
ModelName: "old-model",
|
||||||
Model: "openai/old",
|
Model: "openai/old",
|
||||||
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||||
|
Enabled: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ModelName: "new-model",
|
ModelName: "new-model",
|
||||||
Model: "openai/new",
|
Model: "openai/new",
|
||||||
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||||
|
Enabled: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -356,16 +381,19 @@ func TestListAvailableModels_MarkerLogic(t *testing.T) {
|
||||||
ModelName: "first-model",
|
ModelName: "first-model",
|
||||||
Model: "openai/first",
|
Model: "openai/first",
|
||||||
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||||
|
Enabled: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ModelName: "middle-model",
|
ModelName: "middle-model",
|
||||||
Model: "openai/middle",
|
Model: "openai/middle",
|
||||||
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||||
|
Enabled: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ModelName: "last-model",
|
ModelName: "last-model",
|
||||||
Model: "openai/last",
|
Model: "openai/last",
|
||||||
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
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
|
```json
|
||||||
{
|
{
|
||||||
"gateway": {
|
"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`.
|
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
|
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
|
### Structure du Workspace
|
||||||
|
|
||||||
PicoClaw stocke les données dans votre workspace configuré (par défaut : `~/.picoclaw/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
|
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`)にデータを保存します:
|
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
|
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
|
### Layout do Workspace
|
||||||
|
|
||||||
O PicoClaw armazena dados no seu workspace configurado (padrão: `~/.picoclaw/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
|
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
|
### Bố Cục Workspace
|
||||||
|
|
||||||
PicoClaw lưu trữ dữ liệu trong workspace đã cấu hình (mặc định: `~/.picoclaw/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
|
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)
|
### 工作区布局 (Workspace Layout)
|
||||||
|
|
||||||
PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/workspace`):
|
PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/workspace`):
|
||||||
|
|
|
||||||
26
go.mod
26
go.mod
|
|
@ -7,9 +7,10 @@ require (
|
||||||
github.com/BurntSushi/toml v1.6.0
|
github.com/BurntSushi/toml v1.6.0
|
||||||
github.com/adhocore/gronx v1.19.6
|
github.com/adhocore/gronx v1.19.6
|
||||||
github.com/anthropics/anthropic-sdk-go v1.26.0
|
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/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/bwmarrin/discordgo v0.29.0
|
||||||
github.com/caarlos0/env/v11 v11.4.0
|
github.com/caarlos0/env/v11 v11.4.0
|
||||||
github.com/creack/pty v1.1.24
|
github.com/creack/pty v1.1.24
|
||||||
|
|
@ -36,22 +37,21 @@ require (
|
||||||
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4
|
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4
|
||||||
golang.org/x/oauth2 v0.36.0
|
golang.org/x/oauth2 v0.36.0
|
||||||
golang.org/x/term v0.41.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
|
google.golang.org/protobuf v1.36.11
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
maunium.net/go/mautrix v0.26.4
|
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
|
rsc.io/qr v0.2.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
filippo.io/edwards25519 v1.2.0 // indirect
|
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.8 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // 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/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/configsources v1.4.21 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 // 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/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/accept-encoding v1.13.7 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // 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/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
|
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
|
||||||
github.com/gdamore/encoding v1.0.1 // 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/godbus/dbus/v5 v5.1.0 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.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/spf13/pflag v1.0.10 // indirect
|
||||||
github.com/vektah/gqlparser/v2 v2.5.27 // indirect
|
github.com/vektah/gqlparser/v2 v2.5.27 // indirect
|
||||||
go.mau.fi/libsignal v0.2.1 // 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/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
|
||||||
golang.org/x/text v0.35.0 // 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/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // 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 v1.15.0 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.6 // 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/go-resty/resty/v2 v2.17.1 // indirect
|
||||||
github.com/gogo/protobuf v1.3.2 // indirect
|
github.com/gogo/protobuf v1.3.2 // indirect
|
||||||
github.com/google/jsonschema-go v0.4.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/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 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
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.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
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.7 h1:3kGOqnh1pPeddVa/E37XNTaWJ8W6vrbYV9lJEkCnhuY=
|
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.7/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
|
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 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/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 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/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 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/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.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
|
||||||
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/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
|
||||||
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.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
|
||||||
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/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 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/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.4 h1:W6tKfa/s37faUnwJ71pGqsBO7/wfUX1L7tVprupQGo4=
|
||||||
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/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 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/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=
|
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/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 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3RlfU=
|
||||||
github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
|
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.2.0 h1:RnrIIirmtp4wGgqSQFJ2k9phbeveIxOtYZqDogoNEa0=
|
||||||
github.com/github/copilot-sdk/go v0.1.32/go.mod h1:qc2iEF7hdO8kzSvbyGvrcGhuk2fzdW4xTtT0+1EH2ts=
|
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-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.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q=
|
||||||
github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4=
|
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/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.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
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.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/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.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
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 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
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.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.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/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 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
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/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 h1:hsmlwsM+VqfF70cpdZEeIUKer2XWCQmQPK0u0tHy3ZQ=
|
||||||
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4/go.mod h1:mXCRFyPEPn4jqWz6Afirn8vY7DpHCPnlKq6I2cWwFHM=
|
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 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
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=
|
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.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 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
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.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
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-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-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
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=
|
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 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
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.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
|
||||||
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
|
||||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
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 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
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.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||||
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
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 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
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.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
|
||||||
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
|
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 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
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/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
|
modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
|
||||||
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
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 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,8 @@ type processOptions struct {
|
||||||
SessionKey string // Session identifier for history/context
|
SessionKey string // Session identifier for history/context
|
||||||
Channel string // Target channel for tool execution
|
Channel string // Target channel for tool execution
|
||||||
ChatID string // Target chat ID 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
|
SenderID string // Current sender ID for dynamic context
|
||||||
SenderDisplayName string // Current sender display name for dynamic context
|
SenderDisplayName string // Current sender display name for dynamic context
|
||||||
UserMessage string // User message content (may include prefix)
|
UserMessage string // User message content (may include prefix)
|
||||||
|
|
@ -104,6 +106,7 @@ const (
|
||||||
metadataKeyAccountID = "account_id"
|
metadataKeyAccountID = "account_id"
|
||||||
metadataKeyGuildID = "guild_id"
|
metadataKeyGuildID = "guild_id"
|
||||||
metadataKeyTeamID = "team_id"
|
metadataKeyTeamID = "team_id"
|
||||||
|
metadataKeyReplyToMessage = "reply_to_message_id"
|
||||||
metadataKeyParentPeerKind = "parent_peer_kind"
|
metadataKeyParentPeerKind = "parent_peer_kind"
|
||||||
metadataKeyParentPeerID = "parent_peer_id"
|
metadataKeyParentPeerID = "parent_peer_id"
|
||||||
)
|
)
|
||||||
|
|
@ -222,17 +225,37 @@ func registerSharedTools(
|
||||||
// Message tool
|
// Message tool
|
||||||
if cfg.Tools.IsToolEnabled("message") {
|
if cfg.Tools.IsToolEnabled("message") {
|
||||||
messageTool := tools.NewMessageTool()
|
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)
|
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer pubCancel()
|
defer pubCancel()
|
||||||
return msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
|
return msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
|
||||||
Channel: channel,
|
Channel: channel,
|
||||||
ChatID: chatID,
|
ChatID: chatID,
|
||||||
Content: content,
|
Content: content,
|
||||||
|
ReplyToMessageID: replyToMessageID,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
agent.Tools.Register(messageTool)
|
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)
|
// Send file tool (outbound media via MediaStore — store injected later by SetMediaStore)
|
||||||
if cfg.Tools.IsToolEnabled("send_file") {
|
if cfg.Tools.IsToolEnabled("send_file") {
|
||||||
|
|
@ -1315,6 +1338,8 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
||||||
SessionKey: sessionKey,
|
SessionKey: sessionKey,
|
||||||
Channel: msg.Channel,
|
Channel: msg.Channel,
|
||||||
ChatID: msg.ChatID,
|
ChatID: msg.ChatID,
|
||||||
|
MessageID: msg.MessageID,
|
||||||
|
ReplyToMessageID: inboundMetadata(msg, metadataKeyReplyToMessage),
|
||||||
SenderID: msg.SenderID,
|
SenderID: msg.SenderID,
|
||||||
SenderDisplayName: msg.Sender.DisplayName,
|
SenderDisplayName: msg.Sender.DisplayName,
|
||||||
UserMessage: msg.Content,
|
UserMessage: msg.Content,
|
||||||
|
|
@ -2384,8 +2409,15 @@ turnLoop:
|
||||||
}
|
}
|
||||||
|
|
||||||
toolStart := time.Now()
|
toolStart := time.Now()
|
||||||
toolResult := ts.agent.Tools.ExecuteWithContext(
|
execCtx := tools.WithToolInboundContext(
|
||||||
turnCtx,
|
turnCtx,
|
||||||
|
ts.channel,
|
||||||
|
ts.chatID,
|
||||||
|
ts.opts.MessageID,
|
||||||
|
ts.opts.ReplyToMessageID,
|
||||||
|
)
|
||||||
|
toolResult := ts.agent.Tools.ExecuteWithContext(
|
||||||
|
execCtx,
|
||||||
toolName,
|
toolName,
|
||||||
toolArgs,
|
toolArgs,
|
||||||
ts.channel,
|
ts.channel,
|
||||||
|
|
|
||||||
|
|
@ -531,6 +531,20 @@ func TestToolContext_Updates(t *testing.T) {
|
||||||
if got := tools.ToolChannel(context.Background()); got != "" {
|
if got := tools.ToolChannel(context.Background()); got != "" {
|
||||||
t.Errorf("expected empty channel from bare context, got %q", 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
|
// 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.
|
// ReactToMessage implements channels.ReactionCapable.
|
||||||
// Adds a reaction (randomly chosen from config) and returns an undo function to remove it.
|
// 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) {
|
func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) {
|
||||||
// Get emoji list from config
|
// Get emoji list from config (Feishu emoji_type keys, e.g. Pin, THUMBSUP).
|
||||||
emojiList := c.config.RandomReactionEmoji
|
// Ignore empty entries so a list like ["", "Pin"] does not randomly pick "" (API 231001).
|
||||||
var chosenEmoji string
|
var candidates []string
|
||||||
if len(emojiList) == 0 {
|
for _, e := range c.config.RandomReactionEmoji {
|
||||||
// Default to "Pin" if no config
|
e = strings.TrimSpace(e)
|
||||||
chosenEmoji = "Pin"
|
if e != "" {
|
||||||
} else {
|
candidates = append(candidates, e)
|
||||||
idx := rand.Intn(len(emojiList))
|
}
|
||||||
chosenEmoji = emojiList[idx]
|
}
|
||||||
|
chosenEmoji := "Pin"
|
||||||
|
if len(candidates) > 0 {
|
||||||
|
chosenEmoji = candidates[rand.Intn(len(candidates))]
|
||||||
}
|
}
|
||||||
|
|
||||||
req := larkim.NewCreateMessageReactionReqBuilder().
|
req := larkim.NewCreateMessageReactionReqBuilder().
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@ import (
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/caarlos0/env/v11"
|
"github.com/caarlos0/env/v11"
|
||||||
|
|
||||||
|
|
@ -20,89 +20,8 @@ import (
|
||||||
// rrCounter is a global counter for round-robin load balancing across models.
|
// rrCounter is a global counter for round-robin load balancing across models.
|
||||||
var rrCounter atomic.Uint64
|
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
|
// CurrentVersion is the latest config schema version
|
||||||
const CurrentVersion = 1
|
const CurrentVersion = 2
|
||||||
|
|
||||||
// Config is the current config structure with version support
|
// Config is the current config structure with version support
|
||||||
type Config struct {
|
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)
|
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.
|
// isVirtual marks this model as a virtual model generated from multi-key expansion.
|
||||||
// Virtual models should not be persisted to config files.
|
// Virtual models should not be persisted to config files.
|
||||||
isVirtual bool
|
isVirtual bool
|
||||||
|
|
@ -1047,6 +971,35 @@ func LoadConfig(path string) (*Config, error) {
|
||||||
defer func(cfg *Config) {
|
defer func(cfg *Config) {
|
||||||
_ = SaveConfig(path, cfg)
|
_ = SaveConfig(path, cfg)
|
||||||
}(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:
|
case CurrentVersion:
|
||||||
// Current version
|
// Current version
|
||||||
cfg, err = loadConfig(data)
|
cfg, err = loadConfig(data)
|
||||||
|
|
@ -1064,18 +1017,15 @@ func LoadConfig(path string) (*Config, error) {
|
||||||
return nil, fmt.Errorf("unsupported config version: %d", versionInfo.Version)
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expand multi-key configs into separate entries for key-level failover
|
// Expand multi-key configs into separate entries for key-level failover
|
||||||
cfg.ModelList = expandMultiKeyModels(cfg.ModelList)
|
cfg.ModelList = expandMultiKeyModels(cfg.ModelList)
|
||||||
|
|
||||||
// Migrate legacy channel config fields to new unified structures
|
|
||||||
cfg.migrateChannelConfigs()
|
|
||||||
|
|
||||||
// Validate model_list for uniqueness and required fields
|
// Validate model_list for uniqueness and required fields
|
||||||
if err := cfg.ValidateModelList(); err != nil {
|
if err = cfg.ValidateModelList(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1092,12 +1042,22 @@ func makeBackup(path string) error {
|
||||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// Create backup of the config file before migration
|
dateSuffix := time.Now().Format(".20060102.bak")
|
||||||
bakPath := path + ".bak"
|
// Backup config file
|
||||||
|
bakPath := path + dateSuffix
|
||||||
if err := fileutil.CopyFile(path, bakPath, 0o600); err != nil {
|
if err := fileutil.CopyFile(path, bakPath, 0o600); err != nil {
|
||||||
logger.ErrorF("failed to create config backup", map[string]any{"error": err})
|
logger.ErrorF("failed to create config backup", map[string]any{"error": err})
|
||||||
return fmt.Errorf("failed to create config backup: %w", 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1113,19 +1073,6 @@ func toNameIndex(list []*ModelConfig) []string {
|
||||||
return nameList
|
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 {
|
func SaveConfig(path string, cfg *Config) error {
|
||||||
if cfg.Version < CurrentVersion {
|
if cfg.Version < CurrentVersion {
|
||||||
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
|
// Temporarily replace ModelList with filtered version for serialization
|
||||||
originalModelList := cfg.ModelList
|
originalModelList := cfg.ModelList
|
||||||
|
defer func() {
|
||||||
|
// Restore original ModelList after serialization
|
||||||
|
cfg.ModelList = originalModelList
|
||||||
|
}()
|
||||||
cfg.ModelList = nonVirtualModels
|
cfg.ModelList = nonVirtualModels
|
||||||
|
|
||||||
if err := saveSecurityConfig(securityPath(path), cfg); err != nil {
|
if err := saveSecurityConfig(securityPath(path), cfg); err != nil {
|
||||||
|
|
@ -1147,8 +1098,6 @@ func SaveConfig(path string, cfg *Config) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := json.MarshalIndent(cfg, "", " ")
|
data, err := json.MarshalIndent(cfg, "", " ")
|
||||||
// Restore original ModelList after serialization
|
|
||||||
cfg.ModelList = originalModelList
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -1218,29 +1167,6 @@ func (c *Config) SecurityCopyFrom(path string) error {
|
||||||
return loadSecurityConfig(c, securityPath(path))
|
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
|
// expandMultiKeyModels expands ModelConfig entries with multiple API keys into
|
||||||
// separate entries for key-level failover. Each key gets its own ModelConfig entry,
|
// 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.
|
// 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
|
// Convert []modelConfigV0 to []ModelConfig
|
||||||
cfg.ModelList = make([]*ModelConfig, len(c.ModelList))
|
cfg.ModelList = make([]*ModelConfig, len(c.ModelList))
|
||||||
for i, m := range c.ModelList {
|
for i, m := range c.ModelList {
|
||||||
cfg.ModelList[i] = &ModelConfig{
|
mergedKeys := toSecureStrings(mergeAPIKeys(m.APIKey, m.APIKeys))
|
||||||
|
mc := &ModelConfig{
|
||||||
ModelName: m.ModelName,
|
ModelName: m.ModelName,
|
||||||
Model: m.Model,
|
Model: m.Model,
|
||||||
APIBase: m.APIBase,
|
APIBase: m.APIBase,
|
||||||
|
|
@ -747,8 +748,13 @@ func (c *configV0) Migrate() (*Config, error) {
|
||||||
MaxTokensField: m.MaxTokensField,
|
MaxTokensField: m.MaxTokensField,
|
||||||
RequestTimeout: m.RequestTimeout,
|
RequestTimeout: m.RequestTimeout,
|
||||||
ThinkingLevel: m.ThinkingLevel,
|
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
|
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 {
|
type webToolsConfigV0 struct {
|
||||||
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_WEB_"`
|
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_WEB_"`
|
||||||
Brave braveConfigV0 ` json:"brave"`
|
Brave braveConfigV0 ` json:"brave"`
|
||||||
|
|
@ -791,7 +843,7 @@ func (v *braveConfigV0) ToBraveConfig() BraveConfig {
|
||||||
return BraveConfig{
|
return BraveConfig{
|
||||||
Enabled: v.Enabled,
|
Enabled: v.Enabled,
|
||||||
MaxResults: v.MaxResults,
|
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,
|
Enabled: v.Enabled,
|
||||||
BaseURL: v.BaseURL,
|
BaseURL: v.BaseURL,
|
||||||
MaxResults: v.MaxResults,
|
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{
|
return PerplexityConfig{
|
||||||
Enabled: v.Enabled,
|
Enabled: v.Enabled,
|
||||||
MaxResults: v.MaxResults,
|
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
|
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")
|
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 {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
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) {
|
if len(result) != len(tt.expected) {
|
||||||
t.Fatalf("expected %d keys, got %d", len(tt.expected), len(result))
|
t.Fatalf("expected %d keys, got %d", len(tt.expected), len(result))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,20 +7,16 @@ package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
"runtime"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/credential"
|
|
||||||
"github.com/sipeed/picoclaw/pkg/fileutil"
|
"github.com/sipeed/picoclaw/pkg/fileutil"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -66,7 +62,6 @@ func saveSecurityConfig(securityPath string, sec *Config) error {
|
||||||
return fileutil.WriteFileAtomic(securityPath, buf.Bytes(), 0o600)
|
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.
|
// SensitiveDataCache caches the strings.Replacer for filtering sensitive data.
|
||||||
// Computed once on first access via sync.Once.
|
// Computed once on first access via sync.Once.
|
||||||
type SensitiveDataCache struct {
|
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/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/credential"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSecurityConfig(t *testing.T) {
|
func TestSecurityConfig(t *testing.T) {
|
||||||
|
|
@ -227,134 +225,3 @@ skills:
|
||||||
assert.Equal(t, "abc", cfg2.Tools.Web.Brave.APIKeys[1].raw)
|
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 {
|
type CronPayload struct {
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
Type string `json:"type"`
|
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Command string `json:"command,omitempty"`
|
Command string `json:"command,omitempty"`
|
||||||
Deliver bool `json:"deliver"`
|
|
||||||
Channel string `json:"channel,omitempty"`
|
Channel string `json:"channel,omitempty"`
|
||||||
To string `json:"to,omitempty"`
|
To string `json:"to,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
@ -410,7 +408,6 @@ func (cs *CronService) AddJob(
|
||||||
name string,
|
name string,
|
||||||
schedule CronSchedule,
|
schedule CronSchedule,
|
||||||
message string,
|
message string,
|
||||||
deliver bool,
|
|
||||||
channel, to string,
|
channel, to string,
|
||||||
) (*CronJob, error) {
|
) (*CronJob, error) {
|
||||||
cs.mu.Lock()
|
cs.mu.Lock()
|
||||||
|
|
@ -429,7 +426,6 @@ func (cs *CronService) AddJob(
|
||||||
Payload: CronPayload{
|
Payload: CronPayload{
|
||||||
Kind: "agent_turn",
|
Kind: "agent_turn",
|
||||||
Message: message,
|
Message: message,
|
||||||
Deliver: deliver,
|
|
||||||
Channel: channel,
|
Channel: channel,
|
||||||
To: to,
|
To: to,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ func TestSaveStore_FilePermissions(t *testing.T) {
|
||||||
|
|
||||||
cs := NewCronService(storePath, nil)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("AddJob failed: %v", err)
|
t.Fatalf("AddJob failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -52,7 +52,7 @@ func TestCronService_CRUD(t *testing.T) {
|
||||||
|
|
||||||
// Test AddJob
|
// Test AddJob
|
||||||
at := time.Now().Add(time.Hour).UnixMilli()
|
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 == "" {
|
if err != nil || job.ID == "" {
|
||||||
t.Fatalf("AddJob failed: %v", err)
|
t.Fatalf("AddJob failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -134,7 +134,7 @@ func TestCronService_ExecutionFlow(t *testing.T) {
|
||||||
|
|
||||||
// Add a job then runs 100ms from now
|
// Add a job then runs 100ms from now
|
||||||
target := time.Now().Add(100 * time.Millisecond).UnixMilli()
|
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
|
// Check for job execution with a timeout
|
||||||
success := false
|
success := false
|
||||||
|
|
@ -167,7 +167,7 @@ func TestCronService_PersistenceIntegrity(t *testing.T) {
|
||||||
// write a job and persist
|
// write a job and persist
|
||||||
cs1 := NewCronService(tmpFile, nil)
|
cs1 := NewCronService(tmpFile, nil)
|
||||||
at := int64(2000000000000)
|
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
|
// check file exists
|
||||||
if _, err := os.Stat(tmpFile); os.IsNotExist(err) {
|
if _, err := os.Stat(tmpFile); os.IsNotExist(err) {
|
||||||
|
|
@ -213,7 +213,7 @@ func TestCronService_ConcurrentAccess(t *testing.T) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
for j := range iterations {
|
for j := range iterations {
|
||||||
at := time.Now().Add(time.Hour).UnixMilli()
|
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)
|
time.Sleep(100 * time.Microsecond)
|
||||||
}
|
}
|
||||||
}(i)
|
}(i)
|
||||||
|
|
|
||||||
|
|
@ -94,13 +94,17 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error
|
||||||
defer panicFunc()
|
defer panicFunc()
|
||||||
|
|
||||||
if err = logger.EnableFileLogging(filepath.Join(homePath, logPath, logFile)); err != nil {
|
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()
|
defer logger.DisableFileLogging()
|
||||||
|
|
||||||
cfg, err := config.LoadConfig(configPath)
|
cfg, err := config.LoadConfig(configPath)
|
||||||
if err != nil {
|
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)
|
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(
|
func executeReload(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
agentLoop *agent.AgentLoop,
|
agentLoop *agent.AgentLoop,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
|
"golang.org/x/term"
|
||||||
)
|
)
|
||||||
|
|
||||||
type LogLevel = zerolog.Level
|
type LogLevel = zerolog.Level
|
||||||
|
|
@ -21,6 +22,8 @@ const (
|
||||||
WARN = zerolog.WarnLevel
|
WARN = zerolog.WarnLevel
|
||||||
ERROR = zerolog.ErrorLevel
|
ERROR = zerolog.ErrorLevel
|
||||||
FATAL = zerolog.FatalLevel
|
FATAL = zerolog.FatalLevel
|
||||||
|
|
||||||
|
Component = "component"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
@ -34,26 +37,45 @@ var (
|
||||||
|
|
||||||
currentLevel = INFO
|
currentLevel = INFO
|
||||||
logger zerolog.Logger
|
logger zerolog.Logger
|
||||||
fileLogger zerolog.Logger
|
|
||||||
logFile *os.File
|
logFile *os.File
|
||||||
once sync.Once
|
once sync.Once
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
writers []io.Writer
|
||||||
|
consoleWriter zerolog.ConsoleWriter
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
once.Do(func() {
|
once.Do(func() {
|
||||||
zerolog.SetGlobalLevel(zerolog.InfoLevel)
|
zerolog.SetGlobalLevel(zerolog.InfoLevel)
|
||||||
|
|
||||||
consoleWriter := zerolog.ConsoleWriter{
|
isTTY := term.IsTerminal(int(os.Stdout.Fd()))
|
||||||
|
|
||||||
|
consoleWriter = zerolog.ConsoleWriter{
|
||||||
Out: os.Stdout,
|
Out: os.Stdout,
|
||||||
TimeFormat: "15:04:05", // TODO: make it configurable???
|
TimeFormat: "15:04:05", // TODO: make it configurable???
|
||||||
|
|
||||||
// Custom formatter to handle multiline strings and JSON objects
|
// Custom formatter to handle multiline strings and JSON objects
|
||||||
FormatFieldValue: formatFieldValue,
|
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()
|
writers = append(writers, consoleWriter)
|
||||||
fileLogger = zerolog.Logger{}
|
|
||||||
|
logger = zerolog.New(io.MultiWriter(writers...)).With().Timestamp().Caller().Logger()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -104,7 +126,15 @@ func SetConsoleLevel(level LogLevel) {
|
||||||
func DisableConsole() {
|
func DisableConsole() {
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
defer mu.Unlock()
|
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 {
|
func GetLevel() LogLevel {
|
||||||
|
|
@ -162,7 +192,14 @@ func EnableFileLogging(filePath string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
logFile = newFile
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -174,7 +211,10 @@ func DisableFileLogging() {
|
||||||
logFile.Close()
|
logFile.Close()
|
||||||
logFile = nil
|
logFile = nil
|
||||||
}
|
}
|
||||||
fileLogger = zerolog.Logger{}
|
if len(writers) > 1 {
|
||||||
|
writers = writers[:1]
|
||||||
|
logger = logger.Output(io.MultiWriter(writers...))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func ConfigureFromEnv() {
|
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++ {
|
for i := 2; i < 15; i++ {
|
||||||
pc, file, _, ok := runtime.Caller(i)
|
pc, file, _, ok := runtime.Caller(i)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
@ -217,10 +278,10 @@ func getCallerSkip() int {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
return i - 1
|
return i - 1, getPackageNameFromFile(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
return 3
|
return 3, locUnknown
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:zerologlint
|
//nolint:zerologlint
|
||||||
|
|
@ -246,33 +307,19 @@ func logMessage(level LogLevel, component string, message string, fields map[str
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
skip := getCallerSkip()
|
skip, pkg := getCallerSkip()
|
||||||
|
|
||||||
event := getEvent(logger, level)
|
event := getEvent(logger, level)
|
||||||
|
|
||||||
if component != "" {
|
if component == "" {
|
||||||
event.Str("component", component)
|
component = pkg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
event.Str(Component, component)
|
||||||
|
|
||||||
appendFields(event, fields)
|
appendFields(event, fields)
|
||||||
|
|
||||||
event.CallerSkipFrame(skip).Msg(message)
|
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) {
|
func appendFields(event *zerolog.Event, fields map[string]any) {
|
||||||
|
|
|
||||||
|
|
@ -406,3 +406,28 @@ func TestConfigureFromEnvNoEnv(t *testing.T) {
|
||||||
os.Unsetenv("PICOCLAW_LOG_FILE")
|
os.Unsetenv("PICOCLAW_LOG_FILE")
|
||||||
ConfigureFromEnv()
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ type toolCtxKey struct{ name string }
|
||||||
var (
|
var (
|
||||||
ctxKeyChannel = &toolCtxKey{"channel"}
|
ctxKeyChannel = &toolCtxKey{"channel"}
|
||||||
ctxKeyChatID = &toolCtxKey{"chatID"}
|
ctxKeyChatID = &toolCtxKey{"chatID"}
|
||||||
|
ctxKeyMessageID = &toolCtxKey{"messageID"}
|
||||||
|
ctxKeyReplyToMessageID = &toolCtxKey{"replyToMessageID"}
|
||||||
)
|
)
|
||||||
|
|
||||||
// WithToolContext returns a child context carrying channel and chatID.
|
// 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
|
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.
|
// ToolChannel extracts the channel from ctx, or "" if unset.
|
||||||
func ToolChannel(ctx context.Context) string {
|
func ToolChannel(ctx context.Context) string {
|
||||||
v, _ := ctx.Value(ctxKeyChannel).(string)
|
v, _ := ctx.Value(ctxKeyChannel).(string)
|
||||||
|
|
@ -44,6 +63,18 @@ func ToolChatID(ctx context.Context) string {
|
||||||
return v
|
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.
|
// 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.
|
// 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{
|
"command": map[string]any{
|
||||||
"type": "string",
|
"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{
|
"command_confirm": map[string]any{
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
|
|
@ -114,15 +114,6 @@ func (t *CronTool) Parameters() map[string]any {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Job ID (for remove/enable/disable)",
|
"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"},
|
"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")
|
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
|
// GHSA-pv8c-p6jf-3fpp: command scheduling requires internal channel. When
|
||||||
// allow_command is disabled, explicit confirmation is required as an override.
|
// allow_command is disabled, explicit confirmation is required as an override.
|
||||||
// Non-command reminders remain open to all channels.
|
// 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 {
|
if !t.allowCommand && !commandConfirm {
|
||||||
return ErrorResult("command_confirm=true is required when allow_command is disabled")
|
return ErrorResult("command_confirm=true is required when allow_command is disabled")
|
||||||
}
|
}
|
||||||
deliver = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Truncate message for job name (max 30 chars)
|
// 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,
|
messagePreview,
|
||||||
schedule,
|
schedule,
|
||||||
message,
|
message,
|
||||||
deliver,
|
|
||||||
channel,
|
channel,
|
||||||
chatID,
|
chatID,
|
||||||
)
|
)
|
||||||
|
|
@ -250,10 +227,6 @@ func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult
|
||||||
job.Payload.Command = command
|
job.Payload.Command = command
|
||||||
needsUpdate = true
|
needsUpdate = true
|
||||||
}
|
}
|
||||||
if msgType != "" {
|
|
||||||
job.Payload.Type = msgType
|
|
||||||
needsUpdate = true
|
|
||||||
}
|
|
||||||
if needsUpdate {
|
if needsUpdate {
|
||||||
t.cronService.UpdateJob(job)
|
t.cronService.UpdateJob(job)
|
||||||
}
|
}
|
||||||
|
|
@ -369,40 +342,12 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string {
|
||||||
return "ok"
|
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)
|
sessionKey := fmt.Sprintf("cron-%s", job.ID)
|
||||||
|
|
||||||
// Prepare the prompt based on type
|
// Call agent with the job message
|
||||||
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
|
|
||||||
response, err := t.executor.ProcessDirectWithChannel(
|
response, err := t.executor.ProcessDirectWithChannel(
|
||||||
ctx,
|
ctx,
|
||||||
prompt,
|
job.Payload.Message,
|
||||||
sessionKey,
|
sessionKey,
|
||||||
channel,
|
channel,
|
||||||
chatID,
|
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) {
|
func TestCronTool_ExecuteJobPublishesErrorWhenExecDisabled(t *testing.T) {
|
||||||
cfg := config.DefaultConfig()
|
cfg := config.DefaultConfig()
|
||||||
cfg.Tools.Exec.Enabled = false
|
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) {
|
func TestCronTool_ExecuteJobReturnsErrorWithoutPublish(t *testing.T) {
|
||||||
executor := &stubJobExecutor{
|
executor := &stubJobExecutor{
|
||||||
response: "this response must not be published",
|
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)
|
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"
|
"sync/atomic"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SendCallback func(channel, chatID, content string) error
|
type SendCallback func(channel, chatID, content, replyToMessageID string) error
|
||||||
|
|
||||||
type MessageTool struct {
|
type MessageTool struct {
|
||||||
sendCallback SendCallback
|
sendCallback SendCallback
|
||||||
|
|
@ -41,6 +41,10 @@ func (t *MessageTool) Parameters() map[string]any {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Optional: target chat/user ID",
|
"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"},
|
"required": []string{"content"},
|
||||||
}
|
}
|
||||||
|
|
@ -69,6 +73,7 @@ func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolRes
|
||||||
|
|
||||||
channel, _ := args["channel"].(string)
|
channel, _ := args["channel"].(string)
|
||||||
chatID, _ := args["chat_id"].(string)
|
chatID, _ := args["chat_id"].(string)
|
||||||
|
replyToMessageID, _ := args["reply_to_message_id"].(string)
|
||||||
|
|
||||||
if channel == "" {
|
if channel == "" {
|
||||||
channel = ToolChannel(ctx)
|
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}
|
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{
|
return &ToolResult{
|
||||||
ForLLM: fmt.Sprintf("sending message: %v", err),
|
ForLLM: fmt.Sprintf("sending message: %v", err),
|
||||||
IsError: true,
|
IsError: true,
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ func TestMessageTool_Execute_Success(t *testing.T) {
|
||||||
tool := NewMessageTool()
|
tool := NewMessageTool()
|
||||||
|
|
||||||
var sentChannel, sentChatID, sentContent string
|
var sentChannel, sentChatID, sentContent string
|
||||||
tool.SetSendCallback(func(channel, chatID, content string) error {
|
tool.SetSendCallback(func(channel, chatID, content, replyToMessageID string) error {
|
||||||
sentChannel = channel
|
sentChannel = channel
|
||||||
sentChatID = chatID
|
sentChatID = chatID
|
||||||
sentContent = content
|
sentContent = content
|
||||||
|
|
@ -61,7 +61,7 @@ func TestMessageTool_Execute_WithCustomChannel(t *testing.T) {
|
||||||
tool := NewMessageTool()
|
tool := NewMessageTool()
|
||||||
|
|
||||||
var sentChannel, sentChatID string
|
var sentChannel, sentChatID string
|
||||||
tool.SetSendCallback(func(channel, chatID, content string) error {
|
tool.SetSendCallback(func(channel, chatID, content, replyToMessageID string) error {
|
||||||
sentChannel = channel
|
sentChannel = channel
|
||||||
sentChatID = chatID
|
sentChatID = chatID
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -96,7 +96,7 @@ func TestMessageTool_Execute_SendFailure(t *testing.T) {
|
||||||
tool := NewMessageTool()
|
tool := NewMessageTool()
|
||||||
|
|
||||||
sendErr := errors.New("network error")
|
sendErr := errors.New("network error")
|
||||||
tool.SetSendCallback(func(channel, chatID, content string) error {
|
tool.SetSendCallback(func(channel, chatID, content, replyToMessageID string) error {
|
||||||
return sendErr
|
return sendErr
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -149,7 +149,7 @@ func TestMessageTool_Execute_NoTargetChannel(t *testing.T) {
|
||||||
tool := NewMessageTool()
|
tool := NewMessageTool()
|
||||||
// No WithToolContext — channel/chatID are empty
|
// 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
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -251,4 +251,37 @@ func TestMessageTool_Parameters(t *testing.T) {
|
||||||
if chatIDProp["type"] != "string" {
|
if chatIDProp["type"] != "string" {
|
||||||
t.Error("Expected chat_id type to be '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) {
|
func TestToolRegistry_ExecuteWithContext_AsyncCallback(t *testing.T) {
|
||||||
r := NewToolRegistry()
|
r := NewToolRegistry()
|
||||||
at := &mockAsyncRegistryTool{
|
at := &mockAsyncRegistryTool{
|
||||||
|
|
|
||||||
|
|
@ -29,18 +29,18 @@ const (
|
||||||
DefaultBM25B = 0.75
|
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
|
// T is the document type; the caller supplies a TextFunc that extracts the
|
||||||
// searchable text from each document.
|
// searchable text from each document.
|
||||||
//
|
//
|
||||||
// The engine is stateless between queries: no caching, no invalidation logic.
|
// The engine precomputes its index once at construction time and reuses it for
|
||||||
// All indexing work is performed inside Search() on every call, making it
|
// subsequent searches. If the corpus content changes, construct a new engine.
|
||||||
// safe to use on corpora that change frequently.
|
|
||||||
type BM25Engine[T any] struct {
|
type BM25Engine[T any] struct {
|
||||||
corpus []T
|
corpus []T
|
||||||
textFunc func(T) string
|
textFunc func(T) string
|
||||||
k1 float64
|
k1 float64
|
||||||
b float64
|
b float64
|
||||||
|
index *bm25Index
|
||||||
}
|
}
|
||||||
|
|
||||||
// BM25Option is a functional option to configure a BM25Engine.
|
// BM25Option is a functional option to configure a BM25Engine.
|
||||||
|
|
@ -51,6 +51,17 @@ type bm25Config struct {
|
||||||
b float64
|
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).
|
// WithK1 overrides the term-frequency saturation constant (default 1.2).
|
||||||
func WithK1(k1 float64) BM25Option {
|
func WithK1(k1 float64) BM25Option {
|
||||||
return func(c *bm25Config) { c.k1 = k1 }
|
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 {
|
for _, o := range opts {
|
||||||
o(&cfg)
|
o(&cfg)
|
||||||
}
|
}
|
||||||
return &BM25Engine[T]{
|
engine := &BM25Engine[T]{
|
||||||
corpus: corpus,
|
corpus: corpus,
|
||||||
textFunc: textFunc,
|
textFunc: textFunc,
|
||||||
k1: cfg.k1,
|
k1: cfg.k1,
|
||||||
b: cfg.b,
|
b: cfg.b,
|
||||||
}
|
}
|
||||||
|
engine.index = buildBM25Index(corpus, textFunc, cfg.k1, cfg.b)
|
||||||
|
return engine
|
||||||
}
|
}
|
||||||
|
|
||||||
// BM25Result is a single ranked result from a Search call.
|
// 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.
|
// Search ranks the corpus against query and returns the top-k results.
|
||||||
// Returns an empty slice (not nil) when there are no matches.
|
// Returns an empty slice (not nil) when there are no matches.
|
||||||
//
|
//
|
||||||
// Complexity: O(N×L) for indexing + O(|Q|×avgPostingLen) for scoring,
|
// Complexity: O(|Q|×avgPostingLen + candidates × log k) per search after the
|
||||||
// where N = corpus size, L = average document length, Q = query terms.
|
// one-time indexing work performed by NewBM25Engine.
|
||||||
// Top-k extraction uses a fixed-size min-heap: O(candidates × log k).
|
|
||||||
func (e *BM25Engine[T]) Search(query string, topK int) []BM25Result[T] {
|
func (e *BM25Engine[T]) Search(query string, topK int) []BM25Result[T] {
|
||||||
if topK <= 0 {
|
if topK <= 0 {
|
||||||
return []BM25Result[T]{}
|
return []BM25Result[T]{}
|
||||||
|
|
@ -104,78 +116,24 @@ func (e *BM25Engine[T]) Search(query string, topK int) []BM25Result[T] {
|
||||||
return []BM25Result[T]{}
|
return []BM25Result[T]{}
|
||||||
}
|
}
|
||||||
|
|
||||||
N := len(e.corpus)
|
if len(e.corpus) == 0 || e.index == nil {
|
||||||
if N == 0 {
|
|
||||||
return []BM25Result[T]{}
|
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
|
// Step 4: score via posting lists
|
||||||
// Deduplicate query terms to avoid double-weighting the same term.
|
// Deduplicate query terms to avoid double-weighting the same term.
|
||||||
unique := bm25Dedupe(queryTerms)
|
unique := bm25Dedupe(queryTerms)
|
||||||
|
|
||||||
scores := make(map[int32]float32)
|
scores := make(map[int32]float32)
|
||||||
for _, term := range unique {
|
for _, term := range unique {
|
||||||
termIDF, ok := idf[term]
|
termIDF, ok := e.index.idf[term]
|
||||||
if !ok {
|
if !ok {
|
||||||
continue // term not in vocabulary → zero contribution
|
continue // term not in vocabulary → zero contribution
|
||||||
}
|
}
|
||||||
for _, docID := range posting[term] {
|
for _, docID := range e.index.posting[term] {
|
||||||
freq := float32(entries[docID].tf[term])
|
freq := float32(e.index.entries[docID].tf[term])
|
||||||
// TF_norm = freq * (k1+1) / (freq + docLenNorm)
|
// 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
|
scores[docID] += termIDF * tfNorm
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -212,6 +170,65 @@ func (e *BM25Engine[T]) Search(query string, topK int) []BM25Result[T] {
|
||||||
return out
|
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.
|
// bm25Tokenize splits s into lowercase tokens, stripping edge punctuation.
|
||||||
func bm25Tokenize(s string) []string {
|
func bm25Tokenize(s string) []string {
|
||||||
raw := strings.Fields(strings.ToLower(s))
|
raw := strings.Fields(strings.ToLower(s))
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
package utils
|
package utils
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strings"
|
||||||
"testing"
|
"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 variables
|
||||||
GO?=CGO_ENABLED=0 go
|
GO?=CGO_ENABLED=0 go
|
||||||
WEB_GO?=$(GO)
|
WEB_GO?=$(GO)
|
||||||
GOFLAGS?=-v -tags stdjson
|
GO_BUILD_TAGS?=goolm,stdjson
|
||||||
|
GOFLAGS?=-v -tags $(GO_BUILD_TAGS)
|
||||||
|
|
||||||
# Build variables
|
# Build variables
|
||||||
BUILD_DIR=build
|
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
|
||||||
VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
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)
|
else ifeq ($(UNAME_S),Windows)
|
||||||
PLATFORM=windows
|
PLATFORM=windows
|
||||||
ARCH=$(UNAME_M)
|
ARCH=$(UNAME_M)
|
||||||
LDFLAGS=-H=windowsgui $(LDFLAGS)
|
PICOCLAW_BINARY_NAME=picoclaw.exe
|
||||||
|
LAUNCHER_GUI_LDFLAG=-H=windowsgui
|
||||||
else
|
else
|
||||||
PLATFORM=$(UNAME_S)
|
PLATFORM=$(UNAME_S)
|
||||||
ARCH=$(UNAME_M)
|
ARCH=$(UNAME_M)
|
||||||
endif
|
endif
|
||||||
|
|
||||||
|
LAUNCHER_LDFLAGS=$(strip $(LAUNCHER_GUI_LDFLAG) $(LDFLAGS))
|
||||||
|
|
||||||
# Run both frontend and backend dev servers
|
# Run both frontend and backend dev servers
|
||||||
dev:
|
dev: build-dev-picoclaw
|
||||||
@if [ ! -f $(BUILD_DIR)/picoclaw-launcher ] || [ ! -d backend/dist ]; then \
|
@if [ ! -f "$(BACKEND_DIST)/index.html" ]; then \
|
||||||
echo "Build artifacts not found, building..."; \
|
echo "Embedded frontend not found, building..."; \
|
||||||
$(MAKE) build; \
|
$(MAKE) build-frontend; \
|
||||||
fi
|
fi
|
||||||
@echo "Starting backend and frontend dev servers..."
|
@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)
|
# Start frontend dev server (Vite, with proxy to backend)
|
||||||
dev-frontend:
|
dev-frontend:
|
||||||
cd frontend && pnpm dev
|
cd $(FRONTEND_DIR) && pnpm dev
|
||||||
|
|
||||||
# Start backend dev server
|
# Start backend dev server
|
||||||
dev-backend:
|
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 frontend and embed into Go binary
|
||||||
build:
|
build: build-frontend
|
||||||
cd frontend && pnpm build:backend
|
@mkdir -p "$$(dirname "$(OUTPUT)")"
|
||||||
${WEB_GO} build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/picoclaw-launcher ./backend/
|
${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
|
# Run all tests
|
||||||
test:
|
test:
|
||||||
cd backend && ${WEB_GO} test ./...
|
cd $(BACKEND_DIR) && ${WEB_GO} test ./...
|
||||||
cd frontend && pnpm lint
|
cd $(FRONTEND_DIR) && pnpm lint
|
||||||
|
|
||||||
# Lint and format
|
# Lint and format
|
||||||
lint:
|
lint:
|
||||||
cd backend && ${WEB_GO} vet ./...
|
cd $(BACKEND_DIR) && ${WEB_GO} vet ./...
|
||||||
cd frontend && pnpm check
|
cd $(FRONTEND_DIR) && pnpm check
|
||||||
|
|
||||||
# Clean build artifacts
|
# Clean build artifacts
|
||||||
clean:
|
clean:
|
||||||
rm -rf frontend/dist backend/dist $(BUILD_DIR)
|
rm -rf $(FRONTEND_DIR)/dist $(BACKEND_DIST) $(BUILD_DIR)
|
||||||
mkdir -p backend/dist && touch backend/dist/.gitkeep
|
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`.
|
`web/` contains the standalone WebUI launcher 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.
|
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
|
## 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.
|
- `backend/`
|
||||||
* **`frontend/`**: The Vite + React + TanStack Router single-page application (SPA). It provides the interactive user interface.
|
- 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
|
### Prerequisites
|
||||||
|
|
||||||
* Go 1.25+
|
- Go `1.25+`
|
||||||
* Node.js 20+ with pnpm
|
- 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
|
```bash
|
||||||
make dev
|
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
|
```bash
|
||||||
make dev-frontend # Vite dev server
|
make dev-frontend
|
||||||
make dev-backend # Go backend
|
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
|
```bash
|
||||||
make build
|
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
|
```bash
|
||||||
make test # Run backend tests and frontend lint
|
make build OUTPUT=/tmp/picoclaw-launcher
|
||||||
make lint # Run go vet and prettier/eslint
|
|
||||||
make clean # Remove all build artifacts
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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)
|
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
||||||
return
|
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) {
|
if execAllowRemoteOmitted(body) {
|
||||||
cfg.Tools.Exec.AllowRemote = config.DefaultConfig().Tools.Exec.AllowRemote
|
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)
|
http.Error(w, fmt.Sprintf("Failed to apply security config: %v", err), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
applyConfigSecretsFromMap(&cfg, raw)
|
||||||
|
|
||||||
if errs := validateConfig(&cfg); len(errs) > 0 {
|
if errs := validateConfig(&cfg); len(errs) > 0 {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
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)
|
http.Error(w, fmt.Sprintf("Failed to apply security config: %v", err), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
applyConfigSecretsFromMap(&newCfg, base)
|
||||||
|
|
||||||
if errs := validateConfig(&newCfg); len(errs) > 0 {
|
if errs := validateConfig(&newCfg); len(errs) > 0 {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
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) {
|
func TestHandlePatchConfig_AllowsInvalidDenyRegexPatternsWhenDenyPatternsDisabled(t *testing.T) {
|
||||||
configPath, cleanup := setupOAuthTestEnv(t)
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ type modelResponse struct {
|
||||||
ThinkingLevel string `json:"thinking_level,omitempty"`
|
ThinkingLevel string `json:"thinking_level,omitempty"`
|
||||||
ExtraBody map[string]any `json:"extra_body,omitempty"`
|
ExtraBody map[string]any `json:"extra_body,omitempty"`
|
||||||
// Meta
|
// Meta
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
Configured bool `json:"configured"`
|
Configured bool `json:"configured"`
|
||||||
IsDefault bool `json:"is_default"`
|
IsDefault bool `json:"is_default"`
|
||||||
IsVirtual bool `json:"is_virtual"`
|
IsVirtual bool `json:"is_virtual"`
|
||||||
|
|
@ -85,6 +86,7 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) {
|
||||||
RequestTimeout: m.RequestTimeout,
|
RequestTimeout: m.RequestTimeout,
|
||||||
ThinkingLevel: m.ThinkingLevel,
|
ThinkingLevel: m.ThinkingLevel,
|
||||||
ExtraBody: m.ExtraBody,
|
ExtraBody: m.ExtraBody,
|
||||||
|
Enabled: m.Enabled,
|
||||||
Configured: configured[i],
|
Configured: configured[i],
|
||||||
IsDefault: m.ModelName == defaultModel,
|
IsDefault: m.ModelName == defaultModel,
|
||||||
IsVirtual: m.IsVirtual(),
|
IsVirtual: m.IsVirtual(),
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,9 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||||
// Launcher service parameters (port/public)
|
// Launcher service parameters (port/public)
|
||||||
h.registerLauncherConfigRoutes(mux)
|
h.registerLauncherConfigRoutes(mux)
|
||||||
|
|
||||||
|
// Runtime build/version metadata
|
||||||
|
h.registerVersionRoutes(mux)
|
||||||
|
|
||||||
// WeChat QR login flow
|
// WeChat QR login flow
|
||||||
h.registerWeixinRoutes(mux)
|
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,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
|
|
@ -22,7 +25,7 @@
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.20",
|
"dayjs": "^1.11.20",
|
||||||
"i18next": "^25.8.14",
|
"i18next": "^26.0.1",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"jotai": "^2.18.1",
|
"jotai": "^2.18.1",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
|
|
@ -42,7 +45,7 @@
|
||||||
"wrap-ansi": "^10.0.0"
|
"wrap-ansi": "^10.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^10.0.1",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tanstack/router-plugin": "^1.164.0",
|
"@tanstack/router-plugin": "^1.164.0",
|
||||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||||
|
|
@ -50,16 +53,16 @@
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.57.1",
|
"@typescript-eslint/eslint-plugin": "^8.57.1",
|
||||||
"@vitejs/plugin-react": "^5.2.0",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^10.1.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.26",
|
"eslint-plugin-react-refresh": "^0.4.26",
|
||||||
"globals": "^16.5.0",
|
"globals": "^17.4.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.57.1",
|
"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[]
|
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> {
|
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
const res = await launcherFetch(path, options)
|
const res = await launcherFetch(path, options)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|
@ -62,3 +69,7 @@ export async function setLauncherConfig(
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getSystemVersionInfo(): Promise<SystemVersionInfo> {
|
||||||
|
return request<SystemVersionInfo>("/api/system/version")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,12 @@ import {
|
||||||
IconSparkles,
|
IconSparkles,
|
||||||
IconTools,
|
IconTools,
|
||||||
} from "@tabler/icons-react"
|
} from "@tabler/icons-react"
|
||||||
|
import { useQuery } from "@tanstack/react-query"
|
||||||
import { Link, useRouterState } from "@tanstack/react-router"
|
import { Link, useRouterState } from "@tanstack/react-router"
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
|
import { getSystemVersionInfo } from "@/api/system"
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
|
|
@ -27,6 +29,7 @@ import {
|
||||||
SidebarGroupLabel,
|
SidebarGroupLabel,
|
||||||
SidebarMenu,
|
SidebarMenu,
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
|
SidebarFooter,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
SidebarRail,
|
SidebarRail,
|
||||||
} from "@/components/ui/sidebar"
|
} from "@/components/ui/sidebar"
|
||||||
|
|
@ -78,6 +81,13 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
language: (i18n.resolvedLanguage ?? i18n.language ?? "").toLowerCase(),
|
language: (i18n.resolvedLanguage ?? i18n.language ?? "").toLowerCase(),
|
||||||
t,
|
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(() => {
|
const navGroups: NavGroup[] = React.useMemo(() => {
|
||||||
return [
|
return [
|
||||||
|
|
@ -235,6 +245,26 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
))}
|
))}
|
||||||
</SidebarContent>
|
</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 />
|
<SidebarRail />
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -62,10 +62,8 @@ function asBool(value: unknown): boolean {
|
||||||
|
|
||||||
function buildEditConfig(config: ChannelConfig): ChannelConfig {
|
function buildEditConfig(config: ChannelConfig): ChannelConfig {
|
||||||
const edit: ChannelConfig = { ...config }
|
const edit: ChannelConfig = { ...config }
|
||||||
for (const secretKey of Object.keys(SECRET_FIELD_MAP)) {
|
for (const editKey of Object.values(SECRET_FIELD_MAP)) {
|
||||||
if (secretKey in config) {
|
edit[editKey] = ""
|
||||||
edit[SECRET_FIELD_MAP[secretKey]] = ""
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return edit
|
return edit
|
||||||
}
|
}
|
||||||
|
|
@ -94,17 +92,22 @@ function buildSavePayload(
|
||||||
for (const [key, value] of Object.entries(editConfig)) {
|
for (const [key, value] of Object.entries(editConfig)) {
|
||||||
if (key.startsWith("_")) continue
|
if (key.startsWith("_")) continue
|
||||||
if (key === "enabled") continue
|
if (key === "enabled") continue
|
||||||
|
if (key in SECRET_FIELD_MAP) continue
|
||||||
if (key in SECRET_FIELD_MAP) {
|
|
||||||
const editKey = SECRET_FIELD_MAP[key]
|
|
||||||
const incoming = asString(editConfig[editKey])
|
|
||||||
payload[key] = incoming !== "" ? incoming : value
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
payload[key] = value
|
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") {
|
if (channel.name === "whatsapp_native") {
|
||||||
payload.use_native = true
|
payload.use_native = true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -169,7 +169,7 @@ export function SkillsPage() {
|
||||||
{data.skills.map((skill) => (
|
{data.skills.map((skill) => (
|
||||||
<Card
|
<Card
|
||||||
key={`${skill.source}:${skill.name}`}
|
key={`${skill.source}:${skill.name}`}
|
||||||
className="border-border/60 gap-4 bg-white/80"
|
className="border-border/60 gap-4"
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
@ -211,7 +211,7 @@ export function SkillsPage() {
|
||||||
<div className="text-muted-foreground text-[11px] tracking-[0.18em] uppercase">
|
<div className="text-muted-foreground text-[11px] tracking-[0.18em] uppercase">
|
||||||
{t("pages.agent.skills.path")}
|
{t("pages.agent.skills.path")}
|
||||||
</div>
|
</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}
|
{skill.path}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,12 @@
|
||||||
"labels": {
|
"labels": {
|
||||||
"loading": "Loading..."
|
"loading": "Loading..."
|
||||||
},
|
},
|
||||||
|
"footer": {
|
||||||
|
"version": "Version",
|
||||||
|
"commit": "Commit",
|
||||||
|
"build": "Build",
|
||||||
|
"version_unknown": "Unknown"
|
||||||
|
},
|
||||||
"credentials": {
|
"credentials": {
|
||||||
"description": "Manage OAuth and token-based credentials for supported providers.",
|
"description": "Manage OAuth and token-based credentials for supported providers.",
|
||||||
"loading": "Loading credentials...",
|
"loading": "Loading credentials...",
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,12 @@
|
||||||
"labels": {
|
"labels": {
|
||||||
"loading": "加载中..."
|
"loading": "加载中..."
|
||||||
},
|
},
|
||||||
|
"footer": {
|
||||||
|
"version": "版本",
|
||||||
|
"commit": "提交",
|
||||||
|
"build": "构建",
|
||||||
|
"version_unknown": "未知"
|
||||||
|
},
|
||||||
"credentials": {
|
"credentials": {
|
||||||
"description": "管理已支持服务商的 OAuth 与 Token 凭据。",
|
"description": "管理已支持服务商的 OAuth 与 Token 凭据。",
|
||||||
"loading": "正在加载凭据...",
|
"loading": "正在加载凭据...",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue