From 5a4e42d1b643e42509ed6fe60ef336adad5a027d Mon Sep 17 00:00:00 2001
From: Anton Bogdanovich <27antonb@gmail.com>
Date: Mon, 11 May 2026 16:04:26 -0700
Subject: [PATCH 01/21] feat(message): support media attachments in outbound
tool
---
pkg/agent/agent_init.go | 16 ++
pkg/agent/agent_test.go | 6 +-
pkg/channels/feishu/feishu_64.go | 17 ++
pkg/channels/slack/slack.go | 22 ++
pkg/channels/telegram/telegram.go | 177 +++++++++++++++++
pkg/channels/telegram/telegram_test.go | 265 +++++++++++++++++++++++++
pkg/tools/integration/message.go | 242 ++++++++++++++++++++--
pkg/tools/integration/message_test.go | 118 +++++++++--
8 files changed, 836 insertions(+), 27 deletions(-)
diff --git a/pkg/agent/agent_init.go b/pkg/agent/agent_init.go
index 50f0227a1..14b3f8bfe 100644
--- a/pkg/agent/agent_init.go
+++ b/pkg/agent/agent_init.go
@@ -161,9 +161,16 @@ func registerSharedTools(
// Message tool
if cfg.Tools.IsToolEnabled("message") {
messageTool := tools.NewMessageTool()
+ messageTool.ConfigureLocalMedia(
+ agent.Workspace,
+ cfg.Agents.Defaults.RestrictToWorkspace,
+ cfg.Agents.Defaults.GetMaxMediaSize(),
+ allowReadPaths,
+ )
messageTool.SetSendCallback(func(
ctx context.Context,
channel, chatID, content, replyToMessageID string,
+ mediaParts []bus.MediaPart,
) error {
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer pubCancel()
@@ -173,6 +180,15 @@ func registerSharedTools(
tools.ToolSessionKey(ctx),
tools.ToolSessionScope(ctx),
)
+ if len(mediaParts) > 0 {
+ return msgBus.PublishOutboundMedia(pubCtx, bus.OutboundMediaMessage{
+ Context: outboundCtx,
+ AgentID: outboundAgentID,
+ SessionKey: outboundSessionKey,
+ Scope: outboundScope,
+ Parts: mediaParts,
+ })
+ }
return msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
Context: outboundCtx,
AgentID: outboundAgentID,
diff --git a/pkg/agent/agent_test.go b/pkg/agent/agent_test.go
index 29c2b32ea..6196b3f0b 100644
--- a/pkg/agent/agent_test.go
+++ b/pkg/agent/agent_test.go
@@ -377,7 +377,11 @@ func TestPublishResponseIfNeeded_DismissesToolFeedbackWhenMessageToolAlreadySent
t.Fatal("expected default agent")
}
mt := tools.NewMessageTool()
- mt.SetSendCallback(func(ctx context.Context, channel, chatID, content, replyToMessageID string) error {
+ mt.SetSendCallback(func(
+ ctx context.Context,
+ channel, chatID, content, replyToMessageID string,
+ mediaParts []bus.MediaPart,
+ ) error {
return nil
})
defaultAgent.Tools.Register(mt)
diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go
index d09c021c7..5fd28806c 100644
--- a/pkg/channels/feishu/feishu_64.go
+++ b/pkg/channels/feishu/feishu_64.go
@@ -497,10 +497,18 @@ func (c *FeishuChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess
return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
}
+ caption := firstMediaCaption(msg.Parts)
+ sentAny := false
for _, part := range msg.Parts {
if err := c.sendMediaPart(ctx, msg.ChatID, part, store); err != nil {
return nil, err
}
+ sentAny = true
+ }
+ if sentAny && caption != "" {
+ if _, err := c.sendText(ctx, msg.ChatID, caption); err != nil {
+ return nil, err
+ }
}
if hasTrackedMsg {
@@ -557,6 +565,15 @@ func (c *FeishuChannel) sendMediaPart(
return nil
}
+func firstMediaCaption(parts []bus.MediaPart) string {
+ for _, part := range parts {
+ if caption := strings.TrimSpace(part.Caption); caption != "" {
+ return caption
+ }
+ }
+ return ""
+}
+
// --- Inbound message handling ---
func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.P2MessageReceiveV1) error {
diff --git a/pkg/channels/slack/slack.go b/pkg/channels/slack/slack.go
index fa62a4605..566422fdb 100644
--- a/pkg/channels/slack/slack.go
+++ b/pkg/channels/slack/slack.go
@@ -171,6 +171,8 @@ func (c *SlackChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa
return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
}
+ caption := slackFirstMediaCaption(msg.Parts)
+ sentAny := false
for _, part := range msg.Parts {
localPath, err := store.Resolve(part.Ref)
if err != nil {
@@ -205,6 +207,17 @@ func (c *SlackChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa
})
return nil, fmt.Errorf("slack send media: %w", channels.ErrTemporary)
}
+ sentAny = true
+ }
+
+ if sentAny && caption != "" {
+ opts := []slack.MsgOption{slack.MsgOptionText(caption, false)}
+ if threadTS != "" {
+ opts = append(opts, slack.MsgOptionTS(threadTS))
+ }
+ if _, _, err := c.api.PostMessageContext(ctx, channelID, opts...); err != nil {
+ return nil, fmt.Errorf("slack send media caption fallback: %w", channels.ErrTemporary)
+ }
}
// UploadFile does not expose the posted message timestamp in its
@@ -212,6 +225,15 @@ func (c *SlackChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa
return nil, nil
}
+func slackFirstMediaCaption(parts []bus.MediaPart) string {
+ for _, part := range parts {
+ if caption := strings.TrimSpace(part.Caption); caption != "" {
+ return caption
+ }
+ }
+ return ""
+}
+
// ReactToMessage implements channels.ReactionCapable.
// It adds an "eyes" (đ) reaction to the inbound message and returns an undo function
// that removes the reaction.
diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go
index 45672e5ee..1a57fc6ed 100644
--- a/pkg/channels/telegram/telegram.go
+++ b/pkg/channels/telegram/telegram.go
@@ -45,6 +45,7 @@ var (
)
const defaultMediaGroupDelay = 500 * time.Millisecond
+const telegramCaptionLimit = 1024
type TelegramChannel struct {
*channels.BaseChannel
@@ -639,6 +640,34 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
}
var messageIDs []string
+ leadingCaption := telegramLeadingCaption(msg.Parts)
+ if len([]rune(leadingCaption)) > telegramCaptionLimit {
+ leadingIDs, leadingErr := c.sendCaptionText(ctx, chatID, threadID, leadingCaption)
+ if leadingErr != nil {
+ return nil, leadingErr
+ }
+ messageIDs = append(messageIDs, leadingIDs...)
+ msg = telegramClearMediaCaptions(msg)
+ }
+
+ if len(msg.Parts) > 1 && telegramCanSendMediaGroup(msg.Parts) {
+ groupIDs, err := c.sendImageMediaGroups(ctx, chatID, threadID, store, msg.Parts)
+ if err != nil {
+ logger.ErrorCF("telegram", "Failed to send media group", map[string]any{
+ "count": len(msg.Parts),
+ "error": err.Error(),
+ })
+ return nil, fmt.Errorf("telegram send media group: %w", channels.ErrTemporary)
+ }
+ if len(groupIDs) > 0 {
+ messageIDs = append(messageIDs, groupIDs...)
+ if hasTrackedMsg {
+ c.dismissTrackedToolFeedbackMessage(ctx, trackedChatID, trackedMsgID)
+ }
+ return messageIDs, nil
+ }
+ }
+
for _, part := range msg.Parts {
localPath, err := store.Resolve(part.Ref)
if err != nil {
@@ -742,6 +771,154 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
return messageIDs, nil
}
+func telegramCanSendMediaGroup(parts []bus.MediaPart) bool {
+ if len(parts) < 2 {
+ return false
+ }
+ for _, part := range parts {
+ if part.Type != "image" {
+ return false
+ }
+ }
+ return true
+}
+
+func (c *TelegramChannel) sendImageMediaGroups(
+ ctx context.Context,
+ chatID int64,
+ threadID int,
+ store media.MediaStore,
+ parts []bus.MediaPart,
+) ([]string, error) {
+ const maxGroupSize = 10
+
+ messageIDs := make([]string, 0, len(parts))
+ for start := 0; start < len(parts); start += maxGroupSize {
+ end := start + maxGroupSize
+ if end > len(parts) {
+ end = len(parts)
+ }
+ groupIDs, err := c.sendSingleImageMediaGroup(ctx, chatID, threadID, store, parts[start:end])
+ if err != nil {
+ return nil, err
+ }
+ messageIDs = append(messageIDs, groupIDs...)
+ }
+ return messageIDs, nil
+}
+
+func (c *TelegramChannel) sendSingleImageMediaGroup(
+ ctx context.Context,
+ chatID int64,
+ threadID int,
+ store media.MediaStore,
+ parts []bus.MediaPart,
+) ([]string, error) {
+ opened := make([]*os.File, 0, len(parts))
+ defer func() {
+ for _, file := range opened {
+ file.Close()
+ }
+ }()
+
+ inputMedia := make([]telego.InputMedia, 0, len(parts))
+ for i, part := range parts {
+ localPath, err := store.Resolve(part.Ref)
+ if err != nil {
+ logger.ErrorCF("telegram", "Failed to resolve media ref for media group", map[string]any{
+ "ref": part.Ref,
+ "error": err.Error(),
+ })
+ return nil, err
+ }
+
+ file, err := os.Open(localPath)
+ if err != nil {
+ logger.ErrorCF("telegram", "Failed to open media file for media group", map[string]any{
+ "path": localPath,
+ "error": err.Error(),
+ })
+ return nil, err
+ }
+ opened = append(opened, file)
+
+ mediaItem := &telego.InputMediaPhoto{
+ Type: telego.MediaTypePhoto,
+ Media: telego.InputFile{File: file},
+ }
+ if i == 0 {
+ mediaItem.Caption = part.Caption
+ }
+ inputMedia = append(inputMedia, mediaItem)
+ }
+
+ results, err := c.bot.SendMediaGroup(ctx, &telego.SendMediaGroupParams{
+ ChatID: tu.ID(chatID),
+ MessageThreadID: threadID,
+ Media: inputMedia,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ messageIDs := make([]string, 0, len(results))
+ for _, result := range results {
+ messageIDs = append(messageIDs, strconv.Itoa(result.MessageID))
+ }
+ return messageIDs, nil
+}
+
+func (c *TelegramChannel) sendCaptionText(
+ ctx context.Context,
+ chatID int64,
+ threadID int,
+ text string,
+) ([]string, error) {
+ text = strings.TrimSpace(text)
+ if text == "" {
+ return nil, nil
+ }
+ chunks := channels.SplitMessage(text, c.MaxMessageLength())
+ messageIDs := make([]string, 0, len(chunks))
+ for _, chunk := range chunks {
+ chunk = strings.TrimSpace(chunk)
+ if chunk == "" {
+ continue
+ }
+ msgID, err := c.sendChunk(ctx, sendChunkParams{
+ chatID: chatID,
+ threadID: threadID,
+ content: chunk,
+ mdFallback: chunk,
+ useMarkdownV2: false,
+ })
+ if err != nil {
+ return nil, err
+ }
+ messageIDs = append(messageIDs, msgID)
+ }
+ return messageIDs, nil
+}
+
+func telegramLeadingCaption(parts []bus.MediaPart) string {
+ if len(parts) == 0 {
+ return ""
+ }
+ return strings.TrimSpace(parts[0].Caption)
+}
+
+func telegramClearMediaCaptions(msg bus.OutboundMediaMessage) bus.OutboundMediaMessage {
+ if len(msg.Parts) == 0 {
+ return msg
+ }
+ cloned := msg
+ cloned.Parts = append([]bus.MediaPart(nil), msg.Parts...)
+ for i := range cloned.Parts {
+ cloned.Parts[i].Caption = ""
+ }
+ return cloned
+}
+
func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Message) error {
if message != nil && strings.TrimSpace(message.MediaGroupID) != "" {
return c.bufferMediaGroupMessage(ctx, message)
diff --git a/pkg/channels/telegram/telegram_test.go b/pkg/channels/telegram/telegram_test.go
index 0ebde1328..db5a8e784 100644
--- a/pkg/channels/telegram/telegram_test.go
+++ b/pkg/channels/telegram/telegram_test.go
@@ -110,6 +110,17 @@ func successResponseWithMessageID(t *testing.T, messageID int) *ta.Response {
return &ta.Response{Ok: true, Result: b}
}
+func successMediaGroupResponse(t *testing.T, messageIDs ...int) *ta.Response {
+ t.Helper()
+ messages := make([]telego.Message, 0, len(messageIDs))
+ for _, messageID := range messageIDs {
+ messages = append(messages, telego.Message{MessageID: messageID})
+ }
+ b, err := json.Marshal(messages)
+ require.NoError(t, err)
+ return &ta.Response{Ok: true, Result: b}
+}
+
func successUserResponse(t *testing.T, user *telego.User) *ta.Response {
t.Helper()
b, err := json.Marshal(user)
@@ -237,6 +248,260 @@ func TestSendMedia_ImageNonDimensionErrorDoesNotFallback(t *testing.T) {
assert.NotContains(t, caller.calls[0].URL, "sendDocument")
}
+func TestSendMedia_MultipleImagesUseMediaGroup(t *testing.T) {
+ constructor := &multipartRecordingConstructor{}
+ caller := &stubCaller{
+ callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
+ if strings.Contains(url, "sendMediaGroup") {
+ return successMediaGroupResponse(t, 101, 102), nil
+ }
+ t.Fatalf("unexpected API call: %s", url)
+ return nil, nil
+ },
+ }
+ ch := newTestChannelWithConstructor(t, caller, constructor)
+
+ store := media.NewFileMediaStore()
+ ch.SetMediaStore(store)
+
+ tmpDir := t.TempDir()
+ firstPath := filepath.Join(tmpDir, "first.png")
+ secondPath := filepath.Join(tmpDir, "second.png")
+ require.NoError(t, os.WriteFile(firstPath, []byte("first-image"), 0o644))
+ require.NoError(t, os.WriteFile(secondPath, []byte("second-image"), 0o644))
+
+ firstRef, err := store.Store(firstPath, media.MediaMeta{Filename: "first.png", ContentType: "image/png"}, "scope-1")
+ require.NoError(t, err)
+ secondRef, err := store.Store(secondPath, media.MediaMeta{Filename: "second.png", ContentType: "image/png"}, "scope-1")
+ require.NoError(t, err)
+
+ ids, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
+ ChatID: "12345",
+ Parts: []bus.MediaPart{
+ {Type: "image", Ref: firstRef, Caption: "album caption"},
+ {Type: "image", Ref: secondRef},
+ },
+ })
+
+ require.NoError(t, err)
+ assert.Equal(t, []string{"101", "102"}, ids)
+ require.Len(t, caller.calls, 1)
+ assert.Contains(t, caller.calls[0].URL, "sendMediaGroup")
+ require.Len(t, constructor.calls, 1)
+ require.Len(t, constructor.calls[0].FileSizes, 2)
+
+ var mediaPayload []map[string]any
+ require.NoError(t, json.Unmarshal([]byte(constructor.calls[0].Parameters["media"]), &mediaPayload))
+ require.Len(t, mediaPayload, 2)
+ assert.Equal(t, "album caption", mediaPayload[0]["caption"])
+ _, hasSecondCaption := mediaPayload[1]["caption"]
+ assert.False(t, hasSecondCaption)
+}
+
+func TestSendMedia_MoreThanTenImagesSplitIntoMediaGroups(t *testing.T) {
+ constructor := &multipartRecordingConstructor{}
+ callIndex := 0
+ caller := &stubCaller{
+ callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
+ if !strings.Contains(url, "sendMediaGroup") {
+ t.Fatalf("unexpected API call: %s", url)
+ }
+ callIndex++
+ if callIndex == 1 {
+ return successMediaGroupResponse(t, 1001, 1002, 1003, 1004, 1005, 1006, 1007, 1008, 1009, 1010), nil
+ }
+ if callIndex == 2 {
+ return successMediaGroupResponse(t, 1011, 1012, 1013, 1014, 1015), nil
+ }
+ t.Fatalf("unexpected sendMediaGroup call #%d", callIndex)
+ return nil, nil
+ },
+ }
+ ch := newTestChannelWithConstructor(t, caller, constructor)
+
+ store := media.NewFileMediaStore()
+ ch.SetMediaStore(store)
+
+ tmpDir := t.TempDir()
+ parts := make([]bus.MediaPart, 0, 15)
+ for i := 0; i < 15; i++ {
+ path := filepath.Join(tmpDir, "image-"+strconv.Itoa(i)+".png")
+ require.NoError(t, os.WriteFile(path, []byte("img-"+strconv.Itoa(i)), 0o644))
+ ref, err := store.Store(path, media.MediaMeta{Filename: filepath.Base(path), ContentType: "image/png"}, "scope-1")
+ require.NoError(t, err)
+ part := bus.MediaPart{Type: "image", Ref: ref}
+ if i == 0 {
+ part.Caption = "long album caption"
+ }
+ parts = append(parts, part)
+ }
+
+ ids, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
+ ChatID: "12345",
+ Parts: parts,
+ })
+
+ require.NoError(t, err)
+ assert.Equal(t, []string{
+ "1001", "1002", "1003", "1004", "1005",
+ "1006", "1007", "1008", "1009", "1010",
+ "1011", "1012", "1013", "1014", "1015",
+ }, ids)
+ require.Len(t, caller.calls, 2)
+ require.Len(t, constructor.calls, 2)
+}
+
+func TestSendMedia_SingleImageLongCaptionSendsTextFirst(t *testing.T) {
+ constructor := &multipartRecordingConstructor{}
+ longCaption := strings.Repeat("a", telegramCaptionLimit) + " tail overflow"
+ caller := &stubCaller{
+ callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
+ switch {
+ case strings.Contains(url, "sendMessage"):
+ return successResponseWithMessageID(t, 201), nil
+ case strings.Contains(url, "sendPhoto"):
+ return successResponseWithMessageID(t, 202), nil
+ default:
+ t.Fatalf("unexpected API call: %s", url)
+ return nil, nil
+ }
+ },
+ }
+ ch := newTestChannelWithConstructor(t, caller, constructor)
+
+ store := media.NewFileMediaStore()
+ ch.SetMediaStore(store)
+
+ tmpDir := t.TempDir()
+ path := filepath.Join(tmpDir, "image.png")
+ require.NoError(t, os.WriteFile(path, []byte("img"), 0o644))
+ ref, err := store.Store(path, media.MediaMeta{Filename: "image.png", ContentType: "image/png"}, "scope-1")
+ require.NoError(t, err)
+
+ ids, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
+ ChatID: "12345",
+ Parts: []bus.MediaPart{{
+ Type: "image",
+ Ref: ref,
+ Caption: longCaption,
+ }},
+ })
+
+ require.NoError(t, err)
+ assert.Equal(t, []string{"201", "202"}, ids)
+ require.Len(t, caller.calls, 2)
+ assert.Contains(t, caller.calls[0].URL, "sendMessage")
+ assert.Contains(t, caller.calls[1].URL, "sendPhoto")
+ assert.Equal(t, "", constructor.calls[0].Parameters["caption"])
+}
+
+func TestSendMedia_MediaGroupLongCaptionSendsTextFirst(t *testing.T) {
+ constructor := &multipartRecordingConstructor{}
+ longCaption := strings.Repeat("b", telegramCaptionLimit) + " trailing explanation"
+ caller := &stubCaller{
+ callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
+ switch {
+ case strings.Contains(url, "sendMessage"):
+ return successResponseWithMessageID(t, 301), nil
+ case strings.Contains(url, "sendMediaGroup"):
+ return successMediaGroupResponse(t, 302, 303), nil
+ default:
+ t.Fatalf("unexpected API call: %s", url)
+ return nil, nil
+ }
+ },
+ }
+ ch := newTestChannelWithConstructor(t, caller, constructor)
+
+ store := media.NewFileMediaStore()
+ ch.SetMediaStore(store)
+
+ tmpDir := t.TempDir()
+ firstPath := filepath.Join(tmpDir, "first.png")
+ secondPath := filepath.Join(tmpDir, "second.png")
+ require.NoError(t, os.WriteFile(firstPath, []byte("first-image"), 0o644))
+ require.NoError(t, os.WriteFile(secondPath, []byte("second-image"), 0o644))
+
+ firstRef, err := store.Store(firstPath, media.MediaMeta{Filename: "first.png", ContentType: "image/png"}, "scope-1")
+ require.NoError(t, err)
+ secondRef, err := store.Store(secondPath, media.MediaMeta{Filename: "second.png", ContentType: "image/png"}, "scope-1")
+ require.NoError(t, err)
+
+ ids, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
+ ChatID: "12345",
+ Parts: []bus.MediaPart{
+ {Type: "image", Ref: firstRef, Caption: longCaption},
+ {Type: "image", Ref: secondRef},
+ },
+ })
+
+ require.NoError(t, err)
+ assert.Equal(t, []string{"301", "302", "303"}, ids)
+ require.Len(t, caller.calls, 2)
+ assert.Contains(t, caller.calls[0].URL, "sendMessage")
+ assert.Contains(t, caller.calls[1].URL, "sendMediaGroup")
+}
+
+func TestSendMedia_MultiGroupLongCaptionSendsTextBeforeGroups(t *testing.T) {
+ constructor := &multipartRecordingConstructor{}
+ longCaption := strings.Repeat("c", telegramCaptionLimit) + " overflow before second album"
+ callOrder := make([]string, 0, 3)
+ caller := &stubCaller{
+ callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
+ switch {
+ case strings.Contains(url, "sendMessage"):
+ callOrder = append(callOrder, "text")
+ return successResponseWithMessageID(t, 499), nil
+ case strings.Contains(url, "sendMediaGroup"):
+ callOrder = append(callOrder, "group")
+ if len(callOrder) == 2 {
+ return successMediaGroupResponse(t, 401, 402, 403, 404, 405, 406, 407, 408, 409, 410), nil
+ }
+ if len(callOrder) == 3 {
+ return successMediaGroupResponse(t, 411, 412, 413, 414, 415), nil
+ }
+ t.Fatalf("unexpected sendMediaGroup order: %v", callOrder)
+ return nil, nil
+ default:
+ t.Fatalf("unexpected API call: %s", url)
+ return nil, nil
+ }
+ },
+ }
+ ch := newTestChannelWithConstructor(t, caller, constructor)
+
+ store := media.NewFileMediaStore()
+ ch.SetMediaStore(store)
+
+ tmpDir := t.TempDir()
+ parts := make([]bus.MediaPart, 0, 15)
+ for i := 0; i < 15; i++ {
+ path := filepath.Join(tmpDir, "image-"+strconv.Itoa(i)+".png")
+ require.NoError(t, os.WriteFile(path, []byte("img-"+strconv.Itoa(i)), 0o644))
+ ref, err := store.Store(path, media.MediaMeta{Filename: filepath.Base(path), ContentType: "image/png"}, "scope-1")
+ require.NoError(t, err)
+ part := bus.MediaPart{Type: "image", Ref: ref}
+ if i == 0 {
+ part.Caption = longCaption
+ }
+ parts = append(parts, part)
+ }
+
+ ids, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
+ ChatID: "12345",
+ Parts: parts,
+ })
+
+ require.NoError(t, err)
+ assert.Equal(t, []string{
+ "499",
+ "401", "402", "403", "404", "405",
+ "406", "407", "408", "409", "410",
+ "411", "412", "413", "414", "415",
+ }, ids)
+ assert.Equal(t, []string{"text", "group", "group"}, callOrder)
+}
+
func TestSend_EmptyContent(t *testing.T) {
caller := &stubCaller{
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
diff --git a/pkg/tools/integration/message.go b/pkg/tools/integration/message.go
index 98d87bcb3..f7b7b7fdc 100644
--- a/pkg/tools/integration/message.go
+++ b/pkg/tools/integration/message.go
@@ -3,10 +3,32 @@ package integrationtools
import (
"context"
"fmt"
+ "mime"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
"sync"
+
+ "github.com/h2non/filetype"
+
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/media"
+ fstools "github.com/sipeed/picoclaw/pkg/tools/fs"
)
-type SendCallbackWithContext func(ctx context.Context, channel, chatID, content, replyToMessageID string) error
+type SendCallbackWithContext func(
+ ctx context.Context,
+ channel, chatID, content, replyToMessageID string,
+ mediaParts []bus.MediaPart,
+) error
+
+type messageMediaArg struct {
+ Path string
+ Type string
+ Filename string
+}
// sentTarget records the channel+chatID that the message tool sent to.
type sentTarget struct {
@@ -16,10 +38,13 @@ type sentTarget struct {
type MessageTool struct {
sendCallback SendCallbackWithContext
+ workspace string
+ restrict bool
+ maxFileSize int
+ mediaStore media.MediaStore
+ allowPaths []*regexp.Regexp
mu sync.Mutex
- // sentTargets tracks targets sent to in the current round, keyed by session key
- // to support parallel turns for different sessions.
- sentTargets map[string][]sentTarget
+ sentTargets map[string][]sentTarget
}
func NewMessageTool() *MessageTool {
@@ -33,7 +58,7 @@ func (t *MessageTool) Name() string {
}
func (t *MessageTool) Description() string {
- return "Send a message to user on a chat channel. Use this when you want to communicate something."
+ return "Send a message to the user on a chat channel. Supports text-only, media-only, or text with media attachments."
}
func (t *MessageTool) Parameters() map[string]any {
@@ -42,7 +67,29 @@ func (t *MessageTool) Parameters() map[string]any {
"properties": map[string]any{
"content": map[string]any{
"type": "string",
- "description": "The message content to send",
+ "description": "Optional message text. When media is present, this text is used as the caption/body for the media message.",
+ },
+ "media": map[string]any{
+ "type": "array",
+ "description": "Optional local media attachments to send with the message.",
+ "items": map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "path": map[string]any{
+ "type": "string",
+ "description": "Path to the local file. Relative paths are resolved from workspace.",
+ },
+ "type": map[string]any{
+ "type": "string",
+ "description": "Optional media type hint: image, audio, video, or file.",
+ },
+ "filename": map[string]any{
+ "type": "string",
+ "description": "Optional display filename. Defaults to the basename of path.",
+ },
+ },
+ "required": []string{"path"},
+ },
},
"channel": map[string]any{
"type": "string",
@@ -57,10 +104,32 @@ func (t *MessageTool) Parameters() map[string]any {
"description": "Optional: reply target message ID for channels that support threaded replies",
},
},
- "required": []string{"content"},
+ "anyOf": []map[string]any{
+ {"required": []string{"content"}},
+ {"required": []string{"media"}},
+ },
}
}
+func (t *MessageTool) ConfigureLocalMedia(
+ workspace string,
+ restrict bool,
+ maxFileSize int,
+ allowPaths []*regexp.Regexp,
+) {
+ t.workspace = workspace
+ t.restrict = restrict
+ if maxFileSize <= 0 {
+ maxFileSize = config.DefaultMaxMediaSize
+ }
+ t.maxFileSize = maxFileSize
+ t.allowPaths = allowPaths
+}
+
+func (t *MessageTool) SetMediaStore(store media.MediaStore) {
+ t.mediaStore = store
+}
+
// ResetSentInRound resets the per-round send tracker for the given session key.
// Called by the agent loop at the start of each inbound message processing round.
func (t *MessageTool) ResetSentInRound(sessionKey string) {
@@ -98,9 +167,14 @@ func (t *MessageTool) SetSendCallback(callback SendCallbackWithContext) {
}
func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
- content, ok := args["content"].(string)
- if !ok {
- return &ToolResult{ForLLM: "content is required", IsError: true}
+ content, _ := args["content"].(string)
+ content = strings.TrimSpace(content)
+ mediaArgs, err := parseMessageMediaArgs(args["media"])
+ if err != nil {
+ return &ToolResult{ForLLM: err.Error(), IsError: true}
+ }
+ if content == "" && len(mediaArgs) == 0 {
+ return &ToolResult{ForLLM: "content or media is required", IsError: true}
}
channel, _ := args["channel"].(string)
@@ -122,7 +196,12 @@ func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolRes
return &ToolResult{ForLLM: "Message sending not configured", IsError: true}
}
- if err := t.sendCallback(ctx, channel, chatID, content, replyToMessageID); err != nil {
+ parts, err := t.buildMediaParts(channel, chatID, content, mediaArgs)
+ if err != nil {
+ return &ToolResult{ForLLM: err.Error(), IsError: true, Err: err}
+ }
+
+ if err := t.sendCallback(ctx, channel, chatID, content, replyToMessageID, parts); err != nil {
return &ToolResult{
ForLLM: fmt.Sprintf("sending message: %v", err),
IsError: true,
@@ -135,9 +214,146 @@ func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolRes
t.sentTargets[sessionKey] = append(t.sentTargets[sessionKey], sentTarget{Channel: channel, ChatID: chatID})
t.mu.Unlock()
- // Silent: user already received the message directly
+ status := fmt.Sprintf("Message sent to %s:%s", channel, chatID)
+ if len(parts) > 0 {
+ status = fmt.Sprintf("Message with %d media attachment(s) sent to %s:%s", len(parts), channel, chatID)
+ }
+
return &ToolResult{
- ForLLM: fmt.Sprintf("Message sent to %s:%s", channel, chatID),
+ ForLLM: status,
Silent: true,
}
}
+
+func parseMessageMediaArgs(raw any) ([]messageMediaArg, error) {
+ if raw == nil {
+ return nil, nil
+ }
+ items, ok := raw.([]any)
+ if !ok {
+ return nil, fmt.Errorf("media must be an array")
+ }
+ result := make([]messageMediaArg, 0, len(items))
+ for i, item := range items {
+ obj, ok := item.(map[string]any)
+ if !ok {
+ return nil, fmt.Errorf("media[%d] must be an object", i)
+ }
+ path, _ := obj["path"].(string)
+ path = strings.TrimSpace(path)
+ if path == "" {
+ return nil, fmt.Errorf("media[%d].path is required", i)
+ }
+ typ, _ := obj["type"].(string)
+ filename, _ := obj["filename"].(string)
+ result = append(result, messageMediaArg{
+ Path: path,
+ Type: strings.TrimSpace(typ),
+ Filename: strings.TrimSpace(filename),
+ })
+ }
+ return result, nil
+}
+
+func (t *MessageTool) buildMediaParts(
+ channel, chatID, content string,
+ mediaArgs []messageMediaArg,
+) ([]bus.MediaPart, error) {
+ if len(mediaArgs) == 0 {
+ return nil, nil
+ }
+ if t.mediaStore == nil {
+ return nil, fmt.Errorf("media store not configured")
+ }
+ if strings.TrimSpace(t.workspace) == "" {
+ return nil, fmt.Errorf("message media delivery is not configured")
+ }
+
+ scope := fmt.Sprintf("tool:message:%s:%s", channel, chatID)
+ parts := make([]bus.MediaPart, 0, len(mediaArgs))
+ for i, item := range mediaArgs {
+ resolved, err := fstools.ValidatePathWithAllowPaths(item.Path, t.workspace, t.restrict, t.allowPaths)
+ if err != nil {
+ return nil, fmt.Errorf("invalid media[%d].path: %w", i, err)
+ }
+ info, err := os.Stat(resolved)
+ if err != nil {
+ return nil, fmt.Errorf("media[%d] file not found: %w", i, err)
+ }
+ if info.IsDir() {
+ return nil, fmt.Errorf("media[%d] path is a directory, expected a file", i)
+ }
+ if t.maxFileSize > 0 && info.Size() > int64(t.maxFileSize) {
+ return nil, fmt.Errorf("media[%d] file too large: %d bytes (max %d bytes)", i, info.Size(), t.maxFileSize)
+ }
+
+ filename := item.Filename
+ if filename == "" {
+ filename = filepath.Base(resolved)
+ }
+ contentType := detectMessageMediaType(resolved)
+ partType := normalizeMessageMediaType(item.Type, filename, contentType)
+ ref, err := t.mediaStore.Store(resolved, media.MediaMeta{
+ Filename: filename,
+ ContentType: contentType,
+ Source: "tool:message",
+ CleanupPolicy: media.CleanupPolicyForgetOnly,
+ }, scope)
+ if err != nil {
+ return nil, fmt.Errorf("failed to register media[%d]: %w", i, err)
+ }
+
+ part := bus.MediaPart{
+ Type: partType,
+ Ref: ref,
+ Filename: filename,
+ ContentType: contentType,
+ }
+ if i == 0 && content != "" {
+ part.Caption = content
+ }
+ parts = append(parts, part)
+ }
+ return parts, nil
+}
+
+func detectMessageMediaType(path string) string {
+ kind, err := filetype.MatchFile(path)
+ if err == nil && kind != filetype.Unknown {
+ return kind.MIME.Value
+ }
+ if ext := filepath.Ext(path); ext != "" {
+ if t := mime.TypeByExtension(ext); t != "" {
+ return t
+ }
+ }
+ return "application/octet-stream"
+}
+
+func normalizeMessageMediaType(typeHint, filename, contentType string) string {
+ switch strings.ToLower(strings.TrimSpace(typeHint)) {
+ case "image", "audio", "video", "file":
+ return strings.ToLower(strings.TrimSpace(typeHint))
+ }
+
+ ct := strings.ToLower(strings.TrimSpace(contentType))
+ switch {
+ case strings.HasPrefix(ct, "image/"):
+ return "image"
+ case strings.HasPrefix(ct, "audio/"):
+ return "audio"
+ case strings.HasPrefix(ct, "video/"):
+ return "video"
+ }
+
+ switch strings.ToLower(filepath.Ext(filename)) {
+ case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp":
+ return "image"
+ case ".mp3", ".wav", ".ogg", ".oga", ".m4a", ".flac":
+ return "audio"
+ case ".mp4", ".mov", ".mkv", ".webm", ".avi":
+ return "video"
+ default:
+ return "file"
+ }
+}
diff --git a/pkg/tools/integration/message_test.go b/pkg/tools/integration/message_test.go
index c7b7d2b6e..2d3329d3d 100644
--- a/pkg/tools/integration/message_test.go
+++ b/pkg/tools/integration/message_test.go
@@ -3,8 +3,13 @@ package integrationtools
import (
"context"
"errors"
+ "os"
+ "path/filepath"
+ "regexp"
"testing"
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/media"
"github.com/sipeed/picoclaw/pkg/session"
)
@@ -12,10 +17,17 @@ func TestMessageTool_Execute_Success(t *testing.T) {
tool := NewMessageTool()
var sentChannel, sentChatID, sentContent string
- tool.SetSendCallback(func(ctx context.Context, channel, chatID, content, replyToMessageID string) error {
+ tool.SetSendCallback(func(
+ ctx context.Context,
+ channel, chatID, content, replyToMessageID string,
+ mediaParts []bus.MediaPart,
+ ) error {
sentChannel = channel
sentChatID = chatID
sentContent = content
+ if len(mediaParts) != 0 {
+ t.Fatalf("expected no media parts, got %d", len(mediaParts))
+ }
if ToolAgentID(ctx) != "" || ToolSessionKey(ctx) != "" || ToolSessionScope(ctx) != nil {
t.Fatalf("expected empty turn metadata in basic context, got agent=%q session=%q scope=%+v",
ToolAgentID(ctx), ToolSessionKey(ctx), ToolSessionScope(ctx))
@@ -67,7 +79,11 @@ func TestMessageTool_Execute_WithCustomChannel(t *testing.T) {
tool := NewMessageTool()
var sentChannel, sentChatID string
- tool.SetSendCallback(func(ctx context.Context, channel, chatID, content, replyToMessageID string) error {
+ tool.SetSendCallback(func(
+ ctx context.Context,
+ channel, chatID, content, replyToMessageID string,
+ mediaParts []bus.MediaPart,
+ ) error {
sentChannel = channel
sentChatID = chatID
return nil
@@ -102,7 +118,11 @@ func TestMessageTool_Execute_SendFailure(t *testing.T) {
tool := NewMessageTool()
sendErr := errors.New("network error")
- tool.SetSendCallback(func(ctx context.Context, channel, chatID, content, replyToMessageID string) error {
+ tool.SetSendCallback(func(
+ ctx context.Context,
+ channel, chatID, content, replyToMessageID string,
+ mediaParts []bus.MediaPart,
+ ) error {
return sendErr
})
@@ -142,12 +162,12 @@ func TestMessageTool_Execute_MissingContent(t *testing.T) {
result := tool.Execute(ctx, args)
- // Verify error result for missing content
+ // Verify error result for missing content/media
if !result.IsError {
- t.Error("Expected IsError=true for missing content")
+ t.Error("Expected IsError=true for missing content/media")
}
- if result.ForLLM != "content is required" {
- t.Errorf("Expected ForLLM 'content is required', got '%s'", result.ForLLM)
+ if result.ForLLM != "content or media is required" {
+ t.Errorf("Expected ForLLM 'content or media is required', got '%s'", result.ForLLM)
}
}
@@ -155,7 +175,11 @@ func TestMessageTool_Execute_NoTargetChannel(t *testing.T) {
tool := NewMessageTool()
// No WithToolContext â channel/chatID are empty
- tool.SetSendCallback(func(ctx context.Context, channel, chatID, content, replyToMessageID string) error {
+ tool.SetSendCallback(func(
+ ctx context.Context,
+ channel, chatID, content, replyToMessageID string,
+ mediaParts []bus.MediaPart,
+ ) error {
return nil
})
@@ -226,9 +250,9 @@ func TestMessageTool_Parameters(t *testing.T) {
}
// Check required properties
- required, ok := params["required"].([]string)
- if !ok || len(required) != 1 || required[0] != "content" {
- t.Error("Expected 'content' to be required")
+ anyOf, ok := params["anyOf"].([]map[string]any)
+ if !ok || len(anyOf) != 2 {
+ t.Fatal("Expected anyOf content/media requirement")
}
// Check content property
@@ -240,6 +264,14 @@ func TestMessageTool_Parameters(t *testing.T) {
t.Error("Expected content type to be 'string'")
}
+ mediaProp, ok := props["media"].(map[string]any)
+ if !ok {
+ t.Fatal("Expected 'media' property")
+ }
+ if mediaProp["type"] != "array" {
+ t.Error("Expected media type to be 'array'")
+ }
+
// Check channel property (optional)
channelProp, ok := props["channel"].(map[string]any)
if !ok {
@@ -272,7 +304,11 @@ func TestMessageTool_Execute_WithReplyToMessageID(t *testing.T) {
tool := NewMessageTool()
var sentReplyTo string
- tool.SetSendCallback(func(ctx context.Context, channel, chatID, content, replyToMessageID string) error {
+ tool.SetSendCallback(func(
+ ctx context.Context,
+ channel, chatID, content, replyToMessageID string,
+ mediaParts []bus.MediaPart,
+ ) error {
sentReplyTo = replyToMessageID
return nil
})
@@ -297,7 +333,11 @@ func TestMessageTool_Execute_PropagatesTurnSessionMetadata(t *testing.T) {
var gotAgentID, gotSessionKey string
var gotScope *session.SessionScope
- tool.SetSendCallback(func(ctx context.Context, channel, chatID, content, replyToMessageID string) error {
+ tool.SetSendCallback(func(
+ ctx context.Context,
+ channel, chatID, content, replyToMessageID string,
+ mediaParts []bus.MediaPart,
+ ) error {
gotAgentID = ToolAgentID(ctx)
gotSessionKey = ToolSessionKey(ctx)
gotScope = ToolSessionScope(ctx)
@@ -329,3 +369,55 @@ func TestMessageTool_Execute_PropagatesTurnSessionMetadata(t *testing.T) {
t.Fatalf("ToolSessionScope() = %+v, want chat scope", gotScope)
}
}
+
+func TestMessageTool_Execute_WithMedia(t *testing.T) {
+ tool := NewMessageTool()
+ store := media.NewFileMediaStore()
+ dir := t.TempDir()
+ imgPath := filepath.Join(dir, "photo.jpg")
+ if err := os.WriteFile(imgPath, []byte("fake image bytes"), 0o644); err != nil {
+ t.Fatalf("write image: %v", err)
+ }
+ tool.ConfigureLocalMedia(dir, true, 1024*1024, []*regexp.Regexp{})
+ tool.SetMediaStore(store)
+
+ var gotContent string
+ var gotParts []bus.MediaPart
+ tool.SetSendCallback(func(
+ ctx context.Context,
+ channel, chatID, content, replyToMessageID string,
+ mediaParts []bus.MediaPart,
+ ) error {
+ gotContent = content
+ gotParts = append([]bus.MediaPart(nil), mediaParts...)
+ return nil
+ })
+
+ ctx := WithToolContext(context.Background(), "telegram", "-1001")
+ result := tool.Execute(ctx, map[string]any{
+ "content": "Caption text",
+ "media": []any{
+ map[string]any{
+ "path": imgPath,
+ },
+ },
+ })
+ if result.IsError {
+ t.Fatalf("expected success, got error: %s", result.ForLLM)
+ }
+ if gotContent != "Caption text" {
+ t.Fatalf("content = %q, want Caption text", gotContent)
+ }
+ if len(gotParts) != 1 {
+ t.Fatalf("expected 1 media part, got %d", len(gotParts))
+ }
+ if gotParts[0].Caption != "Caption text" {
+ t.Fatalf("first part caption = %q, want Caption text", gotParts[0].Caption)
+ }
+ if gotParts[0].Ref == "" {
+ t.Fatal("expected media ref to be populated")
+ }
+ if gotParts[0].Type == "" {
+ t.Fatal("expected media type to be inferred")
+ }
+}
From 987f117f318c90228724a51c15838552ff21fcb1 Mon Sep 17 00:00:00 2001
From: Anton Bogdanovich <27antonb@gmail.com>
Date: Mon, 11 May 2026 16:18:12 -0700
Subject: [PATCH 02/21] style(telegram): satisfy formatter rules
---
pkg/channels/telegram/telegram.go | 6 ++++--
pkg/channels/telegram/telegram_test.go | 24 ++++++++++++++++++++----
2 files changed, 24 insertions(+), 6 deletions(-)
diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go
index 1a57fc6ed..8fe325b25 100644
--- a/pkg/channels/telegram/telegram.go
+++ b/pkg/channels/telegram/telegram.go
@@ -44,8 +44,10 @@ var (
reInlineCode = regexp.MustCompile("`([^`]+)`")
)
-const defaultMediaGroupDelay = 500 * time.Millisecond
-const telegramCaptionLimit = 1024
+const (
+ defaultMediaGroupDelay = 500 * time.Millisecond
+ telegramCaptionLimit = 1024
+)
type TelegramChannel struct {
*channels.BaseChannel
diff --git a/pkg/channels/telegram/telegram_test.go b/pkg/channels/telegram/telegram_test.go
index db5a8e784..b52f2c9b2 100644
--- a/pkg/channels/telegram/telegram_test.go
+++ b/pkg/channels/telegram/telegram_test.go
@@ -272,7 +272,11 @@ func TestSendMedia_MultipleImagesUseMediaGroup(t *testing.T) {
firstRef, err := store.Store(firstPath, media.MediaMeta{Filename: "first.png", ContentType: "image/png"}, "scope-1")
require.NoError(t, err)
- secondRef, err := store.Store(secondPath, media.MediaMeta{Filename: "second.png", ContentType: "image/png"}, "scope-1")
+ secondRef, err := store.Store(
+ secondPath,
+ media.MediaMeta{Filename: "second.png", ContentType: "image/png"},
+ "scope-1",
+ )
require.NoError(t, err)
ids, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
@@ -327,7 +331,11 @@ func TestSendMedia_MoreThanTenImagesSplitIntoMediaGroups(t *testing.T) {
for i := 0; i < 15; i++ {
path := filepath.Join(tmpDir, "image-"+strconv.Itoa(i)+".png")
require.NoError(t, os.WriteFile(path, []byte("img-"+strconv.Itoa(i)), 0o644))
- ref, err := store.Store(path, media.MediaMeta{Filename: filepath.Base(path), ContentType: "image/png"}, "scope-1")
+ ref, err := store.Store(
+ path,
+ media.MediaMeta{Filename: filepath.Base(path), ContentType: "image/png"},
+ "scope-1",
+ )
require.NoError(t, err)
part := bus.MediaPart{Type: "image", Ref: ref}
if i == 0 {
@@ -424,7 +432,11 @@ func TestSendMedia_MediaGroupLongCaptionSendsTextFirst(t *testing.T) {
firstRef, err := store.Store(firstPath, media.MediaMeta{Filename: "first.png", ContentType: "image/png"}, "scope-1")
require.NoError(t, err)
- secondRef, err := store.Store(secondPath, media.MediaMeta{Filename: "second.png", ContentType: "image/png"}, "scope-1")
+ secondRef, err := store.Store(
+ secondPath,
+ media.MediaMeta{Filename: "second.png", ContentType: "image/png"},
+ "scope-1",
+ )
require.NoError(t, err)
ids, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
@@ -478,7 +490,11 @@ func TestSendMedia_MultiGroupLongCaptionSendsTextBeforeGroups(t *testing.T) {
for i := 0; i < 15; i++ {
path := filepath.Join(tmpDir, "image-"+strconv.Itoa(i)+".png")
require.NoError(t, os.WriteFile(path, []byte("img-"+strconv.Itoa(i)), 0o644))
- ref, err := store.Store(path, media.MediaMeta{Filename: filepath.Base(path), ContentType: "image/png"}, "scope-1")
+ ref, err := store.Store(
+ path,
+ media.MediaMeta{Filename: filepath.Base(path), ContentType: "image/png"},
+ "scope-1",
+ )
require.NoError(t, err)
part := bus.MediaPart{Type: "image", Ref: ref}
if i == 0 {
From c05e5e29c6b69a5c23b10b38ef5779ec6a547098 Mon Sep 17 00:00:00 2001
From: Anton Bogdanovich <27antonb@gmail.com>
Date: Mon, 11 May 2026 16:32:24 -0700
Subject: [PATCH 03/21] test(message): cover pico and weixin media text
semantics
---
pkg/channels/pico/pico_test.go | 69 ++++++++++++++++++++++++++++++
pkg/channels/weixin/weixin_test.go | 59 +++++++++++++++++++++++++
2 files changed, 128 insertions(+)
diff --git a/pkg/channels/pico/pico_test.go b/pkg/channels/pico/pico_test.go
index a793d7ad7..9cdf79044 100644
--- a/pkg/channels/pico/pico_test.go
+++ b/pkg/channels/pico/pico_test.go
@@ -835,6 +835,75 @@ func TestSendMedia_DismissesTrackedToolFeedbackMessage(t *testing.T) {
}
}
+func TestSendMedia_IncludesCaptionAndAttachmentsInSinglePayload(t *testing.T) {
+ ch := newTestPicoChannel(t)
+ store := media.NewFileMediaStore()
+ ch.SetMediaStore(store)
+
+ if err := ch.Start(context.Background()); err != nil {
+ t.Fatalf("Start() error = %v", err)
+ }
+ defer ch.Stop(context.Background())
+
+ clientConn, received, cleanup := newTestPicoWebSocket(t)
+ defer cleanup()
+ ch.addConnForTest(&picoConn{id: "conn-1", conn: clientConn, sessionID: "sess-1"})
+
+ localPath := filepath.Join(t.TempDir(), "photo.png")
+ if err := os.WriteFile(localPath, []byte("png-body"), 0o600); err != nil {
+ t.Fatalf("WriteFile() error = %v", err)
+ }
+
+ ref, err := store.Store(localPath, media.MediaMeta{
+ Filename: "photo.png",
+ ContentType: "image/png",
+ }, "test-scope")
+ if err != nil {
+ t.Fatalf("Store() error = %v", err)
+ }
+
+ _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
+ ChatID: "pico:sess-1",
+ Parts: []bus.MediaPart{{
+ Ref: ref,
+ Type: "image",
+ Filename: "photo.png",
+ ContentType: "image/png",
+ Caption: "recipe translation",
+ }},
+ })
+ if err != nil {
+ t.Fatalf("SendMedia() error = %v", err)
+ }
+
+ select {
+ case msg := <-received:
+ if msg.Type != TypeMessageCreate {
+ t.Fatalf("message type = %q, want %q", msg.Type, TypeMessageCreate)
+ }
+ payload := msg.Payload
+ if got := payload[PayloadKeyContent]; got != "recipe translation" {
+ t.Fatalf("content = %#v, want %q", got, "recipe translation")
+ }
+ rawAttachments, ok := payload["attachments"].([]any)
+ if !ok || len(rawAttachments) != 1 {
+ t.Fatalf("attachments = %#v, want 1 attachment", payload["attachments"])
+ }
+ attachment, ok := rawAttachments[0].(map[string]any)
+ if !ok {
+ t.Fatalf("attachment = %#v, want map", rawAttachments[0])
+ }
+ if got := attachment["type"]; got != "image" {
+ t.Fatalf("attachment type = %#v, want image", got)
+ }
+ if got := attachment["filename"]; got != "photo.png" {
+ t.Fatalf("attachment filename = %#v, want photo.png", got)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("expected media payload to be delivered")
+ }
+}
+
func TestPicoDownloadURLForRef(t *testing.T) {
got, err := picoDownloadURLForRef("media://attachment-1")
if err != nil {
diff --git a/pkg/channels/weixin/weixin_test.go b/pkg/channels/weixin/weixin_test.go
index aea2cbb0c..587c35a8e 100644
--- a/pkg/channels/weixin/weixin_test.go
+++ b/pkg/channels/weixin/weixin_test.go
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/base64"
+ "encoding/json"
"errors"
"io"
"net/http"
@@ -319,3 +320,61 @@ func TestSelectInboundMediaItemFallsBackToRefMessage(t *testing.T) {
t.Fatalf("selectInboundMediaItem().Type = %d, want %d", item.Type, MessageItemTypeImage)
}
}
+
+func TestSendUploadedMedia_SendsCaptionAsSeparateTextBeforeMedia(t *testing.T) {
+ var requests []SendMessageReq
+ ch := &WeixinChannel{
+ api: &ApiClient{
+ BaseURL: "https://ilinkai.weixin.qq.com/",
+ HttpClient: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
+ if r.URL.Path != "/ilink/bot/sendmessage" {
+ t.Fatalf("sendmessage path = %q, want /ilink/bot/sendmessage", r.URL.Path)
+ }
+ var req SendMessageReq
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ t.Fatalf("decode sendmessage req: %v", err)
+ }
+ requests = append(requests, req)
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(bytes.NewReader([]byte(`{"ret":0,"errcode":0}`))),
+ Header: make(http.Header),
+ }, nil
+ })},
+ },
+ typingCache: make(map[string]typingTicketCacheEntry),
+ }
+
+ err := ch.sendUploadedMedia(
+ context.Background(),
+ "user-1",
+ "ctx-1",
+ "recipe translation",
+ UploadMediaTypeImage,
+ &uploadedFileInfo{
+ downloadParam: "download-token",
+ aesKeyHex: "31323334353637383930616263646566",
+ fileSize: 11,
+ cipherSize: 16,
+ filename: "photo.png",
+ },
+ )
+ if err != nil {
+ t.Fatalf("sendUploadedMedia() error = %v", err)
+ }
+ if len(requests) != 2 {
+ t.Fatalf("sendUploadedMedia() sent %d requests, want 2", len(requests))
+ }
+ if len(requests[0].Msg.ItemList) != 1 || requests[0].Msg.ItemList[0].Type != MessageItemTypeText {
+ t.Fatalf("first request item = %+v, want text item", requests[0].Msg.ItemList)
+ }
+ if got := requests[0].Msg.ItemList[0].TextItem.Text; got != "recipe translation" {
+ t.Fatalf("first request text = %q, want recipe translation", got)
+ }
+ if len(requests[1].Msg.ItemList) != 1 || requests[1].Msg.ItemList[0].Type != MessageItemTypeImage {
+ t.Fatalf("second request item = %+v, want image item", requests[1].Msg.ItemList)
+ }
+ if requests[1].Msg.ItemList[0].ImageItem == nil || requests[1].Msg.ItemList[0].ImageItem.Media == nil {
+ t.Fatalf("second request image media = %+v, want media ref", requests[1].Msg.ItemList[0].ImageItem)
+ }
+}
From 1bf0d898deffda90d12356cbfb053985199e6ed8 Mon Sep 17 00:00:00 2001
From: Anton Bogdanovich <27antonb@gmail.com>
Date: Mon, 11 May 2026 16:45:01 -0700
Subject: [PATCH 04/21] test(message): cover slack and feishu media fallbacks
---
pkg/channels/feishu/feishu_64.go | 8 ++-
pkg/channels/feishu/feishu_64_test.go | 39 ++++++++++++++
pkg/channels/slack/slack.go | 22 +++++---
pkg/channels/slack/slack_test.go | 78 +++++++++++++++++++++++++++
4 files changed, 139 insertions(+), 8 deletions(-)
diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go
index 5fd28806c..2fef72273 100644
--- a/pkg/channels/feishu/feishu_64.go
+++ b/pkg/channels/feishu/feishu_64.go
@@ -52,6 +52,8 @@ type FeishuChannel struct {
progress *channels.ToolFeedbackAnimator
deleteMessageFn func(context.Context, string, string) error
+ sendMediaPartFn func(context.Context, string, bus.MediaPart, media.MediaStore) error
+ sendTextFn func(context.Context, string, string) (string, error)
}
type cachedMessage struct {
@@ -78,6 +80,8 @@ func NewFeishuChannel(bc *config.Channel, cfg *config.FeishuSettings, bus *bus.M
client: lark.NewClient(cfg.AppID, cfg.AppSecret.String(), opts...),
}
ch.deleteMessageFn = ch.deleteMessageAPI
+ ch.sendMediaPartFn = ch.sendMediaPart
+ ch.sendTextFn = ch.sendText
ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage)
ch.SetOwner(ch)
return ch, nil
@@ -500,13 +504,13 @@ func (c *FeishuChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess
caption := firstMediaCaption(msg.Parts)
sentAny := false
for _, part := range msg.Parts {
- if err := c.sendMediaPart(ctx, msg.ChatID, part, store); err != nil {
+ if err := c.sendMediaPartFn(ctx, msg.ChatID, part, store); err != nil {
return nil, err
}
sentAny = true
}
if sentAny && caption != "" {
- if _, err := c.sendText(ctx, msg.ChatID, caption); err != nil {
+ if _, err := c.sendTextFn(ctx, msg.ChatID, caption); err != nil {
return nil, err
}
}
diff --git a/pkg/channels/feishu/feishu_64_test.go b/pkg/channels/feishu/feishu_64_test.go
index d256325ad..1dbacab89 100644
--- a/pkg/channels/feishu/feishu_64_test.go
+++ b/pkg/channels/feishu/feishu_64_test.go
@@ -9,7 +9,9 @@ import (
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
+ "github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
+ "github.com/sipeed/picoclaw/pkg/media"
)
func TestExtractContent(t *testing.T) {
@@ -319,6 +321,43 @@ func TestFinalizeTrackedToolFeedbackMessage_ClearAfterSuccessfulEdit(t *testing.
}
}
+func TestSendMedia_SendsCaptionFallbackAfterMedia(t *testing.T) {
+ ch := &FeishuChannel{
+ BaseChannel: channels.NewBaseChannel("feishu", nil, nil, nil),
+ progress: channels.NewToolFeedbackAnimator(nil),
+ }
+ ch.SetRunning(true)
+ ch.SetMediaStore(media.NewFileMediaStore())
+
+ var mediaOrder []string
+ var textCalls []string
+ ch.sendMediaPartFn = func(ctx context.Context, chatID string, part bus.MediaPart, store media.MediaStore) error {
+ mediaOrder = append(mediaOrder, part.Type)
+ return nil
+ }
+ ch.sendTextFn = func(ctx context.Context, chatID, text string) (string, error) {
+ textCalls = append(textCalls, chatID+"|"+text)
+ return "msg-1", nil
+ }
+
+ _, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
+ ChatID: "oc_123",
+ Parts: []bus.MediaPart{
+ {Type: "image", Caption: "shared caption"},
+ {Type: "file"},
+ },
+ })
+ if err != nil {
+ t.Fatalf("SendMedia() error = %v", err)
+ }
+ if len(mediaOrder) != 2 {
+ t.Fatalf("media sends = %v, want 2 sends", mediaOrder)
+ }
+ if len(textCalls) != 1 || textCalls[0] != "oc_123|shared caption" {
+ t.Fatalf("textCalls = %v, want [oc_123|shared caption]", textCalls)
+ }
+}
+
func TestFinalizeTrackedToolFeedbackMessage_StopsTrackingBeforeEdit(t *testing.T) {
ch := &FeishuChannel{
progress: channels.NewToolFeedbackAnimator(nil),
diff --git a/pkg/channels/slack/slack.go b/pkg/channels/slack/slack.go
index 566422fdb..b021feda9 100644
--- a/pkg/channels/slack/slack.go
+++ b/pkg/channels/slack/slack.go
@@ -29,6 +29,8 @@ type SlackChannel struct {
ctx context.Context
cancel context.CancelFunc
pendingAcks sync.Map
+ uploadFileFn func(context.Context, slack.UploadFileParameters) error
+ postTextFn func(context.Context, string, string, string) error
}
type slackMessageRef struct {
@@ -63,6 +65,18 @@ func NewSlackChannel(
config: cfg,
api: api,
socketClient: socketClient,
+ uploadFileFn: func(ctx context.Context, params slack.UploadFileParameters) error {
+ _, err := api.UploadFileContext(ctx, params)
+ return err
+ },
+ postTextFn: func(ctx context.Context, channelID, threadTS, text string) error {
+ opts := []slack.MsgOption{slack.MsgOptionText(text, false)}
+ if threadTS != "" {
+ opts = append(opts, slack.MsgOptionTS(threadTS))
+ }
+ _, _, err := api.PostMessageContext(ctx, channelID, opts...)
+ return err
+ },
}, nil
}
@@ -193,7 +207,7 @@ func (c *SlackChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa
title = filename
}
- _, err = c.api.UploadFileContext(ctx, slack.UploadFileParameters{
+ err = c.uploadFileFn(ctx, slack.UploadFileParameters{
Channel: channelID,
ThreadTimestamp: threadTS,
File: localPath,
@@ -211,11 +225,7 @@ func (c *SlackChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa
}
if sentAny && caption != "" {
- opts := []slack.MsgOption{slack.MsgOptionText(caption, false)}
- if threadTS != "" {
- opts = append(opts, slack.MsgOptionTS(threadTS))
- }
- if _, _, err := c.api.PostMessageContext(ctx, channelID, opts...); err != nil {
+ if err := c.postTextFn(ctx, channelID, threadTS, caption); err != nil {
return nil, fmt.Errorf("slack send media caption fallback: %w", channels.ErrTemporary)
}
}
diff --git a/pkg/channels/slack/slack_test.go b/pkg/channels/slack/slack_test.go
index a72521d67..b85f3f028 100644
--- a/pkg/channels/slack/slack_test.go
+++ b/pkg/channels/slack/slack_test.go
@@ -1,10 +1,17 @@
package slack
import (
+ "context"
+ "os"
+ "path/filepath"
"testing"
+ slacksdk "github.com/slack-go/slack"
+
"github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/media"
)
func TestParseSlackChatID(t *testing.T) {
@@ -184,3 +191,74 @@ func TestSlackChannelIsAllowed(t *testing.T) {
}
})
}
+
+func TestSendMedia_SendsCaptionFallbackAfterUploads(t *testing.T) {
+ ch := &SlackChannel{
+ BaseChannel: channels.NewBaseChannel("slack", nil, nil, nil),
+ }
+ ch.SetRunning(true)
+
+ store := media.NewFileMediaStore()
+ ch.SetMediaStore(store)
+
+ tmpDir := t.TempDir()
+ localPath := filepath.Join(tmpDir, "report.txt")
+ if err := os.WriteFile(localPath, []byte("attachment body"), 0o600); err != nil {
+ t.Fatalf("WriteFile() error = %v", err)
+ }
+ ref, err := store.Store(localPath, media.MediaMeta{
+ Filename: "report.txt",
+ ContentType: "text/plain",
+ }, "test-scope")
+ if err != nil {
+ t.Fatalf("Store() error = %v", err)
+ }
+
+ var uploaded []slackUploadRecord
+ var posted []string
+ ch.uploadFileFn = func(ctx context.Context, params slacksdk.UploadFileParameters) error {
+ uploaded = append(uploaded, slackUploadRecord{
+ Channel: params.Channel,
+ Thread: params.ThreadTimestamp,
+ File: params.File,
+ Name: params.Filename,
+ Title: params.Title,
+ })
+ return nil
+ }
+ ch.postTextFn = func(ctx context.Context, channelID, threadTS, text string) error {
+ posted = append(posted, channelID+"|"+threadTS+"|"+text)
+ return nil
+ }
+
+ _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
+ ChatID: "C123456/1234567890.123456",
+ Parts: []bus.MediaPart{{
+ Ref: ref,
+ Type: "file",
+ Filename: "report.txt",
+ ContentType: "text/plain",
+ Caption: "shared caption",
+ }},
+ })
+ if err != nil {
+ t.Fatalf("SendMedia() error = %v", err)
+ }
+ if len(uploaded) != 1 {
+ t.Fatalf("uploads = %v, want 1 upload", uploaded)
+ }
+ if uploaded[0].Title != "shared caption" {
+ t.Fatalf("upload title = %q, want shared caption", uploaded[0].Title)
+ }
+ if len(posted) != 1 || posted[0] != "C123456|1234567890.123456|shared caption" {
+ t.Fatalf("posted = %v, want fallback text in same thread", posted)
+ }
+}
+
+type slackUploadRecord struct {
+ Channel string
+ Thread string
+ File string
+ Name string
+ Title string
+}
From ceebda35ee5a519017ecba6b2098d093f93bd9df Mon Sep 17 00:00:00 2001
From: Anton Bogdanovich <27antonb@gmail.com>
Date: Fri, 22 May 2026 16:20:59 -0700
Subject: [PATCH 05/21] fix(message): gate local media attachments
---
docs/guides/configuration.md | 1 +
pkg/agent/agent_init.go | 40 ++++++---
pkg/config/config.go | 8 +-
pkg/config/config_test.go | 10 +++
pkg/config/defaults.go | 7 +-
pkg/tools/integration/message.go | 124 +++++++++++++++-----------
pkg/tools/integration/message_test.go | 64 +++++++++++--
7 files changed, 178 insertions(+), 76 deletions(-)
diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md
index ef4802a78..d582676e7 100644
--- a/docs/guides/configuration.md
+++ b/docs/guides/configuration.md
@@ -400,6 +400,7 @@ Even with `restrict_to_workspace: false`, the `exec` tool blocks these dangerous
|------------|------|---------|-------------|
| `tools.allow_read_paths` | string[] | `[]` | Additional paths allowed for reading outside workspace |
| `tools.allow_write_paths` | string[] | `[]` | Additional paths allowed for writing outside workspace |
+| `tools.message.media_enabled` | bool | `false` | Allows the `message` tool to attach local media files by path. This is separate from `tools.send_file.enabled`; enable it only when unified text/media/caption delivery is intended. |
### Read File Mode
diff --git a/pkg/agent/agent_init.go b/pkg/agent/agent_init.go
index 14b3f8bfe..17629892d 100644
--- a/pkg/agent/agent_init.go
+++ b/pkg/agent/agent_init.go
@@ -161,19 +161,19 @@ func registerSharedTools(
// Message tool
if cfg.Tools.IsToolEnabled("message") {
messageTool := tools.NewMessageTool()
- messageTool.ConfigureLocalMedia(
- agent.Workspace,
- cfg.Agents.Defaults.RestrictToWorkspace,
- cfg.Agents.Defaults.GetMaxMediaSize(),
- allowReadPaths,
- )
+ if cfg.Tools.Message.MediaEnabled {
+ messageTool.ConfigureLocalMedia(
+ agent.Workspace,
+ cfg.Agents.Defaults.RestrictToWorkspace,
+ cfg.Agents.Defaults.GetMaxMediaSize(),
+ allowReadPaths,
+ )
+ }
messageTool.SetSendCallback(func(
ctx context.Context,
channel, chatID, content, replyToMessageID string,
mediaParts []bus.MediaPart,
) error {
- pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer pubCancel()
outboundCtx := bus.NewOutboundContext(channel, chatID, replyToMessageID)
outboundAgentID, outboundSessionKey, outboundScope := outboundTurnMetadata(
tools.ToolAgentID(ctx),
@@ -181,22 +181,38 @@ func registerSharedTools(
tools.ToolSessionScope(ctx),
)
if len(mediaParts) > 0 {
- return msgBus.PublishOutboundMedia(pubCtx, bus.OutboundMediaMessage{
+ outboundMedia := bus.OutboundMediaMessage{
+ Channel: channel,
+ ChatID: chatID,
Context: outboundCtx,
AgentID: outboundAgentID,
SessionKey: outboundSessionKey,
Scope: outboundScope,
Parts: mediaParts,
- })
+ }
+ if al.channelManager != nil && channel != "" {
+ return al.channelManager.SendMedia(ctx, outboundMedia)
+ }
+ pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer pubCancel()
+ return msgBus.PublishOutboundMedia(pubCtx, outboundMedia)
}
- return msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
+ outboundMessage := bus.OutboundMessage{
+ Channel: channel,
+ ChatID: chatID,
Context: outboundCtx,
AgentID: outboundAgentID,
SessionKey: outboundSessionKey,
Scope: outboundScope,
Content: content,
ReplyToMessageID: replyToMessageID,
- })
+ }
+ if al.channelManager != nil && channel != "" {
+ return al.channelManager.SendMessage(ctx, outboundMessage)
+ }
+ pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer pubCancel()
+ return msgBus.PublishOutbound(pubCtx, outboundMessage)
})
agent.Tools.Register(messageTool)
}
diff --git a/pkg/config/config.go b/pkg/config/config.go
index d9608d11e..b36014b9f 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -814,6 +814,12 @@ type ToolConfig struct {
Enabled bool `json:"enabled" yaml:"-" env:"ENABLED"`
}
+type MessageToolsConfig struct {
+ ToolConfig `yaml:"-" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"`
+
+ MediaEnabled bool `json:"media_enabled" yaml:"-" env:"PICOCLAW_TOOLS_MESSAGE_MEDIA_ENABLED"`
+}
+
type BraveConfig struct {
Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
APIKeys SecureStrings `json:"api_keys,omitzero" yaml:"api_keys,omitempty" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEYS"`
@@ -1026,7 +1032,7 @@ type ToolsConfig struct {
InstallSkill ToolConfig `json:"install_skill" yaml:"-" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"`
ListDir ToolConfig `json:"list_dir" yaml:"-" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"`
LoadImage ToolConfig `json:"load_image" yaml:"-" envPrefix:"PICOCLAW_TOOLS_LOAD_IMAGE_"`
- Message ToolConfig `json:"message" yaml:"-" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"`
+ Message MessageToolsConfig `json:"message" yaml:"-"`
ReadFile ReadFileToolConfig `json:"read_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"`
Serial ToolConfig `json:"serial" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SERIAL_"`
SendFile ToolConfig `json:"send_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"`
diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
index e34f23895..213090a15 100644
--- a/pkg/config/config_test.go
+++ b/pkg/config/config_test.go
@@ -1480,6 +1480,16 @@ func TestLoadConfig_LoadImageCanBeDisabled(t *testing.T) {
}
}
+func TestDefaultConfig_MessageMediaDisabled(t *testing.T) {
+ cfg := DefaultConfig()
+ if !cfg.Tools.Message.Enabled {
+ t.Fatal("DefaultConfig().Tools.Message.Enabled should be true")
+ }
+ if cfg.Tools.Message.MediaEnabled {
+ t.Fatal("DefaultConfig().Tools.Message.MediaEnabled should be false")
+ }
+}
+
func TestToolsConfig_GetFilterMinLength(t *testing.T) {
tests := []struct {
name string
diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go
index c411aadf3..d7bd16875 100644
--- a/pkg/config/defaults.go
+++ b/pkg/config/defaults.go
@@ -447,8 +447,11 @@ func DefaultConfig() *Config {
LoadImage: ToolConfig{
Enabled: true,
},
- Message: ToolConfig{
- Enabled: true,
+ Message: MessageToolsConfig{
+ ToolConfig: ToolConfig{
+ Enabled: true,
+ },
+ MediaEnabled: false,
},
ReadFile: ReadFileToolConfig{
Enabled: true,
diff --git a/pkg/tools/integration/message.go b/pkg/tools/integration/message.go
index f7b7b7fdc..fbd8305c6 100644
--- a/pkg/tools/integration/message.go
+++ b/pkg/tools/integration/message.go
@@ -37,14 +37,15 @@ type sentTarget struct {
}
type MessageTool struct {
- sendCallback SendCallbackWithContext
- workspace string
- restrict bool
- maxFileSize int
- mediaStore media.MediaStore
- allowPaths []*regexp.Regexp
- mu sync.Mutex
- sentTargets map[string][]sentTarget
+ sendCallback SendCallbackWithContext
+ workspace string
+ restrict bool
+ maxFileSize int
+ mediaStore media.MediaStore
+ allowPaths []*regexp.Regexp
+ localMediaEnabled bool
+ mu sync.Mutex
+ sentTargets map[string][]sentTarget
}
func NewMessageTool() *MessageTool {
@@ -58,57 +59,66 @@ func (t *MessageTool) Name() string {
}
func (t *MessageTool) Description() string {
+ if !t.localMediaEnabled {
+ return "Send a text message to the user on a chat channel."
+ }
return "Send a message to the user on a chat channel. Supports text-only, media-only, or text with media attachments."
}
func (t *MessageTool) Parameters() map[string]any {
- return map[string]any{
- "type": "object",
- "properties": map[string]any{
- "content": map[string]any{
- "type": "string",
- "description": "Optional message text. When media is present, this text is used as the caption/body for the media message.",
- },
- "media": map[string]any{
- "type": "array",
- "description": "Optional local media attachments to send with the message.",
- "items": map[string]any{
- "type": "object",
- "properties": map[string]any{
- "path": map[string]any{
- "type": "string",
- "description": "Path to the local file. Relative paths are resolved from workspace.",
- },
- "type": map[string]any{
- "type": "string",
- "description": "Optional media type hint: image, audio, video, or file.",
- },
- "filename": map[string]any{
- "type": "string",
- "description": "Optional display filename. Defaults to the basename of path.",
- },
- },
- "required": []string{"path"},
- },
- },
- "channel": map[string]any{
- "type": "string",
- "description": "Optional: target channel (telegram, whatsapp, etc.)",
- },
- "chat_id": map[string]any{
- "type": "string",
- "description": "Optional: target chat/user ID",
- },
- "reply_to_message_id": map[string]any{
- "type": "string",
- "description": "Optional: reply target message ID for channels that support threaded replies",
- },
+ properties := map[string]any{
+ "content": map[string]any{
+ "type": "string",
+ "description": "Optional message text. When media is present, this text is used as the caption/body for the media message.",
},
- "anyOf": []map[string]any{
- {"required": []string{"content"}},
- {"required": []string{"media"}},
+ "channel": map[string]any{
+ "type": "string",
+ "description": "Optional: target channel (telegram, whatsapp, etc.)",
+ },
+ "chat_id": map[string]any{
+ "type": "string",
+ "description": "Optional: target chat/user ID",
+ },
+ "reply_to_message_id": map[string]any{
+ "type": "string",
+ "description": "Optional: reply target message ID for channels that support threaded replies",
},
}
+ params := map[string]any{
+ "type": "object",
+ "properties": properties,
+ "required": []string{"content"},
+ }
+ if t.localMediaEnabled {
+ properties["media"] = map[string]any{
+ "type": "array",
+ "description": "Optional local media attachments to send with the message. Requires tools.message.media_enabled.",
+ "items": map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "path": map[string]any{
+ "type": "string",
+ "description": "Path to the local file. Relative paths are resolved from workspace.",
+ },
+ "type": map[string]any{
+ "type": "string",
+ "description": "Optional media type hint: image, audio, video, or file.",
+ },
+ "filename": map[string]any{
+ "type": "string",
+ "description": "Optional display filename. Defaults to the basename of path.",
+ },
+ },
+ "required": []string{"path"},
+ },
+ }
+ delete(params, "required")
+ params["anyOf"] = []map[string]any{
+ {"required": []string{"content"}},
+ {"required": []string{"media"}},
+ }
+ }
+ return params
}
func (t *MessageTool) ConfigureLocalMedia(
@@ -124,6 +134,7 @@ func (t *MessageTool) ConfigureLocalMedia(
}
t.maxFileSize = maxFileSize
t.allowPaths = allowPaths
+ t.localMediaEnabled = true
}
func (t *MessageTool) SetMediaStore(store media.MediaStore) {
@@ -173,6 +184,12 @@ func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolRes
if err != nil {
return &ToolResult{ForLLM: err.Error(), IsError: true}
}
+ if len(mediaArgs) > 0 && !t.localMediaEnabled {
+ return &ToolResult{
+ ForLLM: "message media attachments are disabled; enable tools.message.media_enabled to send local media through message",
+ IsError: true,
+ }
+ }
if content == "" && len(mediaArgs) == 0 {
return &ToolResult{ForLLM: "content or media is required", IsError: true}
}
@@ -262,6 +279,9 @@ func (t *MessageTool) buildMediaParts(
if len(mediaArgs) == 0 {
return nil, nil
}
+ if !t.localMediaEnabled {
+ return nil, fmt.Errorf("message media attachments are disabled")
+ }
if t.mediaStore == nil {
return nil, fmt.Errorf("media store not configured")
}
diff --git a/pkg/tools/integration/message_test.go b/pkg/tools/integration/message_test.go
index 2d3329d3d..eea345c1c 100644
--- a/pkg/tools/integration/message_test.go
+++ b/pkg/tools/integration/message_test.go
@@ -250,9 +250,9 @@ func TestMessageTool_Parameters(t *testing.T) {
}
// Check required properties
- anyOf, ok := params["anyOf"].([]map[string]any)
- if !ok || len(anyOf) != 2 {
- t.Fatal("Expected anyOf content/media requirement")
+ required, ok := params["required"].([]string)
+ if !ok || len(required) != 1 || required[0] != "content" {
+ t.Fatal("Expected content-only required schema when local media is disabled")
}
// Check content property
@@ -264,12 +264,8 @@ func TestMessageTool_Parameters(t *testing.T) {
t.Error("Expected content type to be 'string'")
}
- mediaProp, ok := props["media"].(map[string]any)
- if !ok {
- t.Fatal("Expected 'media' property")
- }
- if mediaProp["type"] != "array" {
- t.Error("Expected media type to be 'array'")
+ if _, hasMedia := props["media"]; hasMedia {
+ t.Fatal("did not expect 'media' property when local media is disabled")
}
// Check channel property (optional)
@@ -300,6 +296,56 @@ func TestMessageTool_Parameters(t *testing.T) {
}
}
+func TestMessageTool_Parameters_WithLocalMediaEnabled(t *testing.T) {
+ tool := NewMessageTool()
+ tool.ConfigureLocalMedia(t.TempDir(), true, 1024*1024, nil)
+ params := tool.Parameters()
+
+ props, ok := params["properties"].(map[string]any)
+ if !ok {
+ t.Fatal("Expected properties to be a map")
+ }
+ mediaProp, ok := props["media"].(map[string]any)
+ if !ok {
+ t.Fatal("Expected 'media' property")
+ }
+ if mediaProp["type"] != "array" {
+ t.Error("Expected media type to be 'array'")
+ }
+ anyOf, ok := params["anyOf"].([]map[string]any)
+ if !ok || len(anyOf) != 2 {
+ t.Fatal("Expected anyOf content/media requirement")
+ }
+ if _, ok := params["required"]; ok {
+ t.Fatal("did not expect top-level required content when media is enabled")
+ }
+}
+
+func TestMessageTool_Execute_WithMediaDisabled(t *testing.T) {
+ tool := NewMessageTool()
+ tool.SetSendCallback(func(
+ ctx context.Context,
+ channel, chatID, content, replyToMessageID string,
+ mediaParts []bus.MediaPart,
+ ) error {
+ t.Fatal("send callback should not run when message media is disabled")
+ return nil
+ })
+
+ ctx := WithToolContext(context.Background(), "telegram", "-1001")
+ result := tool.Execute(ctx, map[string]any{
+ "media": []any{
+ map[string]any{"path": "photo.jpg"},
+ },
+ })
+ if !result.IsError {
+ t.Fatal("expected error when message media is disabled")
+ }
+ if result.ForLLM != "message media attachments are disabled; enable tools.message.media_enabled to send local media through message" {
+ t.Fatalf("unexpected error: %q", result.ForLLM)
+ }
+}
+
func TestMessageTool_Execute_WithReplyToMessageID(t *testing.T) {
tool := NewMessageTool()
From d609e83313f6b6fcae5e5fdbc1f4e4e0bb2dd98e Mon Sep 17 00:00:00 2001
From: Martin Zapletal <105368210+KrtCZ@users.noreply.github.com>
Date: Sat, 23 May 2026 13:16:25 +0200
Subject: [PATCH 06/21] Add Czech (cs) locale (792 strings)
---
web/frontend/src/i18n/locales/cs.json | 958 ++++++++++++++++++++++++++
1 file changed, 958 insertions(+)
create mode 100644 web/frontend/src/i18n/locales/cs.json
diff --git a/web/frontend/src/i18n/locales/cs.json b/web/frontend/src/i18n/locales/cs.json
new file mode 100644
index 000000000..8f3fa9a25
--- /dev/null
+++ b/web/frontend/src/i18n/locales/cs.json
@@ -0,0 +1,958 @@
+{
+ "navigation": {
+ "chat": "Chat",
+ "model_group": "Modely",
+ "models": "Modely",
+ "credentials": "PÅihlaÅĄovacà Ãēdaje",
+ "agent_group": "Agent",
+ "hub": "Hub",
+ "skills": "Dovednosti",
+ "tools": "NÃĄstroje",
+ "services": "SluÅžby",
+ "channels_group": "KanÃĄly",
+ "show_more_channels": "VÃce",
+ "show_less_channels": "MÊnÄ",
+ "config": "NastavenÃ",
+ "logs": "Logy"
+ },
+ "launcherLogin": {
+ "title": "PÅihlÃĄÅĄenÃ",
+ "description": "Zadejte heslo k dashboardu pro pokraÄenÃ.",
+ "passwordLabel": "Heslo",
+ "passwordPlaceholder": "Zadejte heslo",
+ "submit": "PÅihlÃĄsit se",
+ "errorInvalid": "NesprÃĄvnÊ heslo. Zkuste to znovu.",
+ "errorNetwork": "Chyba sÃtÄ. Zkuste to znovu."
+ },
+ "launcherSetup": {
+ "title": "Nastavenà hesla k dashboardu",
+ "description": "Zvolte heslo pro ochranu pÅÃstupu k dashboardu. Budete ho zadÃĄvat pÅi kaÅždÊm pÅihlÃĄÅĄenÃ.",
+ "passwordLabel": "Heslo",
+ "passwordPlaceholder": "AlespoŠ8 znaků",
+ "confirmLabel": "Potvrzenà hesla",
+ "confirmPlaceholder": "Zopakujte heslo",
+ "submit": "Nastavit heslo",
+ "errorMismatch": "Hesla se neshodujÃ.",
+ "errorNetwork": "Chyba sÃtÄ. Zkuste to znovu."
+ },
+ "chat": {
+ "welcome": "Jak vÃĄm mohu dnes pomoci?",
+ "welcomeDesc": "Zeptejte se mÄ na poÄasÃ, nastavenà nebo jinÊ Ãēkoly. Jsem tu, abych vÃĄm pomohl.",
+ "placeholder": "NapiÅĄte zprÃĄvu...",
+ "disabledPlaceholder": {
+ "gatewayUnknown": "Chat nedostupnÃŊ: stav gateway se stÃĄle zjiÅĄÅĨuje. PoÄkejte chvÃli, pak strÃĄnku obnovte nebo restartujte Launcher.",
+ "gatewayStarting": "Chat nedostupnÃŊ: gateway se spouÅĄtÃ. PoÄkejte na dokonÄenà spuÅĄtÄnà a zkuste to znovu.",
+ "gatewayRestarting": "Chat nedostupnÃŊ: gateway se restartuje. PoÄkejte na dokonÄenà restartu.",
+ "gatewayStopping": "Chat nedostupnÃŊ: gateway se zastavuje. PoÄkejte na zastavenà a pak gateway spusÅĨte znovu.",
+ "gatewayStopped": "Chat nedostupnÃŊ: gateway nenà spuÅĄtÄna. KliknÄte na Spustit gateway v hornà liÅĄtÄ a zkuste to znovu.",
+ "gatewayError": "Chat nedostupnÃŊ: gateway je ve stavu chyby. Zkontrolujte logy a restartujte gateway nebo Launcher.",
+ "websocketConnecting": "PÅipojovÃĄnà ke chat sluÅžbÄ... Äekejte prosÃm.",
+ "websocketDisconnected": "Chat nedostupnÃŊ: WebSocket spojenà bylo pÅeruÅĄeno. Zkontrolujte sÃÅĨ a stav gateway, obnovte strÃĄnku nebo restartujte Launcher.",
+ "websocketError": "Chat nedostupnÃŊ: WebSocket spojenà selhalo. Zkontrolujte sÃÅĨ a stav gateway a zkuste to znovu.",
+ "noDefaultModel": "Chat nedostupnÃŊ: ÅžÃĄdnÃŊ vÃŊchozà model nenà vybrÃĄn. Nastavte vÃŊchozà model na strÃĄnce Modely."
+ },
+ "newChat": "NovÃŊ chat",
+ "notConnected": "Gateway nenà spuÅĄtÄna. SpusÅĨte ji pro zahÃĄjenà chatu.",
+ "thinking": {
+ "step1": "PÅemÃŊÅĄlÃm...",
+ "step2": "Analyzuji vÃĄÅĄ poÅžadavek...",
+ "step3": "PÅipravuji odpovÄÄ...",
+ "step4": "UÅž brzy..."
+ },
+ "reasoningLabel": "UvaÅžovÃĄnÃ",
+ "toolCallsLabel": "VolÃĄnà nÃĄstrojů",
+ "toolCallExplanationLabel": "PoznÃĄmka k volÃĄnÃ",
+ "toolCallFunctionLabel": "Shrnutà volÃĄnÃ",
+ "toolCallArgumentsLabel": "Argumenty",
+ "showAssistantDetails": "Zobrazit Ãēvahy a volÃĄnà nÃĄstrojů",
+ "assistantDetailVisibility": {
+ "none": "SkrÃŊt oboje",
+ "thought": "Zobrazit jen Ãēvahy",
+ "toolCalls": "Zobrazit jen volÃĄnà nÃĄstrojů",
+ "all": "Zobrazit oboje"
+ },
+ "toolLabel": "NÃĄstroj",
+ "codeLabel": "KÃŗd",
+ "copyMessage": "KopÃrovat zprÃĄvu",
+ "copyCode": "KopÃrovat kÃŗd",
+ "copiedLabel": "ZkopÃrovÃĄno",
+ "expandCode": "Rozbalit kÃŗd",
+ "collapseCode": "Sbalit kÃŗd",
+ "history": "Historie",
+ "noHistory": "ZatÃm ÅžÃĄdnÃĄ historie chatu",
+ "historyLoadFailed": "NaÄtenà historie chatu selhalo",
+ "historyOpenFailed": "OtevÅenà historie chatu selhalo",
+ "loadingMore": "NaÄÃtÃĄm dalÅĄÃ...",
+ "deleteSession": "Smazat relaci",
+ "messagesCount": "{{count}} zprÃĄv",
+ "noModel": "Vyberte model",
+ "inputDisabled": {
+ "notConnected": "Gateway nenà spuÅĄtÄna. SpusÅĨte ji pro zahÃĄjenà chatu.",
+ "noModel": "ÅŊÃĄdnÃŊ vÃŊchozà model nenà nastaven. PÅejdÄte na strÃĄnku Modely."
+ },
+ "sendMessage": "Odeslat zprÃĄvu",
+ "sendHint": "Enter pro odeslÃĄnÃ\nShift + Enter pro novÃŊ ÅÃĄdek",
+ "contextTitle": "Kontext",
+ "contextDetail": "Zobrazit detail",
+ "attachImage": "PÅidat obrÃĄzky",
+ "removeImage": "Odebrat obrÃĄzek",
+ "uploadedImage": "NahranÃŊ obrÃĄzek",
+ "invalidImage": "\"{{name}}\" nenà podporovanÃŊ formÃĄt obrÃĄzku.",
+ "imageTooLarge": "\"{{name}}\" pÅekraÄuje limit {{size}}.",
+ "imageReadFailed": "Ätenà souboru \"{{name}}\" selhalo.",
+ "empty": {
+ "noConfiguredModel": "ÅŊÃĄdnÃŊ model nenà nakonfigurovÃĄn",
+ "noConfiguredModelDescription": "PÅed zahÃĄjenÃm chatu musÃte nakonfigurovat alespoÅ jeden AI model s API klÃÄem.",
+ "goToModels": "PÅejÃt na Modely",
+ "noSelectedModel": "ÅŊÃĄdnÃŊ model nenà vybrÃĄn",
+ "noSelectedModelDescription": "MÃĄte nakonfigurovanÊ modely, ale ÅžÃĄdnÃŊ nenà nastaven jako vÃŊchozÃ. PÅed zahÃĄjenÃm chatu vyberte model.",
+ "notRunning": "Gateway nenà spuÅĄtÄna",
+ "notRunningDescription": "Pro zahÃĄjenà chatu spusÅĨte gateway. PouÅžijte tlaÄÃtko Spustit gateway v hornà liÅĄtÄ."
+ },
+ "modelGroup": {
+ "apikey": "API Key",
+ "oauth": "OAuth",
+ "local": "LokÃĄlnÃ"
+ }
+ },
+ "header": {
+ "logout": {
+ "tooltip": "OdhlÃĄsit se",
+ "confirm": "OdhlÃĄsit se",
+ "description": "Opravdu se chcete odhlÃĄsit z dashboardu?"
+ },
+ "gateway": {
+ "stopDialog": {
+ "title": "Zastavit gateway?",
+ "description": "Opravdu chcete gateway zastavit? TÃm se ukonÄà aktivnà chat relace a zastavà inference.",
+ "confirm": "Zastavit gateway"
+ },
+ "action": {
+ "start": "Spustit gateway",
+ "stop": "Zastavit gateway",
+ "restart": "Restartovat gateway"
+ },
+ "status": {
+ "starting": "SpouÅĄtÄnà gateway...",
+ "restarting": "RestartovÃĄnà gateway...",
+ "stopping": "ZastavovÃĄnà gateway..."
+ },
+ "restartRequired": "ZmÄny konfigurace se projevà aÅž po restartu gateway."
+ }
+ },
+ "common": {
+ "cancel": "ZruÅĄit",
+ "close": "ZavÅÃt",
+ "save": "UloÅžit",
+ "saving": "UklÃĄdÃĄm...",
+ "reset": "Resetovat",
+ "confirm": "Potvrdit",
+ "fix": "Opravit",
+ "saveChangesTitle": "MÃĄte neuloÅženÊ zmÄny konfigurace",
+ "restartRequiredTitle": "VyÅžadovÃĄn restart gateway",
+ "restartRequiredDesc": "NejnovÄjÅĄÃ konfigurace {{name}} byla uloÅžena. Pro aktivaci restartujte gateway."
+ },
+ "labels": {
+ "loading": "NaÄÃtÃĄm..."
+ },
+ "footer": {
+ "version": "Verze",
+ "commit": "Commit",
+ "build": "Build",
+ "version_unknown": "NeznÃĄmÃĄ"
+ },
+ "credentials": {
+ "description": "SprÃĄva OAuth a token pÅihlaÅĄovacÃch Ãēdajů pro podporovanÊ providery.",
+ "loading": "NaÄÃtÃĄm pÅihlaÅĄovacà Ãēdaje...",
+ "providers": {
+ "openai": {
+ "description": "Podporuje browser OAuth, device code a token pÅihlÃĄÅĄenÃ."
+ },
+ "anthropic": {
+ "description": "PouÅžÃvÃĄ token pÅihlÃĄÅĄenà pro pÅÃstup ke Claude."
+ },
+ "antigravity": {
+ "description": "PouÅžÃvÃĄ browser OAuth pro Google Cloud Code Assist."
+ }
+ },
+ "status": {
+ "connected": "PÅipojeno",
+ "needsRefresh": "VyÅžaduje obnovenÃ",
+ "expired": "VyprÅĄelo",
+ "notLoggedIn": "NepÅihlÃĄÅĄeno"
+ },
+ "actions": {
+ "browser": "Browser OAuth",
+ "deviceCode": "Device Code",
+ "stopLoading": "Zastavit naÄÃtÃĄnÃ",
+ "saveToken": "UloÅžit",
+ "logout": "OdhlÃĄsit"
+ },
+ "logoutDialog": {
+ "title": "OdhlÃĄsit providera?",
+ "description": "TÃm se odstranà uloÅženÊ pÅihlaÅĄovacà Ãēdaje pro {{provider}}."
+ },
+ "fields": {
+ "openaiToken": "OpenAI token",
+ "anthropicToken": "Anthropic token"
+ },
+ "labels": {
+ "account": "ÃÄet",
+ "email": "E-mail",
+ "project": "Projekt"
+ },
+ "errors": {
+ "loadFailed": "NaÄtenà pÅihlaÅĄovacÃch Ãēdajů selhalo",
+ "flowFailed": "OvÄÅenà auth flow selhalo",
+ "loginFailed": "PÅihlÃĄÅĄenà selhalo",
+ "logoutFailed": "OdhlÃĄÅĄenà selhalo",
+ "invalidBrowserResponse": "NeplatnÃĄ odpovÄÄ browser pÅihlÃĄÅĄenÃ",
+ "invalidDeviceResponse": "NeplatnÃĄ odpovÄÄ device code",
+ "popupBlocked": "Nelze otevÅÃt novou zÃĄloÅžku. Povolte prosÃm pop-upy a zkuste to znovu."
+ },
+ "flow": {
+ "current": "AktuÃĄlnà stav autentizace",
+ "pending": "ÄekÃĄm na autorizaci...",
+ "success": "Autentizace ÃēspÄÅĄnÃĄ",
+ "error": "Autentizace selhala",
+ "expired": "AutentizaÄnà relace vyprÅĄela"
+ },
+ "device": {
+ "title": "OpenAI Device Login",
+ "description": "OtevÅete ovÄÅovacà strÃĄnku a zadejte nÃÅže uvedenÃŊ kÃŗd. Tato strÃĄnka se obnovà automaticky.",
+ "code": "UÅživatelskÃŊ kÃŗd",
+ "url": "OvÄÅovacà URL",
+ "polling": "ZjiÅĄÅĨuji stav pÅihlÃĄÅĄenÃ...",
+ "open": "OtevÅÃt ovÄÅovacà strÃĄnku"
+ }
+ },
+ "models": {
+ "description": "Nastavenà API klÃÄů pro AI providery. V chatu jsou dostupnÊ pouze nakonfigurovanÊ modely.",
+ "defaultChangeSuccess": "VÃŊchozà model aktualizovÃĄn.",
+ "unsavedPrompt": "Tato zmÄna jeÅĄtÄ nebyla uloÅžena. UloÅžte ji do konfigurace modelu.",
+ "restartHint": "ZmÄny konfigurace modelu se projevà po restartu gateway.",
+ "loadError": "NaÄtenà modelů selhalo",
+ "retry": "Zkusit znovu",
+ "providerCatalogUnavailable": "Katalog poskytovatelů backendu nenà dostupnÃŊ. VÃŊbÄr novÃŊch poskytovatelů je zakÃĄzÃĄn, dokud se API modelů ÃēspÄÅĄnÄ nenaÄte.",
+ "noDefaultHintPrefix": "ZatÃm nenà nastaven ÅžÃĄdnÃŊ vÃŊchozà model. KliknÄte na",
+ "noDefaultHintSuffix": "pro nastavenÃ.",
+ "status": {
+ "available": "DostupnÃŊ",
+ "unconfigured": "NenakonfigurovÃĄn",
+ "unreachable": "SluÅžba nedostupnÃĄ"
+ },
+ "badge": {
+ "default": "VÃŊchozÃ",
+ "virtual": "VirtuÃĄlnÃ"
+ },
+ "action": {
+ "edit": "Upravit API klÃÄ",
+ "setDefault": "Nastavit jako vÃŊchozÃ",
+ "delete": "Smazat model",
+ "setDefaultDisabled": {
+ "setting": "Nastavuji jako vÃŊchozÃ...",
+ "unavailable": "NedostupnÃŊ model nelze nastavit jako vÃŊchozÃ",
+ "isDefault": "JiÅž je vÃŊchozÃm modelem",
+ "isVirtual": "VirtuÃĄlnà model nelze nastavit jako vÃŊchozÃ",
+ "unsupportedProvider": "Tento poskytovatel podporuje pouze ASR a nemůŞe bÃŊt vÃŊchozÃm modelem chatu."
+ },
+ "deleteDisabled": {
+ "isDefault": "VÃŊchozà model nelze smazat"
+ }
+ },
+ "defaultOnSave": {
+ "label": "VÃŊchozà model",
+ "description": "Po uloÅženà automaticky nastavit tento model jako vÃŊchozÃ.",
+ "unsupportedProvider": "Tento poskytovatel můŞe bÃŊt uloÅžen v seznamu modelů, ale nemůŞe bÃŊt pouÅžit jako vÃŊchozà model chatu."
+ },
+ "add": {
+ "button": "PÅidat model",
+ "title": "PÅidat vlastnà model",
+ "description": "PÅidejte OpenAI-kompatibilnà nebo nativnà endpoint modelu.",
+ "modelName": "Alias modelu",
+ "modelNamePlaceholder": "napÅ. my-gpt4",
+ "modelNameHint": "KrÃĄtkÃŊ nÃĄzev pro identifikaci modelu v konverzacÃch.",
+ "modelId": "IdentifikÃĄtor modelu",
+ "modelIdPlaceholder": "napÅ. gpt-4o nebo openai/gpt-4o",
+ "modelIdHint": "Pokud nenà uveden Provider, hodnoty jako openai/gpt-4o se interpretujà ve formÃĄtu provider/model. Je-li Provider uveden, toto pole se bere jako kanonickÊ ID modelu a neparsuje se z nÄj prefix providera.",
+ "errorRequired": "Toto pole je povinnÊ.",
+ "errorDuplicateModelName": "Alias modelu jiÅž existuje. PouÅžijte jinÃŊ nÃĄzev.",
+ "saveError": "PÅidÃĄnà modelu selhalo",
+ "saveSuccess": "Model pÅidÃĄn.",
+ "confirm": "PÅidat model"
+ },
+ "delete": {
+ "title": "Smazat model?",
+ "description": "\"{{name}}\" bude trvale odstranÄn ze seznamu modelů. Tuto akci nelze vrÃĄtit.",
+ "confirm": "Smazat"
+ },
+ "advanced": {
+ "toggle": "PokroÄilÊ moÅžnosti"
+ },
+ "field": {
+ "provider": "Provider",
+ "providerPlaceholder": "napÅ. openai",
+ "providerHint": "VolitelnÊ. Pokud je uvedeno, pouÅžije se jako skuteÄnÃŊ provider a IdentifikÃĄtor modelu se bere jako kanonickÊ ID.",
+ "providerInvalid": "AktuÃĄlnà poskytovatel je neplatnÃŊ. Vyberte podporovanÊho poskytovatele.",
+ "selectProviderFirst": "Nejprve vyberte poskytovatele",
+ "apiBase": "API Base URL",
+ "apiKey": "API Key",
+ "apiKeyPlaceholder": "Zadejte API klÃÄ",
+ "apiKeyPlaceholderSet": "Ponechte prÃĄzdnÊ pro zachovÃĄnà stÃĄvajÃcÃho klÃÄe",
+ "proxy": "HTTP Proxy",
+ "proxyHint": "VolitelnÊ. napÅ. http://127.0.0.1:7890",
+ "authMethod": "Metoda autentizace",
+ "authMethodHint": "Metoda autentizace: oauth, token. Ponechte prÃĄzdnÊ pro autentizaci API klÃÄem.",
+ "authMethodManagedHint": "Tento poskytovatel spravuje reÅžim ovÄÅovÃĄnà automaticky.",
+ "connectMode": "ReÅžim pÅipojenÃ",
+ "connectModeHint": "ReÅžim pÅipojenà pro CLI-based providery: stdio nebo grpc.",
+ "workspace": "Cesta k workspace",
+ "workspaceHint": "Pracovnà adresÃĄÅ pro CLI-based providery (napÅ. GitHub Copilot).",
+ "requestTimeout": "Timeout poÅžadavku (s)",
+ "requestTimeoutHint": "MaximÃĄlnà poÄet sekund ÄekÃĄnà na odpovÄÄ. 0 = pouÅžÃt vÃŊchozÃ.",
+ "rpm": "Rate Limit (RPM)",
+ "rpmHint": "MaximÃĄlnà poÄet poÅžadavků za minutu. 0 = bez omezenÃ.",
+ "thinkingLevel": "ÃroveÅ uvaÅžovÃĄnÃ",
+ "thinkingLevelHint": "RozÅĄÃÅenÃŊ thinking budget: off, low, medium, high, xhigh, adaptive.",
+ "providerDefault": "vÃŊchozà poskytovatele",
+ "maxTokensField": "Pole Max Tokens",
+ "maxTokensFieldHint": "PÅepsat nÃĄzev pole poÅžadavku pro max tokens, napÅ. max_completion_tokens.",
+ "toolSchemaTransform": "Transformace schÊmatu nÃĄstroje",
+ "toolSchemaTransformHint": "VolitelnÃĄ transformace kompatibility pro JSON schÊmata nÃĄstrojů. Ponechte prÃĄzdnÊ pro nativnà chovÃĄnÃ. PodporovanÊ hodnoty: simple.",
+ "streamingEnabled": "StreamovanÊ vÃŊstupy",
+ "streamingEnabledHint": "Povolit tomuto modelu streamovanÊ poÅžadavky. Musà bÃŊt rovnÄÅž povoleno streamovÃĄnà v nastavenà kanÃĄlu.",
+ "extraBody": "Extra Body",
+ "extraBodyHint": "DodateÄnÃĄ JSON pole pro vloÅženà do tÄla poÅžadavku, napÅ. {\"reasoning_split\": true}.",
+ "customHeaders": "Vlastnà hlaviÄky",
+ "customHeadersHint": "DodateÄnÊ HTTP hlaviÄky vklÃĄdanÊ do kaÅždÊho poÅžadavku, napÅ. {\"X-Source\": \"coding-plan\"}.",
+ "invalidJson": "NeplatnÃŊ formÃĄt JSON"
+ },
+ "edit": {
+ "title": "Nastavenà {{name}}",
+ "apiKeyHint": "KlÃÄ je jiÅž nastaven. Ponechte prÃĄzdnÊ pro zachovÃĄnà beze zmÄny.",
+ "oauthNote": "Tento provider pouÅžÃvÃĄ OAuth â API klÃÄ nenà vyÅžadovÃĄn.",
+ "saveError": "UloŞenà selhalo",
+ "saveSuccess": "Konfigurace modelu uloÅžena."
+ },
+ "fetch": {
+ "title": "NaÄÃst dostupnÊ modely",
+ "description": "NaÄÃst seznam modelů od poskytovatele.",
+ "providerLabel": "Poskytovatel:",
+ "needApiKey": "Nejprve zadejte API klÃÄ pro naÄtenà modelů.",
+ "fetching": "NaÄÃtÃĄnà modelů...",
+ "retry": "Zkusit znovu",
+ "filterPlaceholder": "Filtrovat modely...",
+ "found": "Nalezen {{count}} model",
+ "found_plural": "Nalezeno {{count}} modelů",
+ "shown": "(zobrazeno {{count}})",
+ "selectAll": "Vybrat vÅĄe",
+ "deselectAll": "ZruÅĄit vÃŊbÄr vÅĄech",
+ "fill": "Doplnit {{count}} vybranÃŊ model",
+ "fill_plural": "Doplnit {{count}} vybranÊ modely",
+ "failed": "NaÄtenà modelů selhalo"
+ },
+ "catalog": {
+ "button": "UloŞenÊ katalogy",
+ "title": "UloŞenÊ katalogy modelů",
+ "description": "DÅÃve naÄtenÊ seznamy modelů, uloÅženÊ podle API klÃÄe. Vyberte modely a pÅidejte je do konfigurace.",
+ "loading": "NaÄÃtÃĄnà katalogů...",
+ "empty": "ZatÃm ÅžÃĄdnÊ uloÅženÊ katalogy. NaÄtÄte modely od poskytovatele a uloÅžte katalog.",
+ "filterPlaceholder": "Filtrovat modely...",
+ "models": "modely",
+ "fetchedAt": "NaÄteno",
+ "delete": "Smazat katalog",
+ "refresh": "Aktualizovat ze zdroje",
+ "found": "Nalezen {{count}} model",
+ "found_plural": "Nalezeno {{count}} modelů",
+ "selectAll": "Vybrat vÅĄe",
+ "deselectAll": "ZruÅĄit vÃŊbÄr vÅĄech",
+ "addSelected": "PÅidat {{count}} vybranÊ",
+ "addSuccess": "PÅidÃĄno {{count}} modelů do konfigurace.",
+ "needApiKey": "Tyto modely vyÅžadujà API klÃÄ. Po importu bude nutnÊ nastavit pÅihlaÅĄovacà Ãēdaje."
+ },
+ "test": {
+ "title": "Test pÅipojenà modelu",
+ "description": "OvÄÅit, Åže je endpoint modelu dostupnÃŊ a sprÃĄvnÄ nakonfigurovanÃŊ.",
+ "modelLabel": "Model:",
+ "identifierLabel": "IdentifikÃĄtor:",
+ "endpointLabel": "Endpoint:",
+ "testConnection": "Testovat pÅipojenÃ",
+ "testing": "Testuji pÅipojenÃ...",
+ "success": "PÅipojenà ÃēspÄÅĄnÊ",
+ "responseTime": "Doba odezvy: {{ms}} ms",
+ "failed": "PÅipojenà selhalo",
+ "status": "Stav: {{status}}",
+ "testFailed": "Test selhal",
+ "testAgain": "Testovat znovu"
+ },
+ "validation": {
+ "whitespace": "IdentifikÃĄtor modelu nesmà obsahovat mezery",
+ "leadingSlash": "Nesmà zaÄÃnat lomÃtkem /",
+ "consecutiveSlash": "Nesmà obsahovat po sobÄ jdoucà lomÃtka /",
+ "useProvider": "Bude pouÅžit '{{provider}}' jako poskytovatel",
+ "defaultToOpenAI": "ÅŊÃĄdnÃŊ poskytovatel nezadÃĄn, vÃŊchozà je OpenAI",
+ "emptyModel": "NÃĄzev modelu nesmà bÃŊt prÃĄzdnÃŊ",
+ "shouldUse": "'{{provider}}' by mÄl pouÅžÃvat '{{alias}}'",
+ "didYouMean": "Mysleli jste '{{closest}}'?",
+ "unknownProvider": "NeznÃĄmÃŊ poskytovatel '{{provider}}'",
+ "parsed": "poskytovatel={{provider}}, model={{model}}"
+ },
+ "combobox": {
+ "selectProvider": "Vyberte poskytovatele...",
+ "searchProvider": "Hledat poskytovatele...",
+ "noProvider": "ÅŊÃĄdnÃŊ poskytovatel nenalezen.",
+ "noCatalog": "Katalog poskytovatele nenà dostupnÃŊ.",
+ "local": "lokÃĄlnÃ"
+ }
+ },
+ "channels": {
+ "loadError": "NaÄtenà kanÃĄlů selhalo",
+ "name": {
+ "telegram": "Telegram",
+ "discord": "Discord",
+ "slack": "Slack",
+ "feishu": "Feishu",
+ "dingtalk": "DingTalk",
+ "line": "LINE",
+ "qq": "QQ",
+ "onebot": "OneBot",
+ "wecom": "WeCom",
+ "whatsapp": "WhatsApp",
+ "whatsapp_native": "WhatsApp Native",
+ "pico": "Web",
+ "maixcam": "MaixCam",
+ "matrix": "Matrix",
+ "irc": "IRC",
+ "weixin": "WeChat",
+ "mqtt": "MQTT"
+ },
+ "weixin": {
+ "bindTitle": "Propojenà ÃēÄtu WeChat",
+ "bindDesc": "Naskenujte QR kÃŗd pomocà WeChat pro propojenà osobnÃho ÃēÄtu.",
+ "bind": "Propojit WeChat",
+ "rebind": "Znovu propojit",
+ "bound": "WeChat propojen",
+ "notBound": "ÃÄet WeChat zatÃm nenà propojen.",
+ "generating": "Generuji QR kÃŗd...",
+ "scanHint": "OtevÅete WeChat a naskenujte QR kÃŗd",
+ "scanned": "NaskenovÃĄno â potvrÄte ve WeChat",
+ "expired": "QR kÃŗd vyprÅĄel",
+ "retry": "Zkusit znovu",
+ "refresh": "Obnovit QR",
+ "errorGeneric": "Nastala chyba. Zkuste to znovu."
+ },
+ "wecom": {
+ "bindTitle": "Propojenà WeCom",
+ "bindDesc": "Naskenujte QR kÃŗd pomocà WeCom pro propojenà AI Bota.",
+ "bind": "Propojit WeCom",
+ "rebind": "Znovu propojit",
+ "bound": "WeCom propojen",
+ "notBound": "WeCom AI Bot zatÃm nenà propojen.",
+ "generating": "Generuji QR kÃŗd...",
+ "scanHint": "OtevÅete WeCom a naskenujte QR kÃŗd",
+ "scanned": "NaskenovÃĄno, potvrÄte ve WeCom",
+ "expired": "QR kÃŗd vyprÅĄel",
+ "retry": "Zkusit znovu",
+ "refresh": "Obnovit QR",
+ "errorGeneric": "Nastala chyba. Zkuste to znovu."
+ },
+ "field": {
+ "token": "Bot Token",
+ "tokenPlaceholder": "Zadejte bot token",
+ "botToken": "Bot Token",
+ "appToken": "App Token",
+ "appId": "App ID",
+ "appSecret": "App Secret",
+ "verificationToken": "Verification Token",
+ "encryptKey": "Encrypt Key",
+ "baseUrl": "API Base URL",
+ "proxy": "HTTP Proxy",
+ "mentionOnly": "Pouze pÅi zmÃnce",
+ "typingEnabled": "IndikÃĄtor psanÃ",
+ "placeholderEnabled": "Placeholder zprÃĄva",
+ "placeholderText": "Text placeholderu",
+ "streamingEnabled": "StreamovanÊ vÃŊstupy",
+ "streamingThrottleSeconds": "Interval aktualizacà (s)",
+ "streamingMinGrowthChars": "MinimÃĄlnà pÅÃrůstek znaků",
+ "groupTriggerMentionOnly": "Pouze zmÃnka ve skupinÄ",
+ "groupTriggerPrefixes": "Prefixy pro spuÅĄtÄnà ve skupinÄ",
+ "groupTriggerPrefixesPlaceholder": "napÅ. /, !, ?",
+ "randomReactionEmoji": "NÃĄhodnÃĄ emoji reakce",
+ "randomReactionEmojiPlaceholder": "napÅ. THUMBSUP, HEART, SMILE",
+ "isLark": "Lark (mezinÃĄrodnÃ)",
+ "allowFrom": "Povolit od",
+ "allowFromPlaceholder": "napÅ. 123456, 789012",
+ "allowOrigins": "PovolenÊ originy",
+ "allowOriginsPlaceholder": "napÅ. https://example.com, http://localhost:5173",
+ "removeListItem": "Odebrat {{value}}",
+ "secretPlaceholder": "Zadejte secret",
+ "secretHintSet": "Hodnota je jiÅž nastavena. Ponechte prÃĄzdnÊ pro zachovÃĄnà beze zmÄny."
+ },
+ "page": {
+ "notFound": "KanÃĄl \"{{name}}\" nenà podporovÃĄn.",
+ "saveSuccess": "Nastavenà kanÃĄlu uloÅženo.",
+ "saveError": "UloÅženà nastavenà kanÃĄlu selhalo",
+ "savePrompt": "Tato zmÄna jeÅĄtÄ nebyla uloÅžena. UloÅžte ji do konfigurace kanÃĄlu.",
+ "docLink": "Dokumentace",
+ "enableLabel": "Aktivovat kanÃĄl",
+ "restartRequiredTitle": "VyÅžadovÃĄn restart gateway",
+ "restartRequiredDesc": "Poslednà nastavenà {{name}} bylo uloŞeno. Pro aktivaci restartujte gateway."
+ },
+ "form": {
+ "desc": {
+ "token": "PÅÃstupovÃŊ token bota pro pÅipojenà k API platformy.",
+ "botToken": "Token bota pro odesÃlÃĄnà a pÅÃjem zprÃĄv.",
+ "appToken": "Token aplikace pro Socket Mode pÅipojenÃ.",
+ "appId": "UnikÃĄtnà ID aplikace pro autentizaci.",
+ "appSecret": "Secret aplikace pro podepisovÃĄnà a autentizaci.",
+ "verificationToken": "Verification token pro event callbacky.",
+ "encryptKey": "Å ifrovacà klÃÄ pro deÅĄifrovÃĄnà callback payloadů.",
+ "baseUrl": "ZÃĄkladnà URL API platformy. VÃŊchozà je oficÃĄlnà endpoint.",
+ "proxy": "Adresa HTTP proxy pro odchozà sÃÅĨovÃŊ provoz.",
+ "mentionOnly": "Reagovat pouze pÅi explicitnà zmÃnce bota ve skupinovÃŊch chatech.",
+ "typingEnabled": "Zobrazovat stav psanà bÄhem generovÃĄnà odpovÄdi.",
+ "placeholderEnabled": "Aktivovat doÄasnÊ placeholder zprÃĄvy pÅed odeslÃĄnÃm finÃĄlnà odpovÄdi.",
+ "streamingEnabled": "Povolit tomuto kanÃĄlu zobrazovat streamovanÊ vÃŊstupy poskytovatele. Musà bÃŊt rovnÄÅž povoleno streamovÃĄnà v nastavenà modelu.",
+ "streamingThrottleSeconds": "MinimÃĄlnà interval mezi průbÄÅžnÃŊmi aktualizacemi streamu. 0 znamenÃĄ pouÅžÃt vÃŊchozà hodnotu. FinÃĄlnà odpovÄdi nejsou omezovÃĄny.",
+ "streamingMinGrowthChars": "MinimÃĄlnà pÅÃrůstek textu pÅed odeslÃĄnÃm dalÅĄÃ průbÄÅžnÊ aktualizace streamu. 0 znamenÃĄ pouÅžÃt vÃŊchozà hodnotu. FinÃĄlnà odpovÄdi nejsou omezovÃĄny.",
+ "groupTriggerMentionOnly": "Ve skupinovÃŊch chatech reagovat pouze pÅi zmÃnce bota.",
+ "groupTriggerPrefixes": "Vlastnà prefixy pro spuÅĄtÄnà ve skupinovÊm chatu. PÅidÃĄvejte poloÅžky po jednÊ nebo vloÅžte vÃce hodnot najednou.",
+ "randomReactionEmoji": "PicoClaw pÅidÃĄvÃĄ emoji reakce na zprÃĄvy uÅživatelů jako potvrzenà pÅijetÃ. PÅÃklady: \"THUMBSUP\", \"HEART\", \"SMILE\". Ponechte prÃĄzdnÊ pro vÃŊchozà emoji \"Pin\".",
+ "isLark": "PouÅžÃt mezinÃĄrodnà domÊnu Lark (open.larksuite.com) mÃsto domÊny Feishu (open.feishu.cn).",
+ "allowFrom": "PovolenÃĄ ID uÅživatelů nebo skupin. PÅidÃĄvejte po jednom nebo vloÅžte vÃce hodnot najednou.",
+ "allowOrigins": "PovolenÊ origin domÊny. PÅidÃĄvejte po jednom nebo vloÅžte vÃce hodnot najednou.",
+ "wsUrl": "URL WebSocket sluÅžby.",
+ "reconnectInterval": "Interval pro opÄtovnÊ pÅipojenà po vÃŊpadku (sekundy).",
+ "bridgeUrl": "URL bridge sluÅžby.",
+ "sessionStorePath": "LokÃĄlnà cesta pro uloÅženà relacÃ.",
+ "useNative": "Zda pouÅžÃt nativnà klientskÃŊ reÅžim.",
+ "host": "Adresa hostitele sluÅžby.",
+ "port": "Port sluÅžby.",
+ "homeserver": "URL Matrix homeserveru.",
+ "userId": "UÅživatelskÊ ID ÃēÄtu.",
+ "deviceId": "ID zaÅÃzenÃ.",
+ "joinOnInvite": "Automaticky vstoupit do mÃstnostà pÅi pozvÃĄnÃ.",
+ "clientId": "Client ID pro autentizaci platformy.",
+ "corpId": "Corp ID organizace.",
+ "agentId": "Agent ID podnikovÊ aplikace.",
+ "webhookUrl": "CelÃĄ URL webhooku.",
+ "webhookHost": "Hostitel pro naslouchÃĄnà webhooku.",
+ "webhookPort": "Port pro naslouchÃĄnà webhooku.",
+ "webhookPath": "Cesta route webhooku.",
+ "replyTimeout": "Timeout odpovÄdi v sekundÃĄch.",
+ "maxSteps": "MaximÃĄlnà poÄet kroků zpracovÃĄnÃ.",
+ "welcomeMessage": "Obsah uvÃtacà zprÃĄvy pro novÊ relace.",
+ "allowTokenQuery": "Povolit token v URL query parametrech.",
+ "pingInterval": "Interval heartbeatu pÅipojenà v sekundÃĄch.",
+ "readTimeout": "Timeout Ätenà v sekundÃĄch.",
+ "writeTimeout": "Timeout zÃĄpisu v sekundÃĄch.",
+ "maxConnections": "MaximÃĄlnà poÄet soubÄÅžnÃŊch pÅipojenÃ.",
+ "server": "Adresa IRC serveru.",
+ "tls": "Zda aktivovat TLS.",
+ "nick": "PÅezdÃvka bota.",
+ "user": "IRC uŞivatelskÊ jmÊno.",
+ "realName": "ZobrazovanÊ celÊ jmÊno.",
+ "channels": "IRC kanÃĄly pro pÅipojenÃ.",
+ "requestCaps": "Seznam IRC capabilities poÅžadovanÃŊch pÅi pÅipojenÃ.",
+ "maxBase64FileSizeMiB": "MaximÃĄlnà velikost v MiB pro pÅevod lokÃĄlnÃch souborů do base64 pÅed nahrÃĄnÃm. 0 znamenÃĄ bez omezenÃ. Platà pouze pro lokÃĄlnà soubory, ne pro URL uploady.",
+ "genericField": "SlouŞà ke konfiguraci {{field}}.",
+ "broker": "Adresa MQTT brokeru.",
+ "mqttAgentId": "JedineÄnÃŊ identifikÃĄtor tÊto instance, pouÅžÃvÃĄ se k sestavenà cesty tÊmatu.",
+ "topicPrefix": "Prefix tÊmatu. VÃŊchozà hodnota je /picoclaw.",
+ "mqttUsername": "UÅživatelskÊ jmÊno pro ovÄÅenà u brokeru (volitelnÊ).",
+ "mqttPassword": "Heslo pro ovÄÅenà u brokeru (volitelnÊ).",
+ "mqttClientId": "ID MQTT klienta. Ponechte prÃĄzdnÊ pro automatickÊ generovÃĄnÃ.",
+ "keepAlive": "Interval keepalive v sekundÃĄch. VÃŊchozà hodnota je 60.",
+ "qos": "ÃroveÅ QoS zprÃĄv: 0 = nejvÃŊÅĄe jednou, 1 = alespoÅ jednou, 2 = pÅesnÄ jednou."
+ }
+ },
+ "validation": {
+ "requiredField": "Toto pole je povinnÊ."
+ },
+ "mqtt": {
+ "protocolTitle": "ReferenÄnà pÅÃruÄka protokolu",
+ "protocolDesc": "Klienti odesÃlajà a pÅijÃmajà zprÃĄvy pomocà nÃĄsledujÃcÃho formÃĄtu tÊmatu a obsahu.",
+ "uplink": "Uplink (Klient â Agent)",
+ "downlink": "Downlink (Agent â Klient)",
+ "topicParams": "Parametry tÊmatu",
+ "fieldText": "text",
+ "uplinkTextDesc": "PÅirozenÃŊ pokyn od uÅživatele (povinnÊ).",
+ "downlinkTextDesc": "Text odpovÄdi agenta. V reÅžimu streamovÃĄnà zÅetÄzte vÃce zprÃĄv ve sprÃĄvnÊm poÅadà pro Ãēplnou odpovÄÄ.",
+ "topicPrefixDesc": "Prefix tÊmatu, odpovÃdÃĄ vÃŊÅĄe uvedenÊ konfiguraci.",
+ "agentIdDesc": "ID agenta, odpovÃdÃĄ vÃŊÅĄe uvedenÊ konfiguraci.",
+ "clientIdDesc": "IdentifikÃĄtor definovanÃŊ klientem. DoporuÄenÃ: vygenerujte UUID pÅi prvnÃm spuÅĄtÄnà a uloÅžte jej, aby stejnÊ zaÅÃzenà vÅždy pouÅžÃvalo stejnÊ ID.",
+ "clientIdPlaceholder": "Automaticky generovÃĄno, pokud je prÃĄzdnÊ",
+ "secretSet": "JiÅž nakonfigurovÃĄno. Ponechte prÃĄzdnÊ pro zachovÃĄnà stÃĄvajÃcà hodnoty.",
+ "secretEmpty": "Nenà nakonfigurovÃĄno"
+ }
+ },
+ "pages": {
+ "agent": {
+ "load_error": "NaÄtenà informacà o podpoÅe agenta selhalo.",
+ "skills": {
+ "empty": "ÅŊÃĄdnÊ dovednosti nejsou aktuÃĄlnÄ dostupnÊ.",
+ "install_success": "{{name}} nainstalovÃĄno.",
+ "install_error": "Instalace dovednosti selhala.",
+ "search_placeholder": "Hledat podle nÃĄzvu, popisu nebo registru",
+ "source_label": "Typ",
+ "sort_label": "Åadit",
+ "import": "Importovat dovednost",
+ "import_success": "Dovednost importovÃĄna.",
+ "import_error": "Import dovednosti selhal.",
+ "import_invalid_type": "PodporovÃĄny jsou pouze soubory dovednostà ve formÃĄtu Markdown nebo ZIP.",
+ "import_invalid_size": "Soubor dovednosti musà bÃŊt menÅĄÃ neÅž 1 MB.",
+ "import_constraints": "Import souboru dovednosti ve formÃĄtu Markdown nebo ZIP, max. 1 MB",
+ "view": "Zobrazit",
+ "delete": "Smazat",
+ "delete_title": "Smazat dovednost?",
+ "delete_description": "\"{{name}}\" bude odstranÄna z dovednostà workspace.",
+ "delete_confirm": "Smazat",
+ "delete_success": "Dovednost smazÃĄna.",
+ "delete_error": "SmazÃĄnà dovednosti selhalo.",
+ "viewer_title": "Obsah dovednosti",
+ "viewer_description": "Zde si pÅeÄtÄte aktuÃĄlnà obsah SKILL.md.",
+ "load_detail_error": "NaÄtenà obsahu dovednosti selhalo.",
+ "no_description": "Popis nenà k dispozici.",
+ "no_results": "ÅŊÃĄdnÊ dovednosti neodpovÃdajà aktuÃĄlnÃm filtrům.",
+ "dropzone_title": "Importovat do workspace",
+ "dropzone_description": "PÅetÃĄhnÄte soubor dovednosti nebo vyberte ze disku.",
+ "dropzone_label": "PÅetÃĄhnÄte soubor dovednosti sem",
+ "dropzone_active": "PusÅĨte pro import dovednosti",
+ "dropzone_release": "Dovednost bude normalizovÃĄna a uloÅžena do adresÃĄÅe dovednostà workspace.",
+ "marketplace_title": "Objevit dovednosti",
+ "marketplace_description": "Prohledejte registry dovednostà a nainstalujte uÅžiteÄnÊ dovednosti do tohoto workspace",
+ "marketplace_search_placeholder": "Hledat schopnosti jako github, docker, database...",
+ "marketplace_search_action": "Hledat",
+ "marketplace_search_status": "Stav hledÃĄnÃ",
+ "marketplace_install_status": "Stav instalace",
+ "marketplace_notice_title": "BezpeÄnostnà upozornÄnÃ",
+ "marketplace_notice_body": "Dovednosti z registru jsou obsah tÅetÃch stran. PÅed instalacà zkontrolujte autora, URL strÃĄnky, instrukce a veÅĄkerÃŊ poÅžadovanÃŊ kÃŗd nebo pÅihlaÅĄovacà Ãēdaje.",
+ "marketplace_status_disabled": "ZakÃĄzÃĄno. Nejprve aktivujte pÅÃsluÅĄnÃŊ nÃĄstroj na strÃĄnce NÃĄstroje.",
+ "marketplace_status_enable_hint": "Nejprve aktivujte pÅÃsluÅĄnÃŊ nÃĄstroj na strÃĄnce NÃĄstroje.",
+ "marketplace_search_error": "ProhledÃĄnà registrů selhalo.",
+ "marketplace_loading_results": "HledÃĄm dovednosti...",
+ "marketplace_loading_more": "NaÄÃtÃĄm dalÅĄÃ dovednosti...",
+ "marketplace_results_title": "{{count}} vÃŊsledků pro \"{{query}}\"",
+ "marketplace_results_hint": "VÃŊsledky z registru se instalujà do aktuÃĄlnÃho workspace.",
+ "marketplace_install_action": "Instalovat",
+ "marketplace_installed": "NainstalovÃĄno",
+ "marketplace_view_installed": "Zobrazit lokÃĄlnÃ",
+ "marketplace_installed_hint": "JiŞ dostupnÊ v tomto workspace jako \"{{name}}\".",
+ "marketplace_empty_results": "ÅŊÃĄdnÊ instalovatelnÊ dovednosti neodpovÃdajà \"{{query}}\".",
+ "marketplace_idle": "Hledejte schopnost pro zobrazenà instalovatelnÃŊch dovednostà z nakonfigurovanÃŊch registrů.",
+ "marketplace_unavailable": "ProhledÃĄvÃĄnà registrů je momentÃĄlnÄ nedostupnÊ. Zkontrolujte nastavenà nÃĄstrojů Dovednosti.",
+ "sort": {
+ "name_asc": "NÃĄzev (A-Z)",
+ "name_desc": "NÃĄzev (Z-A)",
+ "source": "Typ"
+ },
+ "origin": {
+ "all": "VÅĄechny typy",
+ "builtin": "VestavÄnÊ",
+ "third_party": "TÅetà strany",
+ "manual": "RuÄnÃ"
+ },
+ "summary": {
+ "total": "Celkem dovednostÃ"
+ },
+ "detail_tabs": {
+ "preview": "NÃĄhled",
+ "raw": "ZdrojovÃŊ text",
+ "meta": "Metadata"
+ },
+ "metadata": {
+ "name": "NÃĄzev",
+ "description": "Popis",
+ "registry": "Registr",
+ "url": "URL",
+ "version": "NainstalovanÃĄ verze",
+ "lines": "PoÄet ÅÃĄdků",
+ "characters": "PoÄet znaků"
+ },
+ "marketplace_installDisabled": {
+ "installing": "Instaluji...",
+ "installed": "JiÅž nainstalovÃĄno",
+ "cannotInstall": "Nelze nainstalovat: pÅÃsluÅĄnÃŊ nÃĄstroj nenà aktivnÃ"
+ }
+ },
+ "tools": {
+ "search_placeholder": "Hledat nÃĄstroje...",
+ "no_results": "ÅŊÃĄdnÊ nÃĄstroje neodpovÃdajà kritÊriÃm.",
+ "filter": {
+ "all": "VÅĄechny stavy",
+ "enabled": "AktivnÃ",
+ "disabled": "NeaktivnÃ",
+ "blocked": "BlokovanÊ"
+ },
+ "empty": "ÅŊÃĄdnÊ nÃĄstroje nejsou dostupnÊ.",
+ "enable_success": "NÃĄstroj aktivovÃĄn.",
+ "disable_success": "NÃĄstroj deaktivovÃĄn.",
+ "toggle_error": "Aktualizace stavu nÃĄstroje selhala.",
+ "library_title": "Knihovna nÃĄstrojů",
+ "library_description": "ProchÃĄzejte a spravujte sadu nÃĄstrojů dostupnÃŊch pro vaÅĄe AI agenty.",
+ "web_search": {
+ "title": "WebovÊ vyhledÃĄvÃĄnÃ",
+ "description": "Poskytuje agentům schopnost webovÊho vyhledÃĄvÃĄnà pro nalezenà aktuÃĄlnÃch informacÃ. Automaticky smÄruje na optimÃĄlnÃho aktivnÃho providera.",
+ "unsaved_prompt": "Tato zmÄna jeÅĄtÄ nebyla uloÅžena. UloÅžte ji do konfigurace webovÊho vyhledÃĄvÃĄnÃ.",
+ "global_settings": "ObecnÊ",
+ "providers_config": "Integrace",
+ "load_error": "NaÄtenà nastavenà webovÊho vyhledÃĄvÃĄnà selhalo.",
+ "save": "UloÅžit zmÄny",
+ "open_settings": "OtevÅÃt nastavenÃ",
+ "save_success": "Nastavenà ÃēspÄÅĄnÄ uloÅženo.",
+ "save_error": "UloŞenà nastavenà selhalo.",
+ "provider": "PrimÃĄrnà provider",
+ "provider_description": "Vyberte vÃŊchozÃho providera pro zpracovÃĄnà poÅžadavků webovÊho vyhledÃĄvÃĄnÃ.",
+ "proxy": "HTTPS Proxy",
+ "proxy_description": "VolitelnÃĄ globÃĄlnà HTTP/S proxy pro podkladovÊ webovÊ poÅžadavky.",
+ "prefer_native": "Preferovat nativnà vyhledÃĄvÃĄnÃ",
+ "prefer_native_hint": "Pokud je aktivnÃ, model můŞe pouÅžÃt vlastnà vestavÄnou schopnost vyhledÃĄvÃĄnà mÃsto nakonfigurovanÊho seznamu providerů.",
+ "provider_hint": "Aktivujte tohoto providera a vyplÅte poÅžadovanÃĄ nastavenà pÅipojenÃ.",
+ "max_results": "Max vÃŊsledků",
+ "base_url": "Base URL",
+ "base_url_placeholder": "VolitelnÊ pÅepsÃĄnà endpointu",
+ "api_key": "API Key / Token",
+ "api_key_placeholder": "Zadejte API klÃÄ, ponechte prÃĄzdnÊ pro zachovÃĄnà stÃĄvajÃcÃho",
+ "none": "NedostupnÊ"
+ },
+ "status": {
+ "enabled": "AktivnÃ",
+ "disabled": "NeaktivnÃ",
+ "blocked": "BlokovÃĄno"
+ },
+ "categories": {
+ "automation": "Automatizace",
+ "filesystem": "SouborovÃŊ systÊm",
+ "web": "Web",
+ "communication": "Komunikace",
+ "skills": "Dovednosti",
+ "agents": "Agenti",
+ "hardware": "Hardware",
+ "discovery": "Discovery"
+ },
+ "reasons": {
+ "requires_linux": "Tento nÃĄstroj funguje pouze na Linux hostech s potÅebnÃŊmi soubory zaÅÃzenÃ.",
+ "requires_serial_platform": "Tento nÃĄstroj aktuÃĄlnÄ podporuje hostitele Linux, macOS a Windows s dostupnÃŊmi sÊriovÃŊmi porty.",
+ "requires_skills": "Nejprve aktivujte `tools.skills`, aby byl tento nÃĄstroj registru dovednostà dostupnÃŊ.",
+ "requires_subagent": "Nejprve aktivujte `tools.subagent`, aby nÃĄstroj spawn mohl delegovat prÃĄci.",
+ "requires_mcp_discovery": "Nejprve aktivujte `tools.mcp.discovery`, aby byly nÃĄstroje MCP discovery dostupnÊ.",
+ "requires_web_search_provider": "Nakonfigurujte alespoÅ jednoho pÅipravenÊho externÃho providera webovÊho vyhledÃĄvÃĄnÃ."
+ }
+ }
+ },
+ "config": {
+ "load_error": "NaÄtenà konfigurace selhalo. Obnovte strÃĄnku a zkuste to znovu.",
+ "workspace": "AdresÃĄÅ workspace",
+ "workspace_hint": "ZÃĄkladnà adresÃĄÅ pro operace s agentovÃŊmi soubory.",
+ "restrict_workspace": "Omezit na workspace",
+ "restrict_workspace_hint": "Povolit operace se soubory pouze uvnitÅ workspace.",
+ "split_on_marker": "Chatty reÅžim",
+ "split_on_marker_hint": "RozdÄluje dlouhÊ zprÃĄvy na krÃĄtkÊ jako pÅi skuteÄnÊm lidskÊm chatu.",
+ "tool_feedback_enabled": "Tool Feedback",
+ "tool_feedback_enabled_hint": "Odeslat krÃĄtkou poznÃĄmku o provedenà do aktuÃĄlnÃho chatu pÅed spuÅĄtÄnÃm kaÅždÊho nÃĄstroje.",
+ "tool_feedback_separate_messages": "OddÄlenÊ zprÃĄvy zpÄtnÊ vazby",
+ "tool_feedback_separate_messages_hint": "KaÅždou aktualizaci zpÄtnÊ vazby nÃĄstroje uchovejte jako samostatnou zprÃĄvu chatu mÃsto opakovanÊho pouÅžità jedinÊ průbÄÅžnÊ zprÃĄvy.",
+ "tool_feedback_max_args_length": "DÊlka Tool Feedbacku",
+ "tool_feedback_max_args_length_hint": "MaximÃĄlnà poÄet znaků zobrazenÃŊch v kaÅždÊ tool feedback zprÃĄvÄ. Nastavte 0 pro vÃŊchozà hodnotu.",
+ "exec_enabled": "Povolit pÅÃkazy",
+ "exec_enabled_hint": "Aktivuje nebo deaktivuje spouÅĄtÄnà pÅÃkazů. Pokud je deaktivovÃĄno, ÅžÃĄdnÊ pÅÃkazy se nespustÃ.",
+ "allow_remote": "Povolit vzdÃĄlenÊ pÅÃkazy",
+ "allow_remote_hint": "Pokud je aktivnÃ, vzdÃĄlenÊ relace nebo ne-lokÃĄlnà kontexty mohou takÊ spouÅĄtÄt pÅÃkazy. Pokud je deaktivovÃĄno, spouÅĄtÄnà pÅÃkazů je omezeno na lokÃĄlnà bezpeÄnÊ kontexty.",
+ "enable_deny_patterns": "Aktivovat blacklist",
+ "enable_deny_patterns_hint": "Pokud je aktivnÃ, aplikace blokuje pÅÃkazy odpovÃdajÃcà vestavÄnÃŊm nebezpeÄnÃŊm vzorům a vlastnÃmu blacklistu pÅÃkazů nÃÅže.",
+ "exec_timeout_seconds": "Timeout pÅÃkazu (sekundy)",
+ "exec_timeout_seconds_hint": "MaximÃĄlnà doba bÄhu pÅÃkazů. Nastavte 0 pro vÃŊchozà timeout.",
+ "custom_deny_patterns": "Blacklist pÅÃkazů",
+ "custom_deny_patterns_hint": "PÅidejte vlastnà pravidla pro blokovÃĄnà pÅÃkazů, jeden regulÃĄrnà vÃŊraz na ÅÃĄdek. PÅÃkaz odpovÃdajÃcà jakÊmukoli pravidlu bude zablokovÃĄn.",
+ "custom_allow_patterns": "Whitelist pÅÃkazů",
+ "custom_allow_patterns_hint": "PÅidejte vlastnà pravidla pro povolenà pÅÃkazů, jeden regulÃĄrnà vÃŊraz na ÅÃĄdek. PÅÃkaz odpovÃdajÃcà jakÊmukoli pravidlu pÅeskoÄà blacklist, ale ostatnà bezpeÄnostnà limity stÃĄle platÃ.",
+ "custom_patterns_placeholder": "^rm\\s+-rf\\b\n^git\\s+push\\b",
+ "pattern_detector_title": "NÃĄstroj pro testovÃĄnà vzorů",
+ "pattern_detector_hint": "Zadejte pÅÃkaz pro otestovÃĄnÃ, zda odpovÃdÃĄ nÄkterÊmu vzoru blacklistu nebo whitelistu.",
+ "pattern_detector_input_placeholder": "Zadejte pÅÃkaz pro test, napÅ. rm -rf /tmp",
+ "pattern_detector_test_button": "Testovat",
+ "pattern_detector_result_allowed": "Povoleno (odpovÃdÃĄ whitelistu)",
+ "pattern_detector_result_blocked": "BlokovÃĄno (odpovÃdÃĄ blacklistu)",
+ "pattern_detector_result_no_match": "ÅŊÃĄdnÃĄ shoda (pouÅžijà se vÃŊchozà pravidla)",
+ "allow_shell_execution": "Povolit naplÃĄnovanÊ pÅÃkazy",
+ "allow_shell_execution_hint": "Povolit naplÃĄnovanÃŊm ÃēlohÃĄm vÃŊchozà spouÅĄtÄnà pÅÃkazů. Pokud je deaktivovÃĄno, uÅživatelÊ musà pÅedat command_confirm=true pro naplÃĄnovÃĄnà pÅÃkazu.",
+ "cron_exec_timeout": "Timeout naplÃĄnovanÊho pÅÃkazu (minuty)",
+ "cron_exec_timeout_hint": "MaximÃĄlnà doba bÄhu naplÃĄnovanÃŊch pÅÃkazů. Nastavte 0 pro deaktivaci timeoutu.",
+ "max_tokens": "Max Tokens",
+ "max_tokens_hint": "Hornà limit tokenů na odpovÄÄ modelu.",
+ "context_window": "Context Window",
+ "context_window_hint": "Kapacita kontextovÊho okna modelu v tokenech. Ponechte prÃĄzdnÊ pro vÃŊchozà (4à max tokens).",
+ "max_tool_iterations": "Max Tool Iterations",
+ "max_tool_iterations_hint": "MaximÃĄlnà poÄet smyÄek volÃĄnà nÃĄstrojů v jednom Ãēkolu.",
+ "summarize_threshold": "PrÃĄh pro sumarizaci zprÃĄv",
+ "summarize_threshold_hint": "Spustit sumarizaci po tomto poÄtu zprÃĄv.",
+ "summarize_token_percent": "Procento tokenů pro sumarizaci",
+ "summarize_token_percent_hint": "PouÅžÃvÃĄ se pÅi spuÅĄtÄnà sumarizace konverzace.",
+ "turn_profile": "ZÃĄsady kontextu poÅžadavku",
+ "turn_profile_hint": "ÅÃdÃ, jakÃŊ kontext nese kaÅždÃŊ poÅžadavek. Ponechte zakÃĄzÃĄno pro zachovÃĄnà bÄÅžnÊho chovÃĄnà chatu.",
+ "turn_profile_enabled": "Povolit zÃĄsadu",
+ "turn_profile_enabled_hint": "Pokud je povoleno, tato zÃĄsada se pouÅžije na kaÅždÃŊ novÃŊ tah. Pokud je zakÃĄzÃĄno, PicoClaw pouÅžÃvÃĄ původnà chovÃĄnà kontextu.",
+ "turn_profile_mode_default": "VÃŊchozÃ",
+ "turn_profile_mode_off": "Vypnuto",
+ "turn_profile_mode_custom": "PovolenÃŊ seznam",
+ "turn_profile_history": "HistorickÃŊ kontext",
+ "turn_profile_history_hint": "VÃŊchozà nastavenà zahrnuje pÅedchozà zprÃĄvy z tÊto session. Vypnutà způsobÃ, Åže tah bude fungovat jako novÃŊ chat a jeho vÃŊsledek se neuloŞà do historie.",
+ "turn_profile_system_prompt": "SystÊmovÃŊ kontext",
+ "turn_profile_system_prompt_hint": "VÃŊchozà nastavenà zahrnuje identitu PicoClaw, workspace, pamÄÅĨ a instrukce runtime. Vypnuto zachovÃĄ pouze systÊmovÊ prompty explicitnÄ dodanÊ poÅžadavkem.",
+ "turn_profile_skills": "Dovednostnà prompty",
+ "turn_profile_skills_hint": "VÃŊchozà nastavenà zahrnuje dostupnÊ dovednosti a aktivnà instrukce. Vypnuto je skryje. PovolenÃŊ seznam zachovÃĄ jen dovednosti zadanÊ jeden nÃĄzev na ÅÃĄdek.",
+ "turn_profile_skills_allow_placeholder": "nÃĄzev-dovednosti\ndalÅĄÃ-dovednost",
+ "turn_profile_tools": "VolatelnÊ nÃĄstroje",
+ "turn_profile_tools_hint": "VÃŊchozà nastavenà zpÅÃstupnà bÄÅžnÊ nÃĄstroje. Vypnuto zabrÃĄnà volÃĄnà nÃĄstrojů. PovolenÃŊ seznam zachovÃĄ pouze nÃĄstroje zadanÊ jeden nÃĄzev na ÅÃĄdek, napÅÃklad web_search.",
+ "turn_profile_tools_allow_placeholder": "web_search\nweb_fetch",
+ "session_scope": "Rozsah relace",
+ "session_scope_hint": "Způsob izolace kontextu chatu mezi partnery/kanÃĄly.",
+ "session_scope_per_channel_peer": "Podle kanÃĄlu + partnera",
+ "session_scope_per_channel_peer_desc": "OddÄlenÃŊ kontext pro kaÅždÊho uÅživatele v kaÅždÊm kanÃĄlu.",
+ "session_scope_per_channel": "Podle kanÃĄlu",
+ "session_scope_per_channel_desc": "Jeden sdÃlenÃŊ kontext na kanÃĄl.",
+ "session_scope_per_peer": "Podle partnera",
+ "session_scope_per_peer_desc": "Jeden kontext na uÅživatele napÅÃÄ kanÃĄly.",
+ "session_scope_global": "GlobÃĄlnÃ",
+ "session_scope_global_desc": "VÅĄechny zprÃĄvy sdÃlejà jeden globÃĄlnà kontext.",
+ "heartbeat_enabled": "Heartbeat",
+ "heartbeat_enabled_hint": "OdesÃlat pravidelnÊ heartbeat zprÃĄvy.",
+ "heartbeat_interval": "Interval heartbeatu (minuty)",
+ "heartbeat_interval_hint": "Interval v minutÃĄch mezi heartbeat signÃĄly.",
+ "devices_enabled": "Aktivovat zaÅÃzenÃ",
+ "devices_enabled_hint": "Aktivovat integrace hardwarovÃŊch zaÅÃzenÃ.",
+ "monitor_usb": "Sledovat USB",
+ "monitor_usb_hint": "Sledovat udÃĄlosti pÅipojenÃ/odpojenà USB pÅi aktivnÃch zaÅÃzenÃch.",
+ "autostart_label": "Spustit pÅi pÅihlÃĄÅĄenÃ",
+ "autostart_hint": "Automaticky spustit PicoClaw Web pÅi pÅihlÃĄÅĄenÃ.",
+ "autostart_unsupported": "SpuÅĄtÄnà pÅi pÅihlÃĄÅĄenà nenà na tÊto platformÄ podporovÃĄno.",
+ "autostart_load_error": "NaÄtenà stavu spuÅĄtÄnà pÅi pÅihlÃĄÅĄenà selhalo.",
+ "server_port": "Port sluÅžby",
+ "server_port_hint": "HTTP port pouÅžÃvanÃŊ PicoClaw Web.",
+ "launcher_section_hint": "ZmÄny v tÊto sekci se projevà aÅž po restartu launcheru.",
+ "gateway_restart_hint": "ZmÄny v tÊto ÄÃĄsti se projevà po restartu gateway.",
+ "dashboard_password": "PÅihlaÅĄovacà heslo",
+ "dashboard_password_hint": "Nastavit novÊ pÅihlaÅĄovacà heslo.",
+ "dashboard_password_placeholder": "AlespoŠ8 znaků",
+ "dashboard_password_confirm": "Potvrzenà novÊho hesla",
+ "dashboard_password_confirm_hint": "Zadejte novÊ pÅihlaÅĄovacà heslo znovu.",
+ "dashboard_password_confirm_placeholder": "Zopakujte heslo",
+ "dashboard_password_required": "Zadejte a potvrÄte novÊ pÅihlaÅĄovacà heslo.",
+ "dashboard_password_mismatch": "PÅihlaÅĄovacà hesla se neshodujÃ.",
+ "dashboard_password_min_length": "PÅihlaÅĄovacà heslo musà mÃt alespoÅ 8 znaků.",
+ "lan_access": "Aktivovat pÅÃstup z LAN",
+ "lan_access_hint": "Povolit pÅÃstup z ostatnÃch zaÅÃzenà v lokÃĄlnà sÃti.",
+ "allowed_cidrs": "PovolenÊ sÃÅĨovÊ CIDRy",
+ "allowed_cidrs_hint": "Ke sluÅžbÄ majà pÅÃstup pouze klienti z tÄchto CIDR rozsahů. Jeden na ÅÃĄdek nebo oddÄlenÊ ÄÃĄrkou. Ponechte prÃĄzdnÊ pro povolenà vÅĄech.",
+ "allowed_cidrs_placeholder": "192.168.1.0/24\n10.0.0.0/8",
+ "evolution_section_hint": "Nechte agenta uÄit se z dokonÄenÃŊch tahů a pÅipravovat vylepÅĄenà dovednostÃ.",
+ "evolution_enabled": "Povolit evoluci",
+ "evolution_enabled_hint": "ZaznamenÃĄvat data uÄenà pro dokonÄenÊ tahy. ReÅžimy NÃĄvrh a Aplikovat mohou takÊ generovat aktualizace dovednostÃ.",
+ "evolution_mode": "ReÅžim evoluce",
+ "evolution_mode_hint": "Pozorovat â pouze zaznamenÃĄvÃĄ data. NÃĄvrh â pÅipravuje kandidÃĄtnà dovednosti. Aplikovat â můŞe zapsat pÅijatÊ nÃĄvrhy do dovednostà workspace.",
+ "evolution_mode_observe": "SledovÃĄnÃ",
+ "evolution_mode_draft": "NÃĄvrh",
+ "evolution_mode_apply": "Aplikace",
+ "evolution_state_dir": "AdresÃĄÅ stavu",
+ "evolution_state_dir_hint": "VolitelnÃŊ adresÃĄÅ pro stav evoluce. Ponechte prÃĄzdnÊ pro pouÅžità vÃŊchozÃho workspace.",
+ "evolution_min_task_count": "MinimÃĄlnà poÄet Ãēloh",
+ "evolution_min_task_count_hint": "MinimÃĄlnà poÄet pÅÃbuznÃŊch Ãēloh, neÅž můŞe vzor vytvoÅit nÃĄvrh.",
+ "evolution_min_success_ratio": "MinimÃĄlnà pomÄr ÃēspÄÅĄnosti",
+ "evolution_min_success_ratio_hint": "PoÅžadovanÃŊ pomÄr ÃēspÄÅĄnosti pro seskupenÊ Ãēlohy. Zadejte hodnotu vÄtÅĄÃ neÅž 0 a nejvÃŊÅĄe 1.",
+ "evolution_cold_path_trigger": "SpouÅĄtÄÄ zpracovÃĄnÃ",
+ "evolution_cold_path_trigger_hint": "Zvolte, kdy se spustà generovÃĄnà nÃĄvrhů pro způsobilÊ zÃĄznamy uÄenÃ.",
+ "evolution_cold_path_after_turn": "Po kaŞdÊm tahu",
+ "evolution_cold_path_scheduled": "PlÃĄnovanÄ",
+ "evolution_cold_path_manual": "Vypnuto",
+ "evolution_cold_path_times": "PlÃĄnovanÊ Äasy",
+ "evolution_cold_path_times_hint": "Äasy spuÅĄtÄnà plÃĄnovanÊho zpracovÃĄnÃ. Zadejte jednu hodnotu HH:MM na ÅÃĄdek.",
+ "mcp_section_hint": "Konfigurujte MCP servery bez ruÄnà Ãēpravy souboru config.json.",
+ "mcp_enabled": "Povolit MCP",
+ "mcp_enabled_hint": "Zapnout nebo vypnout integraci MCP serverů.",
+ "mcp_discovery_enabled": "Povolit MCP Discovery",
+ "mcp_discovery_enabled_hint": "Povolit nÃĄstrojům MCP Discovery prohledÃĄvat registrovanÊ MCP servery.",
+ "mcp_discovery_ttl": "TTL odemÄenà nÃĄstrojů discovery",
+ "mcp_discovery_ttl_hint": "PoÄet TTL tiků, po kterÊ zůstÃĄvajà nalezenÊ nÃĄstroje dostupnÊ po vyhledÃĄvÃĄnÃ.",
+ "mcp_discovery_max_results": "MaximÃĄlnà poÄet vÃŊsledků discovery",
+ "mcp_discovery_max_results_hint": "MaximÃĄlnà poÄet shod MCP discovery vrÃĄcenÃŊch na dotaz.",
+ "mcp_discovery_use_bm25": "PouÅžÃt BM25 ÅazenÃ",
+ "mcp_discovery_use_bm25_hint": "PouÅžÃt lexikÃĄlnà skÃŗrovÃĄnà BM25 pro vÃŊsledky MCP discovery.",
+ "mcp_discovery_use_regex": "Povolit regex vyhledÃĄvÃĄnÃ",
+ "mcp_discovery_use_regex_hint": "Povolit shodu na zÃĄkladÄ regulÃĄrnÃch vÃŊrazů v MCP discovery.",
+ "mcp_servers": "MCP servery",
+ "mcp_servers_hint": "PÅidÃĄvejte, upravujte nebo odebÃrejte MCP servery.",
+ "mcp_server_new": "NovÃŊ MCP server",
+ "mcp_server_add": "PÅidat server",
+ "mcp_server_remove": "Odebrat",
+ "mcp_server_enabled": "Povoleno",
+ "mcp_server_discovery_mode": "ReÅžim discovery",
+ "mcp_server_discovery_mode_inherit": "Sledovat globÃĄlnà reÅžim discovery",
+ "mcp_server_discovery_mode_deferred": "OdloŞenÊ discovery",
+ "mcp_server_discovery_mode_eager": "OkamÅžitÃĄ registrace",
+ "mcp_server_name_placeholder": "NÃĄzev serveru (napÅ. github)",
+ "mcp_server_url_placeholder": "URL serveru (napÅ. https://example.com/mcp)",
+ "mcp_server_command_placeholder": "PÅÃkaz (napÅ. npx)",
+ "mcp_server_env_file_placeholder": "Cesta k souboru prostÅedà (volitelnÊ)",
+ "mcp_server_args_placeholder": "Argumenty, jeden na ÅÃĄdek",
+ "mcp_server_env_placeholder": "JSON objekt prostÅedÃ",
+ "mcp_server_headers_placeholder": "JSON objekt hlaviÄek",
+ "sections": {
+ "agent": "Agent",
+ "runtime": "Runtime",
+ "evolution": "Evoluce",
+ "mcp": "MCP",
+ "exec": "SpouÅĄtÄnà pÅÃkazů",
+ "cron": "Cron Ãēlohy",
+ "launcher": "Launcher",
+ "devices": "ZaÅÃzenÃ"
+ },
+ "open_raw": "Raw konfigurace",
+ "back_to_visual": "VizuÃĄlnà konfigurace",
+ "raw_json_title": "Raw JSON konfigurace",
+ "json_placeholder": "Zadejte platnou JSON konfiguraci...",
+ "save_success": "Konfigurace ÃēspÄÅĄnÄ uloÅžena.",
+ "save_error": "UloŞenà konfigurace selhalo.",
+ "reset_confirm_title": "Resetovat zmÄny",
+ "reset_confirm_desc": "Opravdu chcete zahodit neuloÅženÊ zmÄny a vrÃĄtit se k poslednÃmu uloÅženÊmu stavu?",
+ "reset_success": "ZmÄny byly obnoveny na poslednà uloÅženÃŊ stav.",
+ "invalid_json": "NeplatnÃŊ formÃĄt JSON.",
+ "format_success": "JSON ÃēspÄÅĄnÄ naformÃĄtovÃĄn.",
+ "format_error": "NeplatnÃŊ formÃĄt JSON.",
+ "format": "FormÃĄtovat",
+ "unsaved_changes": "MÃĄte neuloÅženÊ zmÄny.",
+ "factory_reset": "TovÃĄrnà nastavenÃ",
+ "factory_reset_confirm_title": "Obnovit tovÃĄrnà nastavenÃ",
+ "factory_reset_confirm_desc": "TÃmto obnovÃte veÅĄkerou konfiguraci na tovÃĄrnà nastavenÃ. API klÃÄe a bezpeÄnostnà Ãēdaje budou zachovÃĄny. ZÃĄloha aktuÃĄlnà konfigurace bude vytvoÅena.",
+ "factory_reset_confirm": "Obnovit vÃŊchozà nastavenÃ",
+ "factory_reset_success": "Konfigurace byla obnovena na tovÃĄrnà nastavenÃ.",
+ "factory_reset_error": "Obnovenà konfigurace selhalo."
+ },
+ "logs": {
+ "log_level_error": "Aktualizace ÃērovnÄ logovÃĄnà selhala.",
+ "clear": "Vymazat logy",
+ "empty": "ÄekÃĄm na logy..."
+ }
+ },
+ "tour": {
+ "skip": "PÅeskoÄit průvodce",
+ "prev": "PÅedchozÃ",
+ "next": "DalÅĄÃ",
+ "finish": "DokonÄit",
+ "welcome": {
+ "title": "VÃtejte v PicoClaw",
+ "description": "PicoClaw je vÃŊkonnÃĄ platforma pro AI asistenty. PojÄme si dÃĄt chvÃli na dokonÄenà zÃĄkladnÃho nastavenÃ."
+ },
+ "models": {
+ "title": "Nakonfigurujte modely",
+ "description": "KliknÄte na nabÃdku \"Modely\" vlevo pro nastavenà API klÃÄů pro AI providery. V chatu lze pouÅžÃt pouze nakonfigurovanÊ modely."
+ },
+ "gateway": {
+ "title": "SpusÅĨte gateway",
+ "description": "Po nakonfigurovÃĄnà modelů kliknÄte na tlaÄÃtko \"Spustit gateway\" nahoÅe pro zahÃĄjenà chatu s AI."
+ },
+ "docs": {
+ "title": "Zobrazit dokumentaci",
+ "description": "PotÅebujete dalÅĄÃ pomoc? KliknÄte na tlaÄÃtko dokumentace v pravÊm hornÃm rohu pro zobrazenà podrobnÃŊch průvodců a dokumentace ke konfiguraci."
+ }
+ }
+}
From edcae17b41bd54bf7e8758b1040772cc560da78e Mon Sep 17 00:00:00 2001
From: Martin Zapletal <105368210+KrtCZ@users.noreply.github.com>
Date: Sat, 23 May 2026 13:35:44 +0200
Subject: [PATCH 07/21] Register Czech (cs) locale in i18n config
---
web/frontend/src/i18n/index.ts | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/web/frontend/src/i18n/index.ts b/web/frontend/src/i18n/index.ts
index 4da7b3f0d..8c0248915 100644
--- a/web/frontend/src/i18n/index.ts
+++ b/web/frontend/src/i18n/index.ts
@@ -1,4 +1,5 @@
import dayjs from "dayjs"
+import "dayjs/locale/cs"
import "dayjs/locale/en"
import "dayjs/locale/pt-br"
import "dayjs/locale/zh-cn"
@@ -11,6 +12,7 @@ import { initReactI18next } from "react-i18next"
import en from "./locales/en.json"
import ptBr from "./locales/pt-br.json"
import zh from "./locales/zh.json"
+import cs from "./locales/cs.json"
dayjs.extend(relativeTime)
dayjs.extend(localizedFormat)
@@ -34,6 +36,9 @@ i18n
zh: {
translation: zh,
},
+ cs: {
+ translation: cs,
+ },
},
fallbackLng: "en",
debug: false,
@@ -48,6 +53,8 @@ i18n.on("languageChanged", (lng) => {
dayjs.locale("zh-cn")
} else if (lng.startsWith("pt")) {
dayjs.locale("pt-br")
+ } else if (lng.startsWith("cs")) {
+ dayjs.locale("cs")
} else {
dayjs.locale("en")
}
From 23e1485a9829c8131387e6306f40a95ec9baa4b7 Mon Sep 17 00:00:00 2001
From: Martin Zapletal <105368210+KrtCZ@users.noreply.github.com>
Date: Sat, 23 May 2026 13:42:03 +0200
Subject: [PATCH 08/21] =?UTF-8?q?Add=20=C4=8Ce=C5=A1tina=20to=20language?=
=?UTF-8?q?=20switcher?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
web/frontend/src/components/app-header.tsx | 3 +++
1 file changed, 3 insertions(+)
diff --git a/web/frontend/src/components/app-header.tsx b/web/frontend/src/components/app-header.tsx
index 700cc21e0..30c05ee45 100644
--- a/web/frontend/src/components/app-header.tsx
+++ b/web/frontend/src/components/app-header.tsx
@@ -294,6 +294,9 @@ export function AppHeader() {
-
- {selectedSkillDetail.content}
-
- {`{\n "text": "your message"\n}`}
-
+ @@ -199,9 +203,12 @@ export function MqttForm({ {t("channels.mqtt.downlink")}
- {`{\n "text": "agent response"\n}`}
-
+ diff --git a/web/frontend/src/components/chat/assistant-message.tsx b/web/frontend/src/components/chat/assistant-message.tsx index 6527562fe..0a197b25e 100644 --- a/web/frontend/src/components/chat/assistant-message.tsx +++ b/web/frontend/src/components/chat/assistant-message.tsx @@ -197,7 +197,6 @@ export function AssistantMessage({ label={toolName || t("chat.toolCallArgumentsLabel")} className="my-0 shadow-none" bodyClassName="px-3 py-2 text-[12px] leading-relaxed" - wrapLongLines /> )}
SOw=RqhA%C8QR{rvy~mAk(_aQ*G?Y8`3ByL<
z?`F9ITRC5Me+0U5%W=NpF;#z5cLyjaaWPC5sX-EUXzS}%XFkh>3au&`YK~KUPU(M)
zGBw-V>5xb%z!^L8vYXFFL*l@E{ZSeCzp*_ltws2f(DL=%$2_2fF`;~dJeKqGLQdwr
z;S!DpvjrnMi7C|7o~Jkk-en&Oq$$ )fZ7^l9v(nU%H&OyUy5dFAdmkZT0xT
zpi5glWsX|UwfJo9aM}OI(_65$xpiIJE$%MC-KDt0#wB=gcPF?P+TyOo-QAraB~U2t
z1h?W^q-aa~W$*iazT?Or$d#3K%{k^6=e&)%Rg+~wNDTYBD%3A8Cl!MWYqejNuFOa6
z|BALZ^QjAgd(GJ+Jx!5kHk)ZF{JU)^?*x&y3|3Sg@9JUj&35mPKTqHOG3U-QF)<0u
z47tnO`8^H)6#YJF*!laq6RnbG+rY1;bsl^ybXh82tci#coZ!j5N841Fdu_JOQ$F|v
z$XH6}r$e%tGTtzBd<5yA>V`b+McWw-JgmEji;L^&RXDBxc&w}ZQ~Cu8cO7N?cdoHg
zwV&|CupfJ_kjOJ&g#r8Z{z8Cb8U5JogUpy8zn6E`0m(%Yfwv!jZ~X0DcE0cW6aDcH
z^~=49(j$$V#hT>S$DjUxuXcO|KAem1v}}Xj-CiC3o_>7T_