diff --git a/.github/workflows/create_dmg.yml b/.github/workflows/create_dmg.yml new file mode 100644 index 000000000..d0a820944 --- /dev/null +++ b/.github/workflows/create_dmg.yml @@ -0,0 +1,62 @@ +name: Create macOS DMG +on: + workflow_dispatch: + +jobs: + build: + name: Build ${{ matrix.arch }} + runs-on: macos-latest + strategy: + matrix: + # This creates two parallel jobs + arch: [arm64, amd64] + + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + ref: main + + # 1. 安装指定版本的 Go (可选,但推荐) + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + + # 2. 安装 pnpm + - name: Install pnpm + run: brew install pnpm + + # 3. 运行你的 Makefile 编译二进制文件 + - name: Build with Make + run: make build ARCH=${{ matrix.arch }} && make build-macos-app ARCH=${{ matrix.arch }} + + # 4. 签名 + - name: Ad-hoc Sign + run: codesign --force --deep --sign - "build/PicoClaw Launcher.app" + + # 5. 安装打包工具 + - name: Install create-dmg + run: brew install create-dmg + + # 6. 执行打包命令 + - name: Create DMG + run: | + mkdir -p dist + create-dmg \ + --volname "PicoClaw Installer" \ + --window-pos 200 120 \ + --window-size 800 400 \ + --icon-size 100 \ + --icon "PicoClaw Launcher.app" 200 190 \ + --hide-extension "PicoClaw Launcher.app" \ + --app-drop-link 600 185 \ + "dist/picoclaw-${{ matrix.arch }}.dmg" \ + "build/PicoClaw Launcher.app" + + # 7. 上传文件到 GitHub Artifacts (供你下载) + - name: Upload DMG + uses: actions/upload-artifact@v4 + with: + name: macos-dmg-${{ matrix.arch }} + path: dist/*.dmg diff --git a/.golangci.yaml b/.golangci.yaml index ea3107ec8..b2b772406 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -61,6 +61,9 @@ linters: - usestdlibvars - usetesting settings: + gomoddirectives: + replace-allow-list: + - github.com/bwmarrin/discordgo errcheck: check-type-assertions: true check-blank: true diff --git a/Makefile b/Makefile index 992182775..21d8bdeac 100644 --- a/Makefile +++ b/Makefile @@ -93,13 +93,13 @@ ifeq ($(UNAME_S),Linux) endif else ifeq ($(UNAME_S),Darwin) PLATFORM=darwin - WEB_GO=CGO_ENABLED=1 go + WEB_GO=CGO_LDFLAGS="-mmacosx-version-min=10.11" CGO_CFLAGS="-mmacosx-version-min=10.11" CGO_ENABLED=1 go ifeq ($(UNAME_M),x86_64) - ARCH=amd64 + ARCH?=amd64 else ifeq ($(UNAME_M),arm64) - ARCH=arm64 + ARCH?=arm64 else - ARCH=$(UNAME_M) + ARCH?=$(UNAME_M) endif else PLATFORM=$(UNAME_S) @@ -122,7 +122,7 @@ generate: build: generate @echo "Building $(BINARY_NAME) for $(PLATFORM)/$(ARCH)..." @mkdir -p $(BUILD_DIR) - @$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BINARY_PATH) ./$(CMD_DIR) + @GOARCH=${ARCH} $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BINARY_PATH) ./$(CMD_DIR) @echo "Build complete: $(BINARY_PATH)" @ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME) @@ -130,7 +130,7 @@ build: generate build-launcher: @echo "Building picoclaw-launcher for $(PLATFORM)/$(ARCH)..." @mkdir -p $(BUILD_DIR) - @$(MAKE) -C web build \ + @GOARCH=${ARCH} $(MAKE) -C web build \ OUTPUT="$(CURDIR)/$(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH)" \ WEB_GO='$(WEB_GO)' \ GO_BUILD_TAGS='$(GO_BUILD_TAGS)' \ @@ -324,14 +324,13 @@ docker-clean: ## build-macos-app: Build PicoClaw macOS .app bundle (no terminal window) -build-macos-app: +build-macos-app:build-launcher @echo "Building macOS .app bundle..." @if [ "$(UNAME_S)" != "Darwin" ]; then \ echo "Error: This target is only available on macOS"; \ exit 1; \ fi - @cd web && $(MAKE) build && cd .. - @./scripts/build-macos-app.sh $(BINARY_NAME)-$(PLATFORM)-$(ARCH) + @./scripts/build-macos-app.sh $(PLATFORM)-$(ARCH) @echo "macOS .app bundle created: $(BUILD_DIR)/PicoClaw.app" ## help: Show this help message diff --git a/README.fr.md b/README.fr.md index 8a035f9b3..a26c89f14 100644 --- a/README.fr.md +++ b/README.fr.md @@ -57,6 +57,8 @@ ## 📢 Actualités +2026-03-31 📱 **Support Android !** PicoClaw fonctionne maintenant sur Android ! Téléchargez l'APK sur [picoclaw.io](https://picoclaw.io/download) + 2026-03-25 🚀 **v0.2.4 publiée !** Refonte de l'architecture Agent (SubTurn, Hooks, Steering, EventBus), intégration WeChat/WeCom, renforcement de la sécurité (.security.yml, filtrage des données sensibles), nouveaux providers (AWS Bedrock, Azure, Xiaomi MiMo), et 35 corrections de bugs. PicoClaw a atteint **26K Stars** ! 2026-03-17 🚀 **v0.2.3 publiée !** Interface system tray (Windows & Linux), requête de statut des sous-agents (`spawn_status`), rechargement à chaud expérimental du Gateway, sécurisation Cron, et 2 correctifs de sécurité. PicoClaw a atteint **25K Stars** ! @@ -304,7 +306,25 @@ Pour la documentation détaillée du TUI, voir [docs.picoclaw.io](https://docs.p Donnez une seconde vie à votre téléphone vieux de dix ans ! Transformez-le en assistant IA intelligent avec PicoClaw. -**Option 1 : Termux (disponible maintenant)** +**Option 1 : Installation APK** + +Aperçu : + +
![]() |
+ ![]() |
+ ![]() |
+ ![]() |
+
-**Option 2 : Installation APK (bientôt disponible)**
-
-Un APK Android autonome avec WebUI intégré est en développement. Restez à l'écoute !
-
-![]() |
+ ![]() |
+ ![]() |
+ ![]() |
+
-**Opsi 2: Instal APK (segera hadir)**
-
-APK Android mandiri dengan WebUI bawaan sedang dalam pengembangan. Pantau terus!
-
-![]() |
+ ![]() |
+ ![]() |
+ ![]() |
+
-**Opzione 2: APK Install (prossimamente)**
-
-Un APK Android standalone con WebUI integrato è in sviluppo. Resta sintonizzato!
-
-![]() |
+ ![]() |
+ ![]() |
+ ![]() |
+
-**オプション 2: APK インストール(近日公開)**
-
-内蔵 WebUI を備えたスタンドアロン Android APK を開発中です。お楽しみに!
-
-![]() |
+ ![]() |
+ ![]() |
+ ![]() |
+
-**Option 2: APK Install (coming soon)**
-
-A standalone Android APK with built-in WebUI is in development. Stay tuned!
-
-![]() |
+ ![]() |
+ ![]() |
+ ![]() |
+
-**Pilihan 2: APK (akan datang)**
-
-APK Android bebas dengan WebUI terbina dalam sedang dalam pembangunan. Nantikan!
-
-![]() |
+ ![]() |
+ ![]() |
+ ![]() |
+
-**Opção 2: Instalação via APK (em breve)**
-
-Um APK Android independente com WebUI integrado está em desenvolvimento. Fique ligado!
-
-![]() |
+ ![]() |
+ ![]() |
+ ![]() |
+
-**Tùy chọn 2: Cài đặt APK (sắp ra mắt)**
-
-Một APK Android độc lập với WebUI tích hợp đang được phát triển. Hãy đón chờ!
-
-![]() |
+ ![]() |
+ ![]() |
+ ![]() |
+
-**方式二:APK 安装(即将推出)**
-
-内置 WebUI 的独立 Android APK 正在开发中,敬请期待!
-
-%s", escaped))
@@ -57,6 +64,29 @@ func markdownToTelegramHTML(text string) string {
return text
}
+type linkMatch struct {
+ text string
+ links [][2]string // [label, url]
+}
+
+func extractLinks(text string) linkMatch {
+ matches := reLink.FindAllStringSubmatch(text, -1)
+
+ extracted := make([][2]string, 0, len(matches))
+ for _, match := range matches {
+ extracted = append(extracted, [2]string{match[1], match[2]})
+ }
+
+ i := 0
+ text = reLink.ReplaceAllStringFunc(text, func(m string) string {
+ placeholder := fmt.Sprintf("\x00LK%d\x00", i)
+ i++
+ return placeholder
+ })
+
+ return linkMatch{text: text, links: extracted}
+}
+
type codeBlockMatch struct {
text string
codes []string
diff --git a/pkg/channels/telegram/parser_markdown_to_html_test.go b/pkg/channels/telegram/parser_markdown_to_html_test.go
new file mode 100644
index 000000000..7754ee076
--- /dev/null
+++ b/pkg/channels/telegram/parser_markdown_to_html_test.go
@@ -0,0 +1,66 @@
+package telegram
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func Test_markdownToTelegramHTML(t *testing.T) {
+ cases := []struct {
+ name string
+ input string
+ expected string
+ }{
+ {
+ name: "plain text",
+ input: "hello world",
+ expected: "hello world",
+ },
+ {
+ name: "bold",
+ input: "**bold text**",
+ expected: "bold text",
+ },
+ {
+ name: "italic",
+ input: "_italic text_",
+ expected: "italic text",
+ },
+ {
+ name: "link without underscores in URL",
+ input: "[click here](https://example.com/path)",
+ expected: `click here`,
+ },
+ {
+ name: "link with underscores in URL is not corrupted by italic regex",
+ // Google Flights URLs use URL-safe base64 with underscores in the tfs param.
+ // Previously reItalic ran after reLink, matching _text_ inside href and injecting
+ // tags into the URL, which broke the link in Telegram.
+ input: "[3 → 10 сентября — от $202](https://www.google.com/travel/flights/search?tfs=CBwQAho_EgoyURL_safe_base64)",
+ expected: `3 → 10 сентября — от $202`,
+ },
+ {
+ name: "multiple links all survive",
+ input: "[first](https://a.com/path_one) and [second](https://b.com/path_two_x)",
+ expected: `first and second`,
+ },
+ {
+ name: "link label with HTML special chars is escaped",
+ input: "[a & b](https://example.com)",
+ expected: `a & b`,
+ },
+ {
+ name: "HTML special chars in plain text are escaped",
+ input: "a & b < c > d",
+ expected: "a & b < c > d",
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ actual := markdownToTelegramHTML(tc.input)
+ require.Equal(t, tc.expected, actual)
+ })
+ }
+}
diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go
index f64a8f79b..2d59de4dc 100644
--- a/pkg/channels/telegram/telegram.go
+++ b/pkg/channels/telegram/telegram.go
@@ -4,6 +4,7 @@ import (
"context"
"crypto/rand"
"encoding/binary"
+ "errors"
"fmt"
"io"
"net/http"
@@ -168,26 +169,27 @@ func (c *TelegramChannel) Stop(ctx context.Context) error {
return nil
}
-func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
+func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {
if !c.IsRunning() {
- return channels.ErrNotRunning
+ return nil, channels.ErrNotRunning
}
useMarkdownV2 := c.config.Channels.Telegram.UseMarkdownV2
chatID, threadID, err := parseTelegramChatID(msg.ChatID)
if err != nil {
- return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
+ return nil, fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
}
if msg.Content == "" {
- return nil
+ return nil, nil
}
// The Manager already splits messages to ≤4000 chars (WithMaxMessageLength),
// so msg.Content is guaranteed to be within that limit. We still need to
// check if HTML expansion pushes it beyond Telegram's 4096-char API limit.
replyToID := msg.ReplyToMessageID
+ var messageIDs []string
queue := []string{msg.Content}
for len(queue) > 0 {
chunk := queue[0]
@@ -206,16 +208,18 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
}
if smallerLen <= 0 {
- if err := c.sendChunk(ctx, sendChunkParams{
+ msgID, err := c.sendChunk(ctx, sendChunkParams{
chatID: chatID,
threadID: threadID,
content: content,
replyToID: replyToID,
mdFallback: chunk,
useMarkdownV2: useMarkdownV2,
- }); err != nil {
- return err
+ })
+ if err != nil {
+ return nil, err
}
+ messageIDs = append(messageIDs, msgID)
replyToID = ""
continue
}
@@ -244,21 +248,23 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
continue
}
- if err := c.sendChunk(ctx, sendChunkParams{
+ msgID, err := c.sendChunk(ctx, sendChunkParams{
chatID: chatID,
threadID: threadID,
content: content,
replyToID: replyToID,
mdFallback: chunk,
useMarkdownV2: useMarkdownV2,
- }); err != nil {
- return err
+ })
+ if err != nil {
+ return nil, err
}
+ messageIDs = append(messageIDs, msgID)
// Only the first chunk should be a reply; subsequent chunks are normal messages.
replyToID = ""
}
- return nil
+ return messageIDs, nil
}
type sendChunkParams struct {
@@ -275,7 +281,7 @@ type sendChunkParams struct {
func (c *TelegramChannel) sendChunk(
ctx context.Context,
params sendChunkParams,
-) error {
+) (string, error) {
tgMsg := tu.Message(tu.ID(params.chatID), params.content)
tgMsg.MessageThreadID = params.threadID
if params.useMarkdownV2 {
@@ -292,17 +298,19 @@ func (c *TelegramChannel) sendChunk(
}
}
- if _, err := c.bot.SendMessage(ctx, tgMsg); err != nil {
+ pMsg, err := c.bot.SendMessage(ctx, tgMsg)
+ if err != nil {
logParseFailed(err, params.useMarkdownV2)
tgMsg.Text = params.mdFallback
tgMsg.ParseMode = ""
- if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil {
- return fmt.Errorf("telegram send: %w", channels.ErrTemporary)
+ pMsg, err = c.bot.SendMessage(ctx, tgMsg)
+ if err != nil {
+ return "", fmt.Errorf("telegram send: %w", channels.ErrTemporary)
}
}
- return nil
+ return strconv.Itoa(pMsg.MessageID), nil
}
// maxTypingDuration limits how long the typing indicator can run.
@@ -370,8 +378,38 @@ func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messag
}
_, err = c.bot.EditMessageText(ctx, editMsg)
if err != nil {
- logParseFailed(err, useMarkdownV2)
- _, err = c.bot.EditMessageText(ctx, tu.EditMessageText(tu.ID(cid), mid, content))
+ // If it failed because it was already modified (likely from a previous
+ // attempt that timed out on our end but landed on Telegram), we treat
+ // it as success to prevent the Manager from sending a duplicate message.
+ if strings.Contains(err.Error(), "message is not modified") {
+ return nil
+ }
+
+ // Only fallback to plain text if the error looks like a parsing failure (Bad Request).
+ // Network errors or timeouts should NOT trigger a retry with different content.
+ if strings.Contains(err.Error(), "Bad Request") {
+ logParseFailed(err, useMarkdownV2)
+ _, err = c.bot.EditMessageText(ctx, tu.EditMessageText(tu.ID(cid), mid, content))
+ }
+ }
+
+ if err != nil {
+ if strings.Contains(err.Error(), "message is not modified") {
+ return nil
+ }
+
+ if isPostConnectError(err) {
+ logger.WarnCF(
+ "telegram",
+ "EditMessage likely landed but result is unknown; swallowing error to prevent duplicate",
+ map[string]any{
+ "chat_id": chatID,
+ "mid": mid,
+ "error": err.Error(),
+ },
+ )
+ return nil // Swallow to prevent Manager fallback to a new SendMessage
+ }
}
return err
@@ -420,21 +458,22 @@ func (c *TelegramChannel) SendPlaceholder(ctx context.Context, chatID string) (s
}
// SendMedia implements the channels.MediaSender interface.
-func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
+func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) {
if !c.IsRunning() {
- return channels.ErrNotRunning
+ return nil, channels.ErrNotRunning
}
chatID, threadID, err := parseTelegramChatID(msg.ChatID)
if err != nil {
- return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
+ return nil, fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
}
store := c.GetMediaStore()
if store == nil {
- return fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
+ return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
}
+ var messageIDs []string
for _, part := range msg.Parts {
localPath, err := store.Resolve(part.Ref)
if err != nil {
@@ -454,6 +493,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
continue
}
+ var tgResult *telego.Message
switch part.Type {
case "image":
params := &telego.SendPhotoParams{
@@ -462,11 +502,11 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
Photo: telego.InputFile{File: file},
Caption: part.Caption,
}
- _, err = c.bot.SendPhoto(ctx, params)
+ tgResult, err = c.bot.SendPhoto(ctx, params)
if err != nil && strings.Contains(err.Error(), "PHOTO_INVALID_DIMENSIONS") {
if _, seekErr := file.Seek(0, io.SeekStart); seekErr != nil {
file.Close()
- return fmt.Errorf("telegram rewind media after photo failure: %w", channels.ErrTemporary)
+ return nil, fmt.Errorf("telegram rewind media after photo failure: %w", channels.ErrTemporary)
}
docParams := &telego.SendDocumentParams{
@@ -475,7 +515,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
Document: telego.InputFile{File: file},
Caption: part.Caption,
}
- _, err = c.bot.SendDocument(ctx, docParams)
+ tgResult, err = c.bot.SendDocument(ctx, docParams)
}
case "audio":
// Send OGG files with "voice" in the filename as Telegram voice
@@ -488,7 +528,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
Voice: telego.InputFile{File: file},
Caption: part.Caption,
}
- _, err = c.bot.SendVoice(ctx, vparams)
+ tgResult, err = c.bot.SendVoice(ctx, vparams)
} else {
params := &telego.SendAudioParams{
ChatID: tu.ID(chatID),
@@ -496,7 +536,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
Audio: telego.InputFile{File: file},
Caption: part.Caption,
}
- _, err = c.bot.SendAudio(ctx, params)
+ tgResult, err = c.bot.SendAudio(ctx, params)
}
case "video":
params := &telego.SendVideoParams{
@@ -505,7 +545,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
Video: telego.InputFile{File: file},
Caption: part.Caption,
}
- _, err = c.bot.SendVideo(ctx, params)
+ tgResult, err = c.bot.SendVideo(ctx, params)
default: // "file" or unknown types
params := &telego.SendDocumentParams{
ChatID: tu.ID(chatID),
@@ -513,9 +553,12 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
Document: telego.InputFile{File: file},
Caption: part.Caption,
}
- _, err = c.bot.SendDocument(ctx, params)
+ tgResult, err = c.bot.SendDocument(ctx, params)
}
+ if tgResult != nil {
+ messageIDs = append(messageIDs, strconv.Itoa(tgResult.MessageID))
+ }
file.Close()
if err != nil {
@@ -523,11 +566,11 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
"type": part.Type,
"error": err.Error(),
})
- return fmt.Errorf("telegram send media: %w", channels.ErrTemporary)
+ return nil, fmt.Errorf("telegram send media: %w", channels.ErrTemporary)
}
}
- return nil
+ return messageIDs, nil
}
func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Message) error {
@@ -660,6 +703,23 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
content = cleaned
}
+ if message.ReplyToMessage != nil {
+ quotedMedia := quotedTelegramMediaRefs(
+ message.ReplyToMessage,
+ func(fileID, ext, filename string) string {
+ localPath := c.downloadFile(ctx, fileID, ext)
+ if localPath == "" {
+ return ""
+ }
+ return storeMedia(localPath, filename)
+ },
+ )
+ if len(quotedMedia) > 0 {
+ mediaPaths = append(quotedMedia, mediaPaths...)
+ }
+ content = c.prependTelegramQuotedReply(content, message.ReplyToMessage)
+ }
+
// For forum topics, embed the thread ID as "chatID/threadID" so replies
// route to the correct topic and each topic gets its own session.
// Only forum groups (IsForum) are handled; regular group reply threads
@@ -693,6 +753,9 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
"first_name": user.FirstName,
"is_group": fmt.Sprintf("%t", message.Chat.Type != "private"),
}
+ if message.ReplyToMessage != nil {
+ metadata["reply_to_message_id"] = fmt.Sprintf("%d", message.ReplyToMessage.MessageID)
+ }
// Set parent_peer metadata for per-topic agent binding.
if message.Chat.IsForum && threadID != 0 {
@@ -713,6 +776,122 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
return nil
}
+func (c *TelegramChannel) prependTelegramQuotedReply(content string, reply *telego.Message) string {
+ quoted := strings.TrimSpace(telegramQuotedContent(reply))
+ if quoted == "" {
+ return content
+ }
+
+ author := telegramQuotedAuthor(reply)
+ role := c.telegramQuotedRole(reply)
+ if strings.TrimSpace(content) == "" {
+ return fmt.Sprintf("[quoted %s message from %s]: %s", role, author, quoted)
+ }
+ return fmt.Sprintf("[quoted %s message from %s]: %s\n\n%s", role, author, quoted, content)
+}
+
+func (c *TelegramChannel) telegramQuotedRole(message *telego.Message) string {
+ if message == nil {
+ return "unknown"
+ }
+
+ if message.From != nil {
+ if !message.From.IsBot {
+ return "user"
+ }
+ if c.isOwnBotUser(message.From) {
+ return "assistant"
+ }
+ return "bot"
+ }
+
+ if message.SenderChat != nil {
+ return "chat"
+ }
+
+ return "unknown"
+}
+
+func (c *TelegramChannel) isOwnBotUser(user *telego.User) bool {
+ if c == nil || c.bot == nil || user == nil || !user.IsBot {
+ return false
+ }
+
+ if botID := c.bot.ID(); botID != 0 && user.ID == botID {
+ return true
+ }
+
+ botUsername := strings.TrimPrefix(strings.TrimSpace(c.bot.Username()), "@")
+ if botUsername == "" {
+ return false
+ }
+ return strings.EqualFold(strings.TrimPrefix(strings.TrimSpace(user.Username), "@"), botUsername)
+}
+
+func telegramQuotedAuthor(message *telego.Message) string {
+ if message == nil || message.From == nil {
+ return "unknown"
+ }
+ if username := strings.TrimSpace(message.From.Username); username != "" {
+ return username
+ }
+ if firstName := strings.TrimSpace(message.From.FirstName); firstName != "" {
+ return firstName
+ }
+ return "unknown"
+}
+
+func telegramQuotedContent(message *telego.Message) string {
+ if message == nil {
+ return ""
+ }
+
+ var parts []string
+ if text := strings.TrimSpace(message.Text); text != "" {
+ parts = append(parts, text)
+ }
+ if caption := strings.TrimSpace(message.Caption); caption != "" {
+ parts = append(parts, caption)
+ }
+ switch {
+ case len(message.Photo) > 0:
+ parts = append(parts, "[image: photo]")
+ }
+ switch {
+ case message.Voice != nil:
+ parts = append(parts, "[voice]")
+ case message.Audio != nil:
+ parts = append(parts, "[audio]")
+ }
+ if message.Document != nil {
+ parts = append(parts, "[file]")
+ }
+
+ return strings.Join(parts, "\n")
+}
+
+func quotedTelegramMediaRefs(
+ message *telego.Message,
+ resolve func(fileID, ext, filename string) string,
+) []string {
+ if message == nil || resolve == nil {
+ return nil
+ }
+
+ var refs []string
+ if message.Voice != nil {
+ if ref := resolve(message.Voice.FileID, ".ogg", "voice.ogg"); ref != "" {
+ refs = append(refs, ref)
+ }
+ }
+ if message.Audio != nil {
+ if ref := resolve(message.Audio.FileID, ".mp3", "audio.mp3"); ref != "" {
+ refs = append(refs, ref)
+ }
+ }
+ return refs
+}
+
func (c *TelegramChannel) downloadPhoto(ctx context.Context, fileID string) string {
file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID})
if err != nil {
@@ -985,3 +1164,32 @@ func cryptoRandInt() int {
_, _ = rand.Read(b[:])
return int(binary.BigEndian.Uint32(b[:])) | 1 // ensure non-zero
}
+
+// isPostConnectError identifies network errors that likely occurred after
+// the request was transmitted to Telegram (e.g. dropped connection while
+// waiting for response). Swallowing these for edits prevents duplicate
+// fallbacks, at the small risk of leaving a stale placeholder if the
+// edit never actually reached the server.
+func isPostConnectError(err error) bool {
+ if err == nil {
+ return false
+ }
+
+ // Context errors (timeout/canceled) are too broad; they can be triggered
+ // locally before any data is sent. Never swallow them.
+ if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
+ return false
+ }
+
+ msg := strings.ToLower(err.Error())
+ // Narrowly target connection dropouts where the request likely landed.
+ return strings.Contains(msg, "connection reset by peer") ||
+ strings.Contains(msg, "unexpected eof") ||
+ strings.Contains(msg, "connection closed by foreign host") ||
+ strings.Contains(msg, "broken pipe")
+}
+
+// VoiceCapabilities returns the voice capabilities of the channel.
+func (c *TelegramChannel) VoiceCapabilities() channels.VoiceCapabilities {
+ return channels.VoiceCapabilities{ASR: true, TTS: true}
+}
diff --git a/pkg/channels/telegram/telegram_test.go b/pkg/channels/telegram/telegram_test.go
index fd189d9a7..4f7a2600b 100644
--- a/pkg/channels/telegram/telegram_test.go
+++ b/pkg/channels/telegram/telegram_test.go
@@ -7,6 +7,7 @@ import (
"io"
"os"
"path/filepath"
+ "strconv"
"strings"
"testing"
@@ -104,6 +105,13 @@ func successResponse(t *testing.T) *ta.Response {
return &ta.Response{Ok: true, Result: b}
}
+func successUserResponse(t *testing.T, user *telego.User) *ta.Response {
+ t.Helper()
+ b, err := json.Marshal(user)
+ require.NoError(t, err)
+ return &ta.Response{Ok: true, Result: b}
+}
+
// newTestChannel creates a TelegramChannel with a mocked bot for unit testing.
func newTestChannel(t *testing.T, caller *stubCaller) *TelegramChannel {
return newTestChannelWithConstructor(t, caller, &stubConstructor{})
@@ -168,7 +176,7 @@ func TestSendMedia_ImageFallbacksToDocumentOnInvalidDimensions(t *testing.T) {
)
require.NoError(t, err)
- err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
+ _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
ChatID: "12345",
Parts: []bus.MediaPart{{
Type: "image",
@@ -206,7 +214,7 @@ func TestSendMedia_ImageNonDimensionErrorDoesNotFallback(t *testing.T) {
ref, err := store.Store(localPath, media.MediaMeta{Filename: "image.png", ContentType: "image/png"}, "scope-1")
require.NoError(t, err)
- err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
+ _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
ChatID: "12345",
Parts: []bus.MediaPart{{
Type: "image",
@@ -231,7 +239,7 @@ func TestSend_EmptyContent(t *testing.T) {
}
ch := newTestChannel(t, caller)
- err := ch.Send(context.Background(), bus.OutboundMessage{
+ _, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: "",
})
@@ -248,7 +256,7 @@ func TestSend_ShortMessage_SingleCall(t *testing.T) {
}
ch := newTestChannel(t, caller)
- err := ch.Send(context.Background(), bus.OutboundMessage{
+ _, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: "Hello, world!",
})
@@ -271,7 +279,7 @@ func TestSend_LongMessage_SingleCall(t *testing.T) {
longContent := strings.Repeat("a", 4000)
- err := ch.Send(context.Background(), bus.OutboundMessage{
+ _, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: longContent,
})
@@ -294,7 +302,7 @@ func TestSend_HTMLFallback_PerChunk(t *testing.T) {
}
ch := newTestChannel(t, caller)
- err := ch.Send(context.Background(), bus.OutboundMessage{
+ _, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: "Hello **world**",
})
@@ -312,7 +320,7 @@ func TestSend_HTMLFallback_BothFail(t *testing.T) {
}
ch := newTestChannel(t, caller)
- err := ch.Send(context.Background(), bus.OutboundMessage{
+ _, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: "Hello",
})
@@ -334,7 +342,7 @@ func TestSend_LongMessage_HTMLFallback_StopsOnError(t *testing.T) {
longContent := strings.Repeat("x", 4001)
- err := ch.Send(context.Background(), bus.OutboundMessage{
+ _, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: longContent,
})
@@ -364,7 +372,7 @@ func TestSend_MarkdownShortButHTMLLong_MultipleCalls(t *testing.T) {
"HTML expansion must exceed Telegram limit for this test to be meaningful",
)
- err := ch.Send(context.Background(), bus.OutboundMessage{
+ _, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: markdownContent,
})
@@ -399,7 +407,7 @@ func TestSend_HTMLOverflow_WordBoundary(t *testing.T) {
// Ensure the test content matches the intended boundary conditions.
assert.LessOrEqual(t, len([]rune(content)), 4000, "markdown content must not exceed chunk size for this test")
- err := ch.Send(context.Background(), bus.OutboundMessage{
+ _, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "123456",
Content: content,
})
@@ -435,7 +443,7 @@ func TestSend_NotRunning(t *testing.T) {
ch := newTestChannel(t, caller)
ch.SetRunning(false)
- err := ch.Send(context.Background(), bus.OutboundMessage{
+ _, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: "Hello",
})
@@ -453,7 +461,7 @@ func TestSend_InvalidChatID(t *testing.T) {
}
ch := newTestChannel(t, caller)
- err := ch.Send(context.Background(), bus.OutboundMessage{
+ _, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "not-a-number",
Content: "Hello",
})
@@ -510,7 +518,7 @@ func TestSend_WithForumThreadID(t *testing.T) {
}
ch := newTestChannel(t, caller)
- err := ch.Send(context.Background(), bus.OutboundMessage{
+ _, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "-1001234567890/42",
Content: "Hello from topic",
})
@@ -642,6 +650,181 @@ func TestHandleMessage_ReplyThread_NonForum_NoIsolation(t *testing.T) {
assert.Empty(t, inbound.Metadata["parent_peer_id"])
}
+func assertHandleMessageQuotedUserReply(
+ t *testing.T,
+ chatID int64,
+ messageID int,
+ userID int64,
+ userName string,
+ userText string,
+ replyMessageID int,
+ replyText string,
+ replyCaption string,
+ replyAuthorID int64,
+ replyAuthorName string,
+ expectedContent string,
+) {
+ t.Helper()
+
+ messageBus := bus.NewMessageBus()
+ ch := &TelegramChannel{
+ BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil),
+ chatIDs: make(map[string]int64),
+ ctx: context.Background(),
+ }
+
+ msg := &telego.Message{
+ Text: userText,
+ MessageID: messageID,
+ Chat: telego.Chat{
+ ID: chatID,
+ Type: "private",
+ },
+ From: &telego.User{
+ ID: userID,
+ FirstName: userName,
+ },
+ ReplyToMessage: &telego.Message{
+ MessageID: replyMessageID,
+ Text: replyText,
+ Caption: replyCaption,
+ From: &telego.User{
+ ID: replyAuthorID,
+ FirstName: replyAuthorName,
+ },
+ },
+ }
+
+ err := ch.handleMessage(context.Background(), msg)
+ require.NoError(t, err)
+
+ inbound, ok := <-messageBus.InboundChan()
+ require.True(t, ok)
+ assert.Equal(t, strconv.Itoa(replyMessageID), inbound.Metadata["reply_to_message_id"])
+ assert.Equal(t, expectedContent, inbound.Content)
+}
+
+func TestHandleMessage_ReplyToMessage_PrependsQuotedTextAndMetadata(t *testing.T) {
+ assertHandleMessageQuotedUserReply(
+ t,
+ 456,
+ 21,
+ 11,
+ "Alice",
+ "follow up",
+ 99,
+ "old context",
+ "",
+ 12,
+ "Bob",
+ "[quoted user message from Bob]: old context\n\nfollow up",
+ )
+}
+
+func TestHandleMessage_ReplyToMessage_UsesCaptionWhenQuotedTextMissing(t *testing.T) {
+ assertHandleMessageQuotedUserReply(
+ t,
+ 789,
+ 22,
+ 13,
+ "Carol",
+ "answer this",
+ 100,
+ "",
+ "caption context",
+ 14,
+ "Dave",
+ "[quoted user message from Dave]: caption context\n\nanswer this",
+ )
+}
+
+func TestHandleMessage_ReplyToOwnBotMessage_UsesAssistantRole(t *testing.T) {
+ messageBus := bus.NewMessageBus()
+ caller := &stubCaller{
+ callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
+ if strings.Contains(url, "getMe") {
+ return successUserResponse(t, &telego.User{
+ ID: 42,
+ IsBot: true,
+ FirstName: "Pico",
+ Username: "afjcjsbx_picoclaw_bot",
+ }), nil
+ }
+ t.Fatalf("unexpected API call: %s", url)
+ return nil, nil
+ },
+ }
+ ch := newTestChannel(t, caller)
+ ch.BaseChannel = channels.NewBaseChannel("telegram", nil, messageBus, nil)
+ ch.ctx = context.Background()
+
+ msg := &telego.Message{
+ Text: "ti ricordi questo file?",
+ MessageID: 23,
+ Chat: telego.Chat{
+ ID: 999,
+ Type: "private",
+ },
+ From: &telego.User{
+ ID: 15,
+ FirstName: "Eve",
+ },
+ ReplyToMessage: &telego.Message{
+ MessageID: 101,
+ Text: "Fatto! Ho creato il file notizie_2026_03_28.md",
+ From: &telego.User{
+ ID: 42,
+ IsBot: true,
+ FirstName: "Pico",
+ Username: "afjcjsbx_picoclaw_bot",
+ },
+ },
+ }
+
+ err := ch.handleMessage(context.Background(), msg)
+ require.NoError(t, err)
+
+ inbound, ok := <-messageBus.InboundChan()
+ require.True(t, ok)
+ assert.Equal(t, "101", inbound.Metadata["reply_to_message_id"])
+ assert.Equal(
+ t,
+ "[quoted assistant message from afjcjsbx_picoclaw_bot]: Fatto! Ho creato il file notizie_2026_03_28.md\n\nti ricordi questo file?",
+ inbound.Content,
+ )
+}
+
+func TestTelegramQuotedContent_IncludesVoiceMarkerAlongsideCaption(t *testing.T) {
+ msg := &telego.Message{
+ Caption: "listen to this",
+ Voice: &telego.Voice{
+ FileID: "voice-file",
+ },
+ }
+
+ assert.Equal(t, "listen to this\n[voice]", telegramQuotedContent(msg))
+}
+
+func TestQuotedTelegramMediaRefs_ResolvesQuotedAudioInOrder(t *testing.T) {
+ msg := &telego.Message{
+ Voice: &telego.Voice{FileID: "voice-file"},
+ Audio: &telego.Audio{FileID: "audio-file"},
+ }
+
+ var calls []string
+ refs := quotedTelegramMediaRefs(msg, func(fileID, ext, filename string) string {
+ calls = append(calls, fileID+"|"+ext+"|"+filename)
+ return "ref://" + filename
+ })
+
+ assert.Equal(
+ t,
+ []string{"voice-file|.ogg|voice.ogg", "audio-file|.mp3|audio.mp3"},
+ calls,
+ )
+ assert.Equal(t, []string{"ref://voice.ogg", "ref://audio.mp3"}, refs)
+}
+
func TestHandleMessage_EmptyContent_Ignored(t *testing.T) {
messageBus := bus.NewMessageBus()
ch := &TelegramChannel{
diff --git a/pkg/channels/voice_capabilities.go b/pkg/channels/voice_capabilities.go
new file mode 100644
index 000000000..34fd24269
--- /dev/null
+++ b/pkg/channels/voice_capabilities.go
@@ -0,0 +1,58 @@
+package channels
+
+// VoiceCapabilities describes whether ASR (speech-to-text) and TTS (text-to-speech)
+// are available for a channel under the current configuration.
+type VoiceCapabilities struct {
+ ASR bool
+ TTS bool
+}
+
+// VoiceCapabilityProvider is an optional interface for channels that want to
+// explicitly declare their ASR/TTS support.
+type VoiceCapabilityProvider interface {
+ VoiceCapabilities() VoiceCapabilities
+}
+
+// Deprecated: Channels should implement VoiceCapabilityProvider instead.
+// To be removed once all existing capable channels conform to the interface.
+var asrCapableChannels = map[string]bool{
+ "discord": true,
+ "telegram": true,
+ "matrix": true,
+ "qq": true,
+ "weixin": true,
+ "line": true,
+ "feishu": true,
+ "onebot": true,
+}
+
+// DetectVoiceCapabilities returns ASR/TTS availability for a channel, gated by
+// whether providers are configured.
+func DetectVoiceCapabilities(channelName string, ch Channel, asrAvailable bool, ttsAvailable bool) VoiceCapabilities {
+ if ch == nil {
+ return VoiceCapabilities{}
+ }
+
+ if vcp, ok := ch.(VoiceCapabilityProvider); ok {
+ caps := vcp.VoiceCapabilities()
+ if !asrAvailable {
+ caps.ASR = false
+ }
+ if !ttsAvailable {
+ caps.TTS = false
+ }
+ return caps
+ }
+
+ caps := VoiceCapabilities{}
+ if asrAvailable {
+ caps.ASR = asrCapableChannels[channelName]
+ }
+ if ttsAvailable {
+ if _, ok := ch.(MediaSender); ok {
+ caps.TTS = true
+ }
+ }
+
+ return caps
+}
diff --git a/pkg/channels/wecom/wecom.go b/pkg/channels/wecom/wecom.go
index 6096b7db3..9689d5171 100644
--- a/pkg/channels/wecom/wecom.go
+++ b/pkg/channels/wecom/wecom.go
@@ -184,20 +184,20 @@ func (c *WeComChannel) BeginStream(_ context.Context, chatID string) (channels.S
}, nil
}
-func (c *WeComChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
+func (c *WeComChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {
if !c.IsRunning() {
- return channels.ErrNotRunning
+ return nil, channels.ErrNotRunning
}
content := strings.TrimSpace(msg.Content)
if content == "" {
- return nil
+ return nil, nil
}
if turn, ok := c.getTurn(msg.ChatID); ok {
if time.Since(turn.CreatedAt) <= wecomStreamMaxDuration {
if err := c.sendStreamReply(turn, content); err == nil {
c.consumeTurn(msg.ChatID, turn)
- return nil
+ return nil, nil
}
}
c.consumeTurn(msg.ChatID, turn)
@@ -205,20 +205,20 @@ func (c *WeComChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
if route, ok := c.routes.Get(msg.ChatID); ok {
if err := c.sendActivePush(route.ChatID, route.ChatType, content); err != nil {
- return err
+ return nil, err
}
- return nil
+ return nil, nil
}
if err := c.sendActivePush(msg.ChatID, 0, content); err != nil {
- return err
+ return nil, err
}
- return nil
+ return nil, nil
}
-func (c *WeComChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
+func (c *WeComChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) {
if !c.IsRunning() {
- return channels.ErrNotRunning
+ return nil, channels.ErrNotRunning
}
route, chatType, hasTurn := c.resolveMediaRoute(msg.ChatID)
@@ -231,7 +231,7 @@ func (c *WeComChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa
if strings.TrimSpace(part.Ref) == "" {
if caption := strings.TrimSpace(part.Caption); caption != "" {
if err := c.sendActivePush(chatID, chatType, caption); err != nil {
- return err
+ return nil, err
}
}
continue
@@ -239,7 +239,7 @@ func (c *WeComChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa
localPath, filename, contentType, cleanup, err := c.resolveOutboundPart(ctx, part)
if err != nil {
- return fmt.Errorf("wecom resolve media %q: %v: %w", part.Ref, err, channels.ErrSendFailed)
+ return nil, fmt.Errorf("wecom resolve media %q: %v: %w", part.Ref, err, channels.ErrSendFailed)
}
func() {
@@ -283,11 +283,11 @@ func (c *WeComChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa
}
}()
if err != nil {
- return err
+ return nil, err
}
}
- return nil
+ return nil, nil
}
func (c *WeComChannel) connectLoop() {
diff --git a/pkg/channels/wecom/wecom_test.go b/pkg/channels/wecom/wecom_test.go
index c7a4adfc0..b3a87e246 100644
--- a/pkg/channels/wecom/wecom_test.go
+++ b/pkg/channels/wecom/wecom_test.go
@@ -190,7 +190,7 @@ func TestSend_StreamFailureFallsBackToActualChatID(t *testing.T) {
return wecomTestAck(nil), nil
}
- if err := ch.Send(context.Background(), bus.OutboundMessage{
+ if _, err := ch.Send(context.Background(), bus.OutboundMessage{
Channel: "wecom",
ChatID: "chat-1",
Content: "hello",
@@ -247,7 +247,7 @@ func TestSend_DoesNotSplitStreamReply(t *testing.T) {
}
content := strings.Repeat("\u4e2d", 30000)
- if err := ch.Send(context.Background(), bus.OutboundMessage{
+ if _, err := ch.Send(context.Background(), bus.OutboundMessage{
Channel: "wecom",
ChatID: "chat-1",
Content: content,
@@ -283,7 +283,7 @@ func TestSend_DoesNotSplitActivePush(t *testing.T) {
}
content := strings.Repeat("a", 30000)
- if err := ch.Send(context.Background(), bus.OutboundMessage{
+ if _, err := ch.Send(context.Background(), bus.OutboundMessage{
Channel: "wecom",
ChatID: "chat-1",
Content: content,
@@ -346,7 +346,7 @@ func TestSendMedia_SendsActiveImage(t *testing.T) {
}
}
- err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
+ _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
Channel: "wecom",
ChatID: "chat-1",
Parts: []bus.MediaPart{{
@@ -457,7 +457,7 @@ func TestSendMedia_UsesTurnImageAndFinishesStream(t *testing.T) {
}
}
- err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
+ _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
Channel: "wecom",
ChatID: "chat-1",
Parts: []bus.MediaPart{{
@@ -553,7 +553,7 @@ func TestSendMedia_SendsActiveFile(t *testing.T) {
}
}
- err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
+ _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
Channel: "wecom",
ChatID: "chat-2",
Parts: []bus.MediaPart{{
diff --git a/pkg/channels/weixin/media.go b/pkg/channels/weixin/media.go
index 4da7f0db9..cf1b45612 100644
--- a/pkg/channels/weixin/media.go
+++ b/pkg/channels/weixin/media.go
@@ -1097,12 +1097,12 @@ func (c *WeixinChannel) StartTyping(ctx context.Context, chatID string) (func(),
}
// SendMedia implements channels.MediaSender.
-func (c *WeixinChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
+func (c *WeixinChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) {
if !c.IsRunning() {
- return basechannels.ErrNotRunning
+ return nil, basechannels.ErrNotRunning
}
if err := c.ensureSessionActive(); err != nil {
- return err
+ return nil, err
}
contextToken := ""
@@ -1110,7 +1110,7 @@ func (c *WeixinChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess
contextToken, _ = v.(string)
}
if contextToken == "" {
- return fmt.Errorf(
+ return nil, fmt.Errorf(
"weixin send media: missing context token for chat %s: %w",
msg.ChatID,
basechannels.ErrSendFailed,
@@ -1125,7 +1125,7 @@ func (c *WeixinChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess
"ref": part.Ref,
"error": err.Error(),
})
- return fmt.Errorf("weixin send media: %w", basechannels.ErrSendFailed)
+ return nil, fmt.Errorf("weixin send media: %w", basechannels.ErrSendFailed)
}
func() {
if cleanup != nil {
@@ -1147,11 +1147,11 @@ func (c *WeixinChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess
"error": err.Error(),
})
if c.remainingPause() > 0 {
- return fmt.Errorf("weixin send media: %w", basechannels.ErrSendFailed)
+ return nil, fmt.Errorf("weixin send media: %w", basechannels.ErrSendFailed)
}
- return fmt.Errorf("weixin send media: %w", basechannels.ErrTemporary)
+ return nil, fmt.Errorf("weixin send media: %w", basechannels.ErrTemporary)
}
}
- return nil
+ return nil, nil
}
diff --git a/pkg/channels/weixin/weixin.go b/pkg/channels/weixin/weixin.go
index 65fabe399..a0d0c96b5 100644
--- a/pkg/channels/weixin/weixin.go
+++ b/pkg/channels/weixin/weixin.go
@@ -358,16 +358,16 @@ func (c *WeixinChannel) handleInboundMessage(ctx context.Context, msg WeixinMess
}
// Send implements channels.Channel by sending a text message to the WeChat user.
-func (c *WeixinChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
+func (c *WeixinChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {
if !c.IsRunning() {
- return channels.ErrNotRunning
+ return nil, channels.ErrNotRunning
}
if err := c.ensureSessionActive(); err != nil {
- return err
+ return nil, err
}
if msg.Content == "" {
- return nil
+ return nil, nil
}
// We need a context_token to send a reply. It should be stored in the conversation metadata.
@@ -386,7 +386,7 @@ func (c *WeixinChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
logger.ErrorCF("weixin", "Missing context token, cannot send message", map[string]any{
"to_user_id": toUserID,
})
- return fmt.Errorf("weixin send: %w: missing context token for chat %s", channels.ErrSendFailed, toUserID)
+ return nil, fmt.Errorf("weixin send: %w: missing context token for chat %s", channels.ErrSendFailed, toUserID)
}
if err := c.sendTextMessage(ctx, toUserID, contextToken, msg.Content); err != nil {
@@ -395,10 +395,15 @@ func (c *WeixinChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
"error": err.Error(),
})
if c.remainingPause() > 0 {
- return fmt.Errorf("weixin send: %w", channels.ErrSendFailed)
+ return nil, fmt.Errorf("weixin send: %w", channels.ErrSendFailed)
}
- return fmt.Errorf("weixin send: %w", channels.ErrTemporary)
+ return nil, fmt.Errorf("weixin send: %w", channels.ErrTemporary)
}
- return nil
+ return nil, nil
+}
+
+// VoiceCapabilities returns the voice capabilities of the channel.
+func (c *WeixinChannel) VoiceCapabilities() channels.VoiceCapabilities {
+ return channels.VoiceCapabilities{ASR: true, TTS: true}
}
diff --git a/pkg/channels/whatsapp/whatsapp.go b/pkg/channels/whatsapp/whatsapp.go
index 70b3e02bf..98622fe37 100644
--- a/pkg/channels/whatsapp/whatsapp.go
+++ b/pkg/channels/whatsapp/whatsapp.go
@@ -104,15 +104,15 @@ func (c *WhatsAppChannel) Stop(ctx context.Context) error {
return nil
}
-func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
+func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {
if !c.IsRunning() {
- return channels.ErrNotRunning
+ return nil, channels.ErrNotRunning
}
// Check ctx before acquiring lock
select {
case <-ctx.Done():
- return ctx.Err()
+ return nil, ctx.Err()
default:
}
@@ -120,7 +120,7 @@ func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
defer c.mu.Unlock()
if c.conn == nil {
- return fmt.Errorf("whatsapp connection not established: %w", channels.ErrTemporary)
+ return nil, fmt.Errorf("whatsapp connection not established: %w", channels.ErrTemporary)
}
payload := map[string]any{
@@ -131,17 +131,17 @@ func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
data, err := json.Marshal(payload)
if err != nil {
- return fmt.Errorf("failed to marshal message: %w", err)
+ return nil, fmt.Errorf("failed to marshal message: %w", err)
}
_ = c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := c.conn.WriteMessage(websocket.TextMessage, data); err != nil {
_ = c.conn.SetWriteDeadline(time.Time{})
- return fmt.Errorf("whatsapp send: %w", channels.ErrTemporary)
+ return nil, fmt.Errorf("whatsapp send: %w", channels.ErrTemporary)
}
_ = c.conn.SetWriteDeadline(time.Time{})
- return nil
+ return nil, nil
}
func (c *WhatsAppChannel) listen() {
diff --git a/pkg/channels/whatsapp_native/whatsapp_native.go b/pkg/channels/whatsapp_native/whatsapp_native.go
index 0809a310a..59cf8fa6c 100644
--- a/pkg/channels/whatsapp_native/whatsapp_native.go
+++ b/pkg/channels/whatsapp_native/whatsapp_native.go
@@ -436,13 +436,13 @@ func (c *WhatsAppNativeChannel) handleIncoming(evt *events.Message) {
c.HandleMessage(c.runCtx, peer, messageID, senderID, chatID, content, mediaPaths, metadata, sender)
}
-func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
+func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {
if !c.IsRunning() {
- return channels.ErrNotRunning
+ return nil, channels.ErrNotRunning
}
select {
case <-ctx.Done():
- return ctx.Err()
+ return nil, ctx.Err()
default:
}
@@ -451,18 +451,18 @@ func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessag
c.mu.Unlock()
if client == nil || !client.IsConnected() {
- return fmt.Errorf("whatsapp connection not established: %w", channels.ErrTemporary)
+ return nil, fmt.Errorf("whatsapp connection not established: %w", channels.ErrTemporary)
}
// Detect unpaired state: the client is connected (to WhatsApp servers)
// but has not completed QR-login yet, so sending would fail.
if client.Store.ID == nil {
- return fmt.Errorf("whatsapp not yet paired (QR login pending): %w", channels.ErrTemporary)
+ return nil, fmt.Errorf("whatsapp not yet paired (QR login pending): %w", channels.ErrTemporary)
}
to, err := parseJID(msg.ChatID)
if err != nil {
- return fmt.Errorf("invalid chat id %q: %w", msg.ChatID, err)
+ return nil, fmt.Errorf("invalid chat id %q: %w", msg.ChatID, err)
}
var waMsg *waE2E.Message
@@ -478,9 +478,9 @@ func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessag
}
if _, err = client.SendMessage(ctx, to, waMsg); err != nil {
- return fmt.Errorf("whatsapp send: %w", channels.ErrTemporary)
+ return nil, fmt.Errorf("whatsapp send: %w", channels.ErrTemporary)
}
- return nil
+ return nil, nil
}
// SendMedia implements the channels.MediaSender interface.
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 87cb31f9e..fcedf45b9 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -226,26 +226,28 @@ type ToolFeedbackConfig struct {
}
type AgentDefaults struct {
- Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
- RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
- AllowReadOutsideWorkspace bool `json:"allow_read_outside_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_ALLOW_READ_OUTSIDE_WORKSPACE"`
- Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
- ModelName string `json:"model_name" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"`
+ Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
+ RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
+ AllowReadOutsideWorkspace bool `json:"allow_read_outside_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_ALLOW_READ_OUTSIDE_WORKSPACE"`
+ Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
+ ModelName string `json:"model_name" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"`
ModelFallbacks []string `json:"model_fallbacks,omitempty"`
- ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
+ ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
- MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
- ContextWindow int `json:"context_window,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_CONTEXT_WINDOW"`
- Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
- MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
- SummarizeMessageThreshold int `json:"summarize_message_threshold" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_MESSAGE_THRESHOLD"`
- SummarizeTokenPercent int `json:"summarize_token_percent" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_TOKEN_PERCENT"`
- MaxMediaSize int `json:"max_media_size,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_MEDIA_SIZE"`
+ MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
+ ContextWindow int `json:"context_window,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_CONTEXT_WINDOW"`
+ Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
+ MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
+ SummarizeMessageThreshold int `json:"summarize_message_threshold" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_MESSAGE_THRESHOLD"`
+ SummarizeTokenPercent int `json:"summarize_token_percent" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_TOKEN_PERCENT"`
+ MaxMediaSize int `json:"max_media_size,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_MEDIA_SIZE"`
Routing *RoutingConfig `json:"routing,omitempty"`
- SteeringMode string `json:"steering_mode,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_STEERING_MODE"` // "one-at-a-time" (default) or "all"
- SubTurn SubTurnConfig `json:"subturn" envPrefix:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_"`
+ SteeringMode string `json:"steering_mode,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_STEERING_MODE"` // "one-at-a-time" (default) or "all"
+ SubTurn SubTurnConfig `json:"subturn" envPrefix:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_"`
ToolFeedback ToolFeedbackConfig `json:"tool_feedback,omitempty"`
- SplitOnMarker bool `json:"split_on_marker" env:"PICOCLAW_AGENTS_DEFAULTS_SPLIT_ON_MARKER"` // split messages on <|[SPLIT]|> marker
+ SplitOnMarker bool `json:"split_on_marker" env:"PICOCLAW_AGENTS_DEFAULTS_SPLIT_ON_MARKER"` // split messages on <|[SPLIT]|> marker
+ ContextManager string `json:"context_manager,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_CONTEXT_MANAGER"`
+ ContextManagerConfig json.RawMessage `json:"context_manager_config,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_CONTEXT_MANAGER_CONFIG"`
}
const DefaultMaxMediaSize = 20 * 1024 * 1024 // 20 MB
@@ -558,9 +560,9 @@ type DevicesConfig struct {
}
type VoiceConfig struct {
- ModelName string `json:"model_name,omitempty" env:"PICOCLAW_VOICE_MODEL_NAME"`
- EchoTranscription bool `json:"echo_transcription" env:"PICOCLAW_VOICE_ECHO_TRANSCRIPTION"`
- ElevenLabsAPIKey string `json:"elevenlabs_api_key,omitempty" env:"PICOCLAW_VOICE_ELEVENLABS_API_KEY"`
+ ModelName string `json:"model_name,omitempty" env:"PICOCLAW_VOICE_MODEL_NAME"`
+ TTSModelName string `json:"tts_model_name,omitempty" env:"PICOCLAW_VOICE_TTS_MODEL_NAME"`
+ EchoTranscription bool `json:"echo_transcription" env:"PICOCLAW_VOICE_ECHO_TRANSCRIPTION"`
}
// ModelConfig represents a model-centric provider configuration.
@@ -598,6 +600,8 @@ type ModelConfig struct {
// 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"`
+ // UserAgent is the user agent string to use for HTTP requests.
+ UserAgent string `json:"user_agent,omitempty" yaml:"-"`
// isVirtual marks this model as a virtual model generated from multi-key expansion.
// Virtual models should not be persisted to config files.
@@ -636,13 +640,6 @@ func (c *ModelConfig) SetAPIKey(value string) {
}
}
-type GatewayConfig struct {
- Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"`
- Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
- HotReload bool `json:"hot_reload" env:"PICOCLAW_GATEWAY_HOT_RELOAD"`
- LogLevel string `json:"log_level,omitempty" env:"PICOCLAW_LOG_LEVEL"`
-}
-
type ToolDiscoveryConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_DISCOVERY_ENABLED"`
TTL int `json:"ttl" env:"PICOCLAW_TOOLS_DISCOVERY_TTL"`
@@ -836,6 +833,7 @@ type ToolsConfig struct {
Message ToolConfig `json:"message" yaml:"-" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"`
ReadFile ReadFileToolConfig `json:"read_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"`
SendFile ToolConfig `json:"send_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"`
+ SendTTS ToolConfig `json:"send_tts" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SEND_TTS_"`
Spawn ToolConfig `json:"spawn" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SPAWN_"`
SpawnStatus ToolConfig `json:"spawn_status" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SPAWN_STATUS_"`
SPI ToolConfig `json:"spi" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SPI_"`
@@ -1288,6 +1286,8 @@ func (t *ToolsConfig) IsToolEnabled(name string) bool {
return t.WebFetch.Enabled
case "send_file":
return t.SendFile.Enabled
+ case "send_tts":
+ return t.SendTTS.Enabled
case "write_file":
return t.WriteFile.Enabled
case "mcp":
diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
index 6734257f4..278dfa43a 100644
--- a/pkg/config/config_test.go
+++ b/pkg/config/config_test.go
@@ -1418,6 +1418,38 @@ func TestConfigLogLevelEmpty(t *testing.T) {
}
}
+func TestResolveGatewayLogLevel(t *testing.T) {
+ dir := t.TempDir()
+ cfgPath := filepath.Join(dir, "config.json")
+ data := `{"version":1,"gateway":{"log_level":"debug"}}`
+ if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil {
+ t.Fatalf("setup: %v", err)
+ }
+
+ if got := ResolveGatewayLogLevel(cfgPath); got != "debug" {
+ t.Fatalf("ResolveGatewayLogLevel() = %q, want %q", got, "debug")
+ }
+}
+
+func TestResolveGatewayLogLevel_UsesEnvOverrideAndNormalizesInvalid(t *testing.T) {
+ dir := t.TempDir()
+ cfgPath := filepath.Join(dir, "config.json")
+ data := `{"version":1,"gateway":{"log_level":"debug"}}`
+ if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil {
+ t.Fatalf("setup: %v", err)
+ }
+
+ t.Setenv("PICOCLAW_LOG_LEVEL", "warning")
+ if got := ResolveGatewayLogLevel(cfgPath); got != "warn" {
+ t.Fatalf("ResolveGatewayLogLevel() with env override = %q, want %q", got, "warn")
+ }
+
+ t.Setenv("PICOCLAW_LOG_LEVEL", "garbage")
+ if got := ResolveGatewayLogLevel(cfgPath); got != DefaultGatewayLogLevel {
+ t.Fatalf("ResolveGatewayLogLevel() with invalid env override = %q, want %q", got, DefaultGatewayLogLevel)
+ }
+}
+
func TestModelConfig_ExtraBodyRoundTrip(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.json")
diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go
index bded97fcd..a9a107975 100644
--- a/pkg/config/defaults.go
+++ b/pkg/config/defaults.go
@@ -185,6 +185,13 @@ func DefaultConfig() *Config {
APIBase: "https://api.deepseek.com/v1",
},
+ // Venice AI - https://venice.ai
+ {
+ ModelName: "venice-uncensored",
+ Model: "venice/venice-uncensored",
+ APIBase: "https://api.venice.ai/api/v1",
+ },
+
// Google Gemini - https://ai.google.dev/
{
ModelName: "gemini-2.0-flash",
@@ -335,6 +342,13 @@ func DefaultConfig() *Config {
APIBase: "http://localhost:8000/v1",
},
+ // LM Studio (local) - http://localhost:1234
+ {
+ ModelName: "lmstudio-local",
+ Model: "lmstudio/openai/gpt-oss-20b",
+ APIBase: "http://localhost:1234/v1",
+ },
+
// Azure OpenAI - https://portal.azure.com
// model_name is a user-friendly alias; the model field's path after "azure/" is your deployment name
{
@@ -347,7 +361,7 @@ func DefaultConfig() *Config {
Host: "127.0.0.1",
Port: 18790,
HotReload: false,
- LogLevel: "warn",
+ LogLevel: DefaultGatewayLogLevel,
},
Tools: ToolsConfig{
FilterSensitiveData: true,
@@ -434,6 +448,9 @@ func DefaultConfig() *Config {
SendFile: ToolConfig{
Enabled: true,
},
+ SendTTS: ToolConfig{
+ Enabled: false,
+ },
MCP: MCPConfig{
ToolConfig: ToolConfig{
Enabled: false,
diff --git a/pkg/config/gateway.go b/pkg/config/gateway.go
new file mode 100644
index 000000000..e9f4085d3
--- /dev/null
+++ b/pkg/config/gateway.go
@@ -0,0 +1,72 @@
+package config
+
+import (
+ "encoding/json"
+ "os"
+
+ "github.com/sipeed/picoclaw/pkg/logger"
+)
+
+const DefaultGatewayLogLevel = "warn"
+
+type GatewayConfig struct {
+ Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"`
+ Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
+ HotReload bool `json:"hot_reload" env:"PICOCLAW_GATEWAY_HOT_RELOAD"`
+ LogLevel string `json:"log_level,omitempty" env:"PICOCLAW_LOG_LEVEL"`
+}
+
+func canonicalGatewayLogLevel(level logger.LogLevel) string {
+ switch level {
+ case logger.DEBUG:
+ return "debug"
+ case logger.INFO:
+ return "info"
+ case logger.WARN:
+ return "warn"
+ case logger.ERROR:
+ return "error"
+ case logger.FATAL:
+ return "fatal"
+ default:
+ return DefaultGatewayLogLevel
+ }
+}
+
+func normalizeGatewayLogLevel(logLevel string) string {
+ if level, ok := logger.ParseLevel(logLevel); ok {
+ return canonicalGatewayLogLevel(level)
+ }
+ return DefaultGatewayLogLevel
+}
+
+// EffectiveGatewayLogLevel returns the normalized runtime log level from a loaded config.
+// Invalid or empty values fall back to the package default.
+func EffectiveGatewayLogLevel(cfg *Config) string {
+ if cfg == nil {
+ return DefaultGatewayLogLevel
+ }
+ return normalizeGatewayLogLevel(cfg.Gateway.LogLevel)
+}
+
+// ResolveGatewayLogLevel reads the configured gateway log level without triggering
+// the full config loader, so startup code can apply logging before config load logs run.
+// The PICOCLAW_LOG_LEVEL environment variable overrides the file value.
+func ResolveGatewayLogLevel(path string) string {
+ cfg := struct {
+ Gateway GatewayConfig `json:"gateway"`
+ }{
+ Gateway: GatewayConfig{LogLevel: DefaultGatewayLogLevel},
+ }
+
+ data, err := os.ReadFile(path)
+ if err == nil {
+ _ = json.Unmarshal(data, &cfg)
+ }
+
+ if envLevel := os.Getenv("PICOCLAW_LOG_LEVEL"); envLevel != "" {
+ cfg.Gateway.LogLevel = envLevel
+ }
+
+ return normalizeGatewayLogLevel(cfg.Gateway.LogLevel)
+}
diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go
index a47bf2ac6..8065a0795 100644
--- a/pkg/gateway/gateway.go
+++ b/pkg/gateway/gateway.go
@@ -6,6 +6,7 @@ import (
"os"
"os/signal"
"path/filepath"
+ "sort"
"strings"
"sync"
"sync/atomic"
@@ -13,6 +14,8 @@ import (
"time"
"github.com/sipeed/picoclaw/pkg/agent"
+ "github.com/sipeed/picoclaw/pkg/audio/asr"
+ "github.com/sipeed/picoclaw/pkg/audio/tts"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
_ "github.com/sipeed/picoclaw/pkg/channels/dingtalk"
@@ -41,7 +44,6 @@ import (
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/state"
"github.com/sipeed/picoclaw/pkg/tools"
- "github.com/sipeed/picoclaw/pkg/voice"
)
const (
@@ -61,6 +63,7 @@ type services struct {
ChannelManager *channels.Manager
DeviceService *devices.Service
HealthServer *health.Server
+ VoiceAgentCancel context.CancelFunc
manualReloadChan chan struct{}
reloading atomic.Bool
authToken string
@@ -70,6 +73,27 @@ type startupBlockedProvider struct {
reason string
}
+func logChannelVoiceCapabilities(cm *channels.Manager, asrAvailable bool, ttsAvailable bool) {
+ if cm == nil {
+ return
+ }
+
+ names := cm.GetEnabledChannels()
+ sort.Strings(names)
+ for _, name := range names {
+ ch, ok := cm.GetChannel(name)
+ if !ok {
+ continue
+ }
+ caps := channels.DetectVoiceCapabilities(name, ch, asrAvailable, ttsAvailable)
+ logger.InfoCF("voice", "Channel voice capabilities", map[string]any{
+ "channel": name,
+ "asr": caps.ASR,
+ "tts": caps.TTS,
+ })
+ }
+}
+
func (p *startupBlockedProvider) Chat(
_ context.Context,
_ []providers.Message,
@@ -98,6 +122,12 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error
}
defer logger.DisableFileLogging()
+ if debug {
+ logger.SetLevel(logger.DEBUG)
+ } else {
+ logger.SetLevelFromString(config.ResolveGatewayLogLevel(configPath))
+ }
+
cfg, err := config.LoadConfig(configPath)
if err != nil {
logger.Fatalf("error loading config: %v", err)
@@ -109,16 +139,17 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error
// Debug mode permanently overrides the config log level to DEBUG.
if debug {
- logger.SetLevel(logger.DEBUG)
fmt.Println("🔍 Debug mode enabled")
} else {
- logger.SetLevelFromString(cfg.Gateway.LogLevel)
- logger.Infof("Log level set to %q", cfg.Gateway.LogLevel)
+ effectiveLogLevel := config.EffectiveGatewayLogLevel(cfg)
+ logger.SetLevelFromString(effectiveLogLevel)
+ logger.Infof("Log level set to %q", effectiveLogLevel)
}
// Enforce singleton: write PID file with generated token.
pidData, err := pid.WritePidFile(homePath, cfg.Gateway.Host, cfg.Gateway.Port)
if err != nil {
+ logger.Warnf("write pid file failed: %v", err)
return fmt.Errorf("singleton check failed: %w", err)
}
defer pid.RemovePidFile(homePath)
@@ -331,11 +362,14 @@ func setupAndStartServices(
agentLoop.SetChannelManager(runningServices.ChannelManager)
agentLoop.SetMediaStore(runningServices.MediaStore)
- if transcriber := voice.DetectTranscriber(cfg); transcriber != nil {
+ transcriber := asr.DetectTranscriber(cfg)
+ if transcriber != nil {
agentLoop.SetTranscriber(transcriber)
logger.InfoCF("voice", "Transcription enabled (agent-level)", map[string]any{"provider": transcriber.Name()})
}
+ ttsAvailable := tts.DetectTTS(cfg) != nil
+
enabledChannels := runningServices.ChannelManager.GetEnabledChannels()
if len(enabledChannels) > 0 {
fmt.Printf("✓ Channels enabled: %s\n", enabledChannels)
@@ -352,6 +386,16 @@ func setupAndStartServices(
return nil, fmt.Errorf("error starting channels: %w", err)
}
+ logChannelVoiceCapabilities(runningServices.ChannelManager, transcriber != nil, ttsAvailable)
+
+ if transcriber != nil {
+ // Start Voice Agent Orchestrator after channels are ready.
+ vaCtx, vaCancel := context.WithCancel(context.Background())
+ runningServices.VoiceAgentCancel = vaCancel
+ voiceAgent := asr.NewAgent(msgBus, transcriber)
+ voiceAgent.Start(vaCtx)
+ }
+
fmt.Printf(
"✓ Health endpoints available at http://%s:%d/health, /ready and /reload (POST)\n",
cfg.Gateway.Host,
@@ -381,6 +425,9 @@ func stopAndCleanupServices(runningServices *services, shutdownTimeout time.Dura
if !isReload && runningServices.ChannelManager != nil {
runningServices.ChannelManager.StopAll(shutdownCtx)
}
+ if runningServices.VoiceAgentCancel != nil {
+ runningServices.VoiceAgentCancel()
+ }
if runningServices.DeviceService != nil {
runningServices.DeviceService.Stop()
}
@@ -476,8 +523,9 @@ func handleConfigReload(
// Debug mode permanently overrides the config log level to DEBUG.
if !debug {
// Update log level last so that reload-related info/warn logs above are not suppressed.
- logger.SetLevelFromString(newCfg.Gateway.LogLevel)
- logger.Infof("Log level changing from current to %q", newCfg.Gateway.LogLevel)
+ effectiveLogLevel := config.EffectiveGatewayLogLevel(newCfg)
+ logger.SetLevelFromString(effectiveLogLevel)
+ logger.Infof("Log level changing from current to %q", effectiveLogLevel)
}
return nil
@@ -556,14 +604,22 @@ func restartServices(
fmt.Println(" ✓ Device event service restarted")
}
- transcriber := voice.DetectTranscriber(cfg)
+ transcriber := asr.DetectTranscriber(cfg)
al.SetTranscriber(transcriber)
if transcriber != nil {
logger.InfoCF("voice", "Transcription re-enabled (agent-level)", map[string]any{"provider": transcriber.Name()})
+
+ // Start Voice Agent Orchestrator on reload
+ vaCtx, vaCancel := context.WithCancel(context.Background())
+ runningServices.VoiceAgentCancel = vaCancel
+ voiceAgent := asr.NewAgent(msgBus, transcriber)
+ voiceAgent.Start(vaCtx)
} else {
logger.InfoCF("voice", "Transcription disabled", nil)
}
+ ttsAvailable := tts.DetectTTS(cfg) != nil
+ logChannelVoiceCapabilities(runningServices.ChannelManager, transcriber != nil, ttsAvailable)
// NOTE: PID file is written once at startup and not updated on reload.
// Changing the gateway listen address requires a full restart.
diff --git a/pkg/logger/panic.go b/pkg/logger/panic.go
index e53e4351a..0a9125dda 100644
--- a/pkg/logger/panic.go
+++ b/pkg/logger/panic.go
@@ -2,12 +2,15 @@ package logger
import (
"fmt"
+ "io"
"os"
"path/filepath"
"runtime/debug"
"time"
)
+var panicWriter io.WriteCloser
+
func InitPanic(filePath string) (func(), error) {
if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil {
return nil, fmt.Errorf("failed to create log directory: %w", err)
@@ -16,21 +19,36 @@ func InitPanic(filePath string) (func(), error) {
if writer == nil {
return nil, fmt.Errorf("failed to create log file: %s", filePath)
}
+ if panicWriter != nil {
+ _ = panicWriter.Close()
+ }
+ panicWriter = writer
return func() {
- defer writer.Close()
+ defer func() {
+ writer.Close()
+ panicWriter = nil
+ }()
if err := recover(); err != nil {
- now := time.Now().Format("2006-01-02 15:04:05")
- stack := debug.Stack()
- logMsg := "\n\n====================\n[" + now + "] PANIC OCCURRED: " + fmt.Sprintf(
- "%v",
- err,
- ) + "\n" + string(
- stack,
- )
-
- writer.Write([]byte(logMsg))
+ RecoverPanicNoExit(err)
os.Exit(1)
}
}, nil
}
+
+func RecoverPanicNoExit(err any) {
+ if panicWriter == nil {
+ Errorf("panicWriter is nil, should not happen")
+ return
+ }
+ now := time.Now().Format("2006-01-02 15:04:05")
+ stack := debug.Stack()
+ logMsg := "\n\n====================\n[" + now + "] PANIC OCCURRED: " + fmt.Sprintf(
+ "%v",
+ err,
+ ) + "\n" + string(
+ stack,
+ )
+
+ panicWriter.Write([]byte(logMsg))
+}
diff --git a/pkg/pid/pidfile.go b/pkg/pid/pidfile.go
index 584b9b2b5..69d02bc65 100644
--- a/pkg/pid/pidfile.go
+++ b/pkg/pid/pidfile.go
@@ -94,6 +94,7 @@ func WritePidFile(homePath, host string, port int) (*PidFileData, error) {
os.Remove(tmp)
return nil, fmt.Errorf("failed to rename pid file: %w", err)
}
+ logger.Debugf("wrote pid file: %s success", pidPath)
return data, nil
}
@@ -108,10 +109,12 @@ func ReadPidFileWithCheck(homePath string) *PidFileData {
pidPath := pidFilePath(homePath)
data, err := readPidFileUnlocked(pidPath)
if err != nil {
+ logger.Debugf("failed to read pid file: %s", err)
return nil
}
if !isProcessRunning(data.PID) {
+ logger.Debugf("process not running, remove pid file: %s", pidPath)
os.Remove(pidPath)
return nil
}
diff --git a/pkg/providers/anthropic_messages/provider.go b/pkg/providers/anthropic_messages/provider.go
index 6a1c473dd..1e865b709 100644
--- a/pkg/providers/anthropic_messages/provider.go
+++ b/pkg/providers/anthropic_messages/provider.go
@@ -41,15 +41,16 @@ type Provider struct {
apiKey string
apiBase string
httpClient *http.Client
+ userAgent string
}
// NewProvider creates a new Anthropic Messages API provider.
-func NewProvider(apiKey, apiBase string) *Provider {
- return NewProviderWithTimeout(apiKey, apiBase, 0)
+func NewProvider(apiKey, apiBase, userAgent string) *Provider {
+ return NewProviderWithTimeout(apiKey, apiBase, userAgent, 0)
}
// NewProviderWithTimeout creates a provider with custom request timeout.
-func NewProviderWithTimeout(apiKey, apiBase string, timeoutSeconds int) *Provider {
+func NewProviderWithTimeout(apiKey, apiBase, userAgent string, timeoutSeconds int) *Provider {
baseURL := normalizeBaseURL(apiBase)
timeout := defaultRequestTimeout
if timeoutSeconds > 0 {
@@ -57,8 +58,9 @@ func NewProviderWithTimeout(apiKey, apiBase string, timeoutSeconds int) *Provide
}
return &Provider{
- apiKey: apiKey,
- apiBase: baseURL,
+ apiKey: apiKey,
+ apiBase: baseURL,
+ userAgent: userAgent,
httpClient: &http.Client{
Timeout: timeout,
},
@@ -105,6 +107,9 @@ func (p *Provider) Chat(
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-API-Key", p.apiKey) //nolint:canonicalheader // Anthropic API requires exact header name
req.Header.Set("Anthropic-Version", defaultAPIVersion)
+ if p.userAgent != "" {
+ req.Header.Set("User-Agent", p.userAgent)
+ }
// Execute request
resp, err := p.httpClient.Do(req)
diff --git a/pkg/providers/anthropic_messages/provider_test.go b/pkg/providers/anthropic_messages/provider_test.go
index 39bc48117..ba9d24b66 100644
--- a/pkg/providers/anthropic_messages/provider_test.go
+++ b/pkg/providers/anthropic_messages/provider_test.go
@@ -411,7 +411,7 @@ func TestNormalizeBaseURL(t *testing.T) {
}
func TestNewProvider(t *testing.T) {
- provider := NewProvider("test-key", "https://api.example.com")
+ provider := NewProvider("test-key", "https://api.example.com", "")
if provider == nil {
t.Fatal("NewProvider() returned nil")
}
@@ -424,7 +424,7 @@ func TestNewProvider(t *testing.T) {
}
func TestGetDefaultModel(t *testing.T) {
- provider := NewProvider("test-key", "")
+ provider := NewProvider("test-key", "", "")
got := provider.GetDefaultModel()
expected := "claude-sonnet-4.6"
if got != expected {
@@ -743,7 +743,7 @@ func TestProviderChatErrors(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create provider using constructor to ensure proper initialization
- provider := NewProvider(tt.apiKey, "https://api.example.com")
+ provider := NewProvider(tt.apiKey, "https://api.example.com", "")
_, err := provider.Chat(context.Background(), tt.messages, nil, "test-model", nil)
if err == nil {
diff --git a/pkg/providers/azure/provider.go b/pkg/providers/azure/provider.go
index 429b26798..7de703248 100644
--- a/pkg/providers/azure/provider.go
+++ b/pkg/providers/azure/provider.go
@@ -36,6 +36,7 @@ type Provider struct {
apiKey string
apiBase string
httpClient *http.Client
+ userAgent string
}
// Option configures the Azure Provider.
@@ -50,11 +51,19 @@ func WithRequestTimeout(timeout time.Duration) Option {
}
}
+// WithUserAgent sets the User-Agent header for requests.
+func WithUserAgent(userAgent string) Option {
+ return func(p *Provider) {
+ p.userAgent = userAgent
+ }
+}
+
// NewProvider creates a new Azure OpenAI provider.
-func NewProvider(apiKey, apiBase, proxy string, opts ...Option) *Provider {
+func NewProvider(apiKey, apiBase, proxy, userAgent string, opts ...Option) *Provider {
p := &Provider{
apiKey: apiKey,
apiBase: strings.TrimRight(apiBase, "/"),
+ userAgent: userAgent,
httpClient: common.NewHTTPClient(proxy),
}
@@ -68,9 +77,9 @@ func NewProvider(apiKey, apiBase, proxy string, opts ...Option) *Provider {
}
// NewProviderWithTimeout creates a new Azure OpenAI provider with a custom request timeout in seconds.
-func NewProviderWithTimeout(apiKey, apiBase, proxy string, requestTimeoutSeconds int) *Provider {
+func NewProviderWithTimeout(apiKey, apiBase, proxy, userAgent string, requestTimeoutSeconds int) *Provider {
return NewProvider(
- apiKey, apiBase, proxy,
+ apiKey, apiBase, proxy, userAgent,
WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second),
)
}
@@ -141,6 +150,9 @@ func (p *Provider) Chat(
if p.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+p.apiKey)
}
+ if p.userAgent != "" {
+ req.Header.Set("User-Agent", p.userAgent)
+ }
resp, err := p.httpClient.Do(req)
if err != nil {
diff --git a/pkg/providers/azure/provider_test.go b/pkg/providers/azure/provider_test.go
index b3752ea50..816ae97dc 100644
--- a/pkg/providers/azure/provider_test.go
+++ b/pkg/providers/azure/provider_test.go
@@ -46,7 +46,7 @@ func TestProviderChat_AzureURLConstruction(t *testing.T) {
}))
defer server.Close()
- p := NewProvider("test-key", server.URL, "")
+ p := NewProvider("test-key", server.URL, "", "")
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "my-gpt5-deployment", nil)
if err != nil {
t.Fatalf("Chat() error = %v", err)
@@ -69,7 +69,7 @@ func TestProviderChat_AzureAuthHeader(t *testing.T) {
}))
defer server.Close()
- p := NewProvider("test-azure-key", server.URL, "")
+ p := NewProvider("test-azure-key", server.URL, "", "")
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
if err != nil {
t.Fatalf("Chat() error = %v", err)
@@ -92,7 +92,7 @@ func TestProviderChat_AzureRequestBodyContainsModel(t *testing.T) {
}))
defer server.Close()
- p := NewProvider("test-key", server.URL, "")
+ p := NewProvider("test-key", server.URL, "", "")
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "my-deployment", nil)
if err != nil {
t.Fatalf("Chat() error = %v", err)
@@ -112,7 +112,7 @@ func TestProviderChat_AzureUsesMaxOutputTokens(t *testing.T) {
}))
defer server.Close()
- p := NewProvider("test-key", server.URL, "")
+ p := NewProvider("test-key", server.URL, "", "")
_, err := p.Chat(
t.Context(),
[]Message{{Role: "user", Content: "hi"}},
@@ -144,7 +144,7 @@ func TestProviderChat_AzureStoreIsFalse(t *testing.T) {
}))
defer server.Close()
- p := NewProvider("test-key", server.URL, "")
+ p := NewProvider("test-key", server.URL, "", "")
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
if err != nil {
t.Fatalf("Chat() error = %v", err)
@@ -161,7 +161,7 @@ func TestProviderChat_AzureHTTPError(t *testing.T) {
}))
defer server.Close()
- p := NewProvider("bad-key", server.URL, "")
+ p := NewProvider("bad-key", server.URL, "", "")
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
if err == nil {
t.Fatal("expected error, got nil")
@@ -176,7 +176,7 @@ func TestProviderChat_AzureRateLimitError(t *testing.T) {
}))
defer server.Close()
- p := NewProvider("test-key", server.URL, "")
+ p := NewProvider("test-key", server.URL, "", "")
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
if err == nil {
t.Fatal("expected error for 429, got nil")
@@ -194,7 +194,7 @@ func TestProviderChat_AzureServerError(t *testing.T) {
}))
defer server.Close()
- p := NewProvider("test-key", server.URL, "")
+ p := NewProvider("test-key", server.URL, "", "")
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
if err == nil {
t.Fatal("expected error for 500, got nil")
@@ -229,7 +229,7 @@ func TestProviderChat_AzureParseTextOutput(t *testing.T) {
}))
defer server.Close()
- p := NewProvider("test-key", server.URL, "")
+ p := NewProvider("test-key", server.URL, "", "")
out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
if err != nil {
t.Fatalf("Chat() error = %v", err)
@@ -270,7 +270,7 @@ func TestProviderChat_AzureParseToolCalls(t *testing.T) {
}))
defer server.Close()
- p := NewProvider("test-key", server.URL, "")
+ p := NewProvider("test-key", server.URL, "", "")
out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "weather?"}}, nil, "deployment", nil)
if err != nil {
t.Fatalf("Chat() error = %v", err)
@@ -287,7 +287,7 @@ func TestProviderChat_AzureParseToolCalls(t *testing.T) {
}
func TestProvider_AzureEmptyAPIBase(t *testing.T) {
- p := NewProvider("test-key", "", "")
+ p := NewProvider("test-key", "", "", "")
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
if err == nil {
t.Fatal("expected error for empty API base")
@@ -295,21 +295,21 @@ func TestProvider_AzureEmptyAPIBase(t *testing.T) {
}
func TestProvider_AzureRequestTimeoutDefault(t *testing.T) {
- p := NewProvider("test-key", "https://example.com", "")
+ p := NewProvider("test-key", "https://example.com", "", "")
if p.httpClient.Timeout != defaultRequestTimeout {
t.Errorf("timeout = %v, want %v", p.httpClient.Timeout, defaultRequestTimeout)
}
}
func TestProvider_AzureRequestTimeoutOverride(t *testing.T) {
- p := NewProvider("test-key", "https://example.com", "", WithRequestTimeout(300*time.Second))
+ p := NewProvider("test-key", "https://example.com", "", "", WithRequestTimeout(300*time.Second))
if p.httpClient.Timeout != 300*time.Second {
t.Errorf("timeout = %v, want %v", p.httpClient.Timeout, 300*time.Second)
}
}
func TestProvider_AzureNewProviderWithTimeout(t *testing.T) {
- p := NewProviderWithTimeout("test-key", "https://example.com", "", 180)
+ p := NewProviderWithTimeout("test-key", "https://example.com", "", "", 180)
if p.httpClient.Timeout != 180*time.Second {
t.Errorf("timeout = %v, want %v", p.httpClient.Timeout, 180*time.Second)
}
@@ -343,7 +343,7 @@ func TestProviderChat_AzureNativeWebSearchInjection(t *testing.T) {
},
}
- p := NewProvider("test-key", server.URL, "")
+ p := NewProvider("test-key", server.URL, "", "")
// With native_search=true: user-defined web_search should be replaced by built-in
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, tools, "deployment",
@@ -393,7 +393,7 @@ func TestProviderChat_AzureNoNativeWebSearch(t *testing.T) {
},
}
- p := NewProvider("test-key", server.URL, "")
+ p := NewProvider("test-key", server.URL, "", "")
// Without native_search: user-defined web_search should be kept as-is
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, tools, "deployment", nil)
diff --git a/pkg/providers/bedrock/provider_bedrock.go b/pkg/providers/bedrock/provider_bedrock.go
index 9ca29455f..3798c5fd8 100644
--- a/pkg/providers/bedrock/provider_bedrock.go
+++ b/pkg/providers/bedrock/provider_bedrock.go
@@ -208,7 +208,10 @@ func (p *Provider) Chat(
if err != nil {
// Check for SSO token expiration errors and provide actionable guidance
if isSSOTokenError(err) {
- return nil, fmt.Errorf("bedrock converse: AWS credentials may have expired. If using AWS SSO, run 'aws sso login' to refresh: %w", err)
+ return nil, fmt.Errorf(
+ "bedrock converse: AWS credentials may have expired. If using AWS SSO, run 'aws sso login' to refresh: %w",
+ err,
+ )
}
return nil, fmt.Errorf("bedrock converse: %w", err)
}
diff --git a/pkg/providers/bedrock/provider_bedrock_test.go b/pkg/providers/bedrock/provider_bedrock_test.go
index 882c2971c..38a5e26da 100644
--- a/pkg/providers/bedrock/provider_bedrock_test.go
+++ b/pkg/providers/bedrock/provider_bedrock_test.go
@@ -583,13 +583,17 @@ func TestIsSSOTokenError(t *testing.T) {
expected: true,
},
{
- name: "full SSO error message",
- err: fmt.Errorf("get identity: get credentials: failed to refresh cached credentials, refresh cached SSO token failed, unable to refresh SSO token"),
+ name: "full SSO error message",
+ err: fmt.Errorf(
+ "get identity: get credentials: failed to refresh cached credentials, refresh cached SSO token failed, unable to refresh SSO token",
+ ),
expected: true,
},
{
- name: "SSO token file missing",
- err: fmt.Errorf("get identity: get credentials: failed to refresh cached credentials, failed to read cached SSO token file, open ~/.aws/sso/cache/abc123.json: no such file or directory"),
+ name: "SSO token file missing",
+ err: fmt.Errorf(
+ "get identity: get credentials: failed to refresh cached credentials, failed to read cached SSO token file, open ~/.aws/sso/cache/abc123.json: no such file or directory",
+ ),
expected: true,
},
}
diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go
index 962e6ae19..ab7277fae 100644
--- a/pkg/providers/factory_provider.go
+++ b/pkg/providers/factory_provider.go
@@ -17,6 +17,49 @@ import (
"github.com/sipeed/picoclaw/pkg/providers/bedrock"
)
+type protocolMeta struct {
+ defaultAPIBase string
+ emptyAPIKeyAllowed bool
+}
+
+var protocolMetaByName = map[string]protocolMeta{
+ "openai": {defaultAPIBase: "https://api.openai.com/v1"},
+ "venice": {defaultAPIBase: "https://api.venice.ai/api/v1"},
+ "openrouter": {defaultAPIBase: "https://openrouter.ai/api/v1"},
+ "litellm": {defaultAPIBase: "http://localhost:4000/v1"},
+ "lmstudio": {defaultAPIBase: "http://localhost:1234/v1", emptyAPIKeyAllowed: true},
+ "novita": {defaultAPIBase: "https://api.novita.ai/openai"},
+ "groq": {defaultAPIBase: "https://api.groq.com/openai/v1"},
+ "zhipu": {defaultAPIBase: "https://open.bigmodel.cn/api/paas/v4"},
+ "gemini": {defaultAPIBase: "https://generativelanguage.googleapis.com/v1beta"},
+ "nvidia": {defaultAPIBase: "https://integrate.api.nvidia.com/v1"},
+ "ollama": {defaultAPIBase: "http://localhost:11434/v1", emptyAPIKeyAllowed: true},
+ "moonshot": {defaultAPIBase: "https://api.moonshot.cn/v1"},
+ "shengsuanyun": {defaultAPIBase: "https://router.shengsuanyun.com/api/v1"},
+ "deepseek": {defaultAPIBase: "https://api.deepseek.com/v1"},
+ "cerebras": {defaultAPIBase: "https://api.cerebras.ai/v1"},
+ "vivgrid": {defaultAPIBase: "https://api.vivgrid.com/v1"},
+ "volcengine": {defaultAPIBase: "https://ark.cn-beijing.volces.com/api/v3"},
+ "qwen": {defaultAPIBase: "https://dashscope.aliyuncs.com/compatible-mode/v1"},
+ "qwen-intl": {defaultAPIBase: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"},
+ "qwen-international": {defaultAPIBase: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"},
+ "dashscope-intl": {defaultAPIBase: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"},
+ "qwen-us": {defaultAPIBase: "https://dashscope-us.aliyuncs.com/compatible-mode/v1"},
+ "dashscope-us": {defaultAPIBase: "https://dashscope-us.aliyuncs.com/compatible-mode/v1"},
+ "coding-plan": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/v1"},
+ "alibaba-coding": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/v1"},
+ "qwen-coding": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/v1"},
+ "coding-plan-anthropic": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"},
+ "alibaba-coding-anthropic": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"},
+ "vllm": {defaultAPIBase: "http://localhost:8000/v1", emptyAPIKeyAllowed: true},
+ "mistral": {defaultAPIBase: "https://api.mistral.ai/v1"},
+ "avian": {defaultAPIBase: "https://api.avian.io/v1"},
+ "minimax": {defaultAPIBase: "https://api.minimaxi.com/v1"},
+ "longcat": {defaultAPIBase: "https://api.longcat.chat/openai"},
+ "modelscope": {defaultAPIBase: "https://api-inference.modelscope.cn/v1"},
+ "mimo": {defaultAPIBase: "https://api.xiaomimimo.com/v1"},
+}
+
// createClaudeAuthProvider creates a Claude provider using OAuth credentials from auth store.
func createClaudeAuthProvider() (LLMProvider, error) {
cred, err := getCredential("anthropic")
@@ -56,6 +99,19 @@ func ExtractProtocol(model string) (protocol, modelID string) {
return protocol, modelID
}
+// ResolveAPIBase returns the configured API base, or the protocol default when
+// the model uses an HTTP-based provider family with a known default endpoint.
+func ResolveAPIBase(cfg *config.ModelConfig) string {
+ if cfg == nil {
+ return ""
+ }
+ if apiBase := strings.TrimSpace(cfg.APIBase); apiBase != "" {
+ return strings.TrimRight(apiBase, "/")
+ }
+ protocol, _ := ExtractProtocol(cfg.Model)
+ return strings.TrimRight(getDefaultAPIBase(protocol), "/")
+}
+
// CreateProviderFromConfig creates a provider based on the ModelConfig.
// It uses the protocol prefix in the Model field to determine which provider to create.
// Supported protocol families include OpenAI-compatible prefixes (e.g., openai, openrouter, groq, gemini),
@@ -73,6 +129,11 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
protocol, modelID := ExtractProtocol(cfg.Model)
+ userAgent := cfg.UserAgent
+ if userAgent == "" {
+ userAgent = fmt.Sprintf("PicoClaw/%s", config.Version)
+ }
+
switch protocol {
case "openai":
// OpenAI with OAuth/token auth (Codex-style)
@@ -96,6 +157,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
apiBase,
cfg.Proxy,
cfg.MaxTokensField,
+ userAgent,
cfg.RequestTimeout,
cfg.ExtraBody,
), modelID, nil
@@ -115,6 +177,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
cfg.APIKey(),
cfg.APIBase,
cfg.Proxy,
+ userAgent,
cfg.RequestTimeout,
), modelID, nil
@@ -154,13 +217,13 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
}
return provider, modelID, nil
- case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia",
+ case "litellm", "lmstudio", "openrouter", "groq", "zhipu", "gemini", "nvidia", "venice",
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
"vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl",
"qwen-us", "dashscope-us", "mistral", "avian", "longcat", "modelscope", "novita",
"coding-plan", "alibaba-coding", "qwen-coding", "mimo":
// All other OpenAI-compatible HTTP providers
- if cfg.APIKey() == "" && cfg.APIBase == "" {
+ if cfg.APIKey() == "" && cfg.APIBase == "" && !isEmptyAPIKeyAllowed(protocol) {
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
}
apiBase := cfg.APIBase
@@ -172,6 +235,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
apiBase,
cfg.Proxy,
cfg.MaxTokensField,
+ userAgent,
cfg.RequestTimeout,
cfg.ExtraBody,
), modelID, nil
@@ -197,6 +261,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
apiBase,
cfg.Proxy,
cfg.MaxTokensField,
+ userAgent,
cfg.RequestTimeout,
extraBody,
), modelID, nil
@@ -223,6 +288,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
apiBase,
cfg.Proxy,
cfg.MaxTokensField,
+ userAgent,
cfg.RequestTimeout,
cfg.ExtraBody,
), modelID, nil
@@ -239,6 +305,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
return anthropicmessages.NewProviderWithTimeout(
cfg.APIKey(),
apiBase,
+ userAgent,
cfg.RequestTimeout,
), modelID, nil
@@ -254,6 +321,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
return anthropicmessages.NewProviderWithTimeout(
cfg.APIKey(),
apiBase,
+ userAgent,
cfg.RequestTimeout,
), modelID, nil
@@ -294,64 +362,30 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
}
}
+func isEmptyAPIKeyAllowed(protocol string) bool {
+ meta, ok := protocolMetaByName[protocol]
+ return ok && meta.emptyAPIKeyAllowed
+}
+
+// IsEmptyAPIKeyAllowedForProtocol reports whether a protocol allows requests
+// without api_key when using its default local endpoint.
+func IsEmptyAPIKeyAllowedForProtocol(protocol string) bool {
+ protocol = strings.ToLower(strings.TrimSpace(protocol))
+ return isEmptyAPIKeyAllowed(protocol)
+}
+
+// DefaultAPIBaseForProtocol returns the configured default API base for a protocol.
+// It returns empty string if the protocol has no default base.
+func DefaultAPIBaseForProtocol(protocol string) string {
+ protocol = strings.ToLower(strings.TrimSpace(protocol))
+ return getDefaultAPIBase(protocol)
+}
+
// getDefaultAPIBase returns the default API base URL for a given protocol.
func getDefaultAPIBase(protocol string) string {
- switch protocol {
- case "openai":
- return "https://api.openai.com/v1"
- case "openrouter":
- return "https://openrouter.ai/api/v1"
- case "litellm":
- return "http://localhost:4000/v1"
- case "novita":
- return "https://api.novita.ai/openai"
- case "groq":
- return "https://api.groq.com/openai/v1"
- case "zhipu":
- return "https://open.bigmodel.cn/api/paas/v4"
- case "gemini":
- return "https://generativelanguage.googleapis.com/v1beta"
- case "nvidia":
- return "https://integrate.api.nvidia.com/v1"
- case "ollama":
- return "http://localhost:11434/v1"
- case "moonshot":
- return "https://api.moonshot.cn/v1"
- case "shengsuanyun":
- return "https://router.shengsuanyun.com/api/v1"
- case "deepseek":
- return "https://api.deepseek.com/v1"
- case "cerebras":
- return "https://api.cerebras.ai/v1"
- case "vivgrid":
- return "https://api.vivgrid.com/v1"
- case "volcengine":
- return "https://ark.cn-beijing.volces.com/api/v3"
- case "qwen":
- return "https://dashscope.aliyuncs.com/compatible-mode/v1"
- case "qwen-intl", "qwen-international", "dashscope-intl":
- return "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
- case "qwen-us", "dashscope-us":
- return "https://dashscope-us.aliyuncs.com/compatible-mode/v1"
- case "coding-plan", "alibaba-coding", "qwen-coding":
- return "https://coding-intl.dashscope.aliyuncs.com/v1"
- case "coding-plan-anthropic", "alibaba-coding-anthropic":
- return "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"
- case "vllm":
- return "http://localhost:8000/v1"
- case "mistral":
- return "https://api.mistral.ai/v1"
- case "avian":
- return "https://api.avian.io/v1"
- case "minimax":
- return "https://api.minimaxi.com/v1"
- case "longcat":
- return "https://api.longcat.chat/openai"
- case "modelscope":
- return "https://api-inference.modelscope.cn/v1"
- case "mimo":
- return "https://api.xiaomimimo.com/v1"
- default:
+ meta, ok := protocolMetaByName[protocol]
+ if !ok {
return ""
}
+ return meta.defaultAPIBase
}
diff --git a/pkg/providers/factory_provider_test.go b/pkg/providers/factory_provider_test.go
index f1fe02cc2..b4f672f7a 100644
--- a/pkg/providers/factory_provider_test.go
+++ b/pkg/providers/factory_provider_test.go
@@ -112,6 +112,7 @@ func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) {
protocol string
}{
{"openai", "openai"},
+ {"venice", "venice"},
{"groq", "groq"},
{"novita", "novita"},
{"openrouter", "openrouter"},
@@ -121,6 +122,7 @@ func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) {
{"vllm", "vllm"},
{"deepseek", "deepseek"},
{"ollama", "ollama"},
+ {"lmstudio", "lmstudio"},
{"longcat", "longcat"},
{"modelscope", "modelscope"},
{"mimo", "mimo"},
@@ -153,6 +155,18 @@ func TestGetDefaultAPIBase_LiteLLM(t *testing.T) {
}
}
+func TestGetDefaultAPIBase_LMStudio(t *testing.T) {
+ if got := getDefaultAPIBase("lmstudio"); got != "http://localhost:1234/v1" {
+ t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "lmstudio", got, "http://localhost:1234/v1")
+ }
+}
+
+func TestGetDefaultAPIBase_Venice(t *testing.T) {
+ if got := getDefaultAPIBase("venice"); got != "https://api.venice.ai/api/v1" {
+ t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "venice", got, "https://api.venice.ai/api/v1")
+ }
+}
+
func TestCreateProviderFromConfig_LiteLLM(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "test-litellm",
@@ -173,6 +187,85 @@ func TestCreateProviderFromConfig_LiteLLM(t *testing.T) {
}
}
+func TestCreateProviderFromConfig_LocalProviders(t *testing.T) {
+ tests := []struct {
+ name string
+ modelName string
+ model string
+ apiKey string
+ wantModelID string
+ }{
+ {
+ name: "LMStudio with API key",
+ modelName: "test-lmstudio",
+ model: "lmstudio/openai/gpt-oss-20b",
+ apiKey: "test-key",
+ wantModelID: "openai/gpt-oss-20b",
+ },
+ {
+ name: "LMStudio without API key",
+ modelName: "test-lmstudio",
+ model: "lmstudio/openai/gpt-oss-20b",
+ apiKey: "",
+ wantModelID: "openai/gpt-oss-20b",
+ },
+ {
+ name: "Ollama with API key",
+ modelName: "test-ollama",
+ model: "ollama/llama3.1:8b",
+ apiKey: "test-key",
+ wantModelID: "llama3.1:8b",
+ },
+ {
+ name: "Ollama without API key",
+ modelName: "test-ollama",
+ model: "ollama/llama3.1:8b",
+ apiKey: "",
+ wantModelID: "llama3.1:8b",
+ },
+ {
+ name: "VLLM with API key",
+ modelName: "test-vllm",
+ model: "vllm/Qwen/Qwen3-8B",
+ apiKey: "test-key",
+ wantModelID: "Qwen/Qwen3-8B",
+ },
+ {
+ name: "VLLM without API key",
+ modelName: "test-vllm",
+ model: "vllm/Qwen/Qwen3-8B",
+ apiKey: "",
+ wantModelID: "Qwen/Qwen3-8B",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cfg := &config.ModelConfig{
+ ModelName: tt.modelName,
+ Model: tt.model,
+ }
+ if tt.apiKey != "" {
+ cfg.SetAPIKey(tt.apiKey)
+ }
+
+ provider, modelID, err := CreateProviderFromConfig(cfg)
+ if err != nil {
+ t.Fatalf("CreateProviderFromConfig() error = %v", err)
+ }
+ if provider == nil {
+ t.Fatal("CreateProviderFromConfig() returned nil provider")
+ }
+ if modelID != tt.wantModelID {
+ t.Errorf("modelID = %q, want %q", modelID, tt.wantModelID)
+ }
+ if _, ok := provider.(*HTTPProvider); !ok {
+ t.Fatalf("expected *HTTPProvider, got %T", provider)
+ }
+ })
+ }
+}
+
func TestCreateProviderFromConfig_LongCat(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "test-longcat",
@@ -276,6 +369,28 @@ func TestCreateProviderFromConfig_Mimo(t *testing.T) {
}
}
+func TestCreateProviderFromConfig_Venice(t *testing.T) {
+ cfg := &config.ModelConfig{
+ ModelName: "test-venice",
+ Model: "venice/venice-uncensored",
+ }
+ cfg.SetAPIKey("test-key")
+
+ provider, modelID, err := CreateProviderFromConfig(cfg)
+ if err != nil {
+ t.Fatalf("CreateProviderFromConfig() error = %v", err)
+ }
+ if provider == nil {
+ t.Fatal("CreateProviderFromConfig() returned nil provider")
+ }
+ if modelID != "venice-uncensored" {
+ t.Errorf("modelID = %q, want %q", modelID, "venice-uncensored")
+ }
+ if _, ok := provider.(*HTTPProvider); !ok {
+ t.Fatalf("expected *HTTPProvider, got %T", provider)
+ }
+}
+
func TestGetDefaultAPIBase_Mimo(t *testing.T) {
if got := getDefaultAPIBase("mimo"); got != "https://api.xiaomimimo.com/v1" {
t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "mimo", got, "https://api.xiaomimimo.com/v1")
@@ -731,6 +846,107 @@ func TestCreateProviderFromConfig_MinimaxPreservesUserExtraBody(t *testing.T) {
}
}
+// openaiCompatResponse is the JSON response used by OpenAI-compatible providers.
+const openaiCompatResponse = `{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}]}`
+
+// anthropicResponse is the JSON response used by Anthropic providers.
+const anthropicResponse = `{"content":[{"type":"text","text":"ok"}],"stop_reason":"end_turn","model":"claude-sonnet-4-20250514","usage":{"input_tokens":10,"output_tokens":5}}`
+
+func TestCreateProviderFromConfig_UserAgent(t *testing.T) {
+ defaultUA := "PicoClaw/" + config.Version
+
+ tests := []struct {
+ name string
+ model string
+ userAgent string
+ apiKey string
+ response string
+ wantUA string
+ chatOpts map[string]any
+ }{
+ {
+ name: "openai default user agent",
+ model: "openai/gpt-4o",
+ apiKey: "test-key",
+ response: openaiCompatResponse,
+ wantUA: defaultUA,
+ },
+ {
+ name: "openai custom user agent",
+ model: "openai/gpt-4o",
+ apiKey: "test-key",
+ userAgent: "MyAgent/1.2.3",
+ response: openaiCompatResponse,
+ wantUA: "MyAgent/1.2.3",
+ },
+ {
+ name: "anthropic default user agent",
+ model: "anthropic/claude-sonnet-4-20250514",
+ apiKey: "test-key",
+ response: anthropicResponse,
+ wantUA: defaultUA,
+ },
+ {
+ name: "anthropic-messages default user agent",
+ model: "anthropic-messages/claude-sonnet-4-20250514",
+ apiKey: "test-key",
+ response: anthropicResponse,
+ wantUA: defaultUA,
+ chatOpts: map[string]any{"max_tokens": 1024},
+ },
+ {
+ name: "azure default user agent",
+ model: "azure/my-deployment",
+ apiKey: "test-azure-key",
+ response: openaiCompatResponse,
+ wantUA: defaultUA,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ var receivedUA string
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ receivedUA = r.Header.Get("User-Agent")
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(tt.response))
+ }))
+ defer server.Close()
+
+ cfg := &config.ModelConfig{
+ ModelName: "test-ua-" + tt.name,
+ Model: tt.model,
+ APIBase: server.URL,
+ UserAgent: tt.userAgent,
+ }
+ cfg.SetAPIKey(tt.apiKey)
+
+ provider, modelID, err := CreateProviderFromConfig(cfg)
+ if err != nil {
+ t.Fatalf("CreateProviderFromConfig() error = %v", err)
+ }
+ if provider == nil {
+ t.Fatal("CreateProviderFromConfig() returned nil provider")
+ }
+
+ _, err = provider.Chat(
+ t.Context(),
+ []Message{{Role: "user", Content: "hi"}},
+ nil,
+ modelID,
+ tt.chatOpts,
+ )
+ if err != nil {
+ t.Fatalf("Chat() error = %v", err)
+ }
+
+ if receivedUA != tt.wantUA {
+ t.Errorf("User-Agent = %q, want %q", receivedUA, tt.wantUA)
+ }
+ })
+ }
+}
+
func TestCreateProviderFromConfig_Bedrock(t *testing.T) {
// Set dummy AWS env vars to make test deterministic
t.Setenv("AWS_ACCESS_KEY_ID", "test-key")
diff --git a/pkg/providers/github_copilot_provider.go b/pkg/providers/github_copilot_provider.go
index 6d642b2b5..472c14257 100644
--- a/pkg/providers/github_copilot_provider.go
+++ b/pkg/providers/github_copilot_provider.go
@@ -41,8 +41,9 @@ func NewGitHubCopilotProvider(uri string, connectMode string, model string) (*Gi
}
session, err := client.CreateSession(context.Background(), &copilot.SessionConfig{
- Model: model,
- Hooks: &copilot.SessionHooks{},
+ Model: model,
+ OnPermissionRequest: copilot.PermissionHandler.ApproveAll,
+ Hooks: &copilot.SessionHooks{},
})
if err != nil {
client.Stop()
diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go
index f2ff52f1d..dae730536 100644
--- a/pkg/providers/http_provider.go
+++ b/pkg/providers/http_provider.go
@@ -24,11 +24,11 @@ func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider {
}
func NewHTTPProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string) *HTTPProvider {
- return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(apiKey, apiBase, proxy, maxTokensField, 0, nil)
+ return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(apiKey, apiBase, proxy, maxTokensField, "", 0, nil)
}
func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
- apiKey, apiBase, proxy, maxTokensField string,
+ apiKey, apiBase, proxy, maxTokensField, userAgent string,
requestTimeoutSeconds int,
extraBody map[string]any,
) *HTTPProvider {
@@ -40,6 +40,7 @@ func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
openai_compat.WithMaxTokensField(maxTokensField),
openai_compat.WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second),
openai_compat.WithExtraBody(extraBody),
+ openai_compat.WithUserAgent(userAgent),
),
}
}
diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go
index 90bc683b8..7cda033ad 100644
--- a/pkg/providers/openai_compat/provider.go
+++ b/pkg/providers/openai_compat/provider.go
@@ -36,18 +36,43 @@ type Provider struct {
maxTokensField string // Field name for max tokens (e.g., "max_completion_tokens" for o1/glm models)
httpClient *http.Client
extraBody map[string]any // Additional fields to inject into request body
+ userAgent string
}
type Option func(*Provider)
const defaultRequestTimeout = common.DefaultRequestTimeout
+var stripModelPrefixProviders = map[string]struct{}{
+ "litellm": {},
+ "venice": {},
+ "moonshot": {},
+ "nvidia": {},
+ "groq": {},
+ "ollama": {},
+ "deepseek": {},
+ "google": {},
+ "openrouter": {},
+ "zhipu": {},
+ "mistral": {},
+ "vivgrid": {},
+ "minimax": {},
+ "novita": {},
+ "lmstudio": {},
+}
+
func WithMaxTokensField(maxTokensField string) Option {
return func(p *Provider) {
p.maxTokensField = maxTokensField
}
}
+func WithUserAgent(userAgent string) Option {
+ return func(p *Provider) {
+ p.userAgent = userAgent
+ }
+}
+
func WithRequestTimeout(timeout time.Duration) Option {
return func(p *Provider) {
if timeout > 0 {
@@ -180,6 +205,9 @@ func (p *Provider) Chat(
}
req.Header.Set("Content-Type", "application/json")
+ if p.userAgent != "" {
+ req.Header.Set("User-Agent", p.userAgent)
+ }
if p.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+p.apiKey)
}
@@ -397,13 +425,11 @@ func normalizeModel(model, apiBase string) string {
}
prefix := strings.ToLower(before)
- switch prefix {
- case "litellm", "moonshot", "nvidia", "groq", "ollama", "deepseek", "google",
- "openrouter", "zhipu", "mistral", "vivgrid", "minimax", "novita":
+ if _, ok := stripModelPrefixProviders[prefix]; ok {
return after
- default:
- return model
}
+
+ return model
}
func buildToolsList(tools []ToolDefinition, nativeSearch bool) []any {
diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go
index ab632ccf3..30aa76eb3 100644
--- a/pkg/providers/openai_compat/provider_test.go
+++ b/pkg/providers/openai_compat/provider_test.go
@@ -432,7 +432,7 @@ func TestProviderChat_StripsMoonshotPrefixAndNormalizesKimiTemperature(t *testin
}
}
-func TestProviderChat_StripsGroqOllamaDeepseekVivgridNovitaPrefixes(t *testing.T) {
+func TestProviderChat_StripsKnownProviderPrefixes(t *testing.T) {
var requestBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -474,6 +474,16 @@ func TestProviderChat_StripsGroqOllamaDeepseekVivgridNovitaPrefixes(t *testing.T
input: "ollama/qwen2.5:14b",
wantModel: "qwen2.5:14b",
},
+ {
+ name: "strips lmstudio prefix and keeps nested model",
+ input: "lmstudio/openai/gpt-oss-20b",
+ wantModel: "openai/gpt-oss-20b",
+ },
+ {
+ name: "strips venice prefix",
+ input: "venice/venice-uncensored",
+ wantModel: "venice-uncensored",
+ },
{
name: "strips deepseek prefix",
input: "deepseek/deepseek-chat",
@@ -579,6 +589,12 @@ func TestNormalizeModel_UsesAPIBase(t *testing.T) {
if got := normalizeModel("deepseek/deepseek-chat", "https://api.deepseek.com/v1"); got != "deepseek-chat" {
t.Fatalf("normalizeModel(deepseek) = %q, want %q", got, "deepseek-chat")
}
+ if got := normalizeModel("lmstudio/openai/gpt-oss-20b", "http://localhost:1234/v1"); got != "openai/gpt-oss-20b" {
+ t.Fatalf("normalizeModel(lmstudio) = %q, want %q", got, "openai/gpt-oss-20b")
+ }
+ if got := normalizeModel("venice/venice-uncensored", "https://api.venice.ai/api/v1"); got != "venice-uncensored" {
+ t.Fatalf("normalizeModel(venice) = %q, want %q", got, "venice-uncensored")
+ }
if got := normalizeModel("openrouter/auto", "https://openrouter.ai/api/v1"); got != "openrouter/auto" {
t.Fatalf("normalizeModel(openrouter) = %q, want %q", got, "openrouter/auto")
}
diff --git a/pkg/tools/load_image.go b/pkg/tools/load_image.go
new file mode 100644
index 000000000..41ea6d054
--- /dev/null
+++ b/pkg/tools/load_image.go
@@ -0,0 +1,163 @@
+package tools
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/media"
+)
+
+// LoadImageTool loads a local image file into the MediaStore and returns a
+// media:// reference. The agent loop's resolveMediaRefs will then base64-encode
+// it and attach it as an image_url part in the next LLM request, enabling
+// vision on local files — the same pipeline used when a user sends an image
+// through a chat channel.
+//
+// This is intentionally different from SendFileTool:
+// - SendFileTool → MediaResult + WithResponseHandled() → sends file to user, ends turn
+// - LoadImageTool → plain ToolResult with media:// in ForLLM → LLM sees the image next turn
+type LoadImageTool struct {
+ workspace string
+ restrict bool
+ maxFileSize int
+ mediaStore media.MediaStore
+ allowPaths []*regexp.Regexp
+
+ defaultChannel string
+ defaultChatID string
+}
+
+func NewLoadImageTool(
+ workspace string,
+ restrict bool,
+ maxFileSize int,
+ store media.MediaStore,
+ allowPaths ...[]*regexp.Regexp,
+) *LoadImageTool {
+ if maxFileSize <= 0 {
+ maxFileSize = config.DefaultMaxMediaSize
+ }
+ var patterns []*regexp.Regexp
+ if len(allowPaths) > 0 {
+ patterns = allowPaths[0]
+ }
+ return &LoadImageTool{
+ workspace: workspace,
+ restrict: restrict,
+ maxFileSize: maxFileSize,
+ mediaStore: store,
+ allowPaths: patterns,
+ }
+}
+
+func (t *LoadImageTool) Name() string { return "load_image" }
+
+func (t *LoadImageTool) Description() string {
+ return "Load a local image file so you can analyze its contents with vision. " +
+ "Supported formats: JPEG, PNG, GIF, WebP, BMP. " +
+ "After calling this tool, describe or analyze the image in your next response."
+}
+
+func (t *LoadImageTool) Parameters() map[string]any {
+ return map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "path": map[string]any{
+ "type": "string",
+ "description": "Path to the local image file. Relative paths are resolved from workspace.",
+ },
+ },
+ "required": []string{"path"},
+ }
+}
+
+func (t *LoadImageTool) SetContext(channel, chatID string) {
+ t.defaultChannel = channel
+ t.defaultChatID = chatID
+}
+
+func (t *LoadImageTool) SetMediaStore(store media.MediaStore) {
+ t.mediaStore = store
+}
+
+func (t *LoadImageTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
+ path, _ := args["path"].(string)
+ if strings.TrimSpace(path) == "" {
+ return ErrorResult("path is required")
+ }
+
+ // Prefer context-injected channel/chatID (set by ExecuteWithContext), fall back to SetContext values.
+ channel := ToolChannel(ctx)
+ if channel == "" {
+ channel = t.defaultChannel
+ }
+ chatID := ToolChatID(ctx)
+ if chatID == "" {
+ chatID = t.defaultChatID
+ }
+ if channel == "" || chatID == "" {
+ return ErrorResult("no target channel/chat available")
+ }
+
+ if t.mediaStore == nil {
+ return ErrorResult("media store not configured")
+ }
+
+ resolved, err := validatePathWithAllowPaths(path, t.workspace, t.restrict, t.allowPaths)
+ if err != nil {
+ return ErrorResult(fmt.Sprintf("invalid path: %v", err))
+ }
+
+ info, err := os.Stat(resolved)
+ if err != nil {
+ return ErrorResult(fmt.Sprintf("file not found: %v", err))
+ }
+ if info.IsDir() {
+ return ErrorResult("path is a directory, expected an image file")
+ }
+ if info.Size() > int64(t.maxFileSize) {
+ return ErrorResult(fmt.Sprintf(
+ "file too large: %d bytes (max %d bytes)", info.Size(), t.maxFileSize,
+ ))
+ }
+
+ // Detect MIME type — reuse the helper already in send_file.go
+ mediaType := detectMediaType(resolved)
+ if !strings.HasPrefix(mediaType, "image/") {
+ return ErrorResult(fmt.Sprintf(
+ "file does not appear to be an image (detected type: %s)", mediaType,
+ ))
+ }
+
+ filename := filepath.Base(resolved)
+ scope := fmt.Sprintf("tool:load_image:%s:%s", channel, chatID)
+
+ ref, err := t.mediaStore.Store(resolved, media.MediaMeta{
+ Filename: filename,
+ ContentType: mediaType,
+ Source: "tool:load_image",
+ CleanupPolicy: media.CleanupPolicyForgetOnly,
+ }, scope)
+ if err != nil {
+ return ErrorResult(fmt.Sprintf("failed to register image in media store: %v", err))
+ }
+
+ // Build the tool result text. The media:// ref will be picked up by
+ // resolveMediaRefs in loop_media.go and converted to a base64 data URL
+ // before the next LLM call, exactly like channel-received images.
+ msg := fmt.Sprintf("Image loaded: %s\n[image: %s]", filename, ref)
+
+ return &ToolResult{
+ ForLLM: msg,
+ ForUser: fmt.Sprintf("Loaded image: %s", filename),
+ // Media refs inside ForLLM are resolved by resolveMediaRefs in the
+ // agent loop before the next LLM call. Do NOT use MediaResult here —
+ // that would send the file to the user channel instead.
+ Media: []string{ref},
+ }
+}
diff --git a/pkg/tools/load_image_test.go b/pkg/tools/load_image_test.go
new file mode 100644
index 000000000..91118f93e
--- /dev/null
+++ b/pkg/tools/load_image_test.go
@@ -0,0 +1,174 @@
+package tools
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/media"
+ "github.com/sipeed/picoclaw/pkg/providers"
+)
+
+func TestLoadImage_PathRequired(t *testing.T) {
+ tool := NewLoadImageTool("/tmp", false, 0, nil)
+ ctx := WithToolContext(context.Background(), "test", "chat1")
+ result := tool.Execute(ctx, map[string]any{})
+ if !result.IsError {
+ t.Fatal("expected error for missing path")
+ }
+}
+
+func TestLoadImage_NilMediaStore(t *testing.T) {
+ tool := NewLoadImageTool("/tmp", false, 0, nil)
+ ctx := WithToolContext(context.Background(), "test", "chat1")
+ result := tool.Execute(ctx, map[string]any{"path": "test.png"})
+ if !result.IsError || result.ForLLM != "media store not configured" {
+ t.Fatalf("expected media store error, got: %s", result.ForLLM)
+ }
+}
+
+func TestLoadImage_NoChannelContext(t *testing.T) {
+ store := media.NewFileMediaStore()
+ tool := NewLoadImageTool("/tmp", false, 0, store)
+ // No WithToolContext — should fail
+ result := tool.Execute(context.Background(), map[string]any{"path": "test.png"})
+ if !result.IsError || result.ForLLM != "no target channel/chat available" {
+ t.Fatalf("expected channel error, got: %s", result.ForLLM)
+ }
+}
+
+func TestLoadImage_NonImageFile(t *testing.T) {
+ dir := t.TempDir()
+ txtFile := filepath.Join(dir, "readme.txt")
+ os.WriteFile(txtFile, []byte("hello"), 0o644)
+
+ store := media.NewFileMediaStore()
+ tool := NewLoadImageTool(dir, false, 0, store)
+ ctx := WithToolContext(context.Background(), "test", "chat1")
+ result := tool.Execute(ctx, map[string]any{"path": txtFile})
+ if !result.IsError {
+ t.Fatal("expected error for non-image file")
+ }
+}
+
+func TestLoadImage_DefaultMaxSize(t *testing.T) {
+ tool := NewLoadImageTool("/tmp", false, 0, nil)
+ if tool.maxFileSize != config.DefaultMaxMediaSize {
+ t.Errorf("expected default max size %d, got %d", config.DefaultMaxMediaSize, tool.maxFileSize)
+ }
+}
+
+func TestLoadImage_FileTooLarge(t *testing.T) {
+ dir := t.TempDir()
+ bigFile := filepath.Join(dir, "big.png")
+ // Create a file with PNG header but exceeding max size
+ data := make([]byte, 1024)
+ copy(data, []byte{0x89, 0x50, 0x4E, 0x47}) // PNG magic bytes
+ os.WriteFile(bigFile, data, 0o644)
+
+ store := media.NewFileMediaStore()
+ tool := NewLoadImageTool(dir, false, 512, store) // maxSize = 512
+ ctx := WithToolContext(context.Background(), "test", "chat1")
+ result := tool.Execute(ctx, map[string]any{"path": bigFile})
+ if !result.IsError {
+ t.Fatal("expected error for oversized file")
+ }
+}
+
+func TestSubagentManager_SetMediaResolver_StoresResolver(t *testing.T) {
+ manager := NewSubagentManager(nil, "gpt-test", "/tmp")
+
+ called := false
+ manager.SetMediaResolver(func(msgs []providers.Message) []providers.Message {
+ called = true
+ return msgs
+ })
+
+ manager.mu.RLock()
+ got := manager.mediaResolver
+ manager.mu.RUnlock()
+
+ if got == nil {
+ t.Fatal("expected mediaResolver to be set")
+ }
+
+ if called {
+ t.Fatal("resolver should not be called during SetMediaResolver")
+ }
+}
+
+func TestLoadImage_SuccessPath(t *testing.T) {
+ dir := t.TempDir()
+
+ // Create a minimal valid PNG file (8-byte signature + minimal IHDR + IEND).
+ // The PNG spec requires the 8-byte magic header: 0x89 P N G \r \n 0x1a \n
+ pngSignature := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}
+ // IHDR chunk: length(13) + "IHDR" + 1x1 px, 8-bit RGB, no interlace + CRC
+ ihdr := []byte{
+ 0x00, 0x00, 0x00, 0x0D, // chunk length = 13
+ 0x49, 0x48, 0x44, 0x52, // "IHDR"
+ 0x00, 0x00, 0x00, 0x01, // width = 1
+ 0x00, 0x00, 0x00, 0x01, // height = 1
+ 0x08, // bit depth = 8
+ 0x02, // color type = RGB
+ 0x00, 0x00, 0x00, // compression, filter, interlace
+ 0x90, 0x77, 0x53, 0xDE, // CRC (valid for this IHDR)
+ }
+ // IEND chunk
+ iend := []byte{
+ 0x00, 0x00, 0x00, 0x00, // chunk length = 0
+ 0x49, 0x45, 0x4E, 0x44, // "IEND"
+ 0xAE, 0x42, 0x60, 0x82, // CRC
+ }
+
+ pngData := make([]byte, 0, len(pngSignature)+len(ihdr)+len(iend))
+ pngData = append(pngData, pngSignature...)
+ pngData = append(pngData, ihdr...)
+ pngData = append(pngData, iend...)
+
+ imgPath := filepath.Join(dir, "test_image.png")
+ if err := os.WriteFile(imgPath, pngData, 0o644); err != nil {
+ t.Fatalf("failed to create test PNG: %v", err)
+ }
+
+ store := media.NewFileMediaStore()
+ tool := NewLoadImageTool(dir, false, 0, store)
+ ctx := WithToolContext(context.Background(), "test", "chat1")
+
+ result := tool.Execute(ctx, map[string]any{"path": imgPath})
+
+ // 1. Must not be an error
+ if result.IsError {
+ t.Fatalf("expected success, got error: %s", result.ForLLM)
+ }
+
+ // 2. Media must contain exactly one media:// ref
+ if len(result.Media) != 1 {
+ t.Fatalf("expected 1 media ref, got %d", len(result.Media))
+ }
+ if !strings.HasPrefix(result.Media[0], "media://") {
+ t.Errorf("expected media ref to start with 'media://', got: %s", result.Media[0])
+ }
+
+ // 3. ForLLM must contain the [image: marker
+ if !strings.Contains(result.ForLLM, "[image:") {
+ t.Errorf("expected ForLLM to contain '[image:' marker, got: %s", result.ForLLM)
+ }
+
+ // 4. ForLLM should also contain the media:// ref
+ if !strings.Contains(result.ForLLM, result.Media[0]) {
+ t.Errorf("expected ForLLM to contain media ref %q, got: %s", result.Media[0], result.ForLLM)
+ }
+
+ // 5. Verify the ref is resolvable in the store
+ resolved, err := store.Resolve(result.Media[0])
+ if err != nil {
+ t.Fatalf("media ref not resolvable: %v", err)
+ }
+ if resolved != imgPath {
+ t.Errorf("expected resolved path %q, got %q", imgPath, resolved)
+ }
+}
diff --git a/pkg/tools/registry.go b/pkg/tools/registry.go
index 56af8d695..e51dff71a 100644
--- a/pkg/tools/registry.go
+++ b/pkg/tools/registry.go
@@ -228,6 +228,7 @@ func (r *ToolRegistry) ExecuteWithContext(
func() {
defer func() {
if re := recover(); re != nil {
+ logger.RecoverPanicNoExit(re)
errMsg := fmt.Sprintf("Tool '%s' crashed with panic: %v", name, re)
logger.ErrorCF("tool", "Tool execution panic recovered",
map[string]any{
diff --git a/pkg/tools/subagent.go b/pkg/tools/subagent.go
index 9a1a8b802..ada89efb7 100644
--- a/pkg/tools/subagent.go
+++ b/pkg/tools/subagent.go
@@ -67,6 +67,12 @@ type SubagentManager struct {
hasTemperature bool
nextID int
spawner SpawnSubTurnFunc
+
+ // mediaResolver resolves media:// refs in tool-loop messages before
+ // each LLM call in the legacy RunToolLoop fallback path.
+ // This lets subagents reuse the same media handling behavior as the
+ // main agent loop without importing pkg/agent and creating a cycle.
+ mediaResolver func([]providers.Message) []providers.Message
}
func NewSubagentManager(
@@ -90,6 +96,17 @@ func (sm *SubagentManager) SetSpawner(spawner SpawnSubTurnFunc) {
sm.spawner = spawner
}
+// SetMediaResolver injects a message preprocessor that resolves media:// refs
+// into LLM-ready content before each tool-loop iteration.
+// This is only used by the legacy RunToolLoop fallback path.
+func (sm *SubagentManager) SetMediaResolver(
+ resolver func([]providers.Message) []providers.Message,
+) {
+ sm.mu.Lock()
+ defer sm.mu.Unlock()
+ sm.mediaResolver = resolver
+}
+
// SetLLMOptions sets max tokens and temperature for subagent LLM calls.
func (sm *SubagentManager) SetLLMOptions(maxTokens int, temperature float64) {
sm.mu.Lock()
@@ -177,6 +194,7 @@ func (sm *SubagentManager) runTask(
temperature := sm.temperature
hasMaxTokens := sm.hasMaxTokens
hasTemperature := sm.hasTemperature
+ mediaResolver := sm.mediaResolver
sm.mu.RUnlock()
var result *ToolResult
@@ -223,6 +241,7 @@ After completing the task, provide a clear summary of what was done.`
Tools: tools,
MaxIterations: maxIter,
LLMOptions: llmOptions,
+ MediaResolver: mediaResolver,
}, messages, task.OriginChannel, task.OriginChatID)
if err == nil {
diff --git a/pkg/tools/toolloop.go b/pkg/tools/toolloop.go
index 387813e94..ac568f598 100644
--- a/pkg/tools/toolloop.go
+++ b/pkg/tools/toolloop.go
@@ -24,6 +24,11 @@ type ToolLoopConfig struct {
Tools *ToolRegistry
MaxIterations int
LLMOptions map[string]any
+
+ // MediaResolver resolves media:// refs in messages before each LLM call.
+ // This is optional and is mainly used by subagent legacy fallback execution
+ // so subagents can reuse the same multimodal media handling as the main loop.
+ MediaResolver func(messages []providers.Message) []providers.Message
}
// ToolLoopResult contains the result of running the tool loop.
@@ -63,8 +68,27 @@ func RunToolLoop(
if llmOpts == nil {
llmOpts = map[string]any{}
}
- // 3. Call LLM
- response, err := config.Provider.Chat(ctx, messages, providerToolDefs, config.Model, llmOpts)
+
+ // 3. Resolve media:// refs and Call LLM.
+ // Tools like load_image produce media:// refs in their result messages.
+ // Without this step, the LLM would receive raw "media://uuid" strings
+ // instead of base64-encoded image data URLs.
+ //
+ // We build a separate callMessages slice so that:
+ // (a) the resolver output is used for the LLM call only,
+ // (b) the original `messages` slice keeps the unresolved refs for
+ // subsequent iterations — the resolver is idempotent but working
+ // on the original avoids double-encoding issues.
+ //
+ // On iteration 1 the initial user messages typically have no media://
+ // refs (they come from plain text), so this is effectively a no-op;
+ // it becomes relevant from iteration 2 onward when tool results may
+ // contain media refs.
+ callMessages := messages
+ if config.MediaResolver != nil && iteration > 1 {
+ callMessages = config.MediaResolver(messages)
+ }
+ response, err := config.Provider.Chat(ctx, callMessages, providerToolDefs, config.Model, llmOpts)
if err != nil {
logger.ErrorCF("toolloop", "LLM call failed",
map[string]any{
@@ -161,11 +185,15 @@ func RunToolLoop(
for _, r := range results {
contentForLLM := r.result.ContentForLLM()
- messages = append(messages, providers.Message{
+ toolMsg := providers.Message{
Role: "tool",
Content: contentForLLM,
ToolCallID: r.tc.ID,
- })
+ }
+ if len(r.result.Media) > 0 && !r.result.ResponseHandled {
+ toolMsg.Media = append(toolMsg.Media, r.result.Media...)
+ }
+ messages = append(messages, toolMsg)
}
}
diff --git a/pkg/tools/tts_send.go b/pkg/tools/tts_send.go
new file mode 100644
index 000000000..3d569e3f7
--- /dev/null
+++ b/pkg/tools/tts_send.go
@@ -0,0 +1,82 @@
+package tools
+
+import (
+ "context"
+ "strings"
+
+ "github.com/sipeed/picoclaw/pkg/audio/tts"
+ "github.com/sipeed/picoclaw/pkg/media"
+)
+
+type SendTTSTool struct {
+ provider tts.TTSProvider
+ mediaStore media.MediaStore
+}
+
+func NewSendTTSTool(provider tts.TTSProvider, store media.MediaStore) *SendTTSTool {
+ return &SendTTSTool{
+ provider: provider,
+ mediaStore: store,
+ }
+}
+
+func (t *SendTTSTool) Name() string { return "send_tts" }
+
+func (t *SendTTSTool) Description() string {
+ return "Synthesize speech from text and send it as an audio file to the user."
+}
+
+func (t *SendTTSTool) Parameters() map[string]any {
+ return map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "text": map[string]any{
+ "type": "string",
+ "description": "The text to synthesize into speech. NOTE: Reply in a highly concise, conversational, oral style suitable for text-to-speech. Do not use markdown, emojis, asterisks, or code blocks. Speak naturally.",
+ },
+ "filename": map[string]any{
+ "type": "string",
+ "description": "Optional filename for the audio file (e.g., response.ogg).",
+ },
+ },
+ "required": []string{"text"},
+ }
+}
+
+func (t *SendTTSTool) SetMediaStore(store media.MediaStore) {
+ t.mediaStore = store
+}
+
+func (t *SendTTSTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
+ text, _ := args["text"].(string)
+ text = strings.TrimSpace(text)
+ if text == "" {
+ return ErrorResult("text is required")
+ }
+
+ channel := ToolChannel(ctx)
+ chatID := ToolChatID(ctx)
+ filename, _ := args["filename"].(string)
+
+ ref, err := tts.SynthesizeAndStore(
+ ctx,
+ t.provider,
+ t.mediaStore,
+ text,
+ filename,
+ channel,
+ chatID,
+ )
+ if err != nil {
+ return ErrorResult(err.Error()).WithError(err)
+ }
+
+ // Return with ForUser set to original text, Media containing the audio ref,
+ // and mark as ResponseHandled so the audio is sent immediately without LLM intervention.
+ return &ToolResult{
+ ForLLM: "TTS audio sent",
+ ForUser: text,
+ Media: []string{ref},
+ ResponseHandled: true,
+ }
+}
diff --git a/pkg/updater/updater.go b/pkg/updater/updater.go
new file mode 100644
index 000000000..e73c1e859
--- /dev/null
+++ b/pkg/updater/updater.go
@@ -0,0 +1,707 @@
+package updater
+
+import (
+ "archive/tar"
+ "archive/zip"
+ "compress/gzip"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "regexp"
+ "runtime"
+ "strings"
+ "time"
+
+ "github.com/minio/selfupdate"
+ "github.com/spf13/cobra"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+// httpClient is a shared HTTP client used for release checks and downloads.
+// The Timeout value applies to the entire HTTP request: dialing, TLS
+// handshake, redirects, and reading the response body. It is NOT only
+// a connection (dial) timeout. To control lower-level timeouts (dial,
+// TLS handshake, response header wait), supply a custom Transport with
+// an appropriately configured net.Dialer.
+var httpClient = &http.Client{Timeout: 2 * time.Minute}
+
+// DownloadAndExtractRelease downloads a release archive (or uses a direct
+// asset URL) and extracts it to a temporary directory. It returns the
+// extraction directory on success. If releaseURL is empty, the latest
+// release of the current project is used. platform/arch can be used to
+// select the correct asset (e.g. "linux", "amd64").
+func DownloadAndExtractRelease(releaseURL, platform, arch string) (string, error) {
+ assetURL, checksum, err := findAssetInfo(releaseURL, platform, arch)
+ if err != nil {
+ return "", err
+ }
+
+ // Download asset to temp file. Use the asset URL extension so
+ // extractArchive can detect the archive format (zip/tar.gz/tar).
+ tmpPattern := "picoclaw-release-*"
+ if u, perr := url.Parse(assetURL); perr == nil {
+ base := filepath.Base(u.Path)
+ lbase := strings.ToLower(base)
+ switch {
+ case strings.HasSuffix(lbase, ".zip"):
+ tmpPattern += ".zip"
+ case strings.HasSuffix(lbase, ".tar.gz") || strings.HasSuffix(lbase, ".tgz"):
+ tmpPattern += ".tar.gz"
+ case strings.HasSuffix(lbase, ".tar"):
+ tmpPattern += ".tar"
+ default:
+ tmpPattern += ".archive"
+ }
+ } else {
+ tmpPattern += ".archive"
+ }
+
+ tmpFile, err := os.CreateTemp("", tmpPattern)
+ if err != nil {
+ return "", err
+ }
+ tmpPath := tmpFile.Name()
+ defer tmpFile.Close()
+
+ resp, err := httpClient.Get(assetURL)
+ if err != nil {
+ os.Remove(tmpPath)
+ return "", err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ os.Remove(tmpPath)
+ return "", fmt.Errorf("failed to download asset: status %d", resp.StatusCode)
+ }
+
+ // Stream download while computing SHA256 to avoid a second download.
+ // Also show a simple progress line to stderr so users see activity.
+ h := sha256.New()
+ pw := &progressWriter{total: resp.ContentLength}
+ mw := io.MultiWriter(tmpFile, h, pw)
+ if _, err = io.Copy(mw, resp.Body); err != nil {
+ _ = os.Remove(tmpPath)
+ return "", err
+ }
+ // ensure final progress line ends with newline
+ pw.Finish()
+
+ // verify checksum if available
+ if checksum != "" {
+ got := hex.EncodeToString(h.Sum(nil))
+ if !strings.EqualFold(got, checksum) {
+ _ = os.Remove(tmpPath)
+ return "", fmt.Errorf("checksum mismatch: got %s expected %s", got, checksum)
+ }
+ }
+
+ // Extract
+ destDir, err := os.MkdirTemp("", "picoclaw-extract-*")
+ if err != nil {
+ os.Remove(tmpPath)
+ return "", err
+ }
+
+ if err := extractArchive(tmpPath, destDir); err != nil {
+ os.Remove(tmpPath)
+ os.RemoveAll(destDir)
+ return "", err
+ }
+
+ // cleanup archive file; keep extracted contents
+ _ = os.Remove(tmpPath)
+ return destDir, nil
+}
+
+// UpdateSelfFromRelease downloads the release matching the given parameters,
+// extracts it and applies the binary named programName to update the
+// currently running executable using minio/selfupdate.
+// If releaseURL is empty, the latest release is used. If platform or arch
+// is empty, runtime values are used.
+func UpdateSelfFromRelease(releaseURL, platform, arch, programName string) error {
+ if platform == "" {
+ platform = runtime.GOOS
+ }
+ if arch == "" {
+ arch = runtime.GOARCH
+ }
+
+ dir, err := DownloadAndExtractRelease(releaseURL, platform, arch)
+ if err != nil {
+ return err
+ }
+ defer os.RemoveAll(dir)
+
+ binPath, err := findBinaryInDir(dir, programName)
+ if err != nil {
+ return err
+ }
+
+ // ensure executable bit on non-windows
+ if runtime.GOOS != "windows" {
+ _ = os.Chmod(binPath, 0o755)
+ }
+
+ f, err := os.Open(binPath)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+
+ // Backup current executable so we can roll back if needed.
+ var opts selfupdate.Options
+ if exePath, err := os.Executable(); err == nil {
+ opts.OldSavePath = exePath + ".old"
+ }
+
+ if err := selfupdate.Apply(f, opts); err != nil {
+ return fmt.Errorf("apply update: %w", err)
+ }
+
+ return nil
+}
+
+// UpdateSelf updates the running executable by fetching the latest release
+// and applying the binary matching programName.
+func UpdateSelf(programName string) error {
+ // By default, select the latest stable release when no explicit
+ // release URL is provided. Use --nightly or a custom URL to override.
+ return UpdateSelfFromRelease("", runtime.GOOS, runtime.GOARCH, programName)
+}
+
+// GetReleaseAPIURL returns the GitHub Releases API URL for the given repo owner.
+// Example: owner="sky5454" -> https://api.github.com/repos/sky5454/picoclaw/releases/latest
+func GetReleaseAPIURL(owner string) string {
+ return fmt.Sprintf("https://api.github.com/repos/%s/picoclaw/releases/latest", owner)
+}
+
+// GetProdReleaseAPIURL returns the production release API URL (upstream).
+func GetProdReleaseAPIURL() string {
+ return GetReleaseAPIURL("sipeed")
+}
+
+// GetReleaseTagAPIURL returns the GitHub Releases API URL for a specific tag.
+// Example: owner="sipeed", tag="nightly" -> https://api.github.com/repos/sipeed/picoclaw/releases/tags/nightly
+func GetReleaseTagAPIURL(owner, tag string) string {
+ return fmt.Sprintf("https://api.github.com/repos/%s/picoclaw/releases/tags/%s", owner, tag)
+}
+
+// GetNightlyReleaseAPIURL returns the nightly release API URL for the production repo.
+func GetNightlyReleaseAPIURL() string {
+ return GetReleaseTagAPIURL("sipeed", "nightly")
+}
+
+// findAssetURL resolves the appropriate asset URL for the given release
+// selector. It accepts direct archive URLs as well as GitHub release URLs
+// or empty (latest release for the project).
+func findAssetInfo(releaseURL, platform, arch string) (string, string, error) {
+ // returns (assetURL, sha256ChecksumHex, error)
+ if looksLikeDirectAssetURL(releaseURL) {
+ return "", "", fmt.Errorf("no checksum found for asset %s", releaseURL)
+ }
+
+ apiURL := buildReleaseAPIURL(releaseURL)
+ if apiURL == "" {
+ // If caller provided an empty releaseURL, default to the
+ // production latest release API URL (stable release).
+ apiURL = GetProdReleaseAPIURL()
+ }
+
+ resp, err := httpClient.Get(apiURL)
+ if err != nil {
+ return "", "", err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return "", "", fmt.Errorf("failed to query releases: status %d", resp.StatusCode)
+ }
+
+ var data struct {
+ TagName string `json:"tag_name"`
+ Assets []struct {
+ Name string `json:"name"`
+ BrowserDownloadURL string `json:"browser_download_url"`
+ Digest string `json:"digest"`
+ } `json:"assets"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
+ return "", "", err
+ }
+
+ // Selection order: platform -> arch -> extension.
+ platformLower := strings.ToLower(platform)
+ archLower := strings.ToLower(arch)
+
+ isZip := func(name string) bool {
+ return strings.HasSuffix(name, ".zip")
+ }
+ isTarGz := func(name string) bool {
+ return strings.HasSuffix(name, ".tar.gz") || strings.HasSuffix(name, ".tgz")
+ }
+ isTar := func(name string) bool { return strings.HasSuffix(name, ".tar") }
+
+ // collect indices of assets that contain platform (if provided)
+ var platformIdx []int
+ for i, a := range data.Assets {
+ n := strings.ToLower(a.Name)
+ if platform == "" || strings.Contains(n, platformLower) {
+ platformIdx = append(platformIdx, i)
+ }
+ }
+
+ pickBest := func(idxs []int) (string, int, bool) {
+ if len(idxs) == 0 {
+ return "", -1, false
+ }
+ // prefer arch matches within idxs; if arch was specified but
+ // no arch match exists among idxs, treat as no candidate.
+ var archIdx []int
+ if arch != "" {
+ aliases := archAliases(archLower)
+ for _, i := range idxs {
+ n := strings.ToLower(data.Assets[i].Name)
+ for _, ali := range aliases {
+ if strings.Contains(n, ali) {
+ archIdx = append(archIdx, i)
+ break
+ }
+ }
+ }
+ if len(archIdx) == 0 {
+ return "", -1, false
+ }
+ }
+ candidates := archIdx
+ if len(candidates) == 0 {
+ candidates = idxs
+ }
+
+ // extension preference
+ if platformLower == "windows" {
+ // prefer .zip only
+ for _, i := range candidates {
+ if isZip(strings.ToLower(data.Assets[i].Name)) {
+ return data.Assets[i].BrowserDownloadURL, i, true
+ }
+ }
+ // if no zip found, fallthrough to first candidate
+ return data.Assets[candidates[0]].BrowserDownloadURL, candidates[0], true
+ }
+
+ // non-windows: prefer tar.gz/tgz, then tar, then zip
+ for _, i := range candidates {
+ if isTarGz(strings.ToLower(data.Assets[i].Name)) {
+ return data.Assets[i].BrowserDownloadURL, i, true
+ }
+ }
+ for _, i := range candidates {
+ if isTar(strings.ToLower(data.Assets[i].Name)) {
+ return data.Assets[i].BrowserDownloadURL, i, true
+ }
+ }
+ for _, i := range candidates {
+ if isZip(strings.ToLower(data.Assets[i].Name)) {
+ return data.Assets[i].BrowserDownloadURL, i, true
+ }
+ }
+ // fallback to first candidate
+ return data.Assets[candidates[0]].BrowserDownloadURL, candidates[0], true
+ }
+
+ // Try platform matches first
+ if url, idx, ok := pickBest(platformIdx); ok {
+ // attempt to find checksum: prefer asset digest from API if present
+ if d := strings.TrimSpace(data.Assets[idx].Digest); d != "" {
+ dLower := strings.ToLower(d)
+ if strings.HasPrefix(dLower, "sha256:") {
+ hexpart := strings.TrimPrefix(dLower, "sha256:")
+ return url, hexpart, nil
+ }
+ // If digest already looks like a 64-hex, return it
+ if ok, _ := regexp.MatchString("(?i)^[a-f0-9]{64}$", dLower); ok {
+ return url, dLower, nil
+ }
+ }
+ // Look for checksum assets and verify by computing the asset's sha256.
+ for j, a := range data.Assets {
+ n := strings.ToLower(a.Name)
+ if strings.Contains(n, "sha256") ||
+ strings.Contains(n, "sha256sum") ||
+ strings.Contains(n, "checksums") ||
+ strings.HasSuffix(n, ".sha256") ||
+ strings.HasSuffix(n, ".sha256sum") {
+ resp2, err := httpClient.Get(data.Assets[j].BrowserDownloadURL)
+ if err != nil {
+ continue
+ }
+ bs, err := io.ReadAll(resp2.Body)
+ resp2.Body.Close()
+ if err != nil {
+ continue
+ }
+ if h, ok := findHashInChecksumContent(bs, url); ok {
+ return url, h, nil
+ }
+ }
+ }
+ // No checksum found for the selected platform asset -> error
+ return "", "", fmt.Errorf("no checksum found for asset %s", url)
+ }
+
+ // No platform match — require explicit platform+arch; fail fast.
+ return "", "", fmt.Errorf("no release asset matching platform %q and arch %q", platform, arch)
+}
+
+func looksLikeDirectAssetURL(u string) bool {
+ if u == "" {
+ return false
+ }
+ lower := strings.ToLower(u)
+ if strings.HasSuffix(lower, ".zip") ||
+ strings.HasSuffix(lower, ".tar.gz") ||
+ strings.HasSuffix(lower, ".tgz") ||
+ strings.HasSuffix(lower, ".tar") {
+ return true
+ }
+ if strings.Contains(lower, "/releases/download/") {
+ return true
+ }
+ return false
+}
+
+func buildReleaseAPIURL(releaseURL string) string {
+ if releaseURL == "" {
+ return ""
+ }
+ if strings.Contains(releaseURL, "api.github.com") {
+ return releaseURL
+ }
+ u, err := url.Parse(releaseURL)
+ if err != nil {
+ return ""
+ }
+ if u.Host != "github.com" {
+ return ""
+ }
+ parts := strings.Split(strings.Trim(u.Path, "/"), "/")
+ if len(parts) < 2 {
+ return ""
+ }
+ owner := parts[0]
+ repo := parts[1]
+ // if tag specified
+ if len(parts) >= 5 && parts[2] == "releases" && parts[3] == "tag" {
+ tag := parts[4]
+ return fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/tags/%s", owner, repo, tag)
+ }
+ // default to latest
+ return fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo)
+}
+
+// NOTE: helper functions to compute SHA256 from URL/path were removed
+// after refactoring to stream the download and verify the checksum
+// during the single download to avoid double-transfer.
+
+// findHashInChecksumContent attempts to locate a 64-hex SHA256 in the
+// checksum file content that corresponds to assetURL. It returns the
+// found hash (lowercase) and true, or "", false if not found.
+func findHashInChecksumContent(bs []byte, assetURL string) (string, bool) {
+ s := strings.ToLower(string(bs))
+ var assetBase string
+ if u, err := url.Parse(assetURL); err == nil {
+ assetBase = strings.ToLower(filepath.Base(u.Path))
+ } else {
+ assetBase = strings.ToLower(filepath.Base(assetURL))
+ }
+ re := regexp.MustCompile(`(?i)\b([a-f0-9]{64})\b`)
+ // prefer a line containing the asset filename
+ for _, line := range strings.Split(s, "\n") {
+ if strings.Contains(line, assetBase) {
+ if m := re.FindString(line); m != "" {
+ return m, true
+ }
+ }
+ }
+ // fallback: if there's exactly one unique 64-hex value, return it
+ matches := re.FindAllString(s, -1)
+ uniq := map[string]struct{}{}
+ for _, m := range matches {
+ uniq[m] = struct{}{}
+ }
+ if len(uniq) == 1 {
+ for k := range uniq {
+ return k, true
+ }
+ }
+ return "", false
+}
+
+// progressWriter implements io.Writer and prints a simple progress
+// line to stderr while bytes are written. It is intended to be used
+// as one writer in an io.MultiWriter so we can stream-to-disk, compute
+// the sha256, and update the progress display in a single pass.
+type progressWriter struct {
+ total int64
+ written int64
+ last time.Time
+}
+
+func (pw *progressWriter) Write(p []byte) (int, error) {
+ n := len(p)
+ pw.written += int64(n)
+ now := time.Now()
+ if pw.last.IsZero() || now.Sub(pw.last) >= 200*time.Millisecond || (pw.total > 0 && pw.written == pw.total) {
+ pw.print()
+ pw.last = now
+ }
+ return n, nil
+}
+
+func (pw *progressWriter) print() {
+ if pw.total > 0 {
+ pct := float64(pw.written) * 100.0 / float64(pw.total)
+ fmt.Fprintf(os.Stderr, "\rDownloading: %s / %s (%.1f%%)", humanBytes(pw.written), humanBytes(pw.total), pct)
+ } else {
+ fmt.Fprintf(os.Stderr, "\rDownloading: %s", humanBytes(pw.written))
+ }
+}
+
+func (pw *progressWriter) Finish() {
+ pw.print()
+ fmt.Fprintln(os.Stderr, "")
+}
+
+func humanBytes(n int64) string {
+ f := float64(n)
+ const (
+ KB = 1024.0
+ MB = KB * 1024.0
+ GB = MB * 1024.0
+ )
+ switch {
+ case f >= GB:
+ return fmt.Sprintf("%.2f GB", f/GB)
+ case f >= MB:
+ return fmt.Sprintf("%.2f MB", f/MB)
+ case f >= KB:
+ return fmt.Sprintf("%.2f KB", f/KB)
+ default:
+ return fmt.Sprintf("%d B", n)
+ }
+}
+
+// archAliases returns common name variants for an architecture string
+// so we can match release asset names like "x86_64" vs Go's "amd64".
+// archAliases returns name variants for an architecture string.
+// If `arch` is empty or matches the local runtime.GOARCH, prefer the
+// compile-time architecture aliases provided by archAliasesForLocal
+// (implemented per-architecture via build tags). For other `arch`
+// values we use a small synonyms map.
+func archAliases(arch string) []string {
+ a := strings.ToLower(arch)
+ if syns, ok := archSynonyms[a]; ok {
+ return syns
+ }
+ return []string{a}
+}
+
+var archSynonyms = map[string][]string{
+ "amd64": {"amd64", "x86_64", "x64"},
+ "x86_64": {"amd64", "x86_64", "x64"},
+ "x64": {"amd64", "x86_64", "x64"},
+ "386": {"386", "x86"},
+ "x86": {"386", "x86"},
+ "arm64": {"arm64", "aarch64"},
+ "aarch64": {"arm64", "aarch64"},
+ "arm": {"arm"},
+}
+
+func extractArchive(archivePath, destDir string) error {
+ lower := strings.ToLower(archivePath)
+ if strings.HasSuffix(lower, ".zip") {
+ return extractZip(archivePath, destDir)
+ }
+ // treat .tar.gz and .tgz as gzip+tar
+ if strings.HasSuffix(lower, ".tar.gz") || strings.HasSuffix(lower, ".tgz") {
+ return extractTarGz(archivePath, destDir)
+ }
+ if strings.HasSuffix(lower, ".tar") {
+ return extractTar(archivePath, destDir)
+ }
+ // fallback: try tar.gz
+ return extractTarGz(archivePath, destDir)
+}
+
+func extractZip(archivePath, destDir string) error {
+ r, err := zip.OpenReader(archivePath)
+ if err != nil {
+ return err
+ }
+ defer r.Close()
+ destClean := filepath.Clean(destDir)
+ for _, f := range r.File {
+ target := filepath.Clean(filepath.Join(destClean, f.Name))
+ if !strings.HasPrefix(target, destClean+string(os.PathSeparator)) && target != destClean {
+ return fmt.Errorf("path traversal detected: %s", f.Name)
+ }
+ if f.FileInfo().IsDir() {
+ if err := os.MkdirAll(target, f.FileInfo().Mode()); err != nil {
+ return err
+ }
+ continue
+ }
+ if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
+ return err
+ }
+ rc, err := f.Open()
+ if err != nil {
+ return err
+ }
+ out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, f.FileInfo().Mode())
+ if err != nil {
+ rc.Close()
+ return err
+ }
+ if _, err := io.Copy(out, rc); err != nil {
+ rc.Close()
+ out.Close()
+ return err
+ }
+ rc.Close()
+ out.Close()
+ }
+ return nil
+}
+
+func extractTarGz(archivePath, destDir string) error {
+ f, err := os.Open(archivePath)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ gzr, err := gzip.NewReader(f)
+ if err != nil {
+ return err
+ }
+ defer gzr.Close()
+ tr := tar.NewReader(gzr)
+ return extractTarFromReader(tr, destDir)
+}
+
+func extractTar(archivePath, destDir string) error {
+ f, err := os.Open(archivePath)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ tr := tar.NewReader(f)
+ return extractTarFromReader(tr, destDir)
+}
+
+// extractTarFromReader contains logic common to extracting entries from a
+// tar.Reader and is used by both extractTarGz and extractTar to avoid
+// duplicated code (golangci-lint: dupl).
+func extractTarFromReader(tr *tar.Reader, destDir string) error {
+ for {
+ hdr, err := tr.Next()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return err
+ }
+ target := filepath.Clean(filepath.Join(filepath.Clean(destDir), hdr.Name))
+ if !strings.HasPrefix(target, filepath.Clean(destDir)+string(os.PathSeparator)) &&
+ target != filepath.Clean(destDir) {
+ return fmt.Errorf("path traversal detected: %s", hdr.Name)
+ }
+ switch hdr.Typeflag {
+ case tar.TypeDir:
+ if err := os.MkdirAll(target, 0o755); err != nil {
+ return err
+ }
+ case tar.TypeReg:
+ if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
+ return err
+ }
+ out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.FileMode(hdr.Mode))
+ if err != nil {
+ return err
+ }
+ if _, err := io.Copy(out, tr); err != nil {
+ out.Close()
+ return err
+ }
+ out.Close()
+ }
+ }
+ return nil
+}
+
+func findBinaryInDir(dir, programName string) (string, error) {
+ wanted := []string{programName}
+ if runtime.GOOS == "windows" {
+ wanted = append([]string{programName + ".exe"}, wanted...)
+ } else {
+ // also accept programs with .exe in archives targeting windows
+ wanted = append(wanted, programName+".exe")
+ }
+
+ var found string
+ if err := filepath.WalkDir(dir, func(p string, d os.DirEntry, err error) error {
+ if err != nil || found != "" {
+ return err
+ }
+ if d.IsDir() {
+ return nil
+ }
+ base := filepath.Base(p)
+ for _, w := range wanted {
+ if base == w {
+ found = p
+ return io.EOF // use EOF to stop walking early
+ }
+ }
+ return nil
+ }); err != nil && err != io.EOF {
+ return "", err
+ }
+ if found == "" {
+ return "", fmt.Errorf("binary %q not found in archive", programName)
+ }
+ return found, nil
+}
+
+// NewUpdateCommand returns a cobra command that triggers UpdateSelfFromRelease.
+func NewUpdateCommand(binaryName string) *cobra.Command {
+ var urlStr, platform, arch string
+ cmd := &cobra.Command{
+ Use: "update",
+ Short: "Check and apply updates from GitHub releases",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if platform == "" {
+ platform = runtime.GOOS
+ }
+ if arch == "" {
+ arch = runtime.GOARCH
+ }
+ fmt.Printf("Current version: %s\n", config.FormatVersion())
+ if err := UpdateSelfFromRelease(urlStr, platform, arch, binaryName); err != nil {
+ return err
+ }
+ fmt.Println("Update applied; restart to use the new version.")
+ return nil
+ },
+ }
+ cmd.Flags().StringVarP(&urlStr, "url", "u", "", "Direct URL to download release asset or release page")
+ cmd.Flags().StringVar(&platform, "platform", "", "Target platform (default: runtime.GOOS)")
+ cmd.Flags().StringVar(&arch, "arch", "", "Target arch (default: runtime.GOARCH)")
+ return cmd
+}
diff --git a/pkg/updater/updater_test.go b/pkg/updater/updater_test.go
new file mode 100644
index 000000000..ff75432e4
--- /dev/null
+++ b/pkg/updater/updater_test.go
@@ -0,0 +1,97 @@
+package updater
+
+import (
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// matchesMagic checks whether the file at path looks like a platform binary
+// by inspecting magic bytes (ELF for linux, MZ for windows).
+func matchesMagic(path, platform string) (bool, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return false, err
+ }
+ defer f.Close()
+ buf := make([]byte, 4)
+ n, err := f.Read(buf)
+ if err != nil && err != io.EOF {
+ return false, err
+ }
+ if n >= 4 && buf[0] == 0x7f && buf[1] == 'E' && buf[2] == 'L' && buf[3] == 'F' {
+ return strings.Contains(platform, "linux"), nil
+ }
+ if n >= 2 && buf[0] == 'M' && buf[1] == 'Z' {
+ return strings.Contains(platform, "windows"), nil
+ }
+ return false, nil
+}
+
+// TestDownloadAndExtractRelease_RealPlatforms downloads the latest release
+// asset for multiple platform/arch combos and inspects the extracted
+// artifacts to ensure a binary-like file is present. This is a network test
+// and is skipped in short mode.
+func TestDownloadAndExtractRelease_RealPlatforms(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skipping network tests in short mode")
+ }
+
+ combos := []struct{ platform, arch string }{
+ {"linux", "amd64"},
+ {"linux", "arm64"},
+ {"windows", "amd64"},
+ {"windows", "arm64"},
+ }
+
+ apiURL := GetProdReleaseAPIURL()
+ for _, c := range combos {
+ t.Run(c.platform+"_"+c.arch, func(t *testing.T) {
+ assetURL, checksum, err := findAssetInfo(apiURL, c.platform, c.arch)
+ if err != nil {
+ // If no checksum could be located for this asset, skip this
+ // combo rather than failing — we require signed/checksummed
+ // releases for real-network tests.
+ t.Skipf("skipping %s/%s: %v", c.platform, c.arch, err)
+ }
+ t.Logf("asset URL: %s checksum: %s", assetURL, checksum)
+
+ // Pass the release API URL (not the direct asset URL) so
+ // DownloadAndExtractRelease can locate and verify the asset.
+ dir, err := DownloadAndExtractRelease(apiURL, c.platform, c.arch)
+ if err != nil {
+ t.Fatalf("DownloadAndExtractRelease failed for %s/%s: %v", c.platform, c.arch, err)
+ }
+ defer os.RemoveAll(dir)
+
+ var found bool
+ _ = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
+ if err != nil || d.IsDir() {
+ return err
+ }
+ info, err := d.Info()
+ if err != nil {
+ return err
+ }
+ if info.Size() < 64 {
+ return nil
+ }
+ ok, err := matchesMagic(path, c.platform)
+ if err != nil {
+ return err
+ }
+ if ok {
+ found = true
+ t.Logf("found artifact: %s (size=%d)", path, info.Size())
+ // continue walking to list all
+ }
+ return nil
+ })
+ if !found {
+ t.Fatalf("no binary-like artifact found for %s/%s", c.platform, c.arch)
+ }
+ })
+ }
+}
diff --git a/pkg/utils/http_retry.go b/pkg/utils/http_retry.go
index 135ea0ef5..514f9781b 100644
--- a/pkg/utils/http_retry.go
+++ b/pkg/utils/http_retry.go
@@ -4,12 +4,16 @@ import (
"context"
"fmt"
"net/http"
+ "strconv"
"time"
)
const maxRetries = 3
-var retryDelayUnit = time.Second
+var (
+ retryDelayUnit = time.Second
+ maxRetrySleepDuration = 1 * time.Minute
+)
func shouldRetry(statusCode int) bool {
return statusCode == http.StatusTooManyRequests ||
@@ -36,7 +40,7 @@ func DoRequestWithRetry(client *http.Client, req *http.Request) (*http.Response,
}
if i < maxRetries-1 {
- if err = sleepWithCtx(req.Context(), retryDelayUnit*time.Duration(i+1)); err != nil {
+ if err = sleepWithCtx(req.Context(), retryDelayForAttempt(resp, i)); err != nil {
if resp != nil {
resp.Body.Close()
}
@@ -47,6 +51,57 @@ func DoRequestWithRetry(client *http.Client, req *http.Request) (*http.Response,
return resp, err
}
+func retryDelayForAttempt(resp *http.Response, attempt int) time.Duration {
+ fallback := retryDelayUnit * time.Duration(attempt+1)
+ if resp == nil || resp.StatusCode != http.StatusTooManyRequests {
+ return clampRetryDelay(fallback)
+ }
+
+ retryAfter := resp.Header.Get("Retry-After")
+ if retryAfter == "" {
+ return clampRetryDelay(fallback)
+ }
+
+ if delay, ok := numericRetryAfterDelay(retryAfter); ok {
+ return delay
+ }
+
+ if when, err := http.ParseTime(retryAfter); err == nil {
+ delay := time.Until(when)
+ if serverDate, err := http.ParseTime(resp.Header.Get("Date")); err == nil {
+ delay = when.Sub(serverDate)
+ }
+ if delay < 0 {
+ return 0
+ }
+ return clampRetryDelay(delay)
+ }
+
+ return clampRetryDelay(fallback)
+}
+
+func numericRetryAfterDelay(retryAfter string) (time.Duration, bool) {
+ seconds, err := strconv.ParseInt(retryAfter, 10, 64)
+ if err != nil || seconds < 0 {
+ return 0, false
+ }
+ maxSeconds := int64(maxRetrySleepDuration / time.Second)
+ if seconds > maxSeconds {
+ return maxRetrySleepDuration, true
+ }
+ return clampRetryDelay(time.Duration(seconds) * time.Second), true
+}
+
+func clampRetryDelay(delay time.Duration) time.Duration {
+ if delay <= 0 {
+ return 0
+ }
+ if delay > maxRetrySleepDuration {
+ return maxRetrySleepDuration
+ }
+ return delay
+}
+
func sleepWithCtx(ctx context.Context, d time.Duration) error {
timer := time.NewTimer(d)
defer timer.Stop()
diff --git a/pkg/utils/http_retry_test.go b/pkg/utils/http_retry_test.go
index d64cd5eda..4d6021ff7 100644
--- a/pkg/utils/http_retry_test.go
+++ b/pkg/utils/http_retry_test.go
@@ -80,6 +80,81 @@ func TestDoRequestWithRetry(t *testing.T) {
}
}
+func TestDoRequestWithRetry_RetryAfter429Honored(t *testing.T) {
+ retryDelayUnit = 10 * time.Millisecond
+ t.Cleanup(func() { retryDelayUnit = time.Second })
+
+ attempts := 0
+ var firstAttemptAt time.Time
+ var secondAttemptAt time.Time
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ attempts++
+ if attempts == 1 {
+ firstAttemptAt = time.Now()
+ w.Header().Set("Retry-After", "1")
+ w.WriteHeader(http.StatusTooManyRequests)
+ return
+ }
+ if attempts == 2 {
+ secondAttemptAt = time.Now()
+ }
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer server.Close()
+
+ client := &http.Client{Timeout: 5 * time.Second}
+ req, err := http.NewRequest(http.MethodGet, server.URL, nil)
+ require.NoError(t, err)
+
+ resp, err := DoRequestWithRetry(client, req)
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+ resp.Body.Close()
+ require.Equal(t, 2, attempts)
+
+ assert.GreaterOrEqual(t, secondAttemptAt.Sub(firstAttemptAt), 900*time.Millisecond)
+}
+
+func TestDoRequestWithRetry_RetryAfter429InvalidFallsBack(t *testing.T) {
+ retryDelayUnit = 50 * time.Millisecond
+ t.Cleanup(func() { retryDelayUnit = time.Second })
+
+ attempts := 0
+ var firstAttemptAt time.Time
+ var secondAttemptAt time.Time
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ attempts++
+ if attempts == 1 {
+ firstAttemptAt = time.Now()
+ w.Header().Set("Retry-After", "invalid")
+ w.WriteHeader(http.StatusTooManyRequests)
+ return
+ }
+ if attempts == 2 {
+ secondAttemptAt = time.Now()
+ }
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer server.Close()
+
+ client := &http.Client{Timeout: 5 * time.Second}
+ req, err := http.NewRequest(http.MethodGet, server.URL, nil)
+ require.NoError(t, err)
+
+ resp, err := DoRequestWithRetry(client, req)
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+ resp.Body.Close()
+ require.Equal(t, 2, attempts)
+
+ assert.GreaterOrEqual(t, secondAttemptAt.Sub(firstAttemptAt), 45*time.Millisecond)
+ assert.Less(t, secondAttemptAt.Sub(firstAttemptAt), 500*time.Millisecond)
+}
+
func TestDoRequestWithRetry_ContextCancel(t *testing.T) {
// Use a long retry delay so cancellation always hits during sleepWithCtx.
retryDelayUnit = 10 * time.Second
@@ -204,3 +279,87 @@ func TestDoRequestWithRetry_Delay(t *testing.T) {
assert.GreaterOrEqual(t, delays[2], time.Millisecond)
}
+
+func TestRetryDelayForAttempt_DateRetryAfterUsesResponseDateHeader(t *testing.T) {
+ maxRetrySleepDuration = time.Minute
+ t.Cleanup(func() { maxRetrySleepDuration = time.Minute })
+
+ serverDate := time.Date(2000, 1, 2, 15, 4, 5, 0, time.UTC)
+ retryAfterAt := serverDate.Add(10 * time.Second)
+ resp := &http.Response{
+ StatusCode: http.StatusTooManyRequests,
+ Header: http.Header{
+ "Retry-After": []string{retryAfterAt.Format(http.TimeFormat)},
+ "Date": []string{serverDate.Format(http.TimeFormat)},
+ },
+ }
+
+ assert.Equal(t, 10*time.Second, retryDelayForAttempt(resp, 0))
+}
+
+func TestRetryDelayForAttempt_DateRetryAfterInvalidOrMissingDateFallsBackSafely(t *testing.T) {
+ maxRetrySleepDuration = 30 * time.Second
+ t.Cleanup(func() { maxRetrySleepDuration = time.Minute })
+
+ retryAfterAt := time.Now().UTC().Add(3 * time.Second).Format(http.TimeFormat)
+ testcases := []struct {
+ name string
+ header http.Header
+ }{
+ {
+ name: "invalid-date-header",
+ header: http.Header{
+ "Retry-After": []string{retryAfterAt},
+ "Date": []string{"invalid-date"},
+ },
+ },
+ {
+ name: "missing-date-header",
+ header: http.Header{
+ "Retry-After": []string{retryAfterAt},
+ },
+ },
+ }
+
+ for _, tc := range testcases {
+ t.Run(tc.name, func(t *testing.T) {
+ resp := &http.Response{
+ StatusCode: http.StatusTooManyRequests,
+ Header: tc.header,
+ }
+
+ delay := retryDelayForAttempt(resp, 0)
+ assert.Greater(t, delay, time.Duration(0))
+ assert.GreaterOrEqual(t, delay, 1500*time.Millisecond)
+ assert.LessOrEqual(t, delay, 5*time.Second)
+ })
+ }
+}
+
+func TestRetryDelayForAttempt_RetryAfterIsCapped(t *testing.T) {
+ maxRetrySleepDuration = 2 * time.Second
+ t.Cleanup(func() { maxRetrySleepDuration = time.Minute })
+
+ resp := &http.Response{
+ StatusCode: http.StatusTooManyRequests,
+ Header: http.Header{
+ "Retry-After": []string{"999999"},
+ },
+ }
+
+ assert.Equal(t, 2*time.Second, retryDelayForAttempt(resp, 0))
+}
+
+func TestRetryDelayForAttempt_RetryAfterNumericOverflowStillCaps(t *testing.T) {
+ maxRetrySleepDuration = 2 * time.Second
+ t.Cleanup(func() { maxRetrySleepDuration = time.Minute })
+
+ resp := &http.Response{
+ StatusCode: http.StatusTooManyRequests,
+ Header: http.Header{
+ "Retry-After": []string{"9223372036854775807"},
+ },
+ }
+
+ assert.Equal(t, 2*time.Second, retryDelayForAttempt(resp, 0))
+}
diff --git a/pkg/voice/groq_transcriber.go b/pkg/voice/groq_transcriber.go
deleted file mode 100644
index b42e598f7..000000000
--- a/pkg/voice/groq_transcriber.go
+++ /dev/null
@@ -1,151 +0,0 @@
-package voice
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "fmt"
- "io"
- "mime/multipart"
- "net/http"
- "os"
- "path/filepath"
- "time"
-
- "github.com/sipeed/picoclaw/pkg/logger"
- "github.com/sipeed/picoclaw/pkg/utils"
-)
-
-type GroqTranscriber struct {
- apiKey string
- apiBase string
- httpClient *http.Client
-}
-
-func NewGroqTranscriber(apiKey string) *GroqTranscriber {
- logger.DebugCF("voice", "Creating Groq transcriber", map[string]any{"has_api_key": apiKey != ""})
-
- apiBase := "https://api.groq.com/openai/v1"
- return &GroqTranscriber{
- apiKey: apiKey,
- apiBase: apiBase,
- httpClient: &http.Client{
- Timeout: 60 * time.Second,
- },
- }
-}
-
-func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) {
- logger.InfoCF("voice", "Starting transcription", map[string]any{"audio_file": audioFilePath})
-
- audioFile, err := os.Open(audioFilePath)
- if err != nil {
- logger.ErrorCF("voice", "Failed to open audio file", map[string]any{"path": audioFilePath, "error": err})
- return nil, fmt.Errorf("failed to open audio file: %w", err)
- }
- defer audioFile.Close()
-
- fileInfo, err := audioFile.Stat()
- if err != nil {
- logger.ErrorCF("voice", "Failed to get file info", map[string]any{"path": audioFilePath, "error": err})
- return nil, fmt.Errorf("failed to get file info: %w", err)
- }
-
- logger.DebugCF("voice", "Audio file details", map[string]any{
- "size_bytes": fileInfo.Size(),
- "file_name": filepath.Base(audioFilePath),
- })
-
- var requestBody bytes.Buffer
- writer := multipart.NewWriter(&requestBody)
-
- part, err := writer.CreateFormFile("file", filepath.Base(audioFilePath))
- if err != nil {
- logger.ErrorCF("voice", "Failed to create form file", map[string]any{"error": err})
- return nil, fmt.Errorf("failed to create form file: %w", err)
- }
-
- copied, err := io.Copy(part, audioFile)
- if err != nil {
- logger.ErrorCF("voice", "Failed to copy file content", map[string]any{"error": err})
- return nil, fmt.Errorf("failed to copy file content: %w", err)
- }
-
- logger.DebugCF("voice", "File copied to request", map[string]any{"bytes_copied": copied})
-
- if err = writer.WriteField("model", "whisper-large-v3"); err != nil {
- logger.ErrorCF("voice", "Failed to write model field", map[string]any{"error": err})
- return nil, fmt.Errorf("failed to write model field: %w", err)
- }
-
- if err = writer.WriteField("response_format", "json"); err != nil {
- logger.ErrorCF("voice", "Failed to write response_format field", map[string]any{"error": err})
- return nil, fmt.Errorf("failed to write response_format field: %w", err)
- }
-
- if err = writer.Close(); err != nil {
- logger.ErrorCF("voice", "Failed to close multipart writer", map[string]any{"error": err})
- return nil, fmt.Errorf("failed to close multipart writer: %w", err)
- }
-
- url := t.apiBase + "/audio/transcriptions"
- req, err := http.NewRequestWithContext(ctx, "POST", url, &requestBody)
- if err != nil {
- logger.ErrorCF("voice", "Failed to create request", map[string]any{"error": err})
- return nil, fmt.Errorf("failed to create request: %w", err)
- }
-
- req.Header.Set("Content-Type", writer.FormDataContentType())
- req.Header.Set("Authorization", "Bearer "+t.apiKey)
-
- logger.DebugCF("voice", "Sending transcription request to Groq API", map[string]any{
- "url": url,
- "request_size_bytes": requestBody.Len(),
- "file_size_bytes": fileInfo.Size(),
- })
-
- resp, err := t.httpClient.Do(req)
- if err != nil {
- logger.ErrorCF("voice", "Failed to send request", map[string]any{"error": err})
- return nil, fmt.Errorf("failed to send request: %w", err)
- }
- defer resp.Body.Close()
-
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- logger.ErrorCF("voice", "Failed to read response", map[string]any{"error": err})
- return nil, fmt.Errorf("failed to read response: %w", err)
- }
-
- if resp.StatusCode != http.StatusOK {
- logger.ErrorCF("voice", "API error", map[string]any{
- "status_code": resp.StatusCode,
- "response": string(body),
- })
- return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
- }
-
- logger.DebugCF("voice", "Received response from Groq API", map[string]any{
- "status_code": resp.StatusCode,
- "response_size_bytes": len(body),
- })
-
- var result TranscriptionResponse
- if err := json.Unmarshal(body, &result); err != nil {
- logger.ErrorCF("voice", "Failed to unmarshal response", map[string]any{"error": err})
- return nil, fmt.Errorf("failed to unmarshal response: %w", err)
- }
-
- logger.InfoCF("voice", "Transcription completed successfully", map[string]any{
- "text_length": len(result.Text),
- "language": result.Language,
- "duration_seconds": result.Duration,
- "transcription_preview": utils.Truncate(result.Text, 50),
- })
-
- return &result, nil
-}
-
-func (t *GroqTranscriber) Name() string {
- return "groq"
-}
diff --git a/pkg/voice/groq_transcriber_test.go b/pkg/voice/groq_transcriber_test.go
deleted file mode 100644
index fdcaa7580..000000000
--- a/pkg/voice/groq_transcriber_test.go
+++ /dev/null
@@ -1,84 +0,0 @@
-package voice
-
-import (
- "context"
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "os"
- "path/filepath"
- "testing"
-)
-
-var _ Transcriber = (*GroqTranscriber)(nil)
-
-func TestGroqTranscriberName(t *testing.T) {
- tr := NewGroqTranscriber("sk-test")
- if got := tr.Name(); got != "groq" {
- t.Errorf("Name() = %q, want %q", got, "groq")
- }
-}
-
-func TestGroqTranscribe(t *testing.T) {
- // Write a minimal fake audio file so the transcriber can open and send it.
- tmpDir := t.TempDir()
- audioPath := filepath.Join(tmpDir, "clip.ogg")
- if err := os.WriteFile(audioPath, []byte("fake-audio-data"), 0o644); err != nil {
- t.Fatalf("failed to write fake audio file: %v", err)
- }
-
- t.Run("success", func(t *testing.T) {
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != "/audio/transcriptions" {
- t.Errorf("unexpected path: %s", r.URL.Path)
- }
- if r.Header.Get("Authorization") != "Bearer sk-test" {
- t.Errorf("unexpected Authorization header: %s", r.Header.Get("Authorization"))
- }
- w.Header().Set("Content-Type", "application/json")
- _ = json.NewEncoder(w).Encode(TranscriptionResponse{
- Text: "hello world",
- Language: "en",
- Duration: 1.5,
- })
- }))
- defer srv.Close()
-
- tr := NewGroqTranscriber("sk-test")
- tr.apiBase = srv.URL
-
- resp, err := tr.Transcribe(context.Background(), audioPath)
- if err != nil {
- t.Fatalf("Transcribe() error: %v", err)
- }
- if resp.Text != "hello world" {
- t.Errorf("Text = %q, want %q", resp.Text, "hello world")
- }
- if resp.Language != "en" {
- t.Errorf("Language = %q, want %q", resp.Language, "en")
- }
- })
-
- t.Run("api error", func(t *testing.T) {
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- http.Error(w, `{"error":"invalid_api_key"}`, http.StatusUnauthorized)
- }))
- defer srv.Close()
-
- tr := NewGroqTranscriber("sk-bad")
- tr.apiBase = srv.URL
-
- _, err := tr.Transcribe(context.Background(), audioPath)
- if err == nil {
- t.Fatal("expected error for non-200 response, got nil")
- }
- })
-
- t.Run("missing file", func(t *testing.T) {
- tr := NewGroqTranscriber("sk-test")
- _, err := tr.Transcribe(context.Background(), filepath.Join(tmpDir, "nonexistent.ogg"))
- if err == nil {
- t.Fatal("expected error for missing file, got nil")
- }
- })
-}
diff --git a/pkg/voice/transcriber.go b/pkg/voice/transcriber.go
deleted file mode 100644
index f56fdeedd..000000000
--- a/pkg/voice/transcriber.go
+++ /dev/null
@@ -1,68 +0,0 @@
-package voice
-
-import (
- "context"
- "strings"
-
- "github.com/sipeed/picoclaw/pkg/config"
- "github.com/sipeed/picoclaw/pkg/providers"
-)
-
-type Transcriber interface {
- Name() string
- Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error)
-}
-
-type TranscriptionResponse struct {
- Text string `json:"text"`
- Language string `json:"language,omitempty"`
- Duration float64 `json:"duration,omitempty"`
-}
-
-func supportsAudioTranscription(model string) bool {
- protocol, _ := providers.ExtractProtocol(model)
-
- switch protocol {
- case "openai", "azure", "azure-openai",
- "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia",
- "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
- "vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl",
- "qwen-us", "dashscope-us", "mistral", "avian", "minimax", "longcat", "modelscope", "novita",
- "coding-plan", "alibaba-coding", "qwen-coding":
- // These protocols all go through the OpenAI-compatible or Azure provider path in
- // providers.CreateProviderFromConfig, so they are the only ones that can supply
- // the audio media payload shape expected by NewAudioModelTranscriber.
-
- // TODO: Further restrict this by modelID, since not every model under these
- // protocols supports audio transcription.
- return true
- default:
- return false
- }
-}
-
-// DetectTranscriber inspects cfg and returns the appropriate Transcriber, or
-// nil if no supported transcription provider is configured.
-func DetectTranscriber(cfg *config.Config) Transcriber {
- if modelName := strings.TrimSpace(cfg.Voice.ModelName); modelName != "" {
- modelCfg, err := cfg.GetModelConfig(modelName)
- if err != nil {
- return nil
- }
- if supportsAudioTranscription(modelCfg.Model) {
- return NewAudioModelTranscriber(modelCfg)
- }
- }
-
- // ElevenLabs voice config (supports Scribe STT).
- if key := strings.TrimSpace(cfg.Voice.ElevenLabsAPIKey); key != "" {
- return NewElevenLabsTranscriber(key)
- }
- // Fall back to any model-list entry that uses the groq/ protocol.
- for _, mc := range cfg.ModelList {
- if strings.HasPrefix(mc.Model, "groq/") && mc.APIKey() != "" {
- return NewGroqTranscriber(mc.APIKey())
- }
- }
- return nil
-}
diff --git a/scripts/build-macos-app.sh b/scripts/build-macos-app.sh
index 76cc72938..df2100aec 100755
--- a/scripts/build-macos-app.sh
+++ b/scripts/build-macos-app.sh
@@ -10,6 +10,8 @@ if [ -z "$EXECUTABLE" ]; then
exit 1
fi
+LAUNCHER_EXECUTABLE="picoclaw-launcher-${EXECUTABLE}"
+EXECUTABLE="picoclaw-${EXECUTABLE}"
echo "executable: $EXECUTABLE"
APP_NAME="PicoClaw Launcher"
@@ -33,17 +35,17 @@ mkdir -p "$APP_RESOURCES"
# Copy executable
echo "Copying executable..."
-if [ -f "./web/build/${APP_EXECUTABLE}" ]; then
- cp "./web/build/${APP_EXECUTABLE}" "${APP_MACOS}/"
+if [ -f "./build/${LAUNCHER_EXECUTABLE}" ]; then
+ cp "./build/${LAUNCHER_EXECUTABLE}" "${APP_MACOS}/${APP_EXECUTABLE}"
else
- echo "Error: ./web/build/${APP_EXECUTABLE} not found. Please build the web backend first."
- echo "Run: make build in web dir"
+ echo "Error: ./build/${LAUNCHER_EXECUTABLE} not found. Please build the web backend first."
+ echo "Run: make build-launcher"
exit 1
fi
-if [ -f "./build/picoclaw" ]; then
- cp "./build/picoclaw" "${APP_MACOS}/"
+if [ -f "./build/${EXECUTABLE}" ]; then
+ cp "./build/${EXECUTABLE}" "${APP_MACOS}/picoclaw"
else
- echo "Error: ./build/picoclaw not found. Please build the main file first."
+ echo "Error: ./build/${EXECUTABLE} not found. Please build the main file first."
echo "Run: make build"
exit 1
fi
@@ -76,10 +78,10 @@ cat > "${APP_CONTENTS}/Info.plist" << 'EOF'
+ {t("pages.agent.skills.marketplace_description")} +
+
+ {selectedSkillDetail.content}
+
+ + {isDragActive + ? t("pages.agent.skills.dropzone_release") + : t("pages.agent.skills.import_constraints")} +
++ {t("pages.agent.load_error")} +
++ Try adjusting your search criteria or status filters. +
+ )} +