mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
Merge remote-tracking branch 'upstream/main'
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [sipeed]
|
||||
@@ -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>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 327 KiB After Width: | Height: | Size: 428 KiB |
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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_"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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": "আরও সাহায্যের প্রয়োজন? বিস্তারিত গাইড এবং কনফিগারেশন ডকুমেন্ট দেখতে উপরের ডান কোণে ডকুমেন্টেশন বোতামে ক্লিক করুন।"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}}\".",
|
||||
|
||||
@@ -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}}\".",
|
||||
|
||||
@@ -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}}”失败。",
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
export * from "./gateway"
|
||||
export * from "./chat"
|
||||
export * from "./code-block"
|
||||
export * from "./tour"
|
||||
|
||||
Reference in New Issue
Block a user