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({ {attachment.filename ))} 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"