Merge remote-tracking branch 'upstream/main'

This commit is contained in:
afjcjsbx
2026-05-31 23:11:18 +02:00
58 changed files with 4692 additions and 241 deletions
+3
View File
@@ -0,0 +1,3 @@
# These are supported funding model platforms
github: [sipeed]
+3 -1
View File
@@ -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`.
<details>
<summary><b>Local deployment (Ollama, vLLM, etc.)</b></summary>
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 327 KiB

After

Width:  |  Height:  |  Size: 428 KiB

+48
View File
@@ -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() {
+1
View File
@@ -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
+9 -2
View File
@@ -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
+23 -8
View File
@@ -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=
+36 -4
View File
@@ -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)
}
+5 -1
View File
@@ -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)
+9
View File
@@ -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))
+5
View File
@@ -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) {
+22 -1
View File
@@ -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 {
+39
View File
@@ -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),
+69
View File
@@ -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 {
+33 -1
View File
@@ -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.
+78
View File
@@ -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
}
+180 -1
View File
@@ -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)
+281
View File
@@ -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) {
+59
View File
@@ -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)
}
}
+7 -1
View File
@@ -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_"`
+10
View File
@@ -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
+5 -2
View File
@@ -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,
+56
View File
@@ -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),
)
}
+24
View File
@@ -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)
}
+39
View File
@@ -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)
}
}
+45 -5
View File
@@ -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 != "" {
+67
View File
@@ -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)
}
}
+17 -8
View File
@@ -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.)
@@ -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")
}
}
+5 -2
View File
@@ -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)
}
}
+9 -1
View File
@@ -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 {
@@ -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")
}
+108 -24
View File
@@ -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) {
+81 -5
View File
@@ -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)
+75 -17
View File
@@ -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)
}
+14
View File
@@ -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) {
+19 -10
View File
@@ -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()
}
}
+29
View File
@@ -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)
}
}
+269 -33
View File
@@ -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"
}
}
+149 -11
View File
@@ -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")
}
}
@@ -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({
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize, rehypeHighlight]}
components={{
pre: MarkdownCodeBlock,
}}
>
{selectedSkillDetail.content}
</ReactMarkdown>
@@ -183,11 +190,12 @@ export function DetailSheet({
) : null}
{detailView === "raw" ? (
<div className="border-border/50 overflow-x-auto rounded-xl border bg-zinc-950 p-5 shadow-sm">
<pre className="font-mono text-[13px] leading-relaxed break-words whitespace-pre-wrap text-zinc-100/90">
<code>{selectedSkillDetail.content}</code>
</pre>
</div>
<MessageCodeBlock
code={selectedSkillDetail.content}
label={t("pages.agent.skills.detail_tabs.raw")}
className="my-0"
bodyClassName="text-[13px] leading-relaxed"
/>
) : null}
{detailView === "meta" ? (
@@ -291,9 +291,15 @@ export function AppHeader() {
<DropdownMenuItem onClick={() => i18n.changeLanguage("pt-BR")}>
Português (Brasil)
</DropdownMenuItem>
<DropdownMenuItem onClick={() => i18n.changeLanguage("bn-IN")}>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => i18n.changeLanguage("zh")}>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => i18n.changeLanguage("cs")}>
Čeština
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -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")}
</p>
<CodeLine>{`${topicBase}/request`}</CodeLine>
<pre className="bg-muted text-foreground rounded px-3 py-2 font-mono text-xs leading-relaxed">
{`{\n "text": "your message"\n}`}
</pre>
<MessageCodeBlock
code={`{\n "text": "your message"\n}`}
language="json"
className="my-0"
bodyClassName="px-3 py-2 text-xs leading-relaxed"
/>
<div className="text-muted-foreground space-y-1 text-xs">
<p>
<span className="text-foreground font-medium">
@@ -199,9 +203,12 @@ export function MqttForm({
{t("channels.mqtt.downlink")}
</p>
<CodeLine>{`${topicBase}/response`}</CodeLine>
<pre className="bg-muted text-foreground rounded px-3 py-2 font-mono text-xs leading-relaxed">
{`{\n "text": "agent response"\n}`}
</pre>
<MessageCodeBlock
code={`{\n "text": "agent response"\n}`}
language="json"
className="my-0"
bodyClassName="px-3 py-2 text-xs leading-relaxed"
/>
<div className="text-muted-foreground space-y-1 text-xs">
<p>
<span className="text-foreground font-medium">
@@ -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
/>
)}
</div>
@@ -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<HTMLTextAreaElement>) => void
onDragEnter: (event: ReactDragEvent<HTMLDivElement>) => void
onDragLeave: (event: ReactDragEvent<HTMLDivElement>) => void
onDragOver: (event: ReactDragEvent<HTMLDivElement>) => void
onDrop: (event: ReactDragEvent<HTMLDivElement>) => 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 (
<div className="before:bg-background pointer-events-none relative z-10 -mt-[24px] shrink-0 overflow-y-auto px-4 pb-[calc(1rem+env(safe-area-inset-bottom))] [scrollbar-gutter:stable] before:pointer-events-none before:absolute before:inset-x-0 before:top-[24px] before:bottom-0 before:content-[''] md:px-8 md:pb-8 lg:px-24 xl:px-48">
<div className="bg-card border-border/60 pointer-events-auto relative mx-auto flex max-w-[1000px] flex-col rounded-2xl border p-3 shadow-sm">
<div className="before:bg-background pointer-events-none relative z-10 -mt-[24px] shrink-0 [scrollbar-gutter:stable] overflow-y-auto px-4 pb-[calc(1rem+env(safe-area-inset-bottom))] before:pointer-events-none before:absolute before:inset-x-0 before:top-[24px] before:bottom-0 before:content-[''] md:px-8 md:pb-8 lg:px-24 xl:px-48">
<div
className={cn(
"bg-card border-border/60 pointer-events-auto relative mx-auto flex max-w-[1000px] flex-col rounded-2xl border p-3 shadow-sm transition-colors",
isDragActive && "border-violet-400/70 bg-violet-500/5",
)}
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDragOver={onDragOver}
onDrop={onDrop}
>
{isDragActive && (
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center rounded-2xl border-2 border-dashed border-violet-400/70 bg-violet-500/10">
<div className="bg-background/95 text-foreground rounded-full px-4 py-2 text-sm font-medium shadow-sm">
{t("chat.dropImagesActive")}
</div>
</div>
)}
{attachments.length > 0 && (
<div className="mb-3 flex flex-wrap gap-2 px-2">
{attachments.map((attachment, index) => (
@@ -115,6 +149,7 @@ export function ChatComposer({
onCompositionEnd={() => {
composingRef.current = false
}}
onPaste={onPaste}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={!canInput}
+103 -63
View File
@@ -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<string> {
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<HTMLDivElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const dragDepthRef = useRef(0)
const [isAtBottom, setIsAtBottom] = useState(true)
const [hasScrolled, setHasScrolled] = useState(false)
const [input, setInput] = useState("")
const [attachments, setAttachments] = useState<ChatAttachment[]>([])
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<HTMLInputElement>) => {
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<HTMLTextAreaElement>,
) => {
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<HTMLDivElement>) => {
if (!hasFileTransfer(event.dataTransfer)) {
return
}
event.preventDefault()
if (!canInput) {
return
}
dragDepthRef.current += 1
setIsDragActive(true)
}
const handleComposerDragLeave = (event: DragEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
if (!hasFileTransfer(event.dataTransfer)) {
return
}
event.preventDefault()
event.dataTransfer.dropEffect = canInput ? "copy" : "none"
}
const handleComposerDrop = async (event: DragEvent<HTMLDivElement>) => {
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() {
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp,image/bmp"
accept={CHAT_IMAGE_ACCEPT}
multiple
className="hidden"
onChange={handleImageSelection}
/>
@@ -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}
/>
</div>
@@ -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 (
<div
@@ -102,6 +140,18 @@ export function MessageCodeBlock({
)}
<span className="hidden sm:inline">{copyLabel}</span>
</Button>
<Button
type="button"
variant="ghost"
size="xs"
className="h-7 px-2 text-[11px] text-zinc-600 hover:bg-zinc-300/70 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100"
onClick={() => setWrapLongLines((current) => !current)}
aria-pressed={wrapLongLines}
aria-label={wrapLabel}
title={wrapLabel}
>
{wrapLabel}
</Button>
<Button
type="button"
variant="ghost"
@@ -123,23 +173,56 @@ export function MessageCodeBlock({
{isExpanded && (
<pre
className={cn(
"m-0 overflow-x-auto bg-transparent px-4 py-3 font-mono text-[13px] leading-6 [&_code]:block [&_code]:bg-transparent [&_code]:p-0 [&_code]:text-inherit",
wrapLongLines ? "break-words whitespace-pre-wrap" : "whitespace-pre",
"m-0 overflow-x-auto bg-transparent px-4 py-3 font-mono text-[13px] leading-6",
bodyClassName,
)}
>
{children ?? (
highlightedHtml ? (
<code
className={cn("hljs", language && `language-${language}`)}
dangerouslySetInnerHTML={{ __html: highlightedHtml }}
/>
) : (
<code className={language ? `language-${language}` : undefined}>
{code}
</code>
)
)}
<code
className={cn(
"block bg-transparent p-0 text-inherit",
children
? renderedCodeState.className
: cn(highlightedHtml && "hljs", language && `language-${language}`),
)}
>
{codeLines.map((line, index) => (
<span
key={`${index}-${line.length}`}
className="grid grid-cols-[var(--code-line-number-width)_minmax(0,1fr)] items-start gap-x-3"
style={
{
"--code-line-number-width": lineNumberWidth,
} as CSSProperties
}
>
<span className="sticky left-0 z-1 select-none bg-[#f6f8fa] text-right text-zinc-500/80 dark:bg-[#0d1117] dark:text-zinc-500">
{index + 1}
</span>
{!children && highlightedLines ? (
<span
className={cn(
"min-w-0",
wrapLongLines
? "break-words whitespace-pre-wrap"
: "whitespace-pre",
)}
dangerouslySetInnerHTML={{ __html: line }}
/>
) : (
<span
className={cn(
"min-w-0",
wrapLongLines
? "break-words whitespace-pre-wrap"
: "whitespace-pre",
)}
>
{line}
</span>
)}
</span>
))}
</code>
</pre>
)}
</div>
@@ -158,6 +241,7 @@ export function MarkdownCodeBlock({
code={code}
language={language}
bodyClassName={className}
trimTrailingEmptyLine
>
{children}
</MessageCodeBlock>
@@ -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
}
@@ -39,7 +39,7 @@ export function UserMessage({
<img
key={`${attachment.url}-${index}`}
src={attachment.url}
alt={attachment.filename || "Uploaded image"}
alt={attachment.filename || t("chat.uploadedImage")}
className="max-h-72 max-w-full object-cover"
/>
))}
@@ -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<string>(CHAT_IMAGE_MIME_TYPES)
const CHAT_IMAGE_EXTENSION_BY_MIME: Record<string, string> = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/gif": ".gif",
"image/webp": ".webp",
"image/bmp": ".bmp",
}
const CHAT_IMAGE_MIME_BY_EXTENSION: Record<string, string> = {
".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<string> {
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<ChatAttachment[]> {
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
}
+14
View File
@@ -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")
}
+960
View File
@@ -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": "আরও সাহায্যের প্রয়োজন? বিস্তারিত গাইড এবং কনফিগারেশন ডকুমেন্ট দেখতে উপরের ডান কোণে ডকুমেন্টেশন বোতামে ক্লিক করুন।"
}
}
}
+959
View File
@@ -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."
}
}
}
+4 -1
View File
@@ -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}}\".",
+4 -1
View File
@@ -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}}\".",
+4 -1
View File
@@ -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}}”失败。",
+11
View File
@@ -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<boolean>(
CODE_BLOCK_WRAP_STORAGE_KEY,
DEFAULT_CODE_BLOCK_WRAP,
undefined,
{ getOnInit: true },
)
+1
View File
@@ -1,3 +1,4 @@
export * from "./gateway"
export * from "./chat"
export * from "./code-block"
export * from "./tour"