diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 000000000..c81be06b3
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1,3 @@
+# These are supported funding model platforms
+
+github: [sipeed]
diff --git a/README.md b/README.md
index 2fa71230d..47191ec06 100644
--- a/README.md
+++ b/README.md
@@ -413,12 +413,14 @@ PicoClaw supports 30+ LLM providers through the `model_list` configuration. Use
| [Ollama](https://ollama.com/) | `ollama/` | Not needed | Local models, self-hosted |
| [vLLM](https://docs.vllm.ai/) | `vllm/` | Not needed | Local deployment, OpenAI-compatible |
| [LiteLLM](https://docs.litellm.ai/) | `litellm/` | Varies | Proxy for 100+ providers |
-| [Azure OpenAI](https://portal.azure.com/) | `azure/` | Required | Enterprise Azure deployment |
+| [Azure OpenAI](https://portal.azure.com/) | `azure/` | API key or Entra ID** | Enterprise Azure deployment |
| [GitHub Copilot](https://github.com/features/copilot) | `github-copilot/` | OAuth | Device code login |
| [Antigravity](https://console.cloud.google.com/) | `antigravity/` | OAuth | Google Cloud AI |
| [AWS Bedrock](https://console.aws.amazon.com/bedrock)* | `bedrock/` | AWS credentials | Claude, Llama, Mistral on AWS |
> \* AWS Bedrock requires build tag: `go build -tags bedrock`. Set `api_base` to a region name (e.g., `us-east-1`) for automatic endpoint resolution across all AWS partitions (aws, aws-cn, aws-us-gov). When using a full endpoint URL instead, you must also configure `AWS_REGION` via environment variable or AWS config/profile.
+>
+> \*\* Azure OpenAI uses `api_key` when set. If `api_key` is omitted, the provider falls back to Microsoft Entra ID via `DefaultAzureCredential` (env vars, workload identity, managed identity, Azure CLI, etc.). The Entra ID path requires build tag: `go build -tags azidentity`.
Local deployment (Ollama, vLLM, etc.)
diff --git a/assets/wechat.png b/assets/wechat.png
index 0cdafb22c..8c3c46429 100644
Binary files a/assets/wechat.png and b/assets/wechat.png differ
diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go
index 1b5d0974a..3f94f8188 100644
--- a/cmd/picoclaw/main.go
+++ b/cmd/picoclaw/main.go
@@ -9,6 +9,8 @@ package main
import (
"fmt"
"os"
+ "runtime"
+ "strings"
"time"
"github.com/spf13/cobra"
@@ -33,6 +35,49 @@ import (
var rootNoColor bool
+// initTermuxSSL detects Termux environment and sets SSL_CERT_FILE if not already set.
+// This fixes X509 certificate errors when running PicoClaw inside Termux or termux-chroot.
+// See: https://github.com/sipeed/picoclaw/issues/2944
+func initTermuxSSL() {
+ // Only applicable on Linux/Android
+ if runtime.GOOS != "linux" && runtime.GOOS != "android" {
+ return
+ }
+
+ // Skip if already set
+ if os.Getenv("SSL_CERT_FILE") != "" {
+ return
+ }
+
+ // Check for Termux prefix in PATH or HOME
+ home := os.Getenv("HOME")
+ path := os.Getenv("PATH")
+
+ isTermux := strings.Contains(home, "com.termux") ||
+ strings.Contains(path, "com.termux") ||
+ strings.Contains(home, "/data/data/com.termux")
+
+ if !isTermux {
+ return
+ }
+
+ // Check common CA bundle locations in Termux
+ caPaths := []string{
+ "$PREFIX/etc/tls/cert.pem",
+ os.Getenv("PREFIX") + "/etc/tls/cert.pem",
+ "/data/data/com.termux/files/usr/etc/tls/cert.pem",
+ "/usr/etc/tls/cert.pem",
+ }
+
+ for _, caPath := range caPaths {
+ expanded := os.ExpandEnv(caPath)
+ if _, err := os.Stat(expanded); err == nil {
+ os.Setenv("SSL_CERT_FILE", expanded)
+ return
+ }
+ }
+}
+
func syncCliUIColor(root *cobra.Command) {
no, _ := root.PersistentFlags().GetBool("no-color")
cliui.Init(no || os.Getenv("NO_COLOR") != "" || os.Getenv("TERM") == "dumb")
@@ -123,6 +168,9 @@ const (
)
func main() {
+ // Initialize Termux SSL certificate detection before anything else
+ initTermuxSSL()
+
cliui.Init(earlyColorDisabled())
if earlyColorDisabled() {
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/go.mod b/go.mod
index c3c05f96d..a02252114 100644
--- a/go.mod
+++ b/go.mod
@@ -4,6 +4,8 @@ go 1.25.10
require (
fyne.io/systray v1.12.1
+ github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1
+ github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1
github.com/SevereCloud/vksdk/v3 v3.3.1
github.com/adhocore/gronx v1.20.0
github.com/anthropics/anthropic-sdk-go v1.26.0
@@ -12,7 +14,7 @@ require (
github.com/aws/aws-sdk-go-v2/config v1.32.17
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.6
github.com/bwmarrin/discordgo v0.29.0
- github.com/caarlos0/env/v11 v11.4.0
+ github.com/caarlos0/env/v11 v11.4.1
github.com/charmbracelet/lipgloss v1.1.0
github.com/creack/pty v1.1.24
github.com/eclipse/paho.mqtt.golang v1.5.1
@@ -31,7 +33,7 @@ require (
github.com/mymmrac/telego v1.9.0
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
github.com/openai/openai-go/v3 v3.22.0
- github.com/pion/rtp v1.10.1
+ github.com/pion/rtp v1.10.2
github.com/pion/webrtc/v3 v3.3.6
github.com/pmezard/go-difflib v1.0.0
github.com/rs/zerolog v1.35.1
@@ -55,6 +57,8 @@ require (
require (
aead.dev/minisign v0.2.0 // indirect
filippo.io/edwards25519 v1.2.0 // indirect
+ github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect
+ github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.16 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect
@@ -82,7 +86,9 @@ require (
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
+ github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/kylelemons/godebug v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
@@ -91,6 +97,7 @@ require (
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect
github.com/pion/randutil v0.1.0 // indirect
+ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/segmentio/asm v1.1.3 // indirect
diff --git a/go.sum b/go.sum
index 27fbdab1d..167daac83 100644
--- a/go.sum
+++ b/go.sum
@@ -5,6 +5,18 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
fyne.io/systray v1.12.1 h1:ygBD6aZXwiOmZoY5N+ukbH9pih0Kq6fYgVeMYbr5skQ=
fyne.io/systray v1.12.1/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8=
+github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4=
+github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY=
+github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM=
+github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=
+github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
+github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/SevereCloud/vksdk/v3 v3.3.1 h1:O86zsp5LQnHE+O5acvuXM/s6S1LyxzVTkF6+Lup0Jyg=
@@ -63,8 +75,8 @@ github.com/bytedance/sonic v1.15.1 h1:nJD5PmM0vY7J8CT6MxoqbVAAMhkSmV2HgRAUrrpLoO
github.com/bytedance/sonic v1.15.1/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA=
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
-github.com/caarlos0/env/v11 v11.4.0 h1:Kcb6t5kIIr4XkoQC9AF2j+8E1Jsrl3Wz/hhm1LtoGAc=
-github.com/caarlos0/env/v11 v11.4.0/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
+github.com/caarlos0/env/v11 v11.4.1 h1:fYwH0sWEsBSMPG7t4e/PEfTFzrWrpjyygXyUnWiSwEw=
+github.com/caarlos0/env/v11 v11.4.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
@@ -164,6 +176,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyf
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
+github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
@@ -179,6 +193,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/larksuite/oapi-sdk-go/v3 v3.7.5 h1:dimv+ZAGia01f4xCDGvCiBHKWMf4K1AB7fGsM+lv5Jw=
github.com/larksuite/oapi-sdk-go/v3 v3.7.5/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
github.com/line/line-bot-sdk-go/v8 v8.20.0 h1:Jv22DV3JuQ5qZvniqUbg504bJrVzffXs2CMpyoiuIZU=
@@ -221,10 +237,12 @@ github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VR
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
-github.com/pion/rtp v1.10.1 h1:xP1prZcCTUuhO2c83XtxyOHJteISg6o8iPsE2acaMtA=
-github.com/pion/rtp v1.10.1/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM=
+github.com/pion/rtp v1.10.2 h1:l+f6tTDcAH6xwepaAoW791ddhuYsJlqRATOzirO04Mo=
+github.com/pion/rtp v1.10.2/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk=
github.com/pion/webrtc/v3 v3.3.6 h1:7XAh4RPtlY1Vul6/GmZrv7z+NnxKA6If0KStXBI2ZLE=
github.com/pion/webrtc/v3 v3.3.6/go.mod h1:zyN7th4mZpV27eXybfR/cnUf3J2DRy8zw/mdjD9JTNM=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -354,8 +372,6 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
-golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
-golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
@@ -386,12 +402,11 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
-golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
diff --git a/pkg/agent/agent_init.go b/pkg/agent/agent_init.go
index 50f0227a1..17629892d 100644
--- a/pkg/agent/agent_init.go
+++ b/pkg/agent/agent_init.go
@@ -161,26 +161,58 @@ func registerSharedTools(
// Message tool
if cfg.Tools.IsToolEnabled("message") {
messageTool := tools.NewMessageTool()
+ 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),
tools.ToolSessionKey(ctx),
tools.ToolSessionScope(ctx),
)
- return msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
+ if len(mediaParts) > 0 {
+ 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)
+ }
+ 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/agent/agent_test.go b/pkg/agent/agent_test.go
index 08cf71d79..c3562bdaa 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/agent/context_seahorse.go b/pkg/agent/context_seahorse.go
index 2a10d2457..6bd32c216 100644
--- a/pkg/agent/context_seahorse.go
+++ b/pkg/agent/context_seahorse.go
@@ -6,6 +6,7 @@ import (
"context"
"encoding/json"
"fmt"
+ "time"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers"
@@ -200,6 +201,7 @@ func providerToSeahorseMessage(msg protocoltypes.Message) seahorse.Message {
ModelName: msg.ModelName,
ReasoningContent: msg.ReasoningContent,
TokenCount: tokenizer.EstimateMessageTokens(msg),
+ CreatedAt: normalizeSeahorseMessageCreatedAt(msg.CreatedAt),
}
// Convert ToolCalls → MessageParts
@@ -235,6 +237,13 @@ func providerToSeahorseMessage(msg protocoltypes.Message) seahorse.Message {
return result
}
+func normalizeSeahorseMessageCreatedAt(createdAt *time.Time) time.Time {
+ if createdAt == nil || createdAt.IsZero() {
+ return time.Time{}
+ }
+ return createdAt.UTC().Truncate(time.Second)
+}
+
// seahorseToProviderMessages converts a seahorse.AssembleResult to []providers.Message.
func seahorseToProviderMessages(result *seahorse.AssembleResult) []protocoltypes.Message {
messages := make([]protocoltypes.Message, 0, len(result.Messages))
diff --git a/pkg/agent/context_seahorse_test.go b/pkg/agent/context_seahorse_test.go
index 101f72ee2..497e1fc44 100644
--- a/pkg/agent/context_seahorse_test.go
+++ b/pkg/agent/context_seahorse_test.go
@@ -171,11 +171,13 @@ func TestProviderToSeahorseMessageWithMedia(t *testing.T) {
}
func TestProviderToSeahorseMessageWithReasoning(t *testing.T) {
+ createdAt := time.Date(2026, 5, 6, 7, 8, 9, 123000000, time.UTC)
msg := protocoltypes.Message{
Role: "assistant",
Content: "response text",
ModelName: "gpt-5.4-mini",
ReasoningContent: "I thought about this carefully",
+ CreatedAt: &createdAt,
}
result := providerToSeahorseMessage(msg)
@@ -185,6 +187,9 @@ func TestProviderToSeahorseMessageWithReasoning(t *testing.T) {
if result.ModelName != "gpt-5.4-mini" {
t.Errorf("ModelName = %q, want %q", result.ModelName, "gpt-5.4-mini")
}
+ if !result.CreatedAt.Equal(time.Date(2026, 5, 6, 7, 8, 9, 0, time.UTC)) {
+ t.Errorf("CreatedAt = %v, want 2026-05-06 07:08:09 UTC", result.CreatedAt)
+ }
}
func TestSeahorseToProviderMessagesWithReasoning(t *testing.T) {
diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go
index d09c021c7..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
@@ -497,8 +501,16 @@ 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 {
+ if err := c.sendMediaPartFn(ctx, msg.ChatID, part, store); err != nil {
+ return nil, err
+ }
+ sentAny = true
+ }
+ if sentAny && caption != "" {
+ if _, err := c.sendTextFn(ctx, msg.ChatID, caption); err != nil {
return nil, err
}
}
@@ -557,6 +569,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/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/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/slack/slack.go b/pkg/channels/slack/slack.go
index fa62a4605..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
}
@@ -171,6 +185,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 {
@@ -191,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,
@@ -205,6 +221,13 @@ 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 != "" {
+ if err := c.postTextFn(ctx, channelID, threadTS, caption); 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 +235,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/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
+}
diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go
index 45672e5ee..8fe325b25 100644
--- a/pkg/channels/telegram/telegram.go
+++ b/pkg/channels/telegram/telegram.go
@@ -44,7 +44,10 @@ var (
reInlineCode = regexp.MustCompile("`([^`]+)`")
)
-const defaultMediaGroupDelay = 500 * time.Millisecond
+const (
+ defaultMediaGroupDelay = 500 * time.Millisecond
+ telegramCaptionLimit = 1024
+)
type TelegramChannel struct {
*channels.BaseChannel
@@ -639,6 +642,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 +773,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..b52f2c9b2 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,276 @@ 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/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)
+ }
+}
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/providers/azure/identity.go b/pkg/providers/azure/identity.go
new file mode 100644
index 000000000..1283cb070
--- /dev/null
+++ b/pkg/providers/azure/identity.go
@@ -0,0 +1,56 @@
+//go:build azidentity
+
+// Entra ID (DefaultAzureCredential) auth adapter.
+// Built only when -tags azidentity is supplied; otherwise identity_stub.go
+// satisfies the same exported API with a friendly error.
+
+package azure
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
+ "github.com/Azure/azure-sdk-for-go/sdk/azidentity"
+)
+
+// azureOpenAIScope is the OAuth scope for Azure OpenAI (Cognitive Services).
+// Service-wide scope, so it covers all regions including sovereign clouds.
+const azureOpenAIScope = "https://cognitiveservices.azure.com/.default"
+
+// NewProviderWithIdentity creates an Azure OpenAI provider authenticated via
+// the DefaultAzureCredential chain (env vars, workload identity, managed
+// identity, Azure CLI, ...). Construction itself only fails if the credential
+// chain cannot be built; misconfigured environments surface their error on
+// the first Chat call when GetToken is invoked.
+func NewProviderWithIdentity(apiBase, proxy, userAgent string, opts ...Option) (*Provider, error) {
+ cred, err := azidentity.NewDefaultAzureCredential(nil)
+ if err != nil {
+ return nil, fmt.Errorf("creating azure default credential: %w", err)
+ }
+
+ ts := func(ctx context.Context) (string, error) {
+ tok, err := cred.GetToken(ctx, policy.TokenRequestOptions{
+ Scopes: []string{azureOpenAIScope},
+ })
+ if err != nil {
+ return "", fmt.Errorf("acquiring azure access token: %w", err)
+ }
+ return tok.Token, nil
+ }
+
+ return NewProviderWithTokenSource(apiBase, proxy, userAgent, ts, opts...), nil
+}
+
+// NewProviderWithIdentityAndTimeout mirrors NewProviderWithTimeout for the
+// identity auth path.
+func NewProviderWithIdentityAndTimeout(
+ apiBase, proxy, userAgent string,
+ requestTimeoutSeconds int,
+) (*Provider, error) {
+ return NewProviderWithIdentity(
+ apiBase, proxy, userAgent,
+ WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second),
+ )
+}
diff --git a/pkg/providers/azure/identity_stub.go b/pkg/providers/azure/identity_stub.go
new file mode 100644
index 000000000..c6d5a60d0
--- /dev/null
+++ b/pkg/providers/azure/identity_stub.go
@@ -0,0 +1,24 @@
+//go:build !azidentity
+
+// Stub for the Entra ID auth path when built without the azidentity tag.
+// Mirrors the exported surface of identity.go so callers compile cleanly
+// in the default build.
+
+package azure
+
+import "fmt"
+
+const azidentityBuildHint = "azure identity auth not available: build with -tags azidentity to enable Entra ID auth, or set api_key"
+
+// NewProviderWithIdentity returns an error in the default build.
+func NewProviderWithIdentity(apiBase, proxy, userAgent string, opts ...Option) (*Provider, error) {
+ return nil, fmt.Errorf("%s", azidentityBuildHint)
+}
+
+// NewProviderWithIdentityAndTimeout returns an error in the default build.
+func NewProviderWithIdentityAndTimeout(
+ apiBase, proxy, userAgent string,
+ requestTimeoutSeconds int,
+) (*Provider, error) {
+ return nil, fmt.Errorf("%s", azidentityBuildHint)
+}
diff --git a/pkg/providers/azure/identity_test.go b/pkg/providers/azure/identity_test.go
new file mode 100644
index 000000000..e40fd1ce1
--- /dev/null
+++ b/pkg/providers/azure/identity_test.go
@@ -0,0 +1,39 @@
+//go:build azidentity
+
+package azure
+
+import (
+ "testing"
+)
+
+func TestNewProviderWithIdentity_Construction(t *testing.T) {
+ // DefaultAzureCredential construction itself does not require any env vars;
+ // failures surface only on the first GetToken call. Verify we get a
+ // non-nil provider back with a token source wired in.
+ p, err := NewProviderWithIdentity("https://example.openai.azure.com", "", "ua-test")
+ if err != nil {
+ t.Fatalf("NewProviderWithIdentity() error = %v", err)
+ }
+ if p == nil {
+ t.Fatal("NewProviderWithIdentity() returned nil provider")
+ }
+ if p.tokenSource == nil {
+ t.Fatal("provider.tokenSource should be set")
+ }
+ if p.apiKey != "" {
+ t.Errorf("provider.apiKey = %q, want empty", p.apiKey)
+ }
+}
+
+func TestNewProviderWithIdentityAndTimeout_Construction(t *testing.T) {
+ p, err := NewProviderWithIdentityAndTimeout("https://example.openai.azure.com", "", "ua-test", 30)
+ if err != nil {
+ t.Fatalf("NewProviderWithIdentityAndTimeout() error = %v", err)
+ }
+ if p == nil {
+ t.Fatal("returned nil provider")
+ }
+ if p.httpClient.Timeout.Seconds() != 30 {
+ t.Errorf("timeout = %v, want 30s", p.httpClient.Timeout)
+ }
+}
diff --git a/pkg/providers/azure/provider.go b/pkg/providers/azure/provider.go
index 7de703248..fdbbf4a30 100644
--- a/pkg/providers/azure/provider.go
+++ b/pkg/providers/azure/provider.go
@@ -33,10 +33,11 @@ const (
// It handles Azure-specific authentication (Bearer token), URL construction
// (Responses API), and request/response formatting.
type Provider struct {
- apiKey string
- apiBase string
- httpClient *http.Client
- userAgent string
+ apiKey string
+ apiBase string
+ httpClient *http.Client
+ userAgent string
+ tokenSource func(ctx context.Context) (string, error)
}
// Option configures the Azure Provider.
@@ -58,6 +59,14 @@ func WithUserAgent(userAgent string) Option {
}
}
+// WithTokenSource sets a callback that returns a bearer token per request.
+// When set, it takes precedence over the static api key.
+func WithTokenSource(ts func(ctx context.Context) (string, error)) Option {
+ return func(p *Provider) {
+ p.tokenSource = ts
+ }
+}
+
// NewProvider creates a new Azure OpenAI provider.
func NewProvider(apiKey, apiBase, proxy, userAgent string, opts ...Option) *Provider {
p := &Provider{
@@ -84,6 +93,30 @@ func NewProviderWithTimeout(apiKey, apiBase, proxy, userAgent string, requestTim
)
}
+// NewProviderWithTokenSource creates a new Azure OpenAI provider that obtains its
+// bearer token from the supplied callback on every request. Used for Entra ID auth
+// where tokens are short-lived and refreshed by the underlying credential.
+func NewProviderWithTokenSource(
+ apiBase, proxy, userAgent string,
+ tokenSource func(ctx context.Context) (string, error),
+ opts ...Option,
+) *Provider {
+ p := &Provider{
+ apiBase: strings.TrimRight(apiBase, "/"),
+ userAgent: userAgent,
+ httpClient: common.NewHTTPClient(proxy),
+ tokenSource: tokenSource,
+ }
+
+ for _, opt := range opts {
+ if opt != nil {
+ opt(p)
+ }
+ }
+
+ return p
+}
+
// Chat sends a request to the Azure OpenAI Responses API endpoint.
// The model parameter is passed in the request body.
func (p *Provider) Chat(
@@ -147,7 +180,14 @@ func (p *Provider) Chat(
}
req.Header.Set("Content-Type", "application/json")
- if p.apiKey != "" {
+ switch {
+ case p.tokenSource != nil:
+ tok, tokErr := p.tokenSource(ctx)
+ if tokErr != nil {
+ return nil, fmt.Errorf("acquiring azure identity token: %w", tokErr)
+ }
+ req.Header.Set("Authorization", "Bearer "+tok)
+ case p.apiKey != "":
req.Header.Set("Authorization", "Bearer "+p.apiKey)
}
if p.userAgent != "" {
diff --git a/pkg/providers/azure/provider_test.go b/pkg/providers/azure/provider_test.go
index 816ae97dc..b39c956fc 100644
--- a/pkg/providers/azure/provider_test.go
+++ b/pkg/providers/azure/provider_test.go
@@ -1,7 +1,9 @@
package azure
import (
+ "context"
"encoding/json"
+ "errors"
"net/http"
"net/http/httptest"
"strings"
@@ -415,3 +417,68 @@ func TestProviderChat_AzureNoNativeWebSearch(t *testing.T) {
t.Errorf("tool type = %v, want %q", tool["type"], "function")
}
}
+
+func TestProviderChat_AzureTokenSourceHeader(t *testing.T) {
+ var capturedAuth string
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ capturedAuth = r.Header.Get("Authorization")
+ writeValidResponse(w)
+ }))
+ defer server.Close()
+
+ ts := func(ctx context.Context) (string, error) {
+ return "fake-entra-token", nil
+ }
+ p := NewProviderWithTokenSource(server.URL, "", "", ts)
+ _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
+ if err != nil {
+ t.Fatalf("Chat() error = %v", err)
+ }
+ if capturedAuth != "Bearer fake-entra-token" {
+ t.Errorf("Authorization header = %q, want %q", capturedAuth, "Bearer fake-entra-token")
+ }
+}
+
+func TestProviderChat_AzureTokenSourceError(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ writeValidResponse(w)
+ }))
+ defer server.Close()
+
+ wantErr := errors.New("creds gone")
+ ts := func(ctx context.Context) (string, error) {
+ return "", wantErr
+ }
+ p := NewProviderWithTokenSource(server.URL, "", "", ts)
+ _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
+ if err == nil {
+ t.Fatal("expected error from token source")
+ }
+ if !strings.Contains(err.Error(), "creds gone") {
+ t.Errorf("error %q should wrap original token source error", err.Error())
+ }
+}
+
+func TestProviderChat_AzureTokenSourcePrecedence(t *testing.T) {
+ var capturedAuth string
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ capturedAuth = r.Header.Get("Authorization")
+ writeValidResponse(w)
+ }))
+ defer server.Close()
+
+ ts := func(ctx context.Context) (string, error) {
+ return "from-token-source", nil
+ }
+ // Provider with both an api_key AND a token source: token source must win.
+ p := NewProvider("static-api-key", server.URL, "", "", WithTokenSource(ts))
+ _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
+ if err != nil {
+ t.Fatalf("Chat() error = %v", err)
+ }
+ if capturedAuth != "Bearer from-token-source" {
+ t.Errorf("Authorization header = %q, want token-source value", capturedAuth)
+ }
+}
diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go
index 7c03daaba..5f0bfe88d 100644
--- a/pkg/providers/factory_provider.go
+++ b/pkg/providers/factory_provider.go
@@ -137,23 +137,32 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
return finalizeProviderFromConfig(provider, modelID, cfg)
case "azure":
- // Azure OpenAI uses deployment-based URLs, api-key header auth,
- // and always sends max_completion_tokens.
- if cfg.APIKey() == "" {
- return nil, "", fmt.Errorf("api_key is required for azure protocol")
- }
+ // Azure OpenAI uses deployment-based URLs. Auth is Bearer token via api_key
+ // when set; otherwise falls back to Entra ID (DefaultAzureCredential).
if cfg.APIBase == "" {
return nil, "", fmt.Errorf(
"api_base is required for azure protocol (e.g., https://your-resource.openai.azure.com)",
)
}
- return finalizeProviderFromConfig(azure.NewProviderWithTimeout(
- cfg.APIKey(),
+ if cfg.APIKey() != "" {
+ return finalizeProviderFromConfig(azure.NewProviderWithTimeout(
+ cfg.APIKey(),
+ cfg.APIBase,
+ cfg.Proxy,
+ userAgent,
+ cfg.RequestTimeout,
+ ), modelID, cfg)
+ }
+ provider, err := azure.NewProviderWithIdentityAndTimeout(
cfg.APIBase,
cfg.Proxy,
userAgent,
cfg.RequestTimeout,
- ), modelID, cfg)
+ )
+ if err != nil {
+ return nil, "", err
+ }
+ return finalizeProviderFromConfig(provider, modelID, cfg)
case "bedrock":
// AWS Bedrock uses AWS SDK credentials (env vars, profiles, IAM roles, etc.)
diff --git a/pkg/providers/factory_provider_azidentity_test.go b/pkg/providers/factory_provider_azidentity_test.go
new file mode 100644
index 000000000..98dfc211e
--- /dev/null
+++ b/pkg/providers/factory_provider_azidentity_test.go
@@ -0,0 +1,36 @@
+//go:build azidentity
+
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+//
+// Copyright (c) 2026 PicoClaw contributors
+
+package providers
+
+import (
+ "testing"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+// With the azidentity build tag, an azure config with no api_key must succeed
+// (falls back to DefaultAzureCredential). Construction does not require any
+// real Azure environment — token acquisition happens on first Chat.
+func TestCreateProviderFromConfig_AzureIdentityFallback(t *testing.T) {
+ cfg := &config.ModelConfig{
+ ModelName: "azure-gpt5",
+ Model: "azure/my-gpt5-deployment",
+ APIBase: "https://my-resource.openai.azure.com",
+ }
+
+ provider, modelID, err := CreateProviderFromConfig(cfg)
+ if err != nil {
+ t.Fatalf("CreateProviderFromConfig() error = %v", err)
+ }
+ if provider == nil {
+ t.Fatal("CreateProviderFromConfig() returned nil provider")
+ }
+ if modelID != "my-gpt5-deployment" {
+ t.Errorf("modelID = %q, want %q", modelID, "my-gpt5-deployment")
+ }
+}
diff --git a/pkg/providers/factory_provider_test.go b/pkg/providers/factory_provider_test.go
index 0b3dd791b..c4ef8a4aa 100644
--- a/pkg/providers/factory_provider_test.go
+++ b/pkg/providers/factory_provider_test.go
@@ -870,8 +870,11 @@ func TestCreateProviderFromConfig_AzureMissingAPIKey(t *testing.T) {
}
_, _, err := CreateProviderFromConfig(cfg)
- if err == nil {
- t.Fatal("CreateProviderFromConfig() expected error for missing API key")
+ // Without api_key the factory falls back to identity auth, which in the
+ // default build is stubbed out and surfaces a build-tag error. With the
+ // azidentity tag, the call succeeds and is covered by a separate test.
+ if err != nil && !strings.Contains(err.Error(), "azidentity") {
+ t.Fatalf("CreateProviderFromConfig() unexpected error = %v", err)
}
}
diff --git a/pkg/providers/oauth/codex_provider.go b/pkg/providers/oauth/codex_provider.go
index 0b125997b..b0d7bd758 100644
--- a/pkg/providers/oauth/codex_provider.go
+++ b/pkg/providers/oauth/codex_provider.go
@@ -104,8 +104,12 @@ func (p *CodexProvider) Chat(
defer stream.Close()
var resp *responses.Response
+ var streamedText strings.Builder
for stream.Next() {
evt := stream.Current()
+ if evt.Type == "response.output_text.delta" {
+ streamedText.WriteString(evt.Delta)
+ }
if evt.Type == "response.completed" || evt.Type == "response.failed" || evt.Type == "response.incomplete" {
evtResp := evt.Response
if evtResp.ID != "" {
@@ -153,7 +157,11 @@ func (p *CodexProvider) Chat(
return nil, fmt.Errorf("codex API call: stream ended without completed response")
}
- return orc.ParseResponseFromStruct(resp), nil
+ parsed := orc.ParseResponseFromStruct(resp)
+ if parsed.Content == "" && streamedText.Len() > 0 {
+ parsed.Content = streamedText.String()
+ }
+ return parsed, nil
}
func (p *CodexProvider) GetDefaultModel() string {
diff --git a/pkg/providers/oauth/codex_provider_test.go b/pkg/providers/oauth/codex_provider_test.go
index aeeb18360..8deeb8d2a 100644
--- a/pkg/providers/oauth/codex_provider_test.go
+++ b/pkg/providers/oauth/codex_provider_test.go
@@ -374,6 +374,51 @@ func TestCodexProvider_ChatRoundTrip(t *testing.T) {
}
}
+func TestCodexProvider_ChatRoundTrip_OutputTextDeltaFallback(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/responses" {
+ http.Error(w, "not found: "+r.URL.Path, http.StatusNotFound)
+ return
+ }
+
+ var reqBody map[string]any
+ if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
+ http.Error(w, "invalid json", http.StatusBadRequest)
+ return
+ }
+ if reqBody["stream"] != true {
+ http.Error(w, "stream must be true", http.StatusBadRequest)
+ return
+ }
+
+ resp := map[string]any{
+ "id": "resp_test",
+ "object": "response",
+ "status": "completed",
+ "output": nil,
+ }
+ writeOutputTextDeltaSSE(w, "OK", resp)
+ }))
+ defer server.Close()
+
+ provider := NewCodexProvider("test-token", "acc-123")
+ provider.client = createOpenAITestClient(server.URL, "test-token", "acc-123")
+
+ resp, err := provider.Chat(
+ t.Context(),
+ []Message{{Role: "user", Content: "Hello"}},
+ nil,
+ "gpt-4o",
+ map[string]any{},
+ )
+ if err != nil {
+ t.Fatalf("Chat() error: %v", err)
+ }
+ if resp.Content != "OK" {
+ t.Errorf("Content = %q, want %q", resp.Content, "OK")
+ }
+}
+
func TestCodexProvider_ChatRoundTrip_WebSearchDisabled(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/responses" {
@@ -647,3 +692,24 @@ func writeCompletedSSE(w http.ResponseWriter, response map[string]any) {
fmt.Fprintf(w, "data: %s\n\n", string(b))
fmt.Fprintf(w, "data: [DONE]\n\n")
}
+
+func writeOutputTextDeltaSSE(w http.ResponseWriter, delta string, response map[string]any) {
+ deltaEvent := map[string]any{
+ "type": "response.output_text.delta",
+ "sequence_number": 1,
+ "delta": delta,
+ }
+ completedEvent := map[string]any{
+ "type": "response.completed",
+ "sequence_number": 2,
+ "response": response,
+ }
+ deltaBytes, _ := json.Marshal(deltaEvent)
+ completedBytes, _ := json.Marshal(completedEvent)
+ w.Header().Set("Content-Type", "text/event-stream")
+ fmt.Fprintf(w, "event: response.output_text.delta\n")
+ fmt.Fprintf(w, "data: %s\n\n", string(deltaBytes))
+ fmt.Fprintf(w, "event: response.completed\n")
+ fmt.Fprintf(w, "data: %s\n\n", string(completedBytes))
+ fmt.Fprintf(w, "data: [DONE]\n\n")
+}
diff --git a/pkg/seahorse/short_engine.go b/pkg/seahorse/short_engine.go
index d275da516..ef493bd18 100644
--- a/pkg/seahorse/short_engine.go
+++ b/pkg/seahorse/short_engine.go
@@ -9,6 +9,7 @@ import (
"regexp"
"strings"
"sync"
+ "time"
_ "modernc.org/sqlite"
@@ -261,6 +262,7 @@ func (e *Engine) Ingest(ctx context.Context, sessionKey string, messages []Messa
msg.ModelName,
msg.ReasoningContent,
msg.TokenCount,
+ msg.CreatedAt,
)
} else {
added, err = e.store.AddMessageWithReasoning(
@@ -271,6 +273,7 @@ func (e *Engine) Ingest(ctx context.Context, sessionKey string, messages []Messa
msg.ModelName,
msg.ReasoningContent,
msg.TokenCount,
+ msg.CreatedAt,
)
}
if err != nil {
@@ -445,10 +448,14 @@ func (e *Engine) Bootstrap(ctx context.Context, sessionKey string, messages []Me
if err != nil {
return fmt.Errorf("bootstrap: repair model_name: %w", err)
}
- if (repairedReasoning || repairedModelName) && len(dbMsgs) == len(messages) {
+ repairedCreatedAt, err := e.repairBootstrapCreatedAt(ctx, dbMsgs, messages)
+ if err != nil {
+ return fmt.Errorf("bootstrap: repair created_at: %w", err)
+ }
+ if (repairedReasoning || repairedModelName || repairedCreatedAt) && len(dbMsgs) == len(messages) {
matched := true
for i := range messages {
- if !messageMatches(dbMsgs[i], messages[i]) {
+ if !messagesMatch(dbMsgs[i], messages[i], messageMatchOptions{}) {
matched = false
break
}
@@ -462,7 +469,7 @@ func (e *Engine) Bootstrap(ctx context.Context, sessionKey string, messages []Me
if len(dbMsgs) == len(messages) {
matched := true
for i := range messages {
- if !messageMatches(dbMsgs[i], messages[i]) {
+ if !messagesMatch(dbMsgs[i], messages[i], messageMatchOptions{}) {
matched = false
break
}
@@ -477,7 +484,7 @@ func (e *Engine) Bootstrap(ctx context.Context, sessionKey string, messages []Me
compareLen := min(len(dbMsgs), len(messages))
for i := range compareLen {
- if messageMatches(dbMsgs[i], messages[i]) {
+ if messagesMatch(dbMsgs[i], messages[i], messageMatchOptions{}) {
anchor = i
} else {
// Mismatch detected - log details and rebuild
@@ -578,7 +585,11 @@ func (e *Engine) repairBootstrapReasoningContent(ctx context.Context, dbMsgs, me
}
for i := range overlap {
- if !messageMatchesIgnoringReasoningAndModelName(dbMsgs[i], messages[i]) {
+ if !messagesMatch(dbMsgs[i], messages[i], messageMatchOptions{
+ IgnoreReasoningContent: true,
+ IgnoreModelName: true,
+ IgnoreCreatedAt: true,
+ }) {
return false, nil
}
if dbMsgs[i].ReasoningContent == messages[i].ReasoningContent {
@@ -629,7 +640,11 @@ func (e *Engine) repairBootstrapModelName(ctx context.Context, dbMsgs, messages
}
for i := range overlap {
- if !messageMatchesIgnoringReasoningAndModelName(dbMsgs[i], messages[i]) {
+ if !messagesMatch(dbMsgs[i], messages[i], messageMatchOptions{
+ IgnoreReasoningContent: true,
+ IgnoreModelName: true,
+ IgnoreCreatedAt: true,
+ }) {
return false, nil
}
if dbMsgs[i].ModelName == messages[i].ModelName {
@@ -666,6 +681,64 @@ func (e *Engine) repairBootstrapModelName(ctx context.Context, dbMsgs, messages
return true, nil
}
+func (e *Engine) repairBootstrapCreatedAt(ctx context.Context, dbMsgs, messages []Message) (bool, error) {
+ if len(dbMsgs) == 0 || len(messages) == 0 {
+ return false, nil
+ }
+
+ overlap := min(len(messages), len(dbMsgs))
+
+ var updates []struct {
+ index int
+ messageID int64
+ createdAt time.Time
+ }
+
+ for i := range overlap {
+ if !messagesMatch(dbMsgs[i], messages[i], messageMatchOptions{
+ IgnoreReasoningContent: true,
+ IgnoreModelName: true,
+ IgnoreCreatedAt: true,
+ }) {
+ return false, nil
+ }
+
+ wantCreatedAt := normalizeMessageCreatedAt(messages[i].CreatedAt)
+ if wantCreatedAt.IsZero() {
+ return false, nil
+ }
+ if dbMsgs[i].CreatedAt.Equal(wantCreatedAt) {
+ continue
+ }
+
+ updates = append(updates, struct {
+ index int
+ messageID int64
+ createdAt time.Time
+ }{
+ index: i,
+ messageID: dbMsgs[i].ID,
+ createdAt: wantCreatedAt,
+ })
+ }
+
+ if len(updates) == 0 {
+ return false, nil
+ }
+
+ for _, update := range updates {
+ if err := e.store.UpdateMessageCreatedAt(ctx, update.messageID, update.createdAt); err != nil {
+ return false, err
+ }
+ dbMsgs[update.index].CreatedAt = update.createdAt
+ }
+
+ logger.InfoCF("seahorse", "bootstrap: repaired message created_at", map[string]any{
+ "messages": len(updates),
+ })
+ return true, nil
+}
+
// truncate shortens a string for logging.
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
@@ -674,29 +747,28 @@ func truncate(s string, maxLen int) string {
return s[:maxLen] + "..."
}
-// messageMatches compares two messages using role + reasoning_content and then
-// either content or parts. TokenCount is NOT compared because it may be
-// re-estimated differently during bootstrap (e.g., via tokenizer.EstimateMessageTokens).
-// For messages with Parts (tool_use, tool_result), compare Parts instead of Content
-// because structured messages are matched by their parts payload.
-func messageMatches(a, b Message) bool {
- if a.Role != b.Role || a.ReasoningContent != b.ReasoningContent || a.ModelName != b.ModelName {
- return false
- }
- return messageMatchesIgnoringReasoning(a, b)
+type messageMatchOptions struct {
+ IgnoreReasoningContent bool
+ IgnoreModelName bool
+ IgnoreCreatedAt bool
}
-func messageMatchesIgnoringReasoning(a, b Message) bool {
- if a.ModelName != b.ModelName {
- return false
- }
- return messageMatchesIgnoringReasoningAndModelName(a, b)
-}
-
-func messageMatchesIgnoringReasoningAndModelName(a, b Message) bool {
+// messagesMatch compares two messages by role and payload, plus the optional
+// metadata fields used by bootstrap repair. TokenCount is intentionally ignored
+// because bootstrap may re-estimate it differently.
+func messagesMatch(a, b Message, opts messageMatchOptions) bool {
if a.Role != b.Role {
return false
}
+ if !opts.IgnoreReasoningContent && a.ReasoningContent != b.ReasoningContent {
+ return false
+ }
+ if !opts.IgnoreModelName && a.ModelName != b.ModelName {
+ return false
+ }
+ if !opts.IgnoreCreatedAt && !messageCreatedAtMatches(a.CreatedAt, b.CreatedAt) {
+ return false
+ }
// If either message has Parts, compare Parts
if len(a.Parts) > 0 || len(b.Parts) > 0 {
return partsMatch(a.Parts, b.Parts)
@@ -705,6 +777,18 @@ func messageMatchesIgnoringReasoningAndModelName(a, b Message) bool {
return a.Content == b.Content
}
+// messageCreatedAtMatches treats missing timestamps as compatible so bootstrap
+// can preserve legacy histories while still enforcing exact equality once both
+// sides carry canonical created_at values.
+func messageCreatedAtMatches(a, b time.Time) bool {
+ na := normalizeMessageCreatedAt(a)
+ nb := normalizeMessageCreatedAt(b)
+ if na.IsZero() || nb.IsZero() {
+ return true
+ }
+ return na.Equal(nb)
+}
+
// partsMatch compares two slices of MessagePart for equality.
func partsMatch(a, b []MessagePart) bool {
if len(a) != len(b) {
diff --git a/pkg/seahorse/short_engine_test.go b/pkg/seahorse/short_engine_test.go
index 337416f6f..2e198673d 100644
--- a/pkg/seahorse/short_engine_test.go
+++ b/pkg/seahorse/short_engine_test.go
@@ -57,8 +57,8 @@ func prepareBootstrapRepairConversation(
}
return conv, []Message{
- {Role: "user", Content: "hello", TokenCount: 3},
- {Role: "assistant", Content: "world", TokenCount: 3},
+ {Role: "user", Content: "hello", TokenCount: 3, CreatedAt: userMsg.CreatedAt},
+ {Role: "assistant", Content: "world", TokenCount: 3, CreatedAt: assistantMsg.CreatedAt},
}
}
@@ -464,13 +464,19 @@ func TestBootstrapRepairsReasoningContentAndModelNameTogether(t *testing.T) {
}
err = eng.Bootstrap(ctx, sessionKey, []Message{
- {Role: "user", Content: "hello", TokenCount: 3},
+ {
+ Role: "user",
+ Content: "hello",
+ TokenCount: 3,
+ CreatedAt: time.Date(2026, 3, 4, 5, 6, 7, 0, time.UTC),
+ },
{
Role: "assistant",
Content: "world",
ModelName: "gpt-5.4",
ReasoningContent: "let me think this through",
TokenCount: 3,
+ CreatedAt: time.Date(2026, 3, 4, 5, 6, 8, 0, time.UTC),
},
})
if err != nil {
@@ -515,6 +521,7 @@ func TestBootstrapRepairsIncorrectNonEmptyModelName(t *testing.T) {
"wrong-model",
"",
3,
+ time.Time{},
)
if err != nil {
t.Fatalf("AddMessageWithReasoning assistant: %v", err)
@@ -545,6 +552,64 @@ func TestBootstrapRepairsIncorrectNonEmptyModelName(t *testing.T) {
}
}
+func TestBootstrapRepairsCreatedAt(t *testing.T) {
+ eng := newTestEngine(t)
+ ctx := context.Background()
+ sessionKey := "agent:repair-created-at"
+ conv, msgs := prepareBootstrapRepairConversation(t, eng, ctx, sessionKey)
+
+ wantCreatedAt := time.Date(2026, 3, 4, 5, 6, 7, 0, time.UTC)
+ msgs[1].CreatedAt = wantCreatedAt
+
+ err := eng.Bootstrap(ctx, sessionKey, msgs)
+ if err != nil {
+ t.Fatalf("Bootstrap: %v", err)
+ }
+
+ stored, err := eng.store.GetMessages(ctx, conv.ConversationID, 10, 0)
+ if err != nil {
+ t.Fatalf("GetMessages: %v", err)
+ }
+ if len(stored) != 2 {
+ t.Fatalf("stored messages = %d, want 2", len(stored))
+ }
+ if !stored[1].CreatedAt.Equal(wantCreatedAt) {
+ t.Fatalf("stored[1].CreatedAt = %v, want %v", stored[1].CreatedAt, wantCreatedAt)
+ }
+}
+
+func TestEngineIngestPreservesCreatedAt(t *testing.T) {
+ eng := newTestEngine(t)
+ ctx := context.Background()
+ wantCreatedAt := time.Date(2026, 4, 5, 6, 7, 8, 0, time.UTC)
+
+ msgs := []Message{
+ {
+ Role: "assistant",
+ Content: "world",
+ TokenCount: 4,
+ CreatedAt: wantCreatedAt,
+ },
+ }
+
+ _, err := eng.Ingest(ctx, "agent:created-at", msgs)
+ if err != nil {
+ t.Fatalf("Ingest: %v", err)
+ }
+
+ conv, _ := eng.store.GetOrCreateConversation(ctx, "agent:created-at")
+ stored, err := eng.store.GetMessages(ctx, conv.ConversationID, 10, 0)
+ if err != nil {
+ t.Fatalf("GetMessages: %v", err)
+ }
+ if len(stored) != 1 {
+ t.Fatalf("stored messages = %d, want 1", len(stored))
+ }
+ if !stored[0].CreatedAt.Equal(wantCreatedAt) {
+ t.Fatalf("stored[0].CreatedAt = %v, want %v", stored[0].CreatedAt, wantCreatedAt)
+ }
+}
+
func TestEngineIngestWithPartsPreservesReasoningContent(t *testing.T) {
eng := newTestEngine(t)
ctx := context.Background()
@@ -864,8 +929,19 @@ func TestBootstrapRepairsMissingReasoningContentWithoutDroppingSummaries(t *test
}
err = eng.Bootstrap(ctx, sessionKey, []Message{
- {Role: "user", Content: "hello", TokenCount: 3},
- {Role: "assistant", Content: "world", ReasoningContent: "let me think this through", TokenCount: 3},
+ {
+ Role: "user",
+ Content: "hello",
+ TokenCount: 3,
+ CreatedAt: time.Date(2026, 3, 4, 5, 6, 7, 0, time.UTC),
+ },
+ {
+ Role: "assistant",
+ Content: "world",
+ ReasoningContent: "let me think this through",
+ TokenCount: 3,
+ CreatedAt: time.Date(2026, 3, 4, 5, 6, 8, 0, time.UTC),
+ },
})
if err != nil {
t.Fatalf("Bootstrap: %v", err)
diff --git a/pkg/seahorse/store.go b/pkg/seahorse/store.go
index b5e32e89d..0a0d07044 100644
--- a/pkg/seahorse/store.go
+++ b/pkg/seahorse/store.go
@@ -8,6 +8,8 @@ import (
"time"
)
+const sqliteTimeLayout = "2006-01-02 15:04:05"
+
// Store provides SQLite storage for seahorse.
type Store struct {
db *sql.DB
@@ -75,8 +77,8 @@ func (s *Store) GetConversationBySessionKey(ctx context.Context, sessionKey stri
if err != nil {
return nil, fmt.Errorf("get conversation by session key: %w", err)
}
- conv.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
- conv.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
+ conv.CreatedAt = parseSQLiteTime(createdAt)
+ conv.UpdatedAt = parseSQLiteTime(updatedAt)
return &conv, nil
}
@@ -153,8 +155,8 @@ func (s *Store) getMessageTimeRange(ctx context.Context, convID int64) (time.Tim
if err != nil || minTime == "" {
return time.Time{}, time.Time{}, err
}
- oldest, _ := time.Parse("2006-01-02 15:04:05", minTime)
- newest, _ := time.Parse("2006-01-02 15:04:05", maxTime)
+ oldest := parseSQLiteTime(minTime)
+ newest := parseSQLiteTime(maxTime)
return oldest, newest, nil
}
@@ -162,7 +164,7 @@ func (s *Store) getMessageTimeRange(ctx context.Context, convID int64) (time.Tim
// AddMessage appends a message to a conversation.
func (s *Store) AddMessage(ctx context.Context, convID int64, role, content string, tokenCount int) (*Message, error) {
- return s.AddMessageWithReasoning(ctx, convID, role, content, "", "", tokenCount)
+ return s.AddMessageWithReasoning(ctx, convID, role, content, "", "", tokenCount, time.Time{})
}
// AddMessageWithReasoning appends a message with reasoning content to a conversation.
@@ -171,16 +173,22 @@ func (s *Store) AddMessageWithReasoning(
convID int64,
role, content, modelName, reasoningContent string,
tokenCount int,
+ createdAt time.Time,
) (*Message, error) {
+ storedCreatedAt := normalizeMessageCreatedAt(createdAt)
+ if storedCreatedAt.IsZero() {
+ storedCreatedAt = normalizeMessageCreatedAt(time.Now())
+ }
result, err := s.db.ExecContext(
ctx,
- "INSERT INTO messages (conversation_id, role, content, model_name, reasoning_content, token_count) VALUES (?, ?, ?, ?, ?, ?)",
+ "INSERT INTO messages (conversation_id, role, content, model_name, reasoning_content, token_count, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
convID,
role,
content,
modelName,
reasoningContent,
tokenCount,
+ formatSQLiteTime(storedCreatedAt),
)
if err != nil {
return nil, fmt.Errorf("add message: %w", err)
@@ -194,6 +202,7 @@ func (s *Store) AddMessageWithReasoning(
ModelName: modelName,
ReasoningContent: reasoningContent,
TokenCount: tokenCount,
+ CreatedAt: storedCreatedAt,
}, nil
}
@@ -231,7 +240,7 @@ func (s *Store) AddMessageWithParts(
parts []MessagePart,
tokenCount int,
) (*Message, error) {
- return s.AddMessageWithPartsAndReasoning(ctx, convID, role, parts, "", "", tokenCount)
+ return s.AddMessageWithPartsAndReasoning(ctx, convID, role, parts, "", "", tokenCount, time.Time{})
}
// AddMessageWithPartsAndReasoning adds a message with structured parts and reasoning content.
@@ -243,6 +252,7 @@ func (s *Store) AddMessageWithPartsAndReasoning(
modelName string,
reasoningContent string,
tokenCount int,
+ createdAt time.Time,
) (*Message, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
@@ -250,18 +260,24 @@ func (s *Store) AddMessageWithPartsAndReasoning(
}
defer tx.Rollback()
+ storedCreatedAt := normalizeMessageCreatedAt(createdAt)
+ if storedCreatedAt.IsZero() {
+ storedCreatedAt = normalizeMessageCreatedAt(time.Now())
+ }
+
// Derive readable content from Parts for FTS5 indexing and summary formatting
readableContent := partsToReadableContent(parts)
result, err := tx.ExecContext(
ctx,
- "INSERT INTO messages (conversation_id, role, content, model_name, reasoning_content, token_count) VALUES (?, ?, ?, ?, ?, ?)",
+ "INSERT INTO messages (conversation_id, role, content, model_name, reasoning_content, token_count, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
convID,
role,
readableContent,
modelName,
reasoningContent,
tokenCount,
+ formatSQLiteTime(storedCreatedAt),
)
if err != nil {
return nil, fmt.Errorf("add message: %w", err)
@@ -299,6 +315,7 @@ func (s *Store) AddMessageWithPartsAndReasoning(
ModelName: modelName,
ReasoningContent: reasoningContent,
TokenCount: tokenCount,
+ CreatedAt: storedCreatedAt,
Parts: make([]MessagePart, len(parts)),
}
for i, p := range parts {
@@ -344,7 +361,7 @@ func (s *Store) GetMessages(ctx context.Context, convID int64, limit int, before
); err != nil {
return nil, err
}
- msg.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
+ msg.CreatedAt = parseSQLiteTime(createdAt)
msgs = append(msgs, msg)
}
if err := rows.Err(); err != nil {
@@ -387,7 +404,7 @@ func (s *Store) GetMessageByID(ctx context.Context, messageID int64) (*Message,
if err != nil {
return nil, err
}
- msg.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
+ msg.CreatedAt = parseSQLiteTime(createdAt)
msg.Parts, _ = s.loadMessageParts(ctx, msg.ID)
return &msg, nil
}
@@ -435,6 +452,32 @@ func (s *Store) UpdateMessageModelName(ctx context.Context, messageID int64, mod
return nil
}
+func (s *Store) UpdateMessageCreatedAt(ctx context.Context, messageID int64, createdAt time.Time) error {
+ storedCreatedAt := normalizeMessageCreatedAt(createdAt)
+ if storedCreatedAt.IsZero() {
+ return fmt.Errorf("message %d created_at cannot be zero", messageID)
+ }
+
+ result, err := s.db.ExecContext(
+ ctx,
+ "UPDATE messages SET created_at = ? WHERE message_id = ?",
+ formatSQLiteTime(storedCreatedAt),
+ messageID,
+ )
+ if err != nil {
+ return fmt.Errorf("update message created_at: %w", err)
+ }
+
+ rowsAffected, err := result.RowsAffected()
+ if err != nil {
+ return fmt.Errorf("update message created_at rows affected: %w", err)
+ }
+ if rowsAffected == 0 {
+ return fmt.Errorf("message %d not found", messageID)
+ }
+ return nil
+}
+
func (s *Store) loadMessageParts(ctx context.Context, msgID int64) ([]MessagePart, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT part_id, message_id, type, text, name, arguments, tool_call_id, media_uri, mime_type
@@ -648,7 +691,7 @@ func (s *Store) GetSummarySourceMessages(ctx context.Context, summaryID string)
); err != nil {
return nil, err
}
- msg.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
+ msg.CreatedAt = parseSQLiteTime(createdAt)
msgs = append(msgs, msg)
}
if err := rows.Err(); err != nil {
@@ -714,8 +757,7 @@ func (s *Store) GetContextItems(ctx context.Context, convID int64) ([]ContextIte
item.MessageID = messageID.Int64
}
if createdAt.Valid {
- t, _ := time.Parse("2006-01-02 15:04:05", createdAt.String)
- item.CreatedAt = t
+ item.CreatedAt = parseSQLiteTime(createdAt.String)
}
items = append(items, item)
}
@@ -1449,7 +1491,7 @@ func (s *Store) scanSearchResults(rows *sql.Rows, withRank bool) ([]SearchResult
}
}
r.Kind = SummaryKind(kind)
- r.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
+ r.CreatedAt = parseSQLiteTime(createdAt)
results = append(results, r)
}
return results, nil
@@ -1573,7 +1615,7 @@ func (s *Store) scanMessageSearchResults(rows *sql.Rows, withRank bool) ([]Searc
}
}
r.Snippet = content
- r.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
+ r.CreatedAt = parseSQLiteTime(createdAt)
results = append(results, r)
}
if err := rows.Err(); err != nil {
@@ -1606,7 +1648,7 @@ func (s *Store) scanSummary(ctx context.Context, where string, args ...any) (*Su
return nil, err
}
sum.Kind = SummaryKind(kind)
- sum.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
+ sum.CreatedAt = parseSQLiteTime(createdAt)
if earliestAt.Valid {
t, _ := time.Parse(time.RFC3339, earliestAt.String)
sum.EarliestAt = &t
@@ -1633,7 +1675,7 @@ func (s *Store) scanSummaries(rows *sql.Rows) ([]Summary, error) {
return nil, err
}
sum.Kind = SummaryKind(kind)
- sum.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
+ sum.CreatedAt = parseSQLiteTime(createdAt)
if earliestAt.Valid {
t, _ := time.Parse(time.RFC3339, earliestAt.String)
sum.EarliestAt = &t
@@ -1659,6 +1701,22 @@ func isUniqueViolation(err error) bool {
contains(err.Error(), "constraint failed"))
}
+func normalizeMessageCreatedAt(createdAt time.Time) time.Time {
+ if createdAt.IsZero() {
+ return time.Time{}
+ }
+ return createdAt.UTC().Truncate(time.Second)
+}
+
+func formatSQLiteTime(t time.Time) string {
+ return normalizeMessageCreatedAt(t).Format(sqliteTimeLayout)
+}
+
+func parseSQLiteTime(raw string) time.Time {
+ parsed, _ := time.Parse(sqliteTimeLayout, raw)
+ return parsed
+}
+
func contains(s, sub string) bool {
return len(s) >= len(sub) && searchSubstring(s, sub)
}
diff --git a/pkg/seahorse/store_test.go b/pkg/seahorse/store_test.go
index 4ed2bb3bb..785873150 100644
--- a/pkg/seahorse/store_test.go
+++ b/pkg/seahorse/store_test.go
@@ -213,6 +213,7 @@ func TestStoreAddAndGetMessagesWithReasoningContent(t *testing.T) {
"gpt-5.4-mini",
"let me think",
5,
+ time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
)
if err != nil {
t.Fatalf("AddMessageWithReasoning: %v", err)
@@ -223,6 +224,9 @@ func TestStoreAddAndGetMessagesWithReasoningContent(t *testing.T) {
if msg.ModelName != "gpt-5.4-mini" {
t.Fatalf("ModelName = %q, want %q", msg.ModelName, "gpt-5.4-mini")
}
+ if !msg.CreatedAt.Equal(time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)) {
+ t.Fatalf("CreatedAt = %v, want 2026-01-02 03:04:05 UTC", msg.CreatedAt)
+ }
msgs, err := s.GetMessages(ctx, conv.ConversationID, 10, 0)
if err != nil {
@@ -237,6 +241,9 @@ func TestStoreAddAndGetMessagesWithReasoningContent(t *testing.T) {
if msgs[0].ModelName != "gpt-5.4-mini" {
t.Errorf("ModelName = %q, want %q", msgs[0].ModelName, "gpt-5.4-mini")
}
+ if !msgs[0].CreatedAt.Equal(time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)) {
+ t.Errorf("CreatedAt = %v, want 2026-01-02 03:04:05 UTC", msgs[0].CreatedAt)
+ }
found, err := s.GetMessageByID(ctx, msg.ID)
if err != nil {
@@ -248,6 +255,9 @@ func TestStoreAddAndGetMessagesWithReasoningContent(t *testing.T) {
if found.ModelName != "gpt-5.4-mini" {
t.Errorf("GetMessageByID ModelName = %q, want %q", found.ModelName, "gpt-5.4-mini")
}
+ if !found.CreatedAt.Equal(time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)) {
+ t.Errorf("GetMessageByID CreatedAt = %v, want 2026-01-02 03:04:05 UTC", found.CreatedAt)
+ }
}
func TestStoreAddMessageWithParts(t *testing.T) {
@@ -301,6 +311,7 @@ func TestStoreAddMessageWithPartsAndReasoningContent(t *testing.T) {
"gpt-5.4",
"need to inspect the file first",
10,
+ time.Date(2026, 2, 3, 4, 5, 6, 0, time.UTC),
)
if err != nil {
t.Fatalf("AddMessageWithPartsAndReasoning: %v", err)
@@ -323,6 +334,9 @@ func TestStoreAddMessageWithPartsAndReasoningContent(t *testing.T) {
if msgs[0].ModelName != "gpt-5.4" {
t.Errorf("ModelName = %q, want %q", msgs[0].ModelName, "gpt-5.4")
}
+ if !msgs[0].CreatedAt.Equal(time.Date(2026, 2, 3, 4, 5, 6, 0, time.UTC)) {
+ t.Errorf("CreatedAt = %v, want 2026-02-03 04:05:06 UTC", msgs[0].CreatedAt)
+ }
}
func TestStoreGetMessageCount(t *testing.T) {
diff --git a/pkg/session/manager.go b/pkg/session/manager.go
index 7ca03744d..f75e3e883 100644
--- a/pkg/session/manager.go
+++ b/pkg/session/manager.go
@@ -60,6 +60,21 @@ func (sm *SessionManager) GetOrCreate(key string) *Session {
return session
}
+func ensureMessageCreatedAt(msg *providers.Message, fallback time.Time) {
+ if msg.CreatedAt != nil && !msg.CreatedAt.IsZero() {
+ return
+ }
+ ts := fallback
+ msg.CreatedAt = &ts
+}
+
+func normalizeHistoryCreatedAt(history []providers.Message) {
+ now := time.Now()
+ for i := range history {
+ ensureMessageCreatedAt(&history[i], now)
+ }
+}
+
func (sm *SessionManager) AddMessage(sessionKey, role, content string) {
sm.AddFullMessage(sessionKey, providers.Message{
Role: role,
@@ -88,9 +103,7 @@ func (sm *SessionManager) AddFullMessage(sessionKey string, msg providers.Messag
}
now := time.Now()
- if msg.CreatedAt == nil {
- msg.CreatedAt = &now
- }
+ ensureMessageCreatedAt(&msg, now)
session.Messages = append(session.Messages, msg)
session.Updated = now
@@ -280,6 +293,7 @@ func (sm *SessionManager) loadSessions() error {
continue
}
session.Messages = messageutil.FilterInvalidHistoryMessages(session.Messages)
+ normalizeHistoryCreatedAt(session.Messages)
sm.sessions[session.Key] = &session
}
@@ -305,13 +319,8 @@ func (sm *SessionManager) SetHistory(key string, history []providers.Message) {
// from the caller's slice.
msgs := make([]providers.Message, len(history))
copy(msgs, history)
- now := time.Now()
- for i := range msgs {
- if msgs[i].CreatedAt == nil {
- msgs[i].CreatedAt = &now
- }
- }
+ normalizeHistoryCreatedAt(msgs)
session.Messages = msgs
- session.Updated = now
+ session.Updated = time.Now()
}
}
diff --git a/pkg/session/manager_test.go b/pkg/session/manager_test.go
index bc5615966..e167941e7 100644
--- a/pkg/session/manager_test.go
+++ b/pkg/session/manager_test.go
@@ -83,3 +83,32 @@ func TestSave_RejectsPathTraversal(t *testing.T) {
t.Errorf("expected foo_bar.json in storage (sanitized from foo/bar)")
}
}
+
+func TestLoadSessions_NormalizesMissingCreatedAt(t *testing.T) {
+ tmpDir := t.TempDir()
+ sessionPath := filepath.Join(tmpDir, "telegram_legacy.json")
+ legacy := `{
+ "key": "telegram:legacy",
+ "messages": [
+ {
+ "role": "user",
+ "content": "hello"
+ }
+ ],
+ "created": "2026-01-01T00:00:00Z",
+ "updated": "2026-01-01T00:00:00Z"
+}`
+
+ if err := os.WriteFile(sessionPath, []byte(legacy), 0o644); err != nil {
+ t.Fatalf("WriteFile: %v", err)
+ }
+
+ sm := NewSessionManager(tmpDir)
+ history := sm.GetHistory("telegram:legacy")
+ if len(history) != 1 {
+ t.Fatalf("history = %d, want 1", len(history))
+ }
+ if history[0].CreatedAt == nil || history[0].CreatedAt.IsZero() {
+ t.Fatalf("history[0].CreatedAt = %v, want non-zero timestamp", history[0].CreatedAt)
+ }
+}
diff --git a/pkg/tools/integration/message.go b/pkg/tools/integration/message.go
index 98d87bcb3..fbd8305c6 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 {
@@ -15,11 +37,15 @@ type sentTarget struct {
}
type MessageTool struct {
- sendCallback SendCallbackWithContext
- 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
+ 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 {
@@ -33,32 +59,86 @@ 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."
+ 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": "The message content to send",
- },
- "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.",
+ },
+ "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",
},
- "required": []string{"content"},
}
+ 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(
+ 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
+ t.localMediaEnabled = true
+}
+
+func (t *MessageTool) SetMediaStore(store media.MediaStore) {
+ t.mediaStore = store
}
// ResetSentInRound resets the per-round send tracker for the given session key.
@@ -98,9 +178,20 @@ 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 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}
}
channel, _ := args["channel"].(string)
@@ -122,7 +213,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 +231,149 @@ 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.localMediaEnabled {
+ return nil, fmt.Errorf("message media attachments are disabled")
+ }
+ 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..eea345c1c 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
})
@@ -228,7 +252,7 @@ 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")
+ t.Fatal("Expected content-only required schema when local media is disabled")
}
// Check content property
@@ -240,6 +264,10 @@ func TestMessageTool_Parameters(t *testing.T) {
t.Error("Expected content type to be 'string'")
}
+ if _, hasMedia := props["media"]; hasMedia {
+ t.Fatal("did not expect 'media' property when local media is disabled")
+ }
+
// Check channel property (optional)
channelProp, ok := props["channel"].(map[string]any)
if !ok {
@@ -268,11 +296,65 @@ 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()
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 +379,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 +415,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")
+ }
+}
diff --git a/web/frontend/src/components/agent/skills/detail-sheet.tsx b/web/frontend/src/components/agent/skills/detail-sheet.tsx
index 4579926d8..9ec0c5a0c 100644
--- a/web/frontend/src/components/agent/skills/detail-sheet.tsx
+++ b/web/frontend/src/components/agent/skills/detail-sheet.tsx
@@ -13,6 +13,10 @@ import rehypeSanitize from "rehype-sanitize"
import remarkGfm from "remark-gfm"
import type { SkillDetailResponse, SkillSupportItem } from "@/api/skills"
+import {
+ MarkdownCodeBlock,
+ MessageCodeBlock,
+} from "@/components/chat/message-code-block"
import {
Sheet,
SheetContent,
@@ -176,6 +180,9 @@ export function DetailSheet({
{selectedSkillDetail.content}
@@ -183,11 +190,12 @@ export function DetailSheet({
) : null}
{detailView === "raw" ? (
-
-
- {selectedSkillDetail.content}
-
-
+
) : null}
{detailView === "meta" ? (
diff --git a/web/frontend/src/components/app-header.tsx b/web/frontend/src/components/app-header.tsx
index 700cc21e0..eb90881fb 100644
--- a/web/frontend/src/components/app-header.tsx
+++ b/web/frontend/src/components/app-header.tsx
@@ -291,9 +291,15 @@ export function AppHeader() {
i18n.changeLanguage("pt-BR")}>
Português (Brasil)
+ i18n.changeLanguage("bn-IN")}>
+ বাংলা
+
i18n.changeLanguage("zh")}>
简体中文
+ i18n.changeLanguage("cs")}>
+ Čeština
+
diff --git a/web/frontend/src/components/channels/channel-forms/mqtt-form.tsx b/web/frontend/src/components/channels/channel-forms/mqtt-form.tsx
index c52ad601c..0be02649f 100644
--- a/web/frontend/src/components/channels/channel-forms/mqtt-form.tsx
+++ b/web/frontend/src/components/channels/channel-forms/mqtt-form.tsx
@@ -1,4 +1,5 @@
import type { ChannelConfig } from "@/api/channels"
+import { MessageCodeBlock } from "@/components/chat/message-code-block"
import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields"
import { Field, KeyInput } from "@/components/shared-form"
import {
@@ -180,9 +181,12 @@ export function MqttForm({
{t("channels.mqtt.uplink")}
{`${topicBase}/request`}
-
- {`{\n "text": "your message"\n}`}
-
+
@@ -199,9 +203,12 @@ export function MqttForm({
{t("channels.mqtt.downlink")}
{`${topicBase}/response`}
-
- {`{\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
/>
)}
diff --git a/web/frontend/src/components/chat/chat-composer.tsx b/web/frontend/src/components/chat/chat-composer.tsx
index 43bc8a463..cae4d2f6d 100644
--- a/web/frontend/src/components/chat/chat-composer.tsx
+++ b/web/frontend/src/components/chat/chat-composer.tsx
@@ -1,5 +1,10 @@
import { IconArrowUp, IconPhotoPlus, IconX } from "@tabler/icons-react"
-import { useRef, type KeyboardEvent as ReactKeyboardEvent } from "react"
+import {
+ type ClipboardEvent as ReactClipboardEvent,
+ type DragEvent as ReactDragEvent,
+ type KeyboardEvent as ReactKeyboardEvent,
+ useRef,
+} from "react"
import { useTranslation } from "react-i18next"
import TextareaAutosize from "react-textarea-autosize"
@@ -30,11 +35,17 @@ interface ChatComposerProps {
attachments: ChatAttachment[]
onInputChange: (value: string) => void
onAddImages: () => void
+ onPaste: (event: ReactClipboardEvent
) => void
+ onDragEnter: (event: ReactDragEvent) => void
+ onDragLeave: (event: ReactDragEvent) => void
+ onDragOver: (event: ReactDragEvent) => void
+ onDrop: (event: ReactDragEvent) => void
onRemoveAttachment: (index: number) => void
onSend: () => void
onContextDetail?: () => void
inputDisabledReason: ChatInputDisabledReason | null
canSend: boolean
+ isDragActive: boolean
contextUsage?: ContextUsage
}
@@ -43,11 +54,17 @@ export function ChatComposer({
attachments,
onInputChange,
onAddImages,
+ onPaste,
+ onDragEnter,
+ onDragLeave,
+ onDragOver,
+ onDrop,
onRemoveAttachment,
onSend,
onContextDetail,
inputDisabledReason,
canSend,
+ isDragActive,
contextUsage,
}: ChatComposerProps) {
const { t } = useTranslation()
@@ -78,8 +95,25 @@ export function ChatComposer({
}
return (
-
-
+
+
+ {isDragActive && (
+
+
+ {t("chat.dropImagesActive")}
+
+
+ )}
+
{attachments.length > 0 && (
{attachments.map((attachment, index) => (
@@ -115,6 +149,7 @@ export function ChatComposer({
onCompositionEnd={() => {
composingRef.current = false
}}
+ onPaste={onPaste}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={!canInput}
diff --git a/web/frontend/src/components/chat/chat-page.tsx b/web/frontend/src/components/chat/chat-page.tsx
index 3b158843c..98766bfd5 100644
--- a/web/frontend/src/components/chat/chat-page.tsx
+++ b/web/frontend/src/components/chat/chat-page.tsx
@@ -1,8 +1,14 @@
import { IconPlus } from "@tabler/icons-react"
import { useAtom } from "jotai"
-import { type ChangeEvent, useEffect, useRef, useState } from "react"
+import {
+ type ChangeEvent,
+ type ClipboardEvent,
+ type DragEvent,
+ useEffect,
+ useRef,
+ useState,
+} from "react"
import { useTranslation } from "react-i18next"
-import { toast } from "sonner"
import { AssistantMessage } from "@/components/chat/assistant-message"
import {
@@ -23,6 +29,12 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
+import {
+ CHAT_IMAGE_ACCEPT,
+ buildChatImageAttachments,
+ getTransferredFiles,
+ hasFileTransfer,
+} from "@/features/chat/image-input"
import { useChatModels } from "@/hooks/use-chat-models"
import { useGateway } from "@/hooks/use-gateway"
import { usePicoChat } from "@/hooks/use-pico-chat"
@@ -36,32 +48,6 @@ import {
} from "@/store/chat"
import type { GatewayState } from "@/store/gateway"
-const MAX_IMAGE_SIZE_BYTES = 7 * 1024 * 1024
-const MAX_IMAGE_SIZE_LABEL = "7 MB"
-const ALLOWED_IMAGE_TYPES = new Set([
- "image/jpeg",
- "image/png",
- "image/gif",
- "image/webp",
- "image/bmp",
-])
-
-function readFileAsDataUrl(file: File): Promise {
- return new Promise((resolve, reject) => {
- const reader = new FileReader()
- reader.onload = () => {
- if (typeof reader.result === "string") {
- resolve(reader.result)
- return
- }
- reject(new Error("Failed to read file"))
- }
- reader.onerror = () =>
- reject(reader.error || new Error("Failed to read file"))
- reader.readAsDataURL(file)
- })
-}
-
function resolveChatInputDisabledReason({
hasDefaultModel,
connectionState,
@@ -118,10 +104,12 @@ export function ChatPage() {
const { t } = useTranslation()
const scrollRef = useRef(null)
const fileInputRef = useRef(null)
+ const dragDepthRef = useRef(0)
const [isAtBottom, setIsAtBottom] = useState(true)
const [hasScrolled, setHasScrolled] = useState(false)
const [input, setInput] = useState("")
const [attachments, setAttachments] = useState([])
+ const [isDragActive, setIsDragActive] = useState(false)
const [assistantDetailVisibility, setAssistantDetailVisibility] = useAtom(
assistantDetailVisibilityAtom,
)
@@ -223,6 +211,19 @@ export function ChatPage() {
setAttachments((prev) => prev.filter((_, itemIndex) => itemIndex !== index))
}
+ const appendImageFiles = async (files: readonly File[]) => {
+ if (!canInput || files.length === 0) {
+ return
+ }
+
+ const nextAttachments = await buildChatImageAttachments(files, t)
+ if (nextAttachments.length === 0) {
+ return
+ }
+
+ setAttachments((prev) => [...prev, ...nextAttachments])
+ }
+
const handleImageSelection = async (event: ChangeEvent) => {
const files = Array.from(event.target.files ?? [])
event.target.value = ""
@@ -231,45 +232,77 @@ export function ChatPage() {
return
}
- const nextAttachments: ChatAttachment[] = []
- for (const file of files) {
- if (!ALLOWED_IMAGE_TYPES.has(file.type)) {
- toast.error(
- t("chat.invalidImage", {
- name: file.name,
- }),
- )
- continue
- }
+ await appendImageFiles(files)
+ }
- if (file.size > MAX_IMAGE_SIZE_BYTES) {
- toast.error(
- t("chat.imageTooLarge", {
- name: file.name,
- size: MAX_IMAGE_SIZE_LABEL,
- }),
- )
- continue
- }
+ const resetDragState = () => {
+ dragDepthRef.current = 0
+ setIsDragActive(false)
+ }
- try {
- nextAttachments.push({
- type: "image",
- filename: file.name,
- url: await readFileAsDataUrl(file),
- })
- } catch {
- toast.error(
- t("chat.imageReadFailed", {
- name: file.name,
- }),
- )
- }
+ const handleComposerPaste = async (
+ event: ClipboardEvent,
+ ) => {
+ const files = getTransferredFiles(event.clipboardData)
+ if (files.length === 0) {
+ return
}
- if (nextAttachments.length > 0) {
- setAttachments(nextAttachments.slice(0, 1))
+ await appendImageFiles(files)
+ }
+
+ const handleComposerDragEnter = (event: DragEvent) => {
+ if (!hasFileTransfer(event.dataTransfer)) {
+ return
}
+
+ event.preventDefault()
+ if (!canInput) {
+ return
+ }
+ dragDepthRef.current += 1
+ setIsDragActive(true)
+ }
+
+ const handleComposerDragLeave = (event: DragEvent) => {
+ if (!hasFileTransfer(event.dataTransfer)) {
+ return
+ }
+
+ event.preventDefault()
+ if (!canInput) {
+ resetDragState()
+ return
+ }
+ dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
+ if (dragDepthRef.current === 0) {
+ setIsDragActive(false)
+ }
+ }
+
+ const handleComposerDragOver = (event: DragEvent) => {
+ if (!hasFileTransfer(event.dataTransfer)) {
+ return
+ }
+
+ event.preventDefault()
+ event.dataTransfer.dropEffect = canInput ? "copy" : "none"
+ }
+
+ const handleComposerDrop = async (event: DragEvent) => {
+ if (!hasFileTransfer(event.dataTransfer)) {
+ return
+ }
+
+ event.preventDefault()
+ const files = getTransferredFiles(event.dataTransfer)
+ resetDragState()
+
+ if (!canInput || files.length === 0) {
+ return
+ }
+
+ await appendImageFiles(files)
}
const canSubmit =
@@ -398,7 +431,8 @@ export function ChatPage() {
@@ -408,6 +442,11 @@ export function ChatPage() {
attachments={attachments}
onInputChange={setInput}
onAddImages={handleAddImages}
+ onPaste={handleComposerPaste}
+ onDragEnter={handleComposerDragEnter}
+ onDragLeave={handleComposerDragLeave}
+ onDragOver={handleComposerDragOver}
+ onDrop={handleComposerDrop}
onRemoveAttachment={handleRemoveAttachment}
onSend={handleSend}
onContextDetail={() => {
@@ -417,6 +456,7 @@ export function ChatPage() {
}}
inputDisabledReason={inputDisabledReason}
canSend={canSubmit}
+ isDragActive={isDragActive}
contextUsage={contextUsage}
/>
diff --git a/web/frontend/src/components/chat/message-code-block.tsx b/web/frontend/src/components/chat/message-code-block.tsx
index 0da081194..79a3706a2 100644
--- a/web/frontend/src/components/chat/message-code-block.tsx
+++ b/web/frontend/src/components/chat/message-code-block.tsx
@@ -3,17 +3,30 @@ import {
IconChevronDown,
IconCopy,
} from "@tabler/icons-react"
+import { useAtom } from "jotai"
import hljs from "highlight.js/lib/core"
import json from "highlight.js/lib/languages/json"
-import { type ComponentProps, type ReactNode, useState } from "react"
+import {
+ type ComponentProps,
+ type CSSProperties,
+ type ReactNode,
+ useState,
+} from "react"
import { useTranslation } from "react-i18next"
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"
import { cn } from "@/lib/utils"
+import { codeBlockWrapAtom } from "@/store/code-block"
import {
extractCodeBlockFromPreNode,
+ extractCodeBlockRenderState,
type MarkdownNode,
+ splitCodeIntoLines,
+ splitHighlightedHtmlIntoLines,
+ splitRenderedCodeContentIntoLines,
+ trimTrailingEmptyRenderedCodeLine,
+ trimTrailingEmptyStringLine,
} from "./message-code-block.utils"
import { Button } from "@/components/ui/button"
@@ -27,10 +40,10 @@ interface MessageCodeBlockProps {
code: string
language?: string | null
label?: string
- children?: ReactNode
className?: string
bodyClassName?: string
- wrapLongLines?: boolean
+ children?: ReactNode
+ trimTrailingEmptyLine?: boolean
}
interface MarkdownCodeBlockProps extends ComponentProps<"pre"> {
@@ -53,13 +66,14 @@ export function MessageCodeBlock({
code,
language = null,
label,
- children,
className,
bodyClassName,
- wrapLongLines = false,
+ children,
+ trimTrailingEmptyLine = false,
}: MessageCodeBlockProps) {
const { t } = useTranslation()
const { copy, isCopied } = useCopyToClipboard()
+ const [wrapLongLines, setWrapLongLines] = useAtom(codeBlockWrapAtom)
const [isExpanded, setIsExpanded] = useState(true)
const blockLabel =
label ??
@@ -68,7 +82,31 @@ export function MessageCodeBlock({
: t("chat.codeLabel").toLocaleLowerCase())
const copyLabel = isCopied ? t("chat.copiedLabel") : t("chat.copyCode")
const expandLabel = isExpanded ? t("chat.collapseCode") : t("chat.expandCode")
+ const wrapLabel = wrapLongLines
+ ? t("chat.disableCodeWrap")
+ : t("chat.enableCodeWrap")
+ const renderedCodeState = children
+ ? extractCodeBlockRenderState(children)
+ : {
+ renderedContent: null,
+ className: undefined,
+ }
const highlightedHtml = !children ? getHighlightedHtml(code, language) : null
+ const highlightedLines = highlightedHtml
+ ? splitHighlightedHtmlIntoLines(highlightedHtml)
+ : null
+ const codeLines = children
+ ? (trimTrailingEmptyLine
+ ? trimTrailingEmptyRenderedCodeLine(
+ splitRenderedCodeContentIntoLines(renderedCodeState.renderedContent),
+ )
+ : splitRenderedCodeContentIntoLines(renderedCodeState.renderedContent))
+ : (trimTrailingEmptyLine
+ ? trimTrailingEmptyStringLine(
+ highlightedLines ?? splitCodeIntoLines(code),
+ )
+ : (highlightedLines ?? splitCodeIntoLines(code)))
+ const lineNumberWidth = `${String(codeLines.length).length + 1}ch`
return (
{copyLabel}
+
@@ -158,6 +241,7 @@ export function MarkdownCodeBlock({
code={code}
language={language}
bodyClassName={className}
+ trimTrailingEmptyLine
>
{children}
diff --git a/web/frontend/src/components/chat/message-code-block.utils.ts b/web/frontend/src/components/chat/message-code-block.utils.ts
index 2133ec638..40e76a2a8 100644
--- a/web/frontend/src/components/chat/message-code-block.utils.ts
+++ b/web/frontend/src/components/chat/message-code-block.utils.ts
@@ -1,3 +1,11 @@
+import {
+ Children,
+ cloneElement,
+ Fragment,
+ isValidElement,
+ type ReactNode,
+} from "react"
+
export interface MarkdownNode {
type?: string
value?: string
@@ -6,7 +14,7 @@ export interface MarkdownNode {
children?: MarkdownNode[]
}
-function toClassNameTokens(className: unknown): string[] {
+export function toClassNameTokens(className: unknown): string[] {
if (typeof className === "string") {
return className.split(/\s+/).filter(Boolean)
}
@@ -72,6 +80,10 @@ export function extractCodeBlockLanguage(className: unknown): string | null {
return languageToken ? languageToken.slice("language-".length) : null
}
+export function stripSingleTrailingLineBreak(value: string): string {
+ return value.replace(/\r?\n$/, "")
+}
+
export function extractCodeBlockFromPreNode(node: MarkdownNode | undefined): {
code: string
language: string | null
@@ -79,7 +91,248 @@ export function extractCodeBlockFromPreNode(node: MarkdownNode | undefined): {
const codeNode = findFirstDescendantByTagName(node, "code")
return {
- code: extractTextFromMarkdownNode(codeNode ?? node),
+ code: stripSingleTrailingLineBreak(extractTextFromMarkdownNode(codeNode ?? node)),
language: extractCodeBlockLanguage(codeNode?.properties?.className),
}
}
+
+export function extractCodeBlockRenderState(children: ReactNode): {
+ renderedContent: ReactNode
+ className: string | undefined
+} {
+ const childNodes = Children.toArray(children)
+ const codeChild = childNodes.find(
+ (child) =>
+ isValidElement<{ children?: ReactNode; className?: unknown }>(child) &&
+ typeof child.type === "string" &&
+ child.type === "code",
+ )
+
+ if (
+ isValidElement<{ children?: ReactNode; className?: unknown }>(codeChild)
+ ) {
+ const classNameTokens = toClassNameTokens(codeChild.props.className)
+ return {
+ renderedContent: codeChild.props.children,
+ className:
+ classNameTokens.length > 0 ? classNameTokens.join(" ") : undefined,
+ }
+ }
+
+ return {
+ renderedContent: children,
+ className: undefined,
+ }
+}
+
+function mergeNodeLineGroups(
+ currentLines: Node[][],
+ nextLines: Node[][],
+): Node[][] {
+ if (nextLines.length === 0) {
+ return currentLines
+ }
+
+ const mergedLines = currentLines.map((line) => [...line])
+ mergedLines[mergedLines.length - 1].push(...nextLines[0])
+
+ for (const line of nextLines.slice(1)) {
+ mergedLines.push([...line])
+ }
+
+ return mergedLines
+}
+
+function splitDomNodeIntoLines(node: Node, ownerDocument: Document): Node[][] {
+ if (node.nodeType === Node.TEXT_NODE) {
+ return (node.textContent ?? "").split("\n").map((line) =>
+ line.length > 0 ? [ownerDocument.createTextNode(line)] : [],
+ )
+ }
+
+ if (node.nodeType !== Node.ELEMENT_NODE) {
+ return [[]]
+ }
+
+ const element = node as Element
+ if (element.tagName.toLowerCase() === "br") {
+ return [
+ [],
+ [],
+ ]
+ }
+
+ const childLines = splitHighlightedHtmlIntoNodeLines(
+ Array.from(element.childNodes),
+ ownerDocument,
+ )
+
+ return childLines.map((lineChildren) => {
+ const clonedElement = element.cloneNode(false)
+ for (const child of lineChildren) {
+ clonedElement.appendChild(child)
+ }
+
+ return [clonedElement]
+ })
+}
+
+function splitHighlightedHtmlIntoNodeLines(
+ nodes: Node[],
+ ownerDocument: Document,
+): Node[][] {
+ let lines: Node[][] = [[]]
+
+ for (const node of nodes) {
+ lines = mergeNodeLineGroups(
+ lines,
+ splitDomNodeIntoLines(node, ownerDocument),
+ )
+ }
+
+ return lines
+}
+
+export function splitCodeIntoLines(code: string): string[] {
+ return code.split("\n")
+}
+
+export function splitHighlightedHtmlIntoLines(highlightedHtml: string): string[] {
+ if (typeof document === "undefined") {
+ return splitCodeIntoLines(highlightedHtml)
+ }
+
+ const container = document.createElement("div")
+ container.innerHTML = highlightedHtml
+
+ return splitHighlightedHtmlIntoNodeLines(
+ Array.from(container.childNodes),
+ document,
+ ).map((lineNodes) => {
+ const lineContainer = document.createElement("div")
+ for (const node of lineNodes) {
+ lineContainer.appendChild(node)
+ }
+
+ return lineContainer.innerHTML
+ })
+}
+
+export function trimTrailingEmptyStringLine(lines: string[]): string[] {
+ if (lines.length > 1 && lines[lines.length - 1] === "") {
+ return lines.slice(0, -1)
+ }
+
+ return lines
+}
+
+function isEmptyRenderedCodeNode(node: ReactNode): boolean {
+ if (node === null || node === undefined || typeof node === "boolean") {
+ return true
+ }
+
+ if (typeof node === "string" || typeof node === "number") {
+ return String(node).length === 0
+ }
+
+ if (Array.isArray(node)) {
+ return node.every(isEmptyRenderedCodeNode)
+ }
+
+ if (!isValidElement<{ children?: ReactNode }>(node)) {
+ return false
+ }
+
+ return Children.toArray(node.props.children).every(isEmptyRenderedCodeNode)
+}
+
+export function trimTrailingEmptyRenderedCodeLine(
+ lines: ReactNode[][],
+): ReactNode[][] {
+ if (
+ lines.length > 1 &&
+ lines[lines.length - 1].every(isEmptyRenderedCodeNode)
+ ) {
+ return lines.slice(0, -1)
+ }
+
+ return lines
+}
+
+function mergeReactLineGroups(
+ currentLines: ReactNode[][],
+ nextLines: ReactNode[][],
+): ReactNode[][] {
+ if (nextLines.length === 0) {
+ return currentLines
+ }
+
+ const mergedLines = currentLines.map((line) => [...line])
+ mergedLines[mergedLines.length - 1].push(...nextLines[0])
+
+ for (const line of nextLines.slice(1)) {
+ mergedLines.push([...line])
+ }
+
+ return mergedLines
+}
+
+function splitTextNodeIntoLines(value: string | number): ReactNode[][] {
+ return String(value).split("\n").map((line) => (line.length > 0 ? [line] : []))
+}
+
+function splitReactNodeIntoLines(node: ReactNode): ReactNode[][] {
+ if (node === null || node === undefined || typeof node === "boolean") {
+ return [[]]
+ }
+
+ if (typeof node === "string" || typeof node === "number") {
+ return splitTextNodeIntoLines(node)
+ }
+
+ if (Array.isArray(node)) {
+ return splitRenderedCodeContentIntoLines(node)
+ }
+
+ if (!isValidElement<{ children?: ReactNode }>(node)) {
+ return [[node]]
+ }
+
+ if (node.type === Fragment) {
+ return splitRenderedCodeContentIntoLines(Children.toArray(node.props.children))
+ }
+
+ if (typeof node.type === "string" && node.type === "br") {
+ return [
+ [],
+ [],
+ ]
+ }
+
+ const childLines = splitRenderedCodeContentIntoLines(
+ Children.toArray(node.props.children),
+ )
+
+ return childLines.map((lineChildren, lineIndex) => [
+ cloneElement(
+ node,
+ {
+ key: `${node.key ?? "code-line"}-${lineIndex}`,
+ },
+ ...lineChildren,
+ ),
+ ])
+}
+
+export function splitRenderedCodeContentIntoLines(
+ content: ReactNode,
+): ReactNode[][] {
+ const contentNodes = Array.isArray(content) ? content : [content]
+ let lines: ReactNode[][] = [[]]
+
+ for (const node of contentNodes) {
+ lines = mergeReactLineGroups(lines, splitReactNodeIntoLines(node))
+ }
+
+ return lines
+}
diff --git a/web/frontend/src/components/chat/user-message.tsx b/web/frontend/src/components/chat/user-message.tsx
index 80ebc7ca0..9a05c961d 100644
--- a/web/frontend/src/components/chat/user-message.tsx
+++ b/web/frontend/src/components/chat/user-message.tsx
@@ -39,7 +39,7 @@ export function UserMessage({

))}
diff --git a/web/frontend/src/features/chat/image-input.ts b/web/frontend/src/features/chat/image-input.ts
new file mode 100644
index 000000000..ed424f084
--- /dev/null
+++ b/web/frontend/src/features/chat/image-input.ts
@@ -0,0 +1,170 @@
+import type { TFunction } from "i18next"
+import { toast } from "sonner"
+
+import type { ChatAttachment } from "@/store/chat"
+
+const CHAT_IMAGE_MIME_TYPES = [
+ "image/jpeg",
+ "image/png",
+ "image/gif",
+ "image/webp",
+ "image/bmp",
+] as const
+
+const CHAT_IMAGE_MIME_TYPE_SET = new Set
(CHAT_IMAGE_MIME_TYPES)
+const CHAT_IMAGE_EXTENSION_BY_MIME: Record = {
+ "image/jpeg": ".jpg",
+ "image/png": ".png",
+ "image/gif": ".gif",
+ "image/webp": ".webp",
+ "image/bmp": ".bmp",
+}
+const CHAT_IMAGE_MIME_BY_EXTENSION: Record = {
+ ".jpg": "image/jpeg",
+ ".jpeg": "image/jpeg",
+ ".png": "image/png",
+ ".gif": "image/gif",
+ ".webp": "image/webp",
+ ".bmp": "image/bmp",
+}
+
+export const CHAT_IMAGE_ACCEPT = CHAT_IMAGE_MIME_TYPES.join(",")
+
+const MAX_CHAT_IMAGE_SIZE_BYTES = 7 * 1024 * 1024
+const MAX_CHAT_IMAGE_SIZE_LABEL = "7 MB"
+
+function readFileAsDataUrl(file: File): Promise {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader()
+ reader.onload = () => {
+ if (typeof reader.result === "string") {
+ resolve(reader.result)
+ return
+ }
+ reject(new Error("Failed to read file"))
+ }
+ reader.onerror = () =>
+ reject(reader.error || new Error("Failed to read file"))
+ reader.readAsDataURL(file)
+ })
+}
+
+function getFileExtension(fileName: string): string {
+ const lastDotIndex = fileName.lastIndexOf(".")
+ if (lastDotIndex === -1) {
+ return ""
+ }
+ return fileName.slice(lastDotIndex).toLowerCase()
+}
+
+function getSupportedImageMimeType(file: File): string | null {
+ const normalizedType = file.type.trim().toLowerCase()
+ if (normalizedType && CHAT_IMAGE_MIME_TYPE_SET.has(normalizedType)) {
+ return normalizedType
+ }
+
+ const extension = getFileExtension(file.name)
+ return CHAT_IMAGE_MIME_BY_EXTENSION[extension] ?? null
+}
+
+function normalizeImageFileForDataUrl(file: File, filename: string): File {
+ const mimeType = getSupportedImageMimeType(file)
+ if (!mimeType || file.type.trim().toLowerCase() === mimeType) {
+ return file
+ }
+
+ const normalizedName = file.name.trim() || filename
+ return new File([file], normalizedName, { type: mimeType })
+}
+
+function getAttachmentFilename(file: File, index: number): string {
+ const trimmedName = file.name.trim()
+ if (trimmedName) {
+ return trimmedName
+ }
+
+ const mimeType = getSupportedImageMimeType(file)
+ const extension = mimeType ? CHAT_IMAGE_EXTENSION_BY_MIME[mimeType] : ".png"
+ return `image-${index + 1}${extension}`
+}
+
+function getTransferItemFiles(dataTransfer: DataTransfer | null): File[] {
+ if (!dataTransfer) {
+ return []
+ }
+
+ const files = Array.from(dataTransfer.files)
+ if (files.length > 0) {
+ return files
+ }
+
+ return Array.from(dataTransfer.items)
+ .filter((item) => item.kind === "file")
+ .map((item) => item.getAsFile())
+ .filter((file): file is File => file !== null)
+}
+
+export function hasFileTransfer(dataTransfer: DataTransfer | null): boolean {
+ if (!dataTransfer) {
+ return false
+ }
+
+ if (dataTransfer.files.length > 0) {
+ return true
+ }
+
+ return Array.from(dataTransfer.items).some((item) => item.kind === "file")
+}
+
+export function getTransferredFiles(dataTransfer: DataTransfer | null) {
+ return getTransferItemFiles(dataTransfer)
+}
+
+export async function buildChatImageAttachments(
+ files: readonly File[],
+ t: TFunction,
+): Promise {
+ const nextAttachments: ChatAttachment[] = []
+
+ for (const [index, file] of files.entries()) {
+ const filename = getAttachmentFilename(file, index)
+
+ const mimeType = getSupportedImageMimeType(file)
+ if (!mimeType) {
+ toast.error(
+ t("chat.invalidImage", {
+ name: filename,
+ }),
+ )
+ continue
+ }
+
+ if (file.size > MAX_CHAT_IMAGE_SIZE_BYTES) {
+ toast.error(
+ t("chat.imageTooLarge", {
+ name: filename,
+ size: MAX_CHAT_IMAGE_SIZE_LABEL,
+ }),
+ )
+ continue
+ }
+
+ try {
+ const normalizedFile = normalizeImageFileForDataUrl(file, filename)
+ nextAttachments.push({
+ type: "image",
+ filename,
+ url: await readFileAsDataUrl(normalizedFile),
+ contentType: mimeType,
+ })
+ } catch {
+ toast.error(
+ t("chat.imageReadFailed", {
+ name: filename,
+ }),
+ )
+ }
+ }
+
+ return nextAttachments
+}
diff --git a/web/frontend/src/i18n/index.ts b/web/frontend/src/i18n/index.ts
index 4da7b3f0d..7eefd943f 100644
--- a/web/frontend/src/i18n/index.ts
+++ b/web/frontend/src/i18n/index.ts
@@ -1,4 +1,6 @@
import dayjs from "dayjs"
+import "dayjs/locale/bn"
+import "dayjs/locale/cs"
import "dayjs/locale/en"
import "dayjs/locale/pt-br"
import "dayjs/locale/zh-cn"
@@ -10,7 +12,9 @@ import { initReactI18next } from "react-i18next"
import en from "./locales/en.json"
import ptBr from "./locales/pt-br.json"
+import bnIn from "./locales/bn-in.json"
import zh from "./locales/zh.json"
+import cs from "./locales/cs.json"
dayjs.extend(relativeTime)
dayjs.extend(localizedFormat)
@@ -31,9 +35,15 @@ i18n
"pt-BR": {
translation: ptBr,
},
+ "bn-IN": {
+ translation: bnIn,
+ },
zh: {
translation: zh,
},
+ cs: {
+ translation: cs,
+ },
},
fallbackLng: "en",
debug: false,
@@ -48,6 +58,10 @@ i18n.on("languageChanged", (lng) => {
dayjs.locale("zh-cn")
} else if (lng.startsWith("pt")) {
dayjs.locale("pt-br")
+ } else if (lng.startsWith("bn")) {
+ dayjs.locale("bn")
+ } else if (lng.startsWith("cs")) {
+ dayjs.locale("cs")
} else {
dayjs.locale("en")
}
diff --git a/web/frontend/src/i18n/locales/bn-in.json b/web/frontend/src/i18n/locales/bn-in.json
new file mode 100644
index 000000000..c9a7249bf
--- /dev/null
+++ b/web/frontend/src/i18n/locales/bn-in.json
@@ -0,0 +1,960 @@
+{
+ "navigation": {
+ "chat": "চ্যাট",
+ "model_group": "মডেল",
+ "models": "মডেল",
+ "credentials": "ক্রেডেনশিয়াল",
+ "agent_group": "এজেন্ট",
+ "hub": "Hub",
+ "skills": "দক্ষতা",
+ "tools": "সরঞ্জাম",
+ "services": "সার্ভিস",
+ "channels_group": "চ্যানেল",
+ "show_more_channels": "আরও",
+ "show_less_channels": "কম",
+ "config": "কনফিগ",
+ "logs": "লগ"
+ },
+ "launcherLogin": {
+ "title": "সাইন ইন",
+ "description": "চালিয়ে যেতে ড্যাশবোর্ড পাসওয়ার্ড দিন।",
+ "passwordLabel": "পাসওয়ার্ড",
+ "passwordPlaceholder": "পাসওয়ার্ড লিখুন",
+ "submit": "সাইন ইন",
+ "errorInvalid": "ভুল পাসওয়ার্ড। আবার চেষ্টা করুন।",
+ "errorNetwork": "নেটওয়ার্ক ত্রুটি। আবার চেষ্টা করুন।"
+ },
+ "launcherSetup": {
+ "title": "ড্যাশবোর্ড পাসওয়ার্ড সেট করুন",
+ "description": "এই ড্যাশবোর্ডে অ্যাক্সেস সুরক্ষিত রাখতে একটি পাসওয়ার্ড বেছে নিন। প্রতিবার সাইন ইনের সময় এটি ব্যবহার করতে হবে।",
+ "passwordLabel": "পাসওয়ার্ড",
+ "passwordPlaceholder": "কমপক্ষে ৮ অক্ষর",
+ "confirmLabel": "পাসওয়ার্ড নিশ্চিত করুন",
+ "confirmPlaceholder": "পাসওয়ার্ড আবার লিখুন",
+ "submit": "পাসওয়ার্ড সেট করুন",
+ "errorMismatch": "পাসওয়ার্ড মিলছে না।",
+ "errorNetwork": "নেটওয়ার্ক ত্রুটি। আবার চেষ্টা করুন।"
+ },
+ "chat": {
+ "welcome": "আজ আমি আপনাকে কীভাবে সাহায্য করতে পারি?",
+ "welcomeDesc": "আবহাওয়া, সেটিংস বা অন্য যেকোনো কাজ সম্পর্কে আমাকে জিজ্ঞাসা করুন। আমি সাহায্যের জন্য এখানে আছি।",
+ "placeholder": "একটি নতুন বার্তা শুরু করুন...",
+ "disabledPlaceholder": {
+ "gatewayUnknown": "চ্যাট করা যাচ্ছে না: গেটওয়ের স্ট্যাটাস এখনও পরীক্ষা করা হচ্ছে। অনুগ্রহ করে অপেক্ষা করুন, তারপর পৃষ্ঠাটি রিফ্রেশ করুন অথবা প্রয়োজনে লঞ্চার পুনরায় চালু করুন।",
+ "gatewayStarting": "চ্যাট করা যাচ্ছে না: গেটওয়ে চালু হচ্ছে। চালু হওয়া শেষ হওয়া পর্যন্ত অপেক্ষা করুন, তারপর আবার চেষ্টা করুন।",
+ "gatewayRestarting": "চ্যাট করা যাচ্ছে না: গেটওয়ে পুনরায় চালু হচ্ছে। অনুগ্রহ করে পুনরায় চালু হওয়া শেষ হওয়া পর্যন্ত অপেক্ষা করুন।",
+ "gatewayStopping": "চ্যাট করা যাচ্ছে না: গেটওয়ে বন্ধ হচ্ছে। বন্ধ হওয়া পর্যন্ত অপেক্ষা করুন, তারপর গেটওয়ে আবার চালু করুন।",
+ "gatewayStopped": "চ্যাট করা যাচ্ছে না: গেটওয়ে শুরু করা হয়নি। উপরের বারে স্টার্ট গেটওয়েতে ক্লিক করুন, তারপর পুনরায় চেষ্টা করুন।",
+ "gatewayError": "চ্যাট করা যাচ্ছে না: গেটওয়ে ত্রুটি অবস্থায় রয়েছে। লগ পরীক্ষা করুন, তারপর গেটওয়ে বা লঞ্চার পুনরায় চালু করুন।",
+ "websocketConnecting": "চ্যাট সার্ভিসের সাথে সংযোগ স্থাপন হচ্ছে... অনুগ্রহ করে অপেক্ষা করুন।",
+ "websocketDisconnected": "চ্যাট করা যাচ্ছে না: WebSocket সংযোগ বিচ্ছিন্ন। নেটওয়ার্ক এবং গেটওয়ের অবস্থা পরীক্ষা করুন, তারপর পৃষ্ঠাটি রিফ্রেশ করুন বা লঞ্চার পুনরায় চালু করুন।",
+ "websocketError": "চ্যাট করা যাচ্ছে না: WebSocket সংযোগ ব্যর্থ হয়েছে। নেটওয়ার্ক এবং গেটওয়ের অবস্থা পরীক্ষা করুন, তারপর পুনরায় চেষ্টা করুন।",
+ "noDefaultModel": "চ্যাট করা যাচ্ছে না: কোনো ডিফল্ট মডেল নির্বাচিত নেই। মডেল পৃষ্ঠায় একটি ডিফল্ট মডেল সেট করুন।"
+ },
+ "newChat": "নতুন চ্যাট",
+ "notConnected": "গেটওয়ে চলছে না। চ্যাট করতে এটি চালু করুন।",
+ "thinking": {
+ "step1": "চিন্তা করা হচ্ছে...",
+ "step2": "আপনার অনুরোধ বিশ্লেষণ করা হচ্ছে...",
+ "step3": "উত্তর প্রস্তুত করা হচ্ছে...",
+ "step4": "প্রায় শেষ..."
+ },
+ "reasoningLabel": "যুক্তি",
+ "toolCallsLabel": "টুল কল",
+ "toolCallExplanationLabel": "কল নোট",
+ "toolCallFunctionLabel": "কল সারাংশ",
+ "toolCallArgumentsLabel": "আর্গুমেন্ট",
+ "showAssistantDetails": "যুক্তি এবং টুল কল",
+ "assistantDetailVisibility": {
+ "none": "কোনোটিই দেখাবেন না",
+ "thought": "শুধু যুক্তি দেখান",
+ "toolCalls": "শুধু টুল কল দেখান",
+ "all": "উভয়ই দেখান"
+ },
+ "toolLabel": "টুল",
+ "codeLabel": "কোড",
+ "copyMessage": "বার্তা কপি করুন",
+ "copyCode": "কোড কপি করুন",
+ "copiedLabel": "কপি করা হয়েছে",
+ "enableCodeWrap": "লাইন র্যাপ করুন",
+ "disableCodeWrap": "র্যাপ নিষ্ক্রিয় করুন",
+ "expandCode": "কোড প্রসারিত করুন",
+ "collapseCode": "কোড সংকুচিত করুন",
+ "history": "ইতিহাস",
+ "noHistory": "এখনও কোনো চ্যাট ইতিহাস নেই",
+ "historyLoadFailed": "চ্যাট ইতিহাস লোড করতে ব্যর্থ",
+ "historyOpenFailed": "এই চ্যাট ইতিহাস খুলতে ব্যর্থ",
+ "loadingMore": "আরও লোড হচ্ছে...",
+ "deleteSession": "সেশন মুছুন",
+ "messagesCount": "{{count}}টি বার্তা",
+ "noModel": "মডেল নির্বাচন করুন",
+ "inputDisabled": {
+ "notConnected": "গেটওয়ে চলছে না। চ্যাট করতে এটি চালু করুন।",
+ "noModel": "কোনো ডিফল্ট মডেল কনফিগার করা নেই। একটি সেট করতে মডেল পৃষ্ঠায় যান।"
+ },
+ "sendMessage": "বার্তা পাঠান",
+ "sendHint": "পাঠাতে Enter চাপুন\nনতুন লাইনের জন্য Shift + Enter",
+ "contextTitle": "প্রসঙ্গ",
+ "contextDetail": "বিস্তারিত দেখুন",
+ "attachImage": "ছবি যোগ করুন",
+ "removeImage": "ছবি সরান",
+ "uploadedImage": "আপলোড করা ছবি",
+ "invalidImage": "\"{{name}}\" একটি সমর্থিত ছবি ফাইল নয়।",
+ "imageTooLarge": "\"{{name}}\" {{size}} সীমা অতিক্রম করেছে।",
+ "imageReadFailed": "\"{{name}}\" পড়তে ব্যর্থ হয়েছে।",
+ "empty": {
+ "noConfiguredModel": "কোনো মডেল কনফিগার করা নেই",
+ "noConfiguredModelDescription": "চ্যাট শুরু করার আগে আপনাকে কমপক্ষে একটি AI মডেল API কী দিয়ে কনফিগার করতে হবে।",
+ "goToModels": "মডেলে যান",
+ "noSelectedModel": "কোনো মডেল নির্বাচিত নেই",
+ "noSelectedModelDescription": "আপনি মডেল কনফিগার করেছেন, কিন্তু কোনোটি ডিফল্ট হিসেবে সেট করা নেই। চ্যাট শুরু করার আগে একটি মডেল নির্বাচন করুন।",
+ "notRunning": "গেটওয়ে চলছে না",
+ "notRunningDescription": "চ্যাট শুরু করতে গেটওয়ে সার্ভিস চালু করুন। উপরের বারে স্টার্ট গেটওয়ে বোতাম ব্যবহার করুন।"
+ },
+ "modelGroup": {
+ "apikey": "API Key",
+ "oauth": "OAuth",
+ "local": "লোকাল"
+ }
+ },
+ "header": {
+ "logout": {
+ "tooltip": "সাইন আউট",
+ "confirm": "সাইন আউট",
+ "description": "আপনি কি ড্যাশবোর্ড থেকে সাইন আউট করতে চান?"
+ },
+ "gateway": {
+ "stopDialog": {
+ "title": "গেটওয়ে সার্ভিস বন্ধ করবেন?",
+ "description": "আপনি কি গেটওয়ে বন্ধ করতে চান? এটি আপনার সক্রিয় চ্যাট সেশন বিচ্ছিন্ন করবে এবং ইনফারেন্স থামাবে।",
+ "confirm": "গেটওয়ে বন্ধ করুন"
+ },
+ "action": {
+ "start": "গেটওয়ে চালু করুন",
+ "stop": "গেটওয়ে বন্ধ করুন",
+ "restart": "গেটওয়ে পুনরায় চালু করুন"
+ },
+ "status": {
+ "starting": "গেটওয়ে চালু হচ্ছে...",
+ "restarting": "গেটওয়ে পুনরায় চালু হচ্ছে...",
+ "stopping": "গেটওয়ে বন্ধ হচ্ছে..."
+ },
+ "restartRequired": "কনফিগারেশনের পরিবর্তন কার্যকর হতে গেটওয়ে পুনরায় চালু করা প্রয়োজন।"
+ }
+ },
+ "common": {
+ "cancel": "বাতিল",
+ "close": "বন্ধ করুন",
+ "save": "সংরক্ষণ করুন",
+ "saving": "সংরক্ষণ করা হচ্ছে...",
+ "reset": "রিসেট",
+ "confirm": "নিশ্চিত করুন",
+ "fix": "ঠিক করুন",
+ "saveChangesTitle": "আপনার অসংরক্ষিত কনফিগারেশন পরিবর্তন রয়েছে",
+ "restartRequiredTitle": "গেটওয়ে পুনরায় চালু করা প্রয়োজন",
+ "restartRequiredDesc": "সর্বশেষ {{name}} কনফিগারেশন সংরক্ষিত হয়েছে। কার্যকর হতে গেটওয়ে পুনরায় চালু করুন।"
+ },
+ "labels": {
+ "loading": "লোড হচ্ছে..."
+ },
+ "footer": {
+ "version": "সংস্করণ",
+ "commit": "কমিট",
+ "build": "বিল্ড",
+ "version_unknown": "অজানা"
+ },
+ "credentials": {
+ "description": "সমর্থিত প্রোভাইডারদের জন্য OAuth এবং টোকেন-ভিত্তিক ক্রেডেনশিয়াল পরিচালনা করুন।",
+ "loading": "ক্রেডেনশিয়াল লোড হচ্ছে...",
+ "providers": {
+ "openai": {
+ "description": "ব্রাউজার OAuth, ডিভাইস কোড এবং টোকেন লগইন সমর্থন করে।"
+ },
+ "anthropic": {
+ "description": "Claude অ্যাক্সেসের জন্য টোকেন লগইন ব্যবহার করে।"
+ },
+ "antigravity": {
+ "description": "Google Cloud Code Assist-এর জন্য ব্রাউজার OAuth ব্যবহার করে।"
+ }
+ },
+ "status": {
+ "connected": "সংযুক্ত",
+ "needsRefresh": "রিফ্রেশ প্রয়োজন",
+ "expired": "মেয়াদ শেষ",
+ "notLoggedIn": "লগ ইন করা নেই"
+ },
+ "actions": {
+ "browser": "ব্রাউজার OAuth",
+ "deviceCode": "ডিভাইস কোড",
+ "stopLoading": "লোডিং বন্ধ করুন",
+ "saveToken": "সংরক্ষণ করুন",
+ "logout": "লগআউট"
+ },
+ "logoutDialog": {
+ "title": "প্রোভাইডার থেকে লগআউট করবেন?",
+ "description": "এটি {{provider}}-এর জন্য সংরক্ষিত আপনার ক্রেডেনশিয়াল মুছে ফেলবে।"
+ },
+ "fields": {
+ "openaiToken": "OpenAI টোকেন",
+ "anthropicToken": "Anthropic টোকেন"
+ },
+ "labels": {
+ "account": "অ্যাকাউন্ট",
+ "email": "ইমেল",
+ "project": "প্রকল্প"
+ },
+ "errors": {
+ "loadFailed": "ক্রেডেনশিয়াল লোড করতে ব্যর্থ",
+ "flowFailed": "প্রমাণীকরণ প্রবাহ পরীক্ষা করতে ব্যর্থ",
+ "loginFailed": "লগইন ব্যর্থ",
+ "logoutFailed": "লগআউট ব্যর্থ",
+ "invalidBrowserResponse": "ব্রাউজার লগইন প্রতিক্রিয়া অবৈধ",
+ "invalidDeviceResponse": "ডিভাইস কোড প্রতিক্রিয়া অবৈধ",
+ "popupBlocked": "নতুন ট্যাব খোলা যাচ্ছে না। অনুগ্রহ করে পপআপ অনুমতি দিন এবং আবার চেষ্টা করুন।"
+ },
+ "flow": {
+ "current": "বর্তমান প্রমাণীকরণ অবস্থা",
+ "pending": "অনুমোদনের জন্য অপেক্ষা করা হচ্ছে...",
+ "success": "প্রমাণীকরণ সফল",
+ "error": "প্রমাণীকরণ ব্যর্থ",
+ "expired": "প্রমাণীকরণ সেশনের মেয়াদ শেষ"
+ },
+ "device": {
+ "title": "OpenAI ডিভাইস লগইন",
+ "description": "যাচাইকরণ পৃষ্ঠা খুলুন এবং নিচের কোডটি লিখুন। এই পৃষ্ঠাটি স্বয়ংক্রিয়ভাবে রিফ্রেশ হবে।",
+ "code": "ইউজার কোড",
+ "url": "যাচাইকরণ URL",
+ "polling": "লগইন অবস্থা পোলিং করা হচ্ছে...",
+ "open": "যাচাইকরণ পৃষ্ঠা খুলুন"
+ }
+ },
+ "models": {
+ "description": "AI প্রোভাইডারদের জন্য API কী কনফিগার করুন। শুধুমাত্র কনফিগার করা মডেলগুলি চ্যাটের জন্য উপলব্ধ।",
+ "defaultChangeSuccess": "ডিফল্ট মডেল আপডেট করা হয়েছে।",
+ "unsavedPrompt": "এই পরিবর্তন এখনও সংরক্ষিত হয়নি। মডেল কনফিগারেশনে লিখতে সংরক্ষণ করুন।",
+ "restartHint": "মডেল কনফিগারেশনের পরিবর্তন গেটওয়ে পুনরায় চালু হওয়ার পরে কার্যকর হয়।",
+ "loadError": "মডেল লোড করতে ব্যর্থ",
+ "retry": "পুনরায় চেষ্টা করুন",
+ "providerCatalogUnavailable": "ব্যাকএন্ড প্রোভাইডার ক্যাটালগ অনুপলব্ধ। মডেল API সফলভাবে লোড না হওয়া পর্যন্ত নতুন প্রোভাইডার নির্বাচন নিষ্ক্রিয়।",
+ "noDefaultHintPrefix": "এখনও কোনো ডিফল্ট মডেল সেট করা নেই। সেট করতে ক্লিক করুন",
+ "noDefaultHintSuffix": "এটি সেট করতে।",
+ "status": {
+ "available": "উপলব্ধ",
+ "unconfigured": "কনফিগার করা নেই",
+ "unreachable": "সার্ভিস অপ্রাপ্য"
+ },
+ "badge": {
+ "default": "ডিফল্ট",
+ "virtual": "ভার্চুয়াল"
+ },
+ "action": {
+ "edit": "API কী সম্পাদনা করুন",
+ "setDefault": "ডিফল্ট হিসেবে সেট করুন",
+ "delete": "মডেল মুছুন",
+ "setDefaultDisabled": {
+ "setting": "ডিফল্ট হিসেবে সেট করা হচ্ছে...",
+ "unavailable": "অনুপলব্ধ মডেলকে ডিফল্ট হিসেবে সেট করা যাবে না",
+ "isDefault": "ইতিমধ্যে ডিফল্ট মডেল",
+ "isVirtual": "ভার্চুয়াল মডেলকে ডিফল্ট হিসেবে সেট করা যাবে না",
+ "unsupportedProvider": "এই প্রোভাইডারকে ডিফল্ট চ্যাট মডেল হিসেবে ব্যবহার করা যাবে না।"
+ },
+ "deleteDisabled": {
+ "isDefault": "ডিফল্ট মডেল মুছে ফেলা যাবে না"
+ }
+ },
+ "defaultOnSave": {
+ "label": "ডিফল্ট মডেল",
+ "description": "সংরক্ষণের পরে স্বয়ংক্রিয়ভাবে এই মডেলটিকে ডিফল্ট হিসেবে সেট করুন।",
+ "unsupportedProvider": "এই প্রোভাইডারকে মডেল তালিকায় সংরক্ষণ করা যাবে কিন্তু ডিফল্ট চ্যাট মডেল হিসেবে ব্যবহার করা যাবে না।"
+ },
+ "add": {
+ "button": "মডেল যোগ করুন",
+ "title": "কাস্টম মডেল যোগ করুন",
+ "description": "একটি OpenAI-সামঞ্জস্যপূর্ণ বা নেটিভ মডেল এন্ডপয়েন্ট যোগ করুন।",
+ "modelName": "মডেল এলিয়াস",
+ "modelNamePlaceholder": "যেমন my-gpt4",
+ "modelNameHint": "কথোপকথনে এই মডেলটি চিহ্নিত করতে ব্যবহৃত একটি ছোট নাম।",
+ "modelId": "মডেল শনাক্তকারী",
+ "modelIdPlaceholder": "যেমন gpt-4o বা openai/gpt-4o",
+ "modelIdHint": "এই ক্ষেত্রটি নির্বাচিত প্রোভাইডারের জন্য canonical মডেল ID হিসেবে বিবেচিত হয়। যদি শনাক্তকারীতে নিজেই একটি স্ল্যাশ থাকে (উদাহরণস্বরূপ openai/gpt-5.4), তবে এটি অপরিবর্তিত রাখা হয় এবং আবার বিভক্ত করা হয় না।",
+ "errorRequired": "এই ক্ষেত্রটি প্রয়োজনীয়।",
+ "errorDuplicateModelName": "মডেল এলিয়াস ইতিমধ্যে বিদ্যমান। অনুগ্রহ করে একটি ভিন্ন নাম ব্যবহার করুন।",
+ "saveError": "মডেল যোগ করতে ব্যর্থ",
+ "saveSuccess": "মডেল যোগ করা হয়েছে।",
+ "confirm": "মডেল যোগ করুন"
+ },
+ "delete": {
+ "title": "মডেল মুছবেন?",
+ "description": "\"{{name}}\" আপনার মডেল তালিকা থেকে স্থায়ীভাবে সরানো হবে। এটি পূর্বাবস্থায় ফেরানো যাবে না।",
+ "confirm": "মুছুন"
+ },
+ "advanced": {
+ "toggle": "উন্নত বিকল্প"
+ },
+ "field": {
+ "provider": "Provider",
+ "providerPlaceholder": "একটি প্রোভাইডার নির্বাচন করুন",
+ "providerHint": "ব্যাকএন্ড ক্যাটালগ থেকে একটি প্রোভাইডার নির্বাচন করুন; মডেল শনাক্তকারীকে সেই প্রোভাইডারের canonical মডেল ID হিসেবে ব্যাখ্যা করা হবে।",
+ "providerInvalid": "বর্তমান প্রোভাইডার অবৈধ। অনুগ্রহ করে একটি সমর্থিত প্রোভাইডার নির্বাচন করুন।",
+ "selectProviderFirst": "প্রথমে একটি প্রোভাইডার নির্বাচন করুন",
+ "apiBase": "API Base URL",
+ "apiKey": "API Key",
+ "apiKeyPlaceholder": "আপনার API কী লিখুন",
+ "apiKeyPlaceholderSet": "বিদ্যমান কী রাখতে খালি রাখুন",
+ "proxy": "HTTP প্রক্সি",
+ "proxyHint": "ঐচ্ছিক। যেমন http://127.0.0.1:7890",
+ "authMethod": "প্রমাণীকরণ পদ্ধতি",
+ "authMethodHint": "প্রমাণীকরণ পদ্ধতি: oauth, token। API কী প্রমাণীকরণের জন্য খালি রাখুন।",
+ "authMethodManagedHint": "এই প্রোভাইডারের প্রমাণীকরণ পদ্ধতি সিস্টেম দ্বারা পরিচালিত।",
+ "connectMode": "সংযোগ মোড",
+ "connectModeHint": "CLI-ভিত্তিক প্রোভাইডারদের জন্য সংযোগ মোড: stdio বা grpc।",
+ "workspace": "ওয়ার্কস্পেস পাথ",
+ "workspaceHint": "CLI-ভিত্তিক প্রোভাইডারদের জন্য কাজের ডিরেক্টরি (যেমন GitHub Copilot)।",
+ "requestTimeout": "অনুরোধ টাইমআউট (সেকেন্ড)",
+ "requestTimeoutHint": "উত্তরের জন্য অপেক্ষা করার সর্বাধিক সেকেন্ড। 0 = ডিফল্ট ব্যবহার করুন।",
+ "rpm": "রেট সীমা (RPM)",
+ "rpmHint": "প্রতি মিনিটে সর্বাধিক অনুরোধ। 0 = কোনো সীমা নেই।",
+ "thinkingLevel": "চিন্তার স্তর",
+ "thinkingLevelHint": "thinking_level বাদ দিতে এবং প্রোভাইডার ডিফল্ট ব্যবহার করতে খালি রাখুন। মান: off, low, medium, high, xhigh, adaptive।",
+ "providerDefault": "প্রোভাইডার ডিফল্ট",
+ "maxTokensField": "ম্যাক্স টোকেন ফিল্ড",
+ "maxTokensFieldHint": "ম্যাক্স টোকেনের জন্য অনুরোধ ক্ষেত্রের নাম ওভাররাইড করুন, যেমন max_completion_tokens।",
+ "toolSchemaTransform": "টুল স্কিমা ট্রান্সফর্ম",
+ "toolSchemaTransformHint": "টুল JSON স্কিমার জন্য ঐচ্ছিক সামঞ্জস্যতা ট্রান্সফর্ম। নেটিভ আচরণের জন্য খালি রাখুন। সমর্থিত মান: simple।",
+ "streamingEnabled": "স্ট্রিমিং আউটপুট",
+ "streamingEnabledHint": "এই মডেল এন্ট্রিকে প্রোভাইডার স্ট্রিমিং অনুরোধ চেষ্টা করার অনুমতি দিন। বর্তমান চ্যানেল স্ট্রিমিং সুইচও সক্ষম থাকতে হবে।",
+ "extraBody": "Extra Body",
+ "extraBodyHint": "অনুরোধ বডিতে ইনজেক্ট করার অতিরিক্ত JSON ক্ষেত্র, যেমন {\"reasoning_split\": true}।",
+ "customHeaders": "Custom Headers",
+ "customHeadersHint": "প্রতিটি অনুরোধে ইনজেক্ট করার অতিরিক্ত HTTP হেডার, যেমন {\"X-Source\": \"coding-plan\"}।",
+ "invalidJson": "অবৈধ JSON ফর্ম্যাট"
+ },
+ "edit": {
+ "title": "{{name}} কনফিগার করুন",
+ "apiKeyHint": "একটি কী ইতিমধ্যে সেট করা হয়েছে। অপরিবর্তিত রাখতে খালি রাখুন।",
+ "oauthNote": "এই প্রোভাইডার OAuth ব্যবহার করে — কোনো API কী প্রয়োজন নেই।",
+ "saveError": "সংরক্ষণ করতে ব্যর্থ",
+ "saveSuccess": "মডেল কনফিগারেশন সংরক্ষিত হয়েছে।"
+ },
+ "fetch": {
+ "title": "উপলব্ধ মডেল আনুন",
+ "description": "আপস্ট্রিম প্রোভাইডার থেকে মডেল তালিকা আনুন।",
+ "providerLabel": "প্রোভাইডার:",
+ "needApiKey": "মডেল আনতে অনুগ্রহ করে প্রথমে একটি API কী লিখুন।",
+ "fetching": "মডেল আনা হচ্ছে...",
+ "retry": "পুনরায় চেষ্টা করুন",
+ "filterPlaceholder": "মডেল ফিল্টার করুন...",
+ "found": "{{count}}টি মডেল পাওয়া গেছে",
+ "found_plural": "{{count}}টি মডেল পাওয়া গেছে",
+ "shown": "({{count}}টি দেখানো হয়েছে)",
+ "selectAll": "সব নির্বাচন করুন",
+ "deselectAll": "সব নির্বাচন বাতিল করুন",
+ "fill": "{{count}}টি নির্বাচিত মডেল পূরণ করুন",
+ "fill_plural": "{{count}}টি নির্বাচিত মডেল পূরণ করুন",
+ "failed": "মডেল আনতে ব্যর্থ"
+ },
+ "catalog": {
+ "button": "সংরক্ষিত ক্যাটালগ",
+ "title": "সংরক্ষিত মডেল ক্যাটালগ",
+ "description": "পূর্বে আনা মডেল তালিকা, প্রতি API কী অনুযায়ী সংরক্ষিত। আপনার কনফিগারেশনে যোগ করতে মডেল নির্বাচন করুন।",
+ "loading": "ক্যাটালগ লোড হচ্ছে...",
+ "empty": "এখনও কোনো সংরক্ষিত ক্যাটালগ নেই। একটি ক্যাটালগ সংরক্ষণ করতে প্রোভাইডার থেকে মডেল আনুন।",
+ "filterPlaceholder": "মডেল ফিল্টার করুন...",
+ "models": "মডেল",
+ "fetchedAt": "আনা হয়েছে",
+ "delete": "ক্যাটালগ মুছুন",
+ "refresh": "আপস্ট্রিম থেকে রিফ্রেশ করুন",
+ "found": "{{count}}টি মডেল পাওয়া গেছে",
+ "found_plural": "{{count}}টি মডেল পাওয়া গেছে",
+ "selectAll": "সব নির্বাচন করুন",
+ "deselectAll": "সব নির্বাচন বাতিল করুন",
+ "addSelected": "{{count}}টি নির্বাচিত যোগ করুন",
+ "addSuccess": "কনফিগারেশনে {{count}}টি মডেল যোগ করা হয়েছে।",
+ "needApiKey": "এই মডেলগুলির একটি API কী প্রয়োজন। আমদানির পরে আপনাকে ক্রেডেনশিয়াল কনফিগার করতে হবে।"
+ },
+ "test": {
+ "title": "মডেল সংযোগ পরীক্ষা করুন",
+ "description": "যাচাই করুন যে মডেল এন্ডপয়েন্টটি অ্যাক্সেসযোগ্য এবং সঠিকভাবে কনফিগার করা হয়েছে।",
+ "modelLabel": "মডেল:",
+ "identifierLabel": "শনাক্তকারী:",
+ "endpointLabel": "এন্ডপয়েন্ট:",
+ "testConnection": "সংযোগ পরীক্ষা করুন",
+ "testing": "সংযোগ পরীক্ষা করা হচ্ছে...",
+ "success": "সংযোগ সফল",
+ "responseTime": "প্রতিক্রিয়া সময়: {{ms}}ms",
+ "failed": "সংযোগ ব্যর্থ",
+ "status": "স্ট্যাটাস: {{status}}",
+ "testFailed": "পরীক্ষা ব্যর্থ",
+ "testAgain": "আবার পরীক্ষা করুন"
+ },
+ "validation": {
+ "whitespace": "মডেল শনাক্তকারীতে স্পেস থাকতে পারবে না",
+ "leadingSlash": "/ দিয়ে শুরু হওয়া উচিত নয়",
+ "consecutiveSlash": "পরপর / থাকা উচিত নয়",
+ "useProvider": "প্রোভাইডার হিসেবে \"{{provider}}\" ব্যবহার করা হবে",
+ "defaultToOpenAI": "কোনো প্রোভাইডার নির্দিষ্ট করা নেই, ডিফল্ট OpenAI",
+ "emptyModel": "মডেলের নাম খালি হতে পারবে না",
+ "shouldUse": "\"{{provider}}\" এর \"{{alias}}\" ব্যবহার করা উচিত",
+ "didYouMean": "আপনি কি \"{{closest}}\" বোঝাতে চেয়েছেন?",
+ "unknownProvider": "অজানা প্রোভাইডার \"{{provider}}\"",
+ "parsed": "প্রোভাইডার={{provider}}, মডেল={{model}}"
+ },
+ "combobox": {
+ "selectProvider": "প্রোভাইডার নির্বাচন করুন...",
+ "searchProvider": "প্রোভাইডার অনুসন্ধান করুন...",
+ "noProvider": "কোনো প্রোভাইডার পাওয়া যায়নি।",
+ "noCatalog": "প্রোভাইডার ক্যাটালগ অনুপলব্ধ।",
+ "local": "লোকাল"
+ }
+ },
+ "channels": {
+ "loadError": "চ্যানেল লোড করতে ব্যর্থ",
+ "name": {
+ "telegram": "Telegram",
+ "discord": "Discord",
+ "slack": "Slack",
+ "feishu": "ফেইশু",
+ "dingtalk": "ডিংটক",
+ "line": "LINE",
+ "qq": "QQ",
+ "onebot": "OneBot",
+ "wecom": "উইকম",
+ "whatsapp": "WhatsApp",
+ "whatsapp_native": "WhatsApp Native",
+ "pico": "Web",
+ "maixcam": "MaixCam",
+ "matrix": "Matrix",
+ "irc": "IRC",
+ "weixin": "উইচ্যাট",
+ "mqtt": "MQTT"
+ },
+ "weixin": {
+ "bindTitle": "WeChat অ্যাকাউন্ট বাইন্ডিং",
+ "bindDesc": "আপনার ব্যক্তিগত অ্যাকাউন্ট বাইন্ড করতে WeChat দিয়ে QR কোড স্ক্যান করুন।",
+ "bind": "WeChat বাইন্ড করুন",
+ "rebind": "পুনরায় বাইন্ড করুন",
+ "bound": "WeChat বাইন্ড করা হয়েছে",
+ "notBound": "WeChat অ্যাকাউন্ট এখনও বাইন্ড করা হয়নি।",
+ "generating": "QR কোড তৈরি করা হচ্ছে...",
+ "scanHint": "WeChat খুলুন এবং QR কোড স্ক্যান করুন",
+ "scanned": "স্ক্যান করা হয়েছে — অনুগ্রহ করে WeChat-এ নিশ্চিত করুন",
+ "expired": "QR কোডের মেয়াদ শেষ",
+ "retry": "আবার চেষ্টা করুন",
+ "refresh": "QR রিফ্রেশ করুন",
+ "errorGeneric": "একটি ত্রুটি ঘটেছে। অনুগ্রহ করে আবার চেষ্টা করুন।"
+ },
+ "wecom": {
+ "bindTitle": "WeCom বাইন্ডিং",
+ "bindDesc": "আপনার AI বট বাইন্ড করতে WeCom দিয়ে QR কোড স্ক্যান করুন।",
+ "bind": "WeCom বাইন্ড করুন",
+ "rebind": "পুনরায় বাইন্ড করুন",
+ "bound": "WeCom বাইন্ড করা হয়েছে",
+ "notBound": "WeCom AI বট এখনও বাইন্ড করা হয়নি।",
+ "generating": "QR কোড তৈরি করা হচ্ছে...",
+ "scanHint": "WeCom খুলুন এবং QR কোড স্ক্যান করুন",
+ "scanned": "স্ক্যান করা হয়েছে, অনুগ্রহ করে WeCom-এ নিশ্চিত করুন",
+ "expired": "QR কোডের মেয়াদ শেষ",
+ "retry": "আবার চেষ্টা করুন",
+ "refresh": "QR রিফ্রেশ করুন",
+ "errorGeneric": "একটি ত্রুটি ঘটেছে। অনুগ্রহ করে আবার চেষ্টা করুন।"
+ },
+ "field": {
+ "token": "Bot Token",
+ "tokenPlaceholder": "বট টোকেন লিখুন",
+ "botToken": "Bot Token",
+ "appToken": "App Token",
+ "appId": "App ID",
+ "appSecret": "App Secret",
+ "verificationToken": "Verification Token",
+ "encryptKey": "Encrypt Key",
+ "baseUrl": "API Base URL",
+ "proxy": "HTTP প্রক্সি",
+ "mentionOnly": "শুধুমাত্র উল্লেখ করলে",
+ "typingEnabled": "টাইপিং সূচক",
+ "placeholderEnabled": "প্লেসহোল্ডার বার্তা",
+ "placeholderText": "প্লেসহোল্ডার টেক্সট",
+ "streamingEnabled": "স্ট্রিমিং আউটপুট",
+ "streamingThrottleSeconds": "আপডেট ব্যবধান (সেকেন্ড)",
+ "streamingMinGrowthChars": "ন্যূনতম বৃদ্ধির অক্ষর",
+ "groupTriggerMentionOnly": "গ্রুপে শুধুমাত্র উল্লেখ",
+ "groupTriggerPrefixes": "গ্রুপ ট্রিগার প্রিফিক্স",
+ "groupTriggerPrefixesPlaceholder": "যেমন /, !, ?",
+ "randomReactionEmoji": "র্যান্ডম প্রতিক্রিয়া ইমোজি",
+ "randomReactionEmojiPlaceholder": "যেমন THUMBSUP, HEART, SMILE",
+ "isLark": "Lark (আন্তর্জাতিক)",
+ "allowFrom": "যাদের থেকে অনুমতি",
+ "allowFromPlaceholder": "যেমন 123456, 789012",
+ "allowOrigins": "অনুমোদিত অরিজিন",
+ "allowOriginsPlaceholder": "যেমন https://example.com, http://localhost:5173",
+ "removeListItem": "{{value}} সরান",
+ "secretPlaceholder": "সিক্রেট লিখুন",
+ "secretHintSet": "একটি মান ইতিমধ্যে সেট করা হয়েছে। অপরিবর্তিত রাখতে খালি রাখুন।"
+ },
+ "page": {
+ "notFound": "চ্যানেল \"{{name}}\" সমর্থিত নয়।",
+ "saveSuccess": "চ্যানেল কনফিগারেশন সংরক্ষিত হয়েছে।",
+ "saveError": "চ্যানেল কনফিগারেশন সংরক্ষণ করতে ব্যর্থ",
+ "savePrompt": "এই পরিবর্তন এখনও সংরক্ষিত হয়নি। চ্যানেল কনফিগারেশনে লিখতে সংরক্ষণ করুন।",
+ "docLink": "ডকুমেন্টেশন",
+ "enableLabel": "চ্যানেল সক্ষম করুন",
+ "restartRequiredTitle": "গেটওয়ে পুনরায় চালু করা প্রয়োজন",
+ "restartRequiredDesc": "সর্বশেষ {{name}} কনফিগারেশন সংরক্ষিত হয়েছে। কার্যকর হতে গেটওয়ে পুনরায় চালু করুন।"
+ },
+ "form": {
+ "desc": {
+ "token": "প্ল্যাটফর্ম API-এর সাথে সংযোগের জন্য ব্যবহৃত বট অ্যাক্সেস টোকেন।",
+ "botToken": "বার্তা পাঠাতে এবং গ্রহণ করতে ব্যবহৃত বট টোকেন।",
+ "appToken": "Socket Mode সংযোগের জন্য ব্যবহৃত অ্যাপ টোকেন।",
+ "appId": "প্রমাণীকরণের জন্য ব্যবহৃত অনন্য অ্যাপ্লিকেশন ID।",
+ "appSecret": "স্বাক্ষর এবং প্রমাণীকরণের জন্য ব্যবহৃত অ্যাপ্লিকেশন সিক্রেট।",
+ "verificationToken": "ইভেন্ট কলব্যাকের জন্য যাচাইকরণ টোকেন।",
+ "encryptKey": "কলব্যাক পেলোড ডিক্রিপ্ট করতে ব্যবহৃত এনক্রিপশন কী।",
+ "baseUrl": "প্ল্যাটফর্ম API বেস URL। ডিফল্টরূপে অফিসিয়াল এন্ডপয়েন্ট ব্যবহৃত হয়।",
+ "proxy": "বহির্গামী নেটওয়ার্ক অ্যাক্সেসের জন্য HTTP প্রক্সি ঠিকানা।",
+ "mentionOnly": "গ্রুপ চ্যাটে শুধুমাত্র বটকে স্পষ্টভাবে উল্লেখ করলে প্রতিক্রিয়া জানান।",
+ "typingEnabled": "সহকারী প্রতিক্রিয়া তৈরি করার সময় টাইপিং স্ট্যাটাস প্রদর্শন করুন।",
+ "placeholderEnabled": "চূড়ান্ত উত্তর পাঠানোর আগে অস্থায়ী প্লেসহোল্ডার বার্তা সক্ষম করুন।",
+ "streamingEnabled": "এই চ্যানেলকে প্রোভাইডার স্ট্রিমিং আউটপুট প্রদর্শনের অনুমতি দিন। বর্তমান মডেল এন্ট্রি স্ট্রিমিং সুইচও সক্ষম থাকতে হবে।",
+ "streamingThrottleSeconds": "মধ্যবর্তী স্ট্রিমিং আপডেটগুলির মধ্যে ন্যূনতম ব্যবধান। 0 মানে ডিফল্ট ব্যবহার করুন। চূড়ান্ত উত্তরে থ্রটল করা হয় না।",
+ "streamingMinGrowthChars": "অন্য একটি মধ্যবর্তী স্ট্রিমিং আপডেট পাঠানোর আগে ন্যূনতম টেক্সট বৃদ্ধি। 0 মানে ডিফল্ট ব্যবহার করুন। চূড়ান্ত উত্তরে থ্রটল করা হয় না।",
+ "groupTriggerMentionOnly": "গ্রুপ চ্যাটে, শুধুমাত্র বটকে উল্লেখ করলেই প্রতিক্রিয়া জানান।",
+ "groupTriggerPrefixes": "কাস্টম গ্রুপ-চ্যাট ট্রিগার প্রিফিক্স। একে একে আইটেম যোগ করুন, অথবা একাধিক মান একবারে পেস্ট করুন।",
+ "randomReactionEmoji": "PicoClaw প্রাপ্তি নিশ্চিত করতে ব্যবহারকারীর বার্তায় ইমোজি প্রতিক্রিয়া যোগ করে। উদাহরণ: \"THUMBSUP\", \"HEART\", \"SMILE\"। ডিফল্ট \"Pin\" ইমোজি ব্যবহার করতে খালি রাখুন।",
+ "isLark": "Feishu ডোমেইন (open.feishu.cn) এর পরিবর্তে Lark আন্তর্জাতিক ডোমেইন (open.larksuite.com) ব্যবহার করুন।",
+ "allowFrom": "অনুমোদিত ব্যবহারকারী বা গ্রুপ ID। একে একে আইটেম যোগ করুন, অথবা একাধিক মান একবারে পেস্ট করুন।",
+ "allowOrigins": "অনুমোদিত অরিজিন ডোমেইন। একে একে আইটেম যোগ করুন, অথবা একাধিক মান একবারে পেস্ট করুন।",
+ "wsUrl": "WebSocket সার্ভিস URL।",
+ "reconnectInterval": "বিচ্ছিন্ন হওয়ার পরে পুনঃসংযোগের ব্যবধান (সেকেন্ড)।",
+ "bridgeUrl": "ব্রিজ সার্ভিস URL।",
+ "sessionStorePath": "সেশন স্টোরেজের জন্য লোকাল পাথ।",
+ "useNative": "নেটিভ ক্লায়েন্ট মোড ব্যবহার করবেন কিনা।",
+ "host": "সার্ভিস হোস্ট ঠিকানা।",
+ "port": "সার্ভিস পোর্ট।",
+ "homeserver": "Matrix হোমসার্ভার URL।",
+ "userId": "অ্যাকাউন্ট ইউজার ID।",
+ "deviceId": "ডিভাইস ID।",
+ "joinOnInvite": "আমন্ত্রিত হলে স্বয়ংক্রিয়ভাবে রুমে যোগ দিন।",
+ "clientId": "প্ল্যাটফর্ম প্রমাণীকরণের জন্য ব্যবহৃত ক্লায়েন্ট ID।",
+ "corpId": "এন্টারপ্রাইজ Corp ID।",
+ "agentId": "এন্টারপ্রাইজ অ্যাপ্লিকেশন এজেন্ট ID।",
+ "webhookUrl": "সম্পূর্ণ webhook URL।",
+ "webhookHost": "Webhook শোনার হোস্ট।",
+ "webhookPort": "Webhook শোনার পোর্ট।",
+ "webhookPath": "Webhook রুট পাথ।",
+ "replyTimeout": "সেকেন্ডে উত্তরের টাইমআউট।",
+ "maxSteps": "প্রক্রিয়াকরণ ধাপের সর্বাধিক সংখ্যা।",
+ "welcomeMessage": "নতুন সেশনের জন্য স্বাগত বার্তার বিষয়বস্তু।",
+ "allowTokenQuery": "URL কোয়েরি প্যারামিটারে টোকেন অনুমতি দিন।",
+ "pingInterval": "সেকেন্ডে সংযোগ হার্টবিট ব্যবধান।",
+ "readTimeout": "সেকেন্ডে রিড টাইমআউট।",
+ "writeTimeout": "সেকেন্ডে রাইট টাইমআউট।",
+ "maxConnections": "একযোগে সংযোগের সর্বাধিক সংখ্যা।",
+ "server": "IRC সার্ভার ঠিকানা।",
+ "tls": "TLS সক্ষম করবেন কিনা।",
+ "nick": "বট ডাকনাম।",
+ "user": "IRC ব্যবহারকারীর নাম।",
+ "realName": "প্রদর্শিত আসল নাম।",
+ "channels": "যোগ দিতে IRC চ্যানেল।",
+ "requestCaps": "সংযোগে অনুরোধ করা IRC ক্ষমতার তালিকা।",
+ "maxBase64FileSizeMiB": "আপলোডের আগে লোকাল ফাইল base64-এ রূপান্তরের জন্য MiB-এ সর্বাধিক আকার। 0 মানে সীমাহীন। শুধুমাত্র লোকাল ফাইলের জন্য প্রযোজ্য, URL আপলোডের জন্য নয়।",
+ "genericField": "{{field}} কনফিগার করতে ব্যবহৃত।",
+ "broker": "MQTT ব্রোকার ঠিকানা।",
+ "mqttAgentId": "এই ইনস্ট্যান্সের জন্য অনন্য শনাক্তকারী, টপিক পাথ তৈরিতে ব্যবহৃত।",
+ "topicPrefix": "টপিক প্রিফিক্স। ডিফল্ট /picoclaw।",
+ "mqttUsername": "ব্রোকার প্রমাণীকরণ ব্যবহারকারীর নাম (ঐচ্ছিক)।",
+ "mqttPassword": "ব্রোকার প্রমাণীকরণ পাসওয়ার্ড (ঐচ্ছিক)।",
+ "mqttClientId": "MQTT ক্লায়েন্ট ID। স্বয়ংক্রিয়ভাবে তৈরি করতে খালি রাখুন।",
+ "keepAlive": "সেকেন্ডে Keepalive ব্যবধান। ডিফল্ট 60।",
+ "qos": "বার্তার সেবার গুণমান স্তর: 0 = সর্বাধিক একবার, 1 = কমপক্ষে একবার, 2 = ঠিক একবার।"
+ }
+ },
+ "validation": {
+ "requiredField": "এই ক্ষেত্রটি প্রয়োজনীয়।"
+ },
+ "mqtt": {
+ "protocolTitle": "প্রোটোকল রেফারেন্স",
+ "protocolDesc": "ক্লায়েন্টরা নিম্নলিখিত টপিক এবং পেলোড ফর্ম্যাট ব্যবহার করে বার্তা পাঠায় এবং গ্রহণ করে।",
+ "uplink": "আপলিঙ্ক (ক্লায়েন্ট → এজেন্ট)",
+ "downlink": "ডাউনলিঙ্ক (এজেন্ট → ক্লায়েন্ট)",
+ "topicParams": "টপিক প্যারামিটার",
+ "fieldText": "text",
+ "uplinkTextDesc": "ব্যবহারকারীর কাছ থেকে প্রাকৃতিক ভাষার নির্দেশনা (প্রয়োজনীয়)।",
+ "downlinkTextDesc": "এজেন্টের উত্তর টেক্সট। স্ট্রিমিং মোডে, সম্পূর্ণ প্রতিক্রিয়ার জন্য একাধিক বার্তা ক্রমানুসারে যুক্ত করুন।",
+ "topicPrefixDesc": "টপিক প্রিফিক্স, উপরের কনফিগারেশনের সাথে মেলে।",
+ "agentIdDesc": "এজেন্ট ID, উপরের কনফিগারেশনের সাথে মেলে।",
+ "clientIdDesc": "ক্লায়েন্ট-সংজ্ঞায়িত শনাক্তকারী। সুপারিশ: প্রথম চালু হওয়ার সময় একটি UUID তৈরি করুন এবং এটি সংরক্ষণ করুন যাতে একই ডিভাইস সবসময় একই ID ব্যবহার করে।",
+ "clientIdPlaceholder": "খালি থাকলে স্বয়ংক্রিয়ভাবে তৈরি",
+ "secretSet": "ইতিমধ্যে কনফিগার করা হয়েছে। অপরিবর্তিত রাখতে খালি রাখুন।",
+ "secretEmpty": "কনফিগার করা নেই"
+ }
+ },
+ "pages": {
+ "agent": {
+ "load_error": "এজেন্ট সমর্থন তথ্য লোড করতে ব্যর্থ।",
+ "skills": {
+ "empty": "বর্তমানে কোনো দক্ষতা উপলব্ধ নেই।",
+ "install_success": "{{name}} ইনস্টল করা হয়েছে।",
+ "install_error": "দক্ষতা ইনস্টল করতে ব্যর্থ।",
+ "search_placeholder": "নাম, বিবরণ বা রেজিস্ট্রি দ্বারা অনুসন্ধান করুন",
+ "source_label": "প্রকার",
+ "sort_label": "সাজান",
+ "import": "দক্ষতা আমদানি করুন",
+ "import_success": "দক্ষতা আমদানি করা হয়েছে।",
+ "import_error": "দক্ষতা আমদানি করতে ব্যর্থ।",
+ "import_invalid_type": "শুধুমাত্র Markdown বা ZIP দক্ষতা ফাইল সমর্থিত।",
+ "import_invalid_size": "দক্ষতা ফাইল 1 MB বা ছোট হতে হবে।",
+ "import_constraints": "1 MB পর্যন্ত একটি Markdown বা ZIP দক্ষতা ফাইল আমদানি করুন",
+ "view": "দেখুন",
+ "delete": "মুছুন",
+ "delete_title": "দক্ষতা মুছবেন?",
+ "delete_description": "\"{{name}}\" ওয়ার্কস্পেস দক্ষতা থেকে সরানো হবে।",
+ "delete_confirm": "মুছুন",
+ "delete_success": "দক্ষতা মুছে ফেলা হয়েছে।",
+ "delete_error": "দক্ষতা মুছতে ব্যর্থ।",
+ "viewer_title": "দক্ষতার বিষয়বস্তু",
+ "viewer_description": "এখানে বর্তমানে কার্যকর SKILL.md বিষয়বস্তু পড়ুন।",
+ "load_detail_error": "দক্ষতার বিষয়বস্তু লোড করতে ব্যর্থ।",
+ "no_description": "কোনো বিবরণ প্রদান করা হয়নি।",
+ "no_results": "বর্তমান ফিল্টারের সাথে কোনো দক্ষতা মেলেনি।",
+ "dropzone_title": "ওয়ার্কস্পেসে আমদানি করুন",
+ "dropzone_description": "এখানে একটি দক্ষতা ফাইল টেনে আনুন বা ডিস্ক থেকে নির্বাচন করুন।",
+ "dropzone_label": "এখানে একটি দক্ষতা ফাইল ফেলুন",
+ "dropzone_active": "এই দক্ষতাটি আমদানি করতে ছাড়ুন",
+ "dropzone_release": "দক্ষতাটি স্বাভাবিকীকরণ করা হবে এবং ওয়ার্কস্পেস দক্ষতা ডিরেক্টরিতে সংরক্ষণ করা হবে।",
+ "marketplace_title": "দক্ষতা আবিষ্কার করুন",
+ "marketplace_description": "দক্ষতা রেজিস্ট্রি অনুসন্ধান করুন এবং এই ওয়ার্কস্পেসে দরকারী দক্ষতা ইনস্টল করুন",
+ "marketplace_search_placeholder": "github, docker, database এর মতো ক্ষমতার জন্য অনুসন্ধান করুন...",
+ "marketplace_search_action": "অনুসন্ধান",
+ "marketplace_search_status": "অনুসন্ধান স্ট্যাটাস",
+ "marketplace_install_status": "ইনস্টল স্ট্যাটাস",
+ "marketplace_notice_title": "নিরাপত্তা বিজ্ঞপ্তি",
+ "marketplace_notice_body": "রেজিস্ট্রি দক্ষতা তৃতীয় পক্ষের বিষয়বস্তু। ইনস্টল করার আগে লেখক, পৃষ্ঠার URL, নির্দেশাবলী এবং প্রয়োজনীয় কোড বা ক্রেডেনশিয়াল পর্যালোচনা করুন।",
+ "marketplace_status_disabled": "নিষ্ক্রিয়। প্রথমে টুল পৃষ্ঠায় সংশ্লিষ্ট টুল সক্ষম করুন।",
+ "marketplace_status_enable_hint": "প্রথমে টুল পৃষ্ঠায় সম্পর্কিত টুল সক্ষম করুন।",
+ "marketplace_search_error": "রেজিস্ট্রি অনুসন্ধান করতে ব্যর্থ।",
+ "marketplace_loading_results": "দক্ষতা অনুসন্ধান করা হচ্ছে...",
+ "marketplace_loading_more": "আরও দক্ষতা লোড করা হচ্ছে...",
+ "marketplace_results_title": "“{{query}}”-এর জন্য {{count}}টি ফলাফল",
+ "marketplace_results_hint": "রেজিস্ট্রি ফলাফল বর্তমান ওয়ার্কস্পেসে ইনস্টল হয়।",
+ "marketplace_install_action": "ইনস্টল",
+ "marketplace_installed": "ইনস্টল করা হয়েছে",
+ "marketplace_view_installed": "লোকাল দেখুন",
+ "marketplace_installed_hint": "“{{name}}” হিসেবে এই ওয়ার্কস্পেসে ইতিমধ্যে উপলব্ধ।",
+ "marketplace_empty_results": "“{{query}}”-এর সাথে কোনো ইনস্টলযোগ্য দক্ষতা মেলেনি।",
+ "marketplace_idle": "কনফিগার করা রেজিস্ট্রি থেকে ইনস্টলযোগ্য দক্ষতা আবিষ্কার করতে একটি ক্ষমতার জন্য অনুসন্ধান করুন।",
+ "marketplace_unavailable": "রেজিস্ট্রি অনুসন্ধান বর্তমানে অনুপলব্ধ। দক্ষতা টুল কনফিগারেশন পরীক্ষা করুন।",
+ "sort": {
+ "name_asc": "নাম (A-Z)",
+ "name_desc": "নাম (Z-A)",
+ "source": "প্রকার"
+ },
+ "origin": {
+ "all": "সব প্রকার",
+ "builtin": "বিল্টইন",
+ "third_party": "তৃতীয় পক্ষ",
+ "manual": "ম্যানুয়াল"
+ },
+ "summary": {
+ "total": "মোট দক্ষতা"
+ },
+ "detail_tabs": {
+ "preview": "প্রিভিউ",
+ "raw": "র",
+ "meta": "মেটাডেটা"
+ },
+ "metadata": {
+ "name": "নাম",
+ "description": "বিবরণ",
+ "registry": "রেজিস্ট্রি",
+ "url": "লিঙ্ক ঠিকানা",
+ "version": "ইনস্টল করা সংস্করণ",
+ "lines": "লাইন সংখ্যা",
+ "characters": "অক্ষর সংখ্যা"
+ },
+ "marketplace_installDisabled": {
+ "installing": "ইনস্টল করা হচ্ছে...",
+ "installed": "ইতিমধ্যে ইনস্টল করা হয়েছে",
+ "cannotInstall": "ইনস্টল করা যাবে না: সম্পর্কিত টুল সক্ষম নয়"
+ }
+ },
+ "tools": {
+ "search_placeholder": "টুল অনুসন্ধান করুন...",
+ "no_results": "আপনার মানদণ্ডের সাথে কোনো টুল মেলেনি।",
+ "filter": {
+ "all": "সব স্ট্যাটাস",
+ "enabled": "সক্ষম",
+ "disabled": "নিষ্ক্রিয়",
+ "blocked": "ব্লক করা"
+ },
+ "empty": "কোনো টুল উপলব্ধ নেই।",
+ "enable_success": "টুল সক্ষম করা হয়েছে।",
+ "disable_success": "টুল নিষ্ক্রিয় করা হয়েছে।",
+ "toggle_error": "টুলের অবস্থা আপডেট করতে ব্যর্থ।",
+ "library_title": "টুল লাইব্রেরি",
+ "library_description": "আপনার AI এজেন্টদের জন্য উপলব্ধ টুলসেট ব্রাউজ এবং পরিচালনা করুন।",
+ "web_search": {
+ "title": "ওয়েব অনুসন্ধান",
+ "description": "এজেন্টদের সর্বশেষ বাস্তব-বিশ্বের তথ্য খুঁজে পেতে ওয়েব অনুসন্ধান ক্ষমতা প্রদান করুন। স্বয়ংক্রিয়ভাবে সর্বোত্তম সক্রিয় প্রোভাইডারে রুট করে।",
+ "unsaved_prompt": "এই পরিবর্তন এখনও সংরক্ষিত হয়নি। ওয়েব অনুসন্ধান কনফিগারেশনে লিখতে সংরক্ষণ করুন।",
+ "global_settings": "সাধারণ",
+ "providers_config": "ইন্টিগ্রেশন",
+ "load_error": "ওয়েব অনুসন্ধান কনফিগারেশন লোড করতে ব্যর্থ।",
+ "save": "পরিবর্তন সংরক্ষণ করুন",
+ "open_settings": "সেটিংস খুলুন",
+ "save_success": "সেটিংস সফলভাবে সংরক্ষিত হয়েছে।",
+ "save_error": "সেটিংস সংরক্ষণ করতে ব্যর্থ।",
+ "provider": "প্রাথমিক প্রোভাইডার",
+ "provider_description": "ওয়েব অনুসন্ধান টুল একটি অনুরোধ পরিচালনা করার সময় ব্যবহার করার জন্য ডিফল্ট প্রোভাইডার নির্বাচন করুন।",
+ "proxy": "HTTPS প্রক্সি",
+ "proxy_description": "অন্তর্নিহিত ওয়েব অনুরোধের জন্য ঐচ্ছিক বৈশ্বিক HTTP/S প্রক্সি।",
+ "prefer_native": "নেটিভ অনুসন্ধান পছন্দ করুন",
+ "prefer_native_hint": "সক্ষম থাকলে, মডেলটি কনফিগার করা প্রোভাইডার তালিকার পরিবর্তে তার বিল্ট-ইন অনুসন্ধান ক্ষমতা ব্যবহার করতে পারে।",
+ "provider_hint": "এই প্রোভাইডারকে সক্ষম করুন এবং কোনো প্রয়োজনীয় সংযোগ সেটিংস পূরণ করুন।",
+ "max_results": "সর্বাধিক ফলাফল",
+ "base_url": "বেস URL",
+ "base_url_placeholder": "ঐচ্ছিক এন্ডপয়েন্ট ওভাররাইড",
+ "api_key": "API কী / টোকেন",
+ "api_key_placeholder": "API কী লিখুন, আসল কী রাখতে এটি খালি রাখুন",
+ "none": "অনুপলব্ধ"
+ },
+ "status": {
+ "enabled": "সক্ষম",
+ "disabled": "নিষ্ক্রিয়",
+ "blocked": "ব্লক করা"
+ },
+ "categories": {
+ "automation": "অটোমেশন",
+ "filesystem": "ফাইলসিস্টেম",
+ "web": "ওয়েব",
+ "communication": "যোগাযোগ",
+ "skills": "দক্ষতা",
+ "agents": "এজেন্ট",
+ "hardware": "হার্ডওয়্যার",
+ "discovery": "আবিষ্কার"
+ },
+ "reasons": {
+ "requires_linux": "এই টুল শুধুমাত্র Linux হোস্টে কাজ করে যেখানে প্রয়োজনীয় ডিভাইস ফাইল উন্মুক্ত আছে।",
+ "requires_serial_platform": "এই টুল বর্তমানে অ্যাক্সেসযোগ্য সিরিয়াল পোর্ট সহ Linux, macOS এবং Windows হোস্ট সমর্থন করে।",
+ "requires_skills": "এই দক্ষতা-রেজিস্ট্রি টুল ব্যবহার করার আগে `tools.skills` সক্ষম করুন।",
+ "requires_subagent": "স্প্যান টুল কাজ অর্পণ করার আগে `tools.subagent` সক্ষম করুন।",
+ "requires_mcp_discovery": "MCP আবিষ্কার টুল উপলব্ধ হওয়ার আগে `tools.mcp.discovery` সক্ষম করুন।",
+ "requires_web_search_provider": "কমপক্ষে একটি প্রস্তুত বাহ্যিক ওয়েব-অনুসন্ধান প্রোভাইডার কনফিগার করুন।"
+ }
+ }
+ },
+ "config": {
+ "load_error": "কনফিগারেশন লোড করতে ব্যর্থ। অনুগ্রহ করে রিফ্রেশ করুন এবং আবার চেষ্টা করুন।",
+ "workspace": "ওয়ার্কস্পেস ডিরেক্টরি",
+ "workspace_hint": "এজেন্ট ফাইল অপারেশনের জন্য বেস ডিরেক্টরি।",
+ "restrict_workspace": "ওয়ার্কস্পেসে সীমাবদ্ধ করুন",
+ "restrict_workspace_hint": "শুধুমাত্র ওয়ার্কস্পেসের ভিতরে ফাইল অপারেশন অনুমতি দিন।",
+ "split_on_marker": "চ্যাটি মোড",
+ "split_on_marker_hint": "বাস্তব মানুষের চ্যাটিংয়ের মতো দীর্ঘ বার্তাগুলিকে ছোট বার্তায় বিভক্ত করুন।",
+ "tool_feedback_enabled": "টুল ফিডব্যাক",
+ "tool_feedback_enabled_hint": "প্রতিটি টুল চালানোর আগে বর্তমান চ্যাটে একটি সংক্ষিপ্ত নির্বাহ নোট পাঠান।",
+ "tool_feedback_separate_messages": "পৃথক ফিডব্যাক বার্তা",
+ "tool_feedback_separate_messages_hint": "একটি একক প্লেসহোল্ডার/প্রগ্রেস বার্তা পুনরায় ব্যবহার করার পরিবর্তে প্রতিটি টুল ফিডব্যাক আপডেটকে তার নিজস্ব চ্যাট বার্তা হিসেবে রাখুন।",
+ "tool_feedback_max_args_length": "টুল আর্গস প্রিভিউ দৈর্ঘ্য",
+ "tool_feedback_max_args_length_hint": "প্রতিটি টুল আর্গুমেন্ট প্রিভিউতে দেখানো অক্ষরের সর্বাধিক সংখ্যা। ডিফল্ট ব্যবহার করতে 0 সেট করুন।",
+ "exec_enabled": "কমান্ড অনুমতি দিন",
+ "exec_enabled_hint": "অ্যাপের জন্য কমান্ড নির্বাহ সক্ষম বা নিষ্ক্রিয় করুন। নিষ্ক্রিয় থাকলে, কোনো কমান্ড অনুরোধ চলবে না।",
+ "allow_remote": "রিমোট কমান্ড অনুমতি দিন",
+ "allow_remote_hint": "সক্ষম থাকলে, রিমোট সেশন বা নন-লোকাল প্রসঙ্গও কমান্ড চালাতে পারে। নিষ্ক্রিয় থাকলে, কমান্ড নির্বাহ স্থানীয় নিরাপদ প্রসঙ্গে সীমাবদ্ধ থাকে।",
+ "enable_deny_patterns": "ব্ল্যাকলিস্ট সক্ষম করুন",
+ "enable_deny_patterns_hint": "সক্ষম থাকলে, অ্যাপ্লিকেশন তার বিল্ট-ইন বিপজ্জনক প্যাটার্ন এবং নিচের কাস্টম কমান্ড ব্ল্যাকলিস্টের সাথে মিলে যাওয়া কমান্ডগুলি ব্লক করে।",
+ "exec_timeout_seconds": "কমান্ড টাইমআউট (সেকেন্ড)",
+ "exec_timeout_seconds_hint": "কমান্ড অনুরোধের জন্য সর্বাধিক রানটাইম। ডিফল্ট টাইমআউট ব্যবহার করতে 0 সেট করুন।",
+ "custom_deny_patterns": "কমান্ড ব্ল্যাকলিস্ট",
+ "custom_deny_patterns_hint": "অতিরিক্ত কমান্ড-ব্লকিং নিয়ম যোগ করুন, প্রতি লাইনে একটি রেগুলার এক্সপ্রেশন। এখানের কোনো নিয়মের সাথে মেলে এমন একটি কমান্ড ব্লক করা হবে।",
+ "custom_allow_patterns": "কমান্ড হোয়াইটলিস্ট",
+ "custom_allow_patterns_hint": "অতিরিক্ত কমান্ড-অনুমতি নিয়ম যোগ করুন, প্রতি লাইনে একটি রেগুলার এক্সপ্রেশন। এখানের কোনো নিয়মের সাথে মেলে এমন একটি কমান্ড ব্ল্যাকলিস্ট মিল এড়িয়ে যায়, তবে অন্যান্য নিরাপত্তা সীমা এখনও প্রযোজ্য।",
+ "custom_patterns_placeholder": "^rm\\s+-rf\\b\n^git\\s+push\\b",
+ "pattern_detector_title": "প্যাটার্ন সনাক্তকরণ টুল",
+ "pattern_detector_hint": "যেকোনো ব্ল্যাকলিস্ট বা হোয়াইটলিস্ট প্যাটার্নের সাথে মেলে কিনা তা পরীক্ষা করতে একটি কমান্ড লিখুন।",
+ "pattern_detector_input_placeholder": "পরীক্ষার জন্য একটি কমান্ড লিখুন, যেমন rm -rf /tmp",
+ "pattern_detector_test_button": "পরীক্ষা",
+ "pattern_detector_result_allowed": "অনুমোদিত (হোয়াইটলিস্টের সাথে মেলে)",
+ "pattern_detector_result_blocked": "ব্লক করা (ব্ল্যাকলিস্টের সাথে মেলে)",
+ "pattern_detector_result_no_match": "কোনো মিল নেই (ডিফল্ট নিয়ম ব্যবহার করবে)",
+ "allow_shell_execution": "সময়সূচী কমান্ড অনুমতি দিন",
+ "allow_shell_execution_hint": "ডিফল্টরূপে সময়সূচী কাজগুলিকে কমান্ড চালানোর অনুমতি দিন। নিষ্ক্রিয় থাকলে, ব্যবহারকারীদের একটি কমান্ড কাজের সময়সূচী করতে command_confirm=true পাস করতে হবে।",
+ "cron_exec_timeout": "সময়সূচী কমান্ড টাইমআউট (মিনিট)",
+ "cron_exec_timeout_hint": "সময়সূচী কমান্ডের জন্য সর্বাধিক রানটাইম। টাইমআউট নিষ্ক্রিয় করতে 0 সেট করুন।",
+ "max_tokens": "ম্যাক্স টোকেন",
+ "max_tokens_hint": "প্রতি মডেল প্রতিক্রিয়ার জন্য উপরের টোকেন সীমা।",
+ "context_window": "প্রসঙ্গ উইন্ডো",
+ "context_window_hint": "টোকেনে মডেল ইনপুট প্রসঙ্গ ধারণক্ষমতা। ডিফল্ট ব্যবহার করতে খালি রাখুন (4x ম্যাক্স টোকেন)।",
+ "max_tool_iterations": "ম্যাক্স টুল ইটারেশন",
+ "max_tool_iterations_hint": "একটি একক কাজের মধ্যে সর্বাধিক টুল-কল লুপ।",
+ "summarize_threshold": "সংক্ষিপ্তসারের বার্তা থ্রেশহোল্ড",
+ "summarize_threshold_hint": "এই সংখ্যক বার্তার পরে সংক্ষিপ্তসার শুরু করুন।",
+ "summarize_token_percent": "সংক্ষিপ্তসারের টোকেন শতাংশ",
+ "summarize_token_percent_hint": "কথোপকথন সংক্ষিপ্তসার ট্রিগার হলে ব্যবহৃত।",
+ "turn_profile": "অনুরোধ প্রসঙ্গ নীতি",
+ "turn_profile_hint": "প্রতিটি অনুরোধে কী প্রসঙ্গ বহন করে তা নিয়ন্ত্রণ করে। স্বাভাবিক চ্যাট আচরণ রাখতে নিষ্ক্রিয় রাখুন।",
+ "turn_profile_enabled": "নীতি সক্ষম করুন",
+ "turn_profile_enabled_hint": "সক্ষম থাকলে, এই নীতি প্রতিটি নতুন টার্নে প্রযোজ্য। নিষ্ক্রিয় থাকলে, PicoClaw মূল প্রসঙ্গ আচরণ ব্যবহার করে।",
+ "turn_profile_mode_default": "ডিফল্ট",
+ "turn_profile_mode_off": "বন্ধ",
+ "turn_profile_mode_custom": "অনুমোদিত তালিকা",
+ "turn_profile_history": "ইতিহাস প্রসঙ্গ",
+ "turn_profile_history_hint": "ডিফল্ট এই সেশনের পূর্ববর্তী বার্তা অন্তর্ভুক্ত করে। বন্ধ করলে টার্নটি একটি নতুন চ্যাটের মতো আচরণ করে এবং তার ফলাফল ইতিহাসে সংরক্ষণ করা এড়িয়ে যায়।",
+ "turn_profile_system_prompt": "সিস্টেম প্রসঙ্গ",
+ "turn_profile_system_prompt_hint": "ডিফল্ট PicoClaw পরিচয়, ওয়ার্কস্পেস, মেমরি এবং রানটাইম নির্দেশাবলী অন্তর্ভুক্ত করে। বন্ধ করলে অনুরোধ দ্বারা স্পষ্টভাবে সরবরাহিত সিস্টেম প্রম্পটগুলিই রাখা হয়।",
+ "turn_profile_skills": "দক্ষতা প্রম্পট",
+ "turn_profile_skills_hint": "ডিফল্ট উপলব্ধ দক্ষতা এবং সক্রিয় দক্ষতা নির্দেশাবলী অন্তর্ভুক্ত করে। বন্ধ করলে সেগুলি লুকায়। অনুমোদিত তালিকা প্রতি লাইনে এক করে প্রবেশ করানো দক্ষতার নামগুলিই রাখে।",
+ "turn_profile_skills_allow_placeholder": "skill-name\nanother-skill",
+ "turn_profile_tools": "কলযোগ্য টুল",
+ "turn_profile_tools_hint": "ডিফল্ট স্বাভাবিক টুল উন্মুক্ত করে। বন্ধ টুল কলগুলি প্রতিরোধ করে। অনুমোদিত তালিকা প্রতি লাইনে এক করে প্রবেশ করানো টুলের নামগুলিই রাখে, যেমন web_search।",
+ "turn_profile_tools_allow_placeholder": "web_search\nweb_fetch",
+ "session_scope": "সেশন স্কোপ",
+ "session_scope_hint": "পিয়ার/চ্যানেল জুড়ে চ্যাট প্রসঙ্গ কীভাবে বিচ্ছিন্ন করা হয়।",
+ "session_scope_per_channel_peer": "প্রতি চ্যানেল + পিয়ার",
+ "session_scope_per_channel_peer_desc": "প্রতিটি চ্যানেলে প্রতিটি ব্যবহারকারীর জন্য আলাদা প্রসঙ্গ।",
+ "session_scope_per_channel": "প্রতি চ্যানেল",
+ "session_scope_per_channel_desc": "প্রতি চ্যানেলে একটি শেয়ার করা প্রসঙ্গ।",
+ "session_scope_per_peer": "প্রতি পিয়ার",
+ "session_scope_per_peer_desc": "চ্যানেল জুড়ে প্রতি ব্যবহারকারীর জন্য একটি প্রসঙ্গ।",
+ "session_scope_global": "গ্লোবাল",
+ "session_scope_global_desc": "সব বার্তা একটি গ্লোবাল প্রসঙ্গ শেয়ার করে।",
+ "heartbeat_enabled": "হার্টবিট",
+ "heartbeat_enabled_hint": "পর্যায়ক্রমিক হার্টবিট বার্তা পাঠান।",
+ "heartbeat_interval": "হার্টবিট ব্যবধান (মিনিট)",
+ "heartbeat_interval_hint": "হার্টবিট সংকেতের মধ্যে মিনিটে ব্যবধান।",
+ "devices_enabled": "ডিভাইস সক্ষম করুন",
+ "devices_enabled_hint": "হার্ডওয়্যার-ডিভাইস ইন্টিগ্রেশন সক্ষম করুন।",
+ "monitor_usb": "USB মনিটর করুন",
+ "monitor_usb_hint": "ডিভাইস সক্ষম থাকলে USB প্লাগ/আনপ্লাগ ইভেন্ট দেখুন।",
+ "autostart_label": "লগইনে চালু করুন",
+ "autostart_hint": "লগ ইন করার সময় স্বয়ংক্রিয়ভাবে PicoClaw Web চালু করুন।",
+ "autostart_unsupported": "এই প্ল্যাটফর্মে লগইনে চালু করা সমর্থিত নয়।",
+ "autostart_load_error": "লগইনে চালু করার স্ট্যাটাস লোড করতে ব্যর্থ।",
+ "server_port": "সার্ভিস পোর্ট",
+ "server_port_hint": "PicoClaw Web দ্বারা ব্যবহৃত HTTP পোর্ট।",
+ "launcher_section_hint": "এই বিভাগের পরিবর্তন লঞ্চার পুনরায় চালু হওয়ার পরে কার্যকর হয়।",
+ "gateway_restart_hint": "এই বিভাগের পরিবর্তন গেটওয়ে পুনরায় চালু হওয়ার পরে কার্যকর হয়।",
+ "dashboard_password": "লগইন পাসওয়ার্ড",
+ "dashboard_password_hint": "একটি নতুন লগইন পাসওয়ার্ড সেট করুন।",
+ "dashboard_password_placeholder": "কমপক্ষে ৮ অক্ষর",
+ "dashboard_password_confirm": "নতুন পাসওয়ার্ড নিশ্চিত করুন",
+ "dashboard_password_confirm_hint": "নতুন লগইন পাসওয়ার্ড আবার লিখুন।",
+ "dashboard_password_confirm_placeholder": "পাসওয়ার্ড আবার লিখুন",
+ "dashboard_password_required": "নতুন লগইন পাসওয়ার্ড লিখুন এবং নিশ্চিত করুন।",
+ "dashboard_password_mismatch": "লগইন পাসওয়ার্ড মিলছে না।",
+ "dashboard_password_min_length": "লগইন পাসওয়ার্ড কমপক্ষে ৮ অক্ষরের হতে হবে।",
+ "lan_access": "LAN অ্যাক্সেস সক্ষম করুন",
+ "lan_access_hint": "আপনার স্থানীয় নেটওয়ার্কের অন্যান্য ডিভাইস থেকে অ্যাক্সেসের অনুমতি দিন।",
+ "allowed_cidrs": "অনুমোদিত নেটওয়ার্ক CIDR",
+ "allowed_cidrs_hint": "শুধুমাত্র এই CIDR পরিসরের ক্লায়েন্টরা সার্ভিস অ্যাক্সেস করতে পারে। প্রতি লাইনে একটি বা কমা দিয়ে আলাদা। সবাইকে অনুমতি দিতে খালি রাখুন।",
+ "allowed_cidrs_placeholder": "192.168.1.0/24\n10.0.0.0/8",
+ "evolution_section_hint": "এজেন্টকে সম্পন্ন টার্ন থেকে শিখতে দিন এবং দক্ষতার উন্নতি প্রস্তুত করুন।",
+ "evolution_enabled": "ইভোলিউশন সক্ষম করুন",
+ "evolution_enabled_hint": "সম্পন্ন টার্নের জন্য শেখার ডেটা রেকর্ড করুন। ড্রাফ্ট এবং অ্যাপ্লাই মোড দক্ষতা আপডেটও তৈরি করতে পারে।",
+ "evolution_mode": "ইভোলিউশন মোড",
+ "evolution_mode_hint": "Observe শুধু ডেটা রেকর্ড করে, Draft প্রার্থী দক্ষতা প্রস্তুত করে, Apply ওয়ার্কস্পেস দক্ষতায় গৃহীত ড্রাফ্ট লিখতে পারে।",
+ "evolution_mode_observe": "পর্যবেক্ষণ",
+ "evolution_mode_draft": "ড্রাফ্ট",
+ "evolution_mode_apply": "প্রয়োগ",
+ "evolution_state_dir": "স্টেট ডিরেক্টরি",
+ "evolution_state_dir_hint": "ইভোলিউশন স্টেটের জন্য ঐচ্ছিক ডিরেক্টরি। ওয়ার্কস্পেস ডিফল্ট ব্যবহার করতে খালি রাখুন।",
+ "evolution_min_task_count": "ন্যূনতম কাজ গণনা",
+ "evolution_min_task_count_hint": "একটি প্যাটার্ন একটি ড্রাফ্ট তৈরি করার আগে প্রয়োজনীয় ন্যূনতম সম্পর্কিত কাজ।",
+ "evolution_min_success_ratio": "ন্যূনতম সাফল্যের অনুপাত",
+ "evolution_min_success_ratio_hint": "ক্লাস্টার্ড কাজের জন্য প্রয়োজনীয় সাফল্যের অনুপাত। 0 এর চেয়ে বড় এবং 1 পর্যন্ত একটি মান ব্যবহার করুন।",
+ "evolution_cold_path_trigger": "কোল্ড পাথ ট্রিগার",
+ "evolution_cold_path_trigger_hint": "যোগ্য শেখার রেকর্ডের জন্য ড্রাফ্ট জেনারেশন কখন চলবে তা চয়ন করুন।",
+ "evolution_cold_path_after_turn": "প্রতিটি টার্নের পরে",
+ "evolution_cold_path_scheduled": "সময়সূচী",
+ "evolution_cold_path_manual": "বন্ধ",
+ "evolution_cold_path_times": "সময়সূচী সময়",
+ "evolution_cold_path_times_hint": "সময়সূচী কোল্ড-পাথ প্রক্রিয়াকরণের জন্য চালানোর সময়। প্রতি লাইনে একটি HH:MM মান লিখুন।",
+ "mcp_section_hint": "ম্যানুয়ালি config.json সম্পাদনা না করে MCP সার্ভার কনফিগার করুন।",
+ "mcp_enabled": "MCP সক্ষম করুন",
+ "mcp_enabled_hint": "MCP সার্ভার ইন্টিগ্রেশন চালু বা বন্ধ করুন।",
+ "mcp_discovery_enabled": "MCP আবিষ্কার সক্ষম করুন",
+ "mcp_discovery_enabled_hint": "MCP আবিষ্কার টুলকে নিবন্ধিত MCP সার্ভার অনুসন্ধান করার অনুমতি দিন।",
+ "mcp_discovery_ttl": "আবিষ্কৃত টুল আনলক TTL",
+ "mcp_discovery_ttl_hint": "অনুসন্ধানের পরে আবিষ্কৃত টুলগুলি কত টুল-নির্বাহ TTL টিক উপলব্ধ থাকবে।",
+ "mcp_discovery_max_results": "আবিষ্কার সর্বাধিক ফলাফল",
+ "mcp_discovery_max_results_hint": "প্রতি কোয়েরিতে ফিরিয়ে দেওয়া সর্বাধিক MCP আবিষ্কার মিল।",
+ "mcp_discovery_use_bm25": "BM25 র্যাঙ্কিং ব্যবহার করুন",
+ "mcp_discovery_use_bm25_hint": "MCP আবিষ্কার ফলাফলের জন্য BM25 লেক্সিকাল স্কোরিং ব্যবহার করুন।",
+ "mcp_discovery_use_regex": "রেগেক্স অনুসন্ধান সক্ষম করুন",
+ "mcp_discovery_use_regex_hint": "MCP আবিষ্কারে রেগেক্স-ভিত্তিক মিলের অনুমতি দিন।",
+ "mcp_servers": "MCP সার্ভার",
+ "mcp_servers_hint": "MCP সার্ভার যোগ, সম্পাদনা বা সরান।",
+ "mcp_server_new": "নতুন MCP সার্ভার",
+ "mcp_server_add": "সার্ভার যোগ করুন",
+ "mcp_server_remove": "সরান",
+ "mcp_server_enabled": "সক্ষম",
+ "mcp_server_discovery_mode": "আবিষ্কার মোড",
+ "mcp_server_discovery_mode_inherit": "গ্লোবাল আবিষ্কার মোড অনুসরণ করুন",
+ "mcp_server_discovery_mode_deferred": "বিলম্বিত আবিষ্কার",
+ "mcp_server_discovery_mode_eager": "ইগার নিবন্ধন",
+ "mcp_server_name_placeholder": "সার্ভারের নাম (যেমন github)",
+ "mcp_server_url_placeholder": "সার্ভার URL (যেমন https://example.com/mcp)",
+ "mcp_server_command_placeholder": "কমান্ড (যেমন npx)",
+ "mcp_server_env_file_placeholder": "পরিবেশ ফাইল পাথ (ঐচ্ছিক)",
+ "mcp_server_args_placeholder": "আর্গস, প্রতি লাইনে একটি",
+ "mcp_server_env_placeholder": "পরিবেশ JSON অবজেক্ট",
+ "mcp_server_headers_placeholder": "হেডার JSON অবজেক্ট",
+ "sections": {
+ "agent": "এজেন্ট",
+ "runtime": "রানটাইম",
+ "evolution": "ইভোলিউশন",
+ "mcp": "MCP",
+ "exec": "কমান্ড চালান",
+ "cron": "ক্রন কাজ",
+ "launcher": "লঞ্চার",
+ "devices": "ডিভাইস"
+ },
+ "open_raw": "র কনফিগ",
+ "back_to_visual": "ভিজ্যুয়াল কনফিগ",
+ "raw_json_title": "র JSON কনফিগারেশন",
+ "json_placeholder": "বৈধ JSON কনফিগারেশন লিখুন...",
+ "save_success": "কনফিগারেশন সফলভাবে সংরক্ষিত হয়েছে।",
+ "save_error": "কনফিগারেশন সংরক্ষণ করতে ব্যর্থ।",
+ "reset_confirm_title": "পরিবর্তন রিসেট করুন",
+ "reset_confirm_desc": "আপনি কি আপনার অসংরক্ষিত পরিবর্তনগুলি সর্বশেষ সংরক্ষিত অবস্থায় রিসেট করতে চান?",
+ "reset_success": "পরিবর্তনগুলি সর্বশেষ সংরক্ষিত অবস্থায় রিসেট করা হয়েছে।",
+ "invalid_json": "অবৈধ JSON ফর্ম্যাট।",
+ "format_success": "JSON সফলভাবে ফর্ম্যাট করা হয়েছে।",
+ "format_error": "অবৈধ JSON ফর্ম্যাট।",
+ "format": "ফর্ম্যাট",
+ "unsaved_changes": "আপনার অসংরক্ষিত পরিবর্তন রয়েছে।",
+ "factory_reset": "ফ্যাক্টরি রিসেট",
+ "factory_reset_confirm_title": "ফ্যাক্টরি ডিফল্টে রিসেট করুন",
+ "factory_reset_confirm_desc": "এটি সমস্ত কনফিগারেশন ফ্যাক্টরি ডিফল্টে রিসেট করবে। API কী এবং নিরাপত্তা ক্রেডেনশিয়াল সংরক্ষিত থাকবে। বর্তমান কনফিগারেশনের একটি ব্যাকআপ তৈরি করা হবে।",
+ "factory_reset_confirm": "ডিফল্টে রিসেট করুন",
+ "factory_reset_success": "কনফিগারেশন ফ্যাক্টরি ডিফল্টে রিসেট করা হয়েছে।",
+ "factory_reset_error": "কনফিগারেশন রিসেট করতে ব্যর্থ।"
+ },
+ "logs": {
+ "log_level_error": "লগ লেভেল আপডেট করতে ব্যর্থ।",
+ "clear": "লগ পরিষ্কার করুন",
+ "empty": "লগের জন্য অপেক্ষা করা হচ্ছে..."
+ }
+ },
+ "tour": {
+ "skip": "ট্যুর এড়িয়ে যান",
+ "prev": "পূর্ববর্তী",
+ "next": "পরবর্তী",
+ "finish": "শেষ করুন",
+ "welcome": {
+ "title": "PicoClaw-এ স্বাগতম",
+ "description": "PicoClaw একটি শক্তিশালী AI সহকারী প্ল্যাটফর্ম। মৌলিক সেটআপ সম্পন্ন করতে আপনাকে সাহায্য করতে কয়েক সেকেন্ড সময় নিই।"
+ },
+ "models": {
+ "title": "মডেল কনফিগার করুন",
+ "description": "AI প্রোভাইডারদের জন্য API কী কনফিগার করতে বাঁ দিকের \"মডেল\" মেনুতে ক্লিক করুন। শুধুমাত্র কনফিগার করা মডেলগুলি চ্যাটের জন্য ব্যবহার করা যেতে পারে।"
+ },
+ "gateway": {
+ "title": "গেটওয়ে চালু করুন",
+ "description": "মডেল কনফিগার করার পরে, AI-এর সাথে চ্যাট শুরু করতে উপরের \"গেটওয়ে চালু করুন\" বোতামে ক্লিক করুন।"
+ },
+ "docs": {
+ "title": "ডকুমেন্টেশন দেখুন",
+ "description": "আরও সাহায্যের প্রয়োজন? বিস্তারিত গাইড এবং কনফিগারেশন ডকুমেন্ট দেখতে উপরের ডান কোণে ডকুমেন্টেশন বোতামে ক্লিক করুন।"
+ }
+ }
+}
diff --git a/web/frontend/src/i18n/locales/cs.json b/web/frontend/src/i18n/locales/cs.json
new file mode 100644
index 000000000..30139c0b9
--- /dev/null
+++ b/web/frontend/src/i18n/locales/cs.json
@@ -0,0 +1,959 @@
+{
+ "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",
+ "dropImagesActive": "Uvolněním přidáte obrázky",
+ "removeImage": "Odebrat obrázek",
+ "uploadedImage": "Přiložený 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."
+ }
+ }
+}
diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json
index 1f15cee11..11ea2cabf 100644
--- a/web/frontend/src/i18n/locales/en.json
+++ b/web/frontend/src/i18n/locales/en.json
@@ -76,6 +76,8 @@
"copyMessage": "Copy message",
"copyCode": "Copy code",
"copiedLabel": "Copied",
+ "enableCodeWrap": "Wrap lines",
+ "disableCodeWrap": "Disable wrap",
"expandCode": "Expand code",
"collapseCode": "Collapse code",
"history": "History",
@@ -95,8 +97,9 @@
"contextTitle": "Context",
"contextDetail": "View Details",
"attachImage": "Add images",
+ "dropImagesActive": "Release to add images",
"removeImage": "Remove image",
- "uploadedImage": "Uploaded image",
+ "uploadedImage": "Attached image",
"invalidImage": "\"{{name}}\" is not a supported image file.",
"imageTooLarge": "\"{{name}}\" exceeds the {{size}} limit.",
"imageReadFailed": "Failed to read \"{{name}}\".",
diff --git a/web/frontend/src/i18n/locales/pt-br.json b/web/frontend/src/i18n/locales/pt-br.json
index 8e27078fc..28df583ba 100644
--- a/web/frontend/src/i18n/locales/pt-br.json
+++ b/web/frontend/src/i18n/locales/pt-br.json
@@ -76,6 +76,8 @@
"copyMessage": "Copiar mensagem",
"copyCode": "Copiar código",
"copiedLabel": "Copiado",
+ "enableCodeWrap": "Quebrar linhas",
+ "disableCodeWrap": "Desativar quebra",
"expandCode": "Expandir código",
"collapseCode": "Recolher código",
"history": "Histórico",
@@ -95,8 +97,9 @@
"contextTitle": "Contexto",
"contextDetail": "Ver Detalhes",
"attachImage": "Adicionar imagens",
+ "dropImagesActive": "Solte para adicionar imagens",
"removeImage": "Remover imagem",
- "uploadedImage": "Imagem enviada",
+ "uploadedImage": "Imagem anexada",
"invalidImage": "\"{{name}}\" não é um arquivo de imagem suportado.",
"imageTooLarge": "\"{{name}}\" excede o limite de {{size}}.",
"imageReadFailed": "Falha ao ler \"{{name}}\".",
diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json
index bd027fee4..d5491e8f0 100644
--- a/web/frontend/src/i18n/locales/zh.json
+++ b/web/frontend/src/i18n/locales/zh.json
@@ -76,6 +76,8 @@
"copyMessage": "复制消息",
"copyCode": "复制代码",
"copiedLabel": "已复制",
+ "enableCodeWrap": "开启换行",
+ "disableCodeWrap": "关闭换行",
"expandCode": "展开代码",
"collapseCode": "折叠代码",
"history": "历史记录",
@@ -95,8 +97,9 @@
"contextTitle": "上下文",
"contextDetail": "查看详情",
"attachImage": "添加图片",
+ "dropImagesActive": "松开以添加图片",
"removeImage": "移除图片",
- "uploadedImage": "已上传图片",
+ "uploadedImage": "已添加图片",
"invalidImage": "“{{name}}”不是支持的图片文件。",
"imageTooLarge": "“{{name}}”超过了 {{size}} 限制。",
"imageReadFailed": "读取“{{name}}”失败。",
diff --git a/web/frontend/src/store/code-block.ts b/web/frontend/src/store/code-block.ts
new file mode 100644
index 000000000..612e45fca
--- /dev/null
+++ b/web/frontend/src/store/code-block.ts
@@ -0,0 +1,11 @@
+import { atomWithStorage } from "jotai/utils"
+
+export const CODE_BLOCK_WRAP_STORAGE_KEY = "picoclaw:code-block-wrap"
+export const DEFAULT_CODE_BLOCK_WRAP = false
+
+export const codeBlockWrapAtom = atomWithStorage(
+ CODE_BLOCK_WRAP_STORAGE_KEY,
+ DEFAULT_CODE_BLOCK_WRAP,
+ undefined,
+ { getOnInit: true },
+)
diff --git a/web/frontend/src/store/index.ts b/web/frontend/src/store/index.ts
index a13b7b161..2ef7631fe 100644
--- a/web/frontend/src/store/index.ts
+++ b/web/frontend/src/store/index.ts
@@ -1,3 +1,4 @@
export * from "./gateway"
export * from "./chat"
+export * from "./code-block"
export * from "./tour"