Merge branch 'main' into feat/model-config-pr3-test-connection
Resolve conflicts with upstream main while preserving PR3 test connection features. Took upstream's improved model catalog masking and robust fetchOpenAICompatibleModels implementation.
This commit is contained in:
commit
abd4f561bf
56 changed files with 2859 additions and 587 deletions
|
|
@ -56,6 +56,14 @@
|
|||
|
||||
## 📢 News
|
||||
|
||||
2026-05-11 🛒 **LicheeRV-Claw on AliExpress!** You can now purchase LicheeRV-Claw from [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), making it easier to try PicoClaw on compact RISC-V hardware.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.aliexpress.com/item/1005006519668532.html">
|
||||
<img src="assets/licheerv-claw.jpg" alt="LicheeRV-Claw on AliExpress" width="520">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
2026-03-31 📱 **Android Support!** PicoClaw now runs on Android! Download the APK at [picoclaw.io](https://picoclaw.io/download)
|
||||
|
||||
2026-03-25 🚀 **v0.2.4 Released!** Agent architecture overhaul (SubTurn, Hooks, Steering, EventBus), WeChat/WeCom integration, security hardening (.security.yml, sensitive data filtering), new providers (AWS Bedrock, Azure, Xiaomi MiMo), and 35 bug fixes. PicoClaw has reached **26K Stars**!
|
||||
|
|
@ -485,6 +493,7 @@ PicoClaw can search the web to provide up-to-date information. Configure in `too
|
|||
| Search Engine | API Key | Free Tier | Link |
|
||||
|--------------|---------|-----------|------|
|
||||
| DuckDuckGo | Not needed | Unlimited | Built-in fallback |
|
||||
| [Gemini Google Search](https://aistudio.google.com/apikey) | Required | Varies | Gemini with Google Search grounding |
|
||||
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Required | 1500/month (daily allocation) | AI-powered, China-optimized |
|
||||
| [Tavily](https://tavily.com) | Required | 1000 queries/month | Optimized for AI Agents |
|
||||
| [Brave Search](https://brave.com/search/api) | Required | 2000 queries/month | Fast and private |
|
||||
|
|
|
|||
BIN
assets/licheerv-claw.jpg
Normal file
BIN
assets/licheerv-claw.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 215 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 359 KiB After Width: | Height: | Size: 432 KiB |
|
|
@ -20,6 +20,15 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"evolution": {
|
||||
"enabled": false,
|
||||
"mode": "observe",
|
||||
"state_dir": "",
|
||||
"min_task_count": 2,
|
||||
"min_success_ratio": 0.7,
|
||||
"cold_path_trigger": "after_turn",
|
||||
"cold_path_times": []
|
||||
},
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
|
|
@ -282,6 +291,12 @@
|
|||
"enabled": false,
|
||||
"max_results": 5
|
||||
},
|
||||
"gemini": {
|
||||
"enabled": false,
|
||||
"api_key": "",
|
||||
"model": "gemini-2.5-flash",
|
||||
"max_results": 5
|
||||
},
|
||||
"perplexity": {
|
||||
"enabled": false,
|
||||
"api_key": "pplx-xxx",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ Internal architecture notes for major runtime mechanisms and subsystem design.
|
|||
- [Session System](session-system.md): session scope allocation, JSONL persistence, alias compatibility, and migration. ([ZH](session-system.zh.md))
|
||||
- [Routing System](routing-system.md): agent dispatch, session policy selection, and light/heavy model routing. ([ZH](routing-system.zh.md))
|
||||
- [Runtime Events](runtime-events.md): runtime event envelope, centralized event logging, filters, and examples. ([ZH](runtime-events.zh.md))
|
||||
- [Agent Self-Evolution](agent-self-evolution.md): learning records, draft generation, application modes, and state layout.
|
||||
- [Hook System Guide](hooks/README.md): current hook architecture and protocol details.
|
||||
- [Agent Refactor](agent-refactor/README.md): notes and checkpoints for the agent refactor work.
|
||||
|
||||
|
|
|
|||
47
docs/architecture/agent-self-evolution.md
Normal file
47
docs/architecture/agent-self-evolution.md
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# Agent Self-Evolution
|
||||
|
||||
Agent self-evolution lets PicoClaw learn from completed turns and turn repeated successful behavior into skill improvements. The runtime is controlled by the top-level `evolution` config block.
|
||||
|
||||
## Flow
|
||||
|
||||
The hot path runs at the end of an agent turn. When `evolution.enabled` is true, it records a learning record with the turn summary, success state, used skills, tool executions, and session/workspace metadata. Heartbeat turns are skipped.
|
||||
|
||||
The cold path groups related task records, checks the configured success threshold, and prepares skill drafts for patterns that have enough evidence. Drafts can target new skills or append/replace/merge existing workspace skills.
|
||||
|
||||
The apply path validates generated `SKILL.md` content before writing. Invalid drafts are rejected before a skill directory or file is created.
|
||||
|
||||
## Safety Considerations
|
||||
|
||||
Evolution creates a persistent feedback loop: user input can become a task record, task records can be clustered into an LLM-generated draft, and an accepted draft can become `SKILL.md` content that is loaded into future agent prompts. Treat generated skill content as prompt-sensitive material, especially in `apply` mode.
|
||||
|
||||
The current local scanner is a narrow guardrail, not a complete safety boundary. It rejects structurally invalid drafts and a small set of obvious secret-like substrings, but it does not reliably detect prompt injection, unsafe instructions, or every form of sensitive data. Use `observe` or `draft` when human review is required before skill changes reach disk.
|
||||
|
||||
In `apply` mode, accepted drafts can update workspace skills automatically. Existing skills are backed up before replacement, but recovery is manual: an operator must restore the desired backup if an applied skill should be rolled back.
|
||||
|
||||
## Modes
|
||||
|
||||
| Mode | Behavior |
|
||||
|------|----------|
|
||||
| `observe` | Record learning data only. No cold-path draft generation runs automatically. |
|
||||
| `draft` | Record learning data and generate candidate skill drafts when the cold path runs. |
|
||||
| `apply` | Generate drafts and allow accepted drafts to update workspace skills. |
|
||||
|
||||
When `evolution.enabled` is false, `mode` is treated as disabled at runtime.
|
||||
|
||||
## Cold Path Trigger
|
||||
|
||||
`cold_path_trigger` only matters in `draft` and `apply` modes.
|
||||
|
||||
| Trigger | Behavior |
|
||||
|---------|----------|
|
||||
| `after_turn` | Run the cold path after eligible turns. |
|
||||
| `scheduled` | Run the cold path at configured `cold_path_times`. |
|
||||
| `manual` | Do not run automatically. There is no user-facing Web/API/CLI trigger yet; code can still invoke `Runtime.RunColdPathOnce`. |
|
||||
|
||||
`cold_path_times` uses `HH:MM` strings and is ignored unless the trigger is `scheduled`.
|
||||
|
||||
## State
|
||||
|
||||
By default, evolution state is stored under the workspace. `state_dir` can redirect that state to another directory. The state includes learning records, clustered pattern records, drafts, and skill profiles.
|
||||
|
||||
For user-facing configuration fields, see the [Configuration Guide](../guides/configuration.md#agent-self-evolution).
|
||||
|
|
@ -15,7 +15,8 @@ The Telegram channel uses long polling via the Telegram Bot API for bot-based co
|
|||
"token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz",
|
||||
"allow_from": ["123456789"],
|
||||
"proxy": "",
|
||||
"use_markdown_v2": false
|
||||
"use_markdown_v2": false,
|
||||
"media_group_delay_ms": 500
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -28,6 +29,7 @@ The Telegram channel uses long polling via the Telegram Bot API for bot-based co
|
|||
| allow_from | array | No | Allowlist of user IDs; empty means all users are allowed |
|
||||
| proxy | string | No | Proxy URL for connecting to the Telegram API (e.g. http://127.0.0.1:7890) |
|
||||
| use_markdown_v2 | bool | No | Enable Telegram MarkdownV2 formatting |
|
||||
| media_group_delay_ms | int | No | Idle delay before processing Telegram media groups/albums. Defaults to 500 ms |
|
||||
|
||||
## Setup
|
||||
|
||||
|
|
|
|||
|
|
@ -69,6 +69,36 @@ PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspa
|
|||
|
||||
> **Note:** Changes to `AGENT.md`, `SOUL.md`, `USER.md` and `memory/MEMORY.md` are automatically detected at runtime via file modification time (mtime) tracking. You do **not** need to restart the gateway after editing these files — the agent picks up the new content on the next request.
|
||||
|
||||
### Agent Self-Evolution
|
||||
|
||||
The `evolution` block controls PicoClaw's self-evolution runtime. When enabled, the agent records completed turns as learning records. In higher modes it can group repeated successful patterns, generate skill drafts, and optionally apply accepted drafts into workspace skills.
|
||||
|
||||
```json
|
||||
{
|
||||
"evolution": {
|
||||
"enabled": false,
|
||||
"mode": "observe",
|
||||
"state_dir": "",
|
||||
"min_task_count": 2,
|
||||
"min_success_ratio": 0.7,
|
||||
"cold_path_trigger": "after_turn",
|
||||
"cold_path_times": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Default | Description |
|
||||
|-------|---------|-------------|
|
||||
| `enabled` | `false` | Enables learning-record capture for completed agent turns. Heartbeat turns are ignored. |
|
||||
| `mode` | `observe` | `observe` records data only. `draft` can generate candidate skill drafts. `apply` can apply accepted drafts to workspace skills. |
|
||||
| `state_dir` | `""` | Optional directory for evolution state. Leave empty to use the default under the workspace. |
|
||||
| `min_task_count` | `2` | Minimum related task records required before a pattern is eligible for draft generation. |
|
||||
| `min_success_ratio` | `0.7` | Minimum success ratio for a task cluster. Use a value greater than `0` and up to `1`. |
|
||||
| `cold_path_trigger` | `after_turn` | Runs draft generation `after_turn`, on a `scheduled` cadence, or disables automatic cold-path runs when set to `manual`. There is no user-facing manual trigger yet. Applies only in `draft` and `apply` modes. |
|
||||
| `cold_path_times` | `[]` | Scheduled run times used when `cold_path_trigger` is `scheduled`, written as `HH:MM` strings. |
|
||||
|
||||
Use `observe` first if you want to inspect learning records without generating skill changes. Use `draft` when you want PicoClaw to prepare reviewable improvements. Use `apply` only when you are comfortable letting accepted drafts update workspace skills.
|
||||
|
||||
### Web launcher dashboard
|
||||
|
||||
**picoclaw-launcher** serves a browser UI that requires password sign-in first. On first run, open `/launcher-setup` to create the dashboard password. Later manual sign-ins use `/launcher-login`.
|
||||
|
|
|
|||
|
|
@ -67,6 +67,36 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work
|
|||
|
||||
> **提示:** 对 `AGENT.md`、`SOUL.md`、`USER.md` 和 `memory/MEMORY.md` 的修改会通过文件修改时间(mtime)在运行时自动检测。**无需重启 gateway**,Agent 将在下一次请求时自动加载最新内容。
|
||||
|
||||
### Agent 自进化
|
||||
|
||||
`evolution` 配置块控制 PicoClaw 的自进化运行时。启用后,Agent 会把已完成的回合记录为学习记录。在更高模式下,它可以聚类重复出现的成功模式、生成技能草稿,并可选择把已接受的草稿应用到工作区技能中。
|
||||
|
||||
```json
|
||||
{
|
||||
"evolution": {
|
||||
"enabled": false,
|
||||
"mode": "observe",
|
||||
"state_dir": "",
|
||||
"min_task_count": 2,
|
||||
"min_success_ratio": 0.7,
|
||||
"cold_path_trigger": "after_turn",
|
||||
"cold_path_times": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `enabled` | `false` | 启用已完成 Agent 回合的学习记录采集。Heartbeat 回合会被忽略。 |
|
||||
| `mode` | `observe` | `observe` 只记录数据;`draft` 可生成候选技能草稿;`apply` 可将已接受草稿应用到工作区技能。 |
|
||||
| `state_dir` | `""` | 自进化状态的可选目录。留空时使用工作区下的默认位置。 |
|
||||
| `min_task_count` | `2` | 一个模式具备生成草稿资格前所需的最小相关任务记录数。 |
|
||||
| `min_success_ratio` | `0.7` | 任务聚类所需的最小成功率,取值需大于 `0`,且不超过 `1`。 |
|
||||
| `cold_path_trigger` | `after_turn` | 草稿生成可在 `after_turn` 后运行、按 `scheduled` 定时运行;设置为 `manual` 时会关闭自动冷路径运行。目前还没有用户可用的手动触发入口。仅在 `draft` 和 `apply` 模式下生效。 |
|
||||
| `cold_path_times` | `[]` | 当 `cold_path_trigger` 为 `scheduled` 时使用的运行时间,格式为 `HH:MM` 字符串。 |
|
||||
|
||||
如果你只想先检查学习记录,建议从 `observe` 开始。需要生成可审查改进时使用 `draft`。只有在你接受让已通过的草稿更新工作区技能时,才使用 `apply`。
|
||||
|
||||
### Web 启动器控制台
|
||||
|
||||
用 **picoclaw-launcher** 打开浏览器控制台前需要先使用密码登录。首次启动时打开 `/launcher-setup` 创建 dashboard 登录密码;后续手动登录使用 `/launcher-login`。
|
||||
|
|
|
|||
|
|
@ -57,6 +57,14 @@
|
|||
|
||||
## 📢 Actualités
|
||||
|
||||
2026-05-11 🛒 **LicheeRV-Claw disponible sur AliExpress !** Vous pouvez désormais acheter le LicheeRV-Claw sur [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), ce qui facilite l'essai de PicoClaw sur du matériel RISC-V compact.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.aliexpress.com/item/1005006519668532.html">
|
||||
<img src="../../assets/licheerv-claw.jpg" alt="LicheeRV-Claw on AliExpress" width="520">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
2026-03-31 📱 **Support Android !** PicoClaw fonctionne maintenant sur Android ! Téléchargez l'APK sur [picoclaw.io](https://picoclaw.io/download)
|
||||
|
||||
2026-03-25 🚀 **v0.2.4 publiée !** Refonte de l'architecture Agent (SubTurn, Hooks, Steering, EventBus), intégration WeChat/WeCom, renforcement de la sécurité (.security.yml, filtrage des données sensibles), nouveaux providers (AWS Bedrock, Azure, Xiaomi MiMo), et 35 corrections de bugs. PicoClaw a atteint **26K Stars** !
|
||||
|
|
|
|||
|
|
@ -56,6 +56,14 @@
|
|||
|
||||
## 📢 Berita
|
||||
|
||||
2026-05-11 🛒 **LicheeRV-Claw tersedia di AliExpress!** Kini Anda dapat membeli LicheeRV-Claw di [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), sehingga lebih mudah mencoba PicoClaw di hardware RISC-V ringkas.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.aliexpress.com/item/1005006519668532.html">
|
||||
<img src="../../assets/licheerv-claw.jpg" alt="LicheeRV-Claw on AliExpress" width="520">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
2026-03-31 📱 **Dukungan Android!** PicoClaw sekarang berjalan di Android! Unduh APK di [picoclaw.io](https://picoclaw.io/download)
|
||||
|
||||
2026-03-25 🚀 **v0.2.4 Dirilis!** Perombakan arsitektur Agent (SubTurn, Hooks, Steering, EventBus), integrasi WeChat/WeCom, penguatan keamanan (.security.yml, penyaringan data sensitif), provider baru (AWS Bedrock, Azure, Xiaomi MiMo), dan 35 perbaikan bug. PicoClaw telah mencapai **26K Stars**!
|
||||
|
|
|
|||
|
|
@ -56,6 +56,14 @@
|
|||
|
||||
## 📢 Novità
|
||||
|
||||
2026-05-11 🛒 **LicheeRV-Claw disponibile su AliExpress!** Ora puoi acquistare LicheeRV-Claw su [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), rendendo più semplice provare PicoClaw su hardware RISC-V compatto.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.aliexpress.com/item/1005006519668532.html">
|
||||
<img src="../../assets/licheerv-claw.jpg" alt="LicheeRV-Claw on AliExpress" width="520">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
2026-03-31 📱 **Supporto Android!** PicoClaw ora funziona su Android! Scarica l'APK su [picoclaw.io](https://picoclaw.io/download)
|
||||
|
||||
2026-03-25 🚀 **v0.2.4 rilasciata!** Revisione dell'architettura Agent (SubTurn, Hooks, Steering, EventBus), integrazione WeChat/WeCom, rafforzamento della sicurezza (.security.yml, filtraggio dati sensibili), nuovi provider (AWS Bedrock, Azure, Xiaomi MiMo) e 35 correzioni di bug. PicoClaw raggiunge **26K Stars**!
|
||||
|
|
|
|||
|
|
@ -56,6 +56,14 @@
|
|||
|
||||
## 📢 ニュース
|
||||
|
||||
2026-05-11 🛒 **LicheeRV-Claw が AliExpress で購入可能に!** [AliExpress](https://www.aliexpress.com/item/1005006519668532.html) から LicheeRV-Claw を購入できるようになり、コンパクトな RISC-V ハードウェアで PicoClaw を試しやすくなりました。
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.aliexpress.com/item/1005006519668532.html">
|
||||
<img src="../../assets/licheerv-claw.jpg" alt="LicheeRV-Claw on AliExpress" width="520">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
2026-03-31 📱 **Android サポート!** PicoClawがAndroidで動作!APKは[picoclaw.io](https://picoclaw.io/download)からダウンロード
|
||||
|
||||
2026-03-25 🚀 **v0.2.4 リリース!** Agent アーキテクチャ全面刷新(SubTurn、Hooks、Steering、EventBus)、WeChat/WeCom 統合、セキュリティ強化(.security.yml、機密データフィルタリング)、新プロバイダー(AWS Bedrock、Azure、Xiaomi MiMo)、35 件のバグ修正。PicoClaw **26K ⭐** 達成!
|
||||
|
|
|
|||
|
|
@ -56,6 +56,14 @@
|
|||
|
||||
## 📢 뉴스
|
||||
|
||||
2026-05-11 🛒 **LicheeRV-Claw를 AliExpress에서 구매할 수 있습니다!** 이제 [AliExpress](https://www.aliexpress.com/item/1005006519668532.html)에서 LicheeRV-Claw를 구매해 소형 RISC-V 하드웨어에서 PicoClaw를 더 쉽게 사용해 볼 수 있습니다.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.aliexpress.com/item/1005006519668532.html">
|
||||
<img src="../../assets/licheerv-claw.jpg" alt="LicheeRV-Claw on AliExpress" width="520">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
2026-03-31 📱 **Android 지원!** PicoClaw가 이제 Android에서 실행됩니다! APK는 [picoclaw.io](https://picoclaw.io/download)에서 다운로드하세요.
|
||||
|
||||
2026-03-25 🚀 **v0.2.4 출시!** 에이전트 아키텍처 전면 개편(SubTurn, Hooks, Steering, EventBus), WeChat/WeCom 통합, 보안 강화(`.security.yml`, 민감 정보 필터링), 새 프로바이더(AWS Bedrock, Azure, Xiaomi MiMo), 그리고 35건의 버그 수정이 포함되었습니다. PicoClaw는 **26K 스타**를 달성했습니다!
|
||||
|
|
|
|||
|
|
@ -56,6 +56,14 @@
|
|||
|
||||
## 📢 Berita
|
||||
|
||||
2026-05-11 🛒 **LicheeRV-Claw tersedia di AliExpress!** Anda kini boleh membeli LicheeRV-Claw di [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), menjadikannya lebih mudah untuk mencuba PicoClaw pada perkakasan RISC-V yang kompak.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.aliexpress.com/item/1005006519668532.html">
|
||||
<img src="../../assets/licheerv-claw.jpg" alt="LicheeRV-Claw on AliExpress" width="520">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
2026-03-31 📱 **Sokongan Android!** PicoClaw sekarang berjalan di Android! Muat turun APK di [picoclaw.io](https://picoclaw.io/download)
|
||||
|
||||
2026-03-25 🚀 **v0.2.4 Dikeluarkan!** Penstrukturan semula seni bina Agent (SubTurn, Hooks, Steering, EventBus), integrasi WeChat/WeCom, penguatan keselamatan (.security.yml, penapisan data sensitif), penyedia baharu (AWS Bedrock, Azure, Xiaomi MiMo), dan 35 pembetulan pepijat. PicoClaw mencapai **26K Stars**!
|
||||
|
|
|
|||
|
|
@ -56,6 +56,14 @@
|
|||
|
||||
## 📢 Novidades
|
||||
|
||||
2026-05-11 🛒 **LicheeRV-Claw no AliExpress!** Agora você pode comprar o LicheeRV-Claw no [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), facilitando testar o PicoClaw em hardware RISC-V compacto.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.aliexpress.com/item/1005006519668532.html">
|
||||
<img src="../../assets/licheerv-claw.jpg" alt="LicheeRV-Claw on AliExpress" width="520">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
2026-03-31 📱 **Suporte Android!** PicoClaw agora roda no Android! Baixe o APK em [picoclaw.io](https://picoclaw.io/download)
|
||||
|
||||
2026-03-25 🚀 **v0.2.4 Lançada!** Reformulação da arquitetura Agent (SubTurn, Hooks, Steering, EventBus), integração WeChat/WeCom, fortalecimento de segurança (.security.yml, filtragem de dados sensíveis), novos providers (AWS Bedrock, Azure, Xiaomi MiMo) e 35 correções de bugs. O PicoClaw atingiu **26K Stars**!
|
||||
|
|
|
|||
|
|
@ -56,6 +56,14 @@
|
|||
|
||||
## 📢 Tin tức
|
||||
|
||||
2026-05-11 🛒 **LicheeRV-Claw đã có trên AliExpress!** Bạn hiện có thể mua LicheeRV-Claw trên [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), giúp việc thử PicoClaw trên phần cứng RISC-V nhỏ gọn dễ dàng hơn.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.aliexpress.com/item/1005006519668532.html">
|
||||
<img src="../../assets/licheerv-claw.jpg" alt="LicheeRV-Claw on AliExpress" width="520">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
2026-03-31 📱 **Hỗ trợ Android!** PicoClaw giờ chạy trên Android! Tải APK tại [picoclaw.io](https://picoclaw.io/download)
|
||||
|
||||
2026-03-25 🚀 **v0.2.4 đã phát hành!** Tái cấu trúc kiến trúc Agent (SubTurn, Hooks, Steering, EventBus), tích hợp WeChat/WeCom, tăng cường bảo mật (.security.yml, lọc dữ liệu nhạy cảm), provider mới (AWS Bedrock, Azure, Xiaomi MiMo) và 35 bản vá lỗi. PicoClaw đã đạt **26K Stars**!
|
||||
|
|
|
|||
|
|
@ -56,6 +56,14 @@
|
|||
|
||||
## 📢 新闻
|
||||
|
||||
2026-05-11 🛒 **LicheeRV-Claw 已上架淘宝!** 现在可以在 [淘宝](https://item.taobao.com/item.htm?abbucket=20&id=764939520376) 购买 LicheeRV-Claw,更方便地在小型 RISC-V 硬件上体验 PicoClaw。
|
||||
|
||||
<p align="center">
|
||||
<a href="https://item.taobao.com/item.htm?abbucket=20&id=764939520376">
|
||||
<img src="../../assets/licheerv-claw.jpg" alt="LicheeRV-Claw on Taobao" width="520">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
2026-03-31 📱 **Android 支持!** PicoClaw 现可在 Android 上运行!APK 下载地址:[picoclaw.io](https://picoclaw.io/download)
|
||||
|
||||
2026-03-25 🚀 **v0.2.4 发布!** Agent 架构全面重构(SubTurn、Hook、Steering、EventBus)、微信/企业微信深度集成、安全体系升级(.security.yml、敏感数据过滤)、新增 Provider(AWS Bedrock、Azure、小米 MiMo),以及 35 项 Bug 修复。PicoClaw 已达 **26K ⭐**!
|
||||
|
|
@ -144,9 +152,9 @@ _*近期版本因快速合并 PR 可能占用 10–20MB,资源优化已列入
|
|||
|
||||
PicoClaw 几乎可以部署在任何 Linux 设备上!
|
||||
|
||||
- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(网口) 或 W(WiFi6) 版本,用于极简家庭助手
|
||||
- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html),或 $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html),用于自动化服务器运维
|
||||
- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) 或 $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera),用于智能监控
|
||||
- $9.9 [LicheeRV-Nano](https://item.taobao.com/item.htm?id=764939520376) E(网口) 或 W(WiFi6) 版本,用于极简家庭助手
|
||||
- $30~50 [NanoKVM](https://item.taobao.com/item.htm?id=811206560480),或 $100 [NanoKVM-Pro](https://item.taobao.com/item.htm?id=994419942411),用于自动化服务器运维
|
||||
- $50 [MaixCAM](https://item.taobao.com/item.htm?id=784724795837) 或 $100 [MaixCAM2](https://item.taobao.com/item.htm?id=1050380368975),用于智能监控
|
||||
|
||||
<https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4>
|
||||
|
||||
|
|
|
|||
|
|
@ -66,6 +66,32 @@ General settings for fetching and processing webpage content.
|
|||
| `enabled` | bool | true | Enable DuckDuckGo search |
|
||||
| `max_results` | int | 5 | Maximum number of results |
|
||||
|
||||
### Gemini Google Search
|
||||
|
||||
Gemini search uses Gemini with Google Search grounding. It returns an AI-synthesized answer with citations from Google Search.
|
||||
|
||||
| Config | Type | Default | Description |
|
||||
|---------------|--------|----------------------|-----------------------------------|
|
||||
| `enabled` | bool | false | Enable Gemini Google Search |
|
||||
| `api_key` | string | - | Google Gemini API key |
|
||||
| `model` | string | `gemini-2.5-flash` | Gemini model used for search |
|
||||
| `max_results` | int | 5 | Maximum number of citations |
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"web": {
|
||||
"gemini": {
|
||||
"enabled": true,
|
||||
"api_key": "YOUR_GEMINI_API_KEY",
|
||||
"model": "gemini-2.5-flash",
|
||||
"max_results": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Baidu Search
|
||||
|
||||
Baidu Search uses the [Qianfan AI Search API](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5), which is AI-powered and optimized for Chinese-language queries.
|
||||
|
|
|
|||
36
go.mod
36
go.mod
|
|
@ -5,7 +5,7 @@ go 1.25.10
|
|||
require (
|
||||
fyne.io/systray v1.12.1
|
||||
github.com/SevereCloud/vksdk/v3 v3.3.1
|
||||
github.com/adhocore/gronx v1.19.6
|
||||
github.com/adhocore/gronx v1.19.7
|
||||
github.com/anthropics/anthropic-sdk-go v1.26.0
|
||||
github.com/atc0005/go-teams-notify/v2 v2.14.0
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.7
|
||||
|
|
@ -15,25 +15,27 @@ require (
|
|||
github.com/caarlos0/env/v11 v11.4.0
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.1
|
||||
github.com/ergochat/irc-go v0.6.0
|
||||
github.com/ergochat/readline v0.1.3
|
||||
github.com/gomarkdown/markdown v0.0.0-20260411013819-759bbc3e3207
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/h2non/filetype v1.1.3
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.6.1
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.7.5
|
||||
github.com/line/line-bot-sdk-go/v8 v8.19.0
|
||||
github.com/mdp/qrterminal/v3 v3.2.1
|
||||
github.com/minio/selfupdate v0.6.0
|
||||
github.com/modelcontextprotocol/go-sdk v1.5.0
|
||||
github.com/muesli/termenv v0.16.0
|
||||
github.com/mymmrac/telego v1.8.0
|
||||
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/webrtc/v3 v3.3.6
|
||||
github.com/pmezard/go-difflib v1.0.0
|
||||
github.com/rs/zerolog v1.35.1
|
||||
github.com/slack-go/slack v0.17.3
|
||||
github.com/slack-go/slack v0.23.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/pflag v1.0.10
|
||||
github.com/stretchr/testify v1.11.1
|
||||
|
|
@ -41,12 +43,12 @@ require (
|
|||
go.mau.fi/util v0.9.8
|
||||
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/term v0.42.0
|
||||
golang.org/x/term v0.43.0
|
||||
golang.org/x/time v0.15.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
maunium.net/go/mautrix v0.27.0
|
||||
modernc.org/sqlite v1.48.2
|
||||
modernc.org/sqlite v1.50.1
|
||||
rsc.io/qr v0.2.0
|
||||
)
|
||||
|
||||
|
|
@ -76,7 +78,6 @@ require (
|
|||
github.com/coder/websocket v1.8.14 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.1 // indirect
|
||||
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
|
|
@ -90,7 +91,6 @@ 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/pmezard/go-difflib v1.0.0 // 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
|
||||
|
|
@ -105,24 +105,24 @@ require (
|
|||
go.opentelemetry.io/otel/metric v1.35.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.35.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
modernc.org/libc v1.70.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
modernc.org/libc v1.72.3 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/andybalholm/brotli v1.2.1 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/bytedance/sonic v1.15.1 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/github/copilot-sdk/go v0.2.0
|
||||
github.com/go-resty/resty/v2 v2.17.1 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/google/jsonschema-go v0.4.3
|
||||
github.com/grbit/go-json v0.11.0 // indirect
|
||||
github.com/klauspost/compress v1.18.4 // indirect
|
||||
github.com/klauspost/compress v1.18.6 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/tidwall/gjson v1.18.0 // indirect
|
||||
github.com/tidwall/match v1.2.0 // indirect
|
||||
|
|
@ -130,14 +130,14 @@ require (
|
|||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.69.0 // indirect
|
||||
github.com/valyala/fasthttp v1.71.0 // indirect
|
||||
github.com/valyala/fastjson v1.6.10 // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
golang.org/x/arch v0.24.0 // indirect
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/net v0.53.0
|
||||
golang.org/x/crypto v0.51.0
|
||||
golang.org/x/net v0.54.0
|
||||
golang.org/x/sync v0.20.0
|
||||
golang.org/x/sys v0.43.0
|
||||
golang.org/x/sys v0.44.0
|
||||
)
|
||||
|
||||
replace github.com/bwmarrin/discordgo => github.com/yeongaori/discordgo-fork v0.0.0-20260319072544-e8e546f5d532
|
||||
|
|
|
|||
76
go.sum
76
go.sum
|
|
@ -9,14 +9,14 @@ github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7Oputl
|
|||
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=
|
||||
github.com/SevereCloud/vksdk/v3 v3.3.1/go.mod h1:c6WaA5aocUYsXfkcUbg2qy45V9M1VDcqHHmHIN14NAw=
|
||||
github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc=
|
||||
github.com/adhocore/gronx v1.19.6/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg=
|
||||
github.com/adhocore/gronx v1.19.7 h1:7hhFwChgDw9eHC3+TQ+OKKBqJnP44oWkDCnnW9nrsuA=
|
||||
github.com/adhocore/gronx v1.19.7/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg=
|
||||
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
|
||||
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
|
||||
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
|
||||
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY=
|
||||
github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q=
|
||||
github.com/atc0005/go-teams-notify/v2 v2.14.0 h1:7N+xw+COnYANLREaAveQ65rsNQ12nIZJED9nMLyscCo=
|
||||
|
|
@ -59,10 +59,10 @@ github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs=
|
|||
github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/bytedance/sonic v1.15.1 h1:nJD5PmM0vY7J8CT6MxoqbVAAMhkSmV2HgRAUrrpLoOw=
|
||||
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/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
|
|
@ -166,8 +166,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
|
|||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
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.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
||||
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
|
|
@ -179,8 +179,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/larksuite/oapi-sdk-go/v3 v3.6.1 h1:vAdu+sX9yXNkKnKnYQeIv6yBkjP37Q1JEJHmMa2eCjQ=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.6.1/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
||||
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.19.0 h1:5FD/1SprRZ8Y0FiUI6syYiBewOs0ak2tuUBMYN0wzE4=
|
||||
github.com/line/line-bot-sdk-go/v8 v8.19.0/go.mod h1:AeSRUuu7WGgveGDJb6DyKyFUOst2UB2aF6LO2cQeuXs=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
|
|
@ -201,8 +201,8 @@ github.com/modelcontextprotocol/go-sdk v1.5.0 h1:CHU0FIX9kpueNkxuYtfYQn1Z0slhFzB
|
|||
github.com/modelcontextprotocol/go-sdk v1.5.0/go.mod h1:gggDIhoemhWs3BGkGwd1umzEXCEMMvAnhTrnbXJKKKA=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/mymmrac/telego v1.8.0 h1:EvIprWo9Cn0MHgumvvqNXPAXO1yJj3pu2cdCCeDxbow=
|
||||
github.com/mymmrac/telego v1.8.0/go.mod h1:pdLV346EgVuq7Xrh3kMggeBiazeHhsdEoK0RTEOPXRM=
|
||||
github.com/mymmrac/telego v1.9.0 h1:ZUJxZaPx/1IgRvVb5lXnUB8FgW5rNYfRe6Q2EJ4OJ+Y=
|
||||
github.com/mymmrac/telego v1.9.0/go.mod h1:tVEB7OqiOPx8elRk9+ETkwiDQrUhWSB2XmAKIY9KmWY=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
|
|
@ -246,8 +246,8 @@ github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfv
|
|||
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
|
||||
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
|
||||
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
|
||||
github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g=
|
||||
github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk=
|
||||
github.com/slack-go/slack v0.23.1 h1:ZS5B96wxxYQRwvJ3/vJFtqtUZi3tXhsZCyT44Nv7M80=
|
||||
github.com/slack-go/slack v0.23.1/go.mod h1:H0yR/YBuRJ39RkE+JpV/d/oEsbanzTRowR82bCN0cEs=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
|
|
@ -283,8 +283,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
|||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
|
||||
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
|
||||
github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k=
|
||||
github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA=
|
||||
github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
|
||||
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
|
||||
github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s=
|
||||
|
|
@ -330,8 +330,8 @@ golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWP
|
|||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
|
|
@ -354,8 +354,8 @@ 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.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
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/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
|
|
@ -388,16 +388,16 @@ 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.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
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/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
|
||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
|
|
@ -405,8 +405,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
|||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
|
|
@ -449,10 +449,10 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
maunium.net/go/mautrix v0.27.0 h1:yfEYwoIluVWkofUgbZl9gP4i5nQTF+QNsxtb+r5bKlM=
|
||||
maunium.net/go/mautrix v0.27.0/go.mod h1:7QpEQiTy6p4LHkXXaZI+N46tGYy8HMhD0JjzZAFoFWs=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
|
||||
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
|
||||
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
|
||||
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
|
||||
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
|
|
@ -461,18 +461,18 @@ modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
|||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
|
||||
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
|
||||
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
|
||||
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.48.2 h1:5CnW4uP8joZtA0LedVqLbZV5GD7F/0x91AXeSyjoh5c=
|
||||
modernc.org/sqlite v1.48.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
||||
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
|
||||
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
|
|
|||
|
|
@ -438,7 +438,10 @@ func (p *Pipeline) CallLLM(
|
|||
// Pico tool-call turns publish their reasoning/content/tool summary as a
|
||||
// structured sequence after the tool-call payload is normalized below.
|
||||
} else if ts.channel == "pico" {
|
||||
go al.publishPicoReasoning(turnCtx, reasoningContent, ts.chatID)
|
||||
// Publish pico thoughts before the turn context is canceled at return time.
|
||||
// The async variant can race with turn teardown and intermittently drop the
|
||||
// thought message in CI even though the LLM produced reasoning content.
|
||||
al.publishPicoReasoning(turnCtx, reasoningContent, ts.chatID)
|
||||
} else {
|
||||
go al.handleReasoning(
|
||||
turnCtx,
|
||||
|
|
|
|||
|
|
@ -191,7 +191,7 @@ func (c *SlackChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa
|
|||
title = filename
|
||||
}
|
||||
|
||||
_, err = c.api.UploadFileV2Context(ctx, slack.UploadFileV2Parameters{
|
||||
_, err = c.api.UploadFileContext(ctx, slack.UploadFileParameters{
|
||||
Channel: channelID,
|
||||
ThreadTimestamp: threadTS,
|
||||
File: localPath,
|
||||
|
|
@ -207,7 +207,7 @@ func (c *SlackChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa
|
|||
}
|
||||
}
|
||||
|
||||
// UploadFileV2 does not expose the posted message timestamp in its
|
||||
// UploadFile does not expose the posted message timestamp in its
|
||||
// response; returning nil avoids conflating file IDs with message IDs.
|
||||
return nil, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
|
@ -43,20 +44,38 @@ var (
|
|||
reInlineCode = regexp.MustCompile("`([^`]+)`")
|
||||
)
|
||||
|
||||
const defaultMediaGroupDelay = 500 * time.Millisecond
|
||||
|
||||
type TelegramChannel struct {
|
||||
*channels.BaseChannel
|
||||
bot *telego.Bot
|
||||
bh *th.BotHandler
|
||||
bc *config.Channel
|
||||
chatIDs map[string]int64
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
tgCfg *config.TelegramSettings
|
||||
progress *channels.ToolFeedbackAnimator
|
||||
bot *telego.Bot
|
||||
bh *th.BotHandler
|
||||
bc *config.Channel
|
||||
chatIDsMu sync.Mutex
|
||||
chatIDs map[string]int64
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
tgCfg *config.TelegramSettings
|
||||
progress *channels.ToolFeedbackAnimator
|
||||
|
||||
registerFunc func(context.Context, []commands.Definition) error
|
||||
commandRegDelayFn func(int) time.Duration
|
||||
commandRegCancel context.CancelFunc
|
||||
|
||||
mediaGroupMu sync.Mutex
|
||||
mediaGroups map[string]*telegramMediaGroup
|
||||
mediaGroupDelay time.Duration
|
||||
}
|
||||
|
||||
type telegramMediaGroup struct {
|
||||
messages []*telego.Message
|
||||
timer *time.Timer
|
||||
generation uint64
|
||||
}
|
||||
|
||||
type telegramMessageParts struct {
|
||||
content []string
|
||||
mediaPaths []string
|
||||
}
|
||||
|
||||
func NewTelegramChannel(
|
||||
|
|
@ -112,11 +131,21 @@ func NewTelegramChannel(
|
|||
bc: bc,
|
||||
chatIDs: make(map[string]int64),
|
||||
tgCfg: telegramCfg,
|
||||
|
||||
mediaGroups: make(map[string]*telegramMediaGroup),
|
||||
mediaGroupDelay: telegramMediaGroupDelay(telegramCfg),
|
||||
}
|
||||
ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func telegramMediaGroupDelay(telegramCfg *config.TelegramSettings) time.Duration {
|
||||
if telegramCfg != nil && telegramCfg.MediaGroupDelayMS > 0 {
|
||||
return time.Duration(telegramCfg.MediaGroupDelayMS) * time.Millisecond
|
||||
}
|
||||
return defaultMediaGroupDelay
|
||||
}
|
||||
|
||||
func (c *TelegramChannel) Start(ctx context.Context) error {
|
||||
logger.InfoC("telegram", "Starting Telegram bot (polling mode)...")
|
||||
|
||||
|
|
@ -167,6 +196,7 @@ func (c *TelegramChannel) Stop(ctx context.Context) error {
|
|||
if c.bh != nil {
|
||||
_ = c.bh.StopWithContext(ctx)
|
||||
}
|
||||
c.flushPendingMediaGroups(ctx)
|
||||
|
||||
// Cancel our context (stops long polling)
|
||||
if c.cancel != nil {
|
||||
|
|
@ -713,6 +743,131 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
|
|||
}
|
||||
|
||||
func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Message) error {
|
||||
if message != nil && strings.TrimSpace(message.MediaGroupID) != "" {
|
||||
return c.bufferMediaGroupMessage(ctx, message)
|
||||
}
|
||||
return c.handleMessages(ctx, []*telego.Message{message})
|
||||
}
|
||||
|
||||
func (c *TelegramChannel) bufferMediaGroupMessage(ctx context.Context, message *telego.Message) error {
|
||||
if message == nil {
|
||||
return fmt.Errorf("message is nil")
|
||||
}
|
||||
groupID := strings.TrimSpace(message.MediaGroupID)
|
||||
if groupID == "" {
|
||||
return c.handleMessages(ctx, []*telego.Message{message})
|
||||
}
|
||||
|
||||
msgCopy := *message
|
||||
msgCopy.Photo = append([]telego.PhotoSize(nil), message.Photo...)
|
||||
key := fmt.Sprintf("%d:%s", message.Chat.ID, groupID)
|
||||
|
||||
c.mediaGroupMu.Lock()
|
||||
if c.mediaGroups == nil {
|
||||
c.mediaGroups = make(map[string]*telegramMediaGroup)
|
||||
}
|
||||
group := c.mediaGroups[key]
|
||||
if group == nil {
|
||||
group = &telegramMediaGroup{}
|
||||
c.mediaGroups[key] = group
|
||||
}
|
||||
group.messages = append(group.messages, &msgCopy)
|
||||
group.generation++
|
||||
generation := group.generation
|
||||
if group.timer != nil {
|
||||
group.timer.Stop()
|
||||
}
|
||||
delay := c.mediaGroupDelay
|
||||
if delay <= 0 {
|
||||
delay = defaultMediaGroupDelay
|
||||
}
|
||||
group.timer = time.AfterFunc(delay, func() {
|
||||
c.flushMediaGroup(c.ctx, key, generation)
|
||||
})
|
||||
c.mediaGroupMu.Unlock()
|
||||
|
||||
logger.DebugCF("telegram", "Buffered media group message", map[string]any{
|
||||
"chat_id": message.Chat.ID,
|
||||
"media_group_id": groupID,
|
||||
"message_id": message.MessageID,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *TelegramChannel) flushPendingMediaGroups(ctx context.Context) {
|
||||
c.mediaGroupMu.Lock()
|
||||
keys := make([]string, 0, len(c.mediaGroups))
|
||||
for key, group := range c.mediaGroups {
|
||||
if group.timer != nil {
|
||||
group.timer.Stop()
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
c.mediaGroupMu.Unlock()
|
||||
|
||||
for _, key := range keys {
|
||||
c.flushMediaGroup(ctx, key, 0)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *TelegramChannel) flushMediaGroup(ctx context.Context, key string, generation uint64) {
|
||||
c.mediaGroupMu.Lock()
|
||||
group := c.mediaGroups[key]
|
||||
if group == nil {
|
||||
c.mediaGroupMu.Unlock()
|
||||
return
|
||||
}
|
||||
if generation != 0 && group.generation != generation {
|
||||
c.mediaGroupMu.Unlock()
|
||||
return
|
||||
}
|
||||
delete(c.mediaGroups, key)
|
||||
if group.timer != nil {
|
||||
group.timer.Stop()
|
||||
}
|
||||
messages := append([]*telego.Message(nil), group.messages...)
|
||||
c.mediaGroupMu.Unlock()
|
||||
|
||||
if len(messages) == 0 {
|
||||
return
|
||||
}
|
||||
slices.SortFunc(messages, func(a, b *telego.Message) int {
|
||||
switch {
|
||||
case a == nil && b == nil:
|
||||
return 0
|
||||
case a == nil:
|
||||
return -1
|
||||
case b == nil:
|
||||
return 1
|
||||
default:
|
||||
return a.MessageID - b.MessageID
|
||||
}
|
||||
})
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := c.handleMessages(ctx, messages); err != nil {
|
||||
logger.ErrorCF("telegram", "Failed to handle media group", map[string]any{
|
||||
"key": key,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (c *TelegramChannel) handleMessages(ctx context.Context, messages []*telego.Message) error {
|
||||
if len(messages) == 0 {
|
||||
return nil
|
||||
}
|
||||
message := messages[0]
|
||||
for _, candidate := range messages {
|
||||
if candidate == nil {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(candidate.Text) != "" || strings.TrimSpace(candidate.Caption) != "" {
|
||||
message = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if message == nil {
|
||||
return fmt.Errorf("message is nil")
|
||||
}
|
||||
|
|
@ -740,7 +895,9 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
|||
}
|
||||
|
||||
chatID := message.Chat.ID
|
||||
c.chatIDsMu.Lock()
|
||||
c.chatIDs[platformID] = chatID
|
||||
c.chatIDsMu.Unlock()
|
||||
|
||||
content := ""
|
||||
mediaPaths := []string{}
|
||||
|
|
@ -764,61 +921,18 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
|||
return localPath // fallback: use raw path
|
||||
}
|
||||
|
||||
if message.Text != "" {
|
||||
content += message.Text
|
||||
}
|
||||
|
||||
if message.Caption != "" {
|
||||
if content != "" {
|
||||
content += "\n"
|
||||
for i, msg := range messages {
|
||||
if msg == nil {
|
||||
continue
|
||||
}
|
||||
content += message.Caption
|
||||
}
|
||||
|
||||
if len(message.Photo) > 0 {
|
||||
photo := message.Photo[len(message.Photo)-1]
|
||||
photoPath := c.downloadPhoto(ctx, photo.FileID)
|
||||
if photoPath != "" {
|
||||
mediaPaths = append(mediaPaths, storeMedia(photoPath, "photo.jpg"))
|
||||
parts := c.collectTelegramMessageParts(ctx, msg, i, len(messages), storeMedia)
|
||||
for _, part := range parts.content {
|
||||
if content != "" {
|
||||
content += "\n"
|
||||
}
|
||||
content += "[image: photo]"
|
||||
}
|
||||
}
|
||||
|
||||
if message.Voice != nil {
|
||||
voicePath := c.downloadFile(ctx, message.Voice.FileID, ".ogg")
|
||||
if voicePath != "" {
|
||||
mediaPaths = append(mediaPaths, storeMedia(voicePath, "voice.ogg"))
|
||||
|
||||
if content != "" {
|
||||
content += "\n"
|
||||
}
|
||||
content += "[voice]"
|
||||
}
|
||||
}
|
||||
|
||||
if message.Audio != nil {
|
||||
audioPath := c.downloadFile(ctx, message.Audio.FileID, ".mp3")
|
||||
if audioPath != "" {
|
||||
mediaPaths = append(mediaPaths, storeMedia(audioPath, "audio.mp3"))
|
||||
if content != "" {
|
||||
content += "\n"
|
||||
}
|
||||
content += "[audio]"
|
||||
}
|
||||
}
|
||||
|
||||
if message.Document != nil {
|
||||
docPath := c.downloadFile(ctx, message.Document.FileID, "")
|
||||
if docPath != "" {
|
||||
mediaPaths = append(mediaPaths, storeMedia(docPath, "document"))
|
||||
if content != "" {
|
||||
content += "\n"
|
||||
}
|
||||
content += "[file]"
|
||||
content += part
|
||||
}
|
||||
mediaPaths = append(mediaPaths, parts.mediaPaths...)
|
||||
}
|
||||
|
||||
if content == "" && len(mediaPaths) == 0 {
|
||||
|
|
@ -917,6 +1031,74 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
|||
return nil
|
||||
}
|
||||
|
||||
func (c *TelegramChannel) collectTelegramMessageParts(
|
||||
ctx context.Context,
|
||||
msg *telego.Message,
|
||||
index int,
|
||||
total int,
|
||||
storeMedia func(localPath, filename string) string,
|
||||
) telegramMessageParts {
|
||||
parts := telegramMessageParts{}
|
||||
if msg == nil {
|
||||
return parts
|
||||
}
|
||||
if text := strings.TrimSpace(msg.Text); text != "" {
|
||||
parts.content = append(parts.content, text)
|
||||
}
|
||||
if caption := strings.TrimSpace(msg.Caption); caption != "" {
|
||||
parts.content = append(parts.content, caption)
|
||||
}
|
||||
if len(msg.Photo) > 0 {
|
||||
photo := msg.Photo[len(msg.Photo)-1]
|
||||
photoPath := c.downloadPhoto(ctx, photo.FileID)
|
||||
if photoPath != "" {
|
||||
photoNumber := index + 1
|
||||
parts.mediaPaths = append(parts.mediaPaths, storeMedia(photoPath, fmt.Sprintf("photo-%d.jpg", photoNumber)))
|
||||
parts.content = append(parts.content, fmt.Sprintf("[image: photo %d]", photoNumber))
|
||||
}
|
||||
}
|
||||
if msg.Voice != nil {
|
||||
voicePath := c.downloadFile(ctx, msg.Voice.FileID, ".ogg")
|
||||
if voicePath != "" {
|
||||
parts.mediaPaths = append(
|
||||
parts.mediaPaths,
|
||||
storeMedia(voicePath, indexedMediaFilename("voice", ".ogg", index, total)),
|
||||
)
|
||||
parts.content = append(parts.content, "[voice]")
|
||||
}
|
||||
}
|
||||
if msg.Audio != nil {
|
||||
audioPath := c.downloadFile(ctx, msg.Audio.FileID, ".mp3")
|
||||
if audioPath != "" {
|
||||
filename := msg.Audio.FileName
|
||||
if strings.TrimSpace(filename) == "" {
|
||||
filename = indexedMediaFilename("audio", ".mp3", index, total)
|
||||
}
|
||||
parts.mediaPaths = append(parts.mediaPaths, storeMedia(audioPath, filename))
|
||||
parts.content = append(parts.content, "[audio]")
|
||||
}
|
||||
}
|
||||
if msg.Document != nil {
|
||||
docPath := c.downloadFile(ctx, msg.Document.FileID, "")
|
||||
if docPath != "" {
|
||||
filename := msg.Document.FileName
|
||||
if strings.TrimSpace(filename) == "" {
|
||||
filename = indexedMediaFilename("document", "", index, total)
|
||||
}
|
||||
parts.mediaPaths = append(parts.mediaPaths, storeMedia(docPath, filename))
|
||||
parts.content = append(parts.content, "[file]")
|
||||
}
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
func indexedMediaFilename(prefix, ext string, index int, total int) string {
|
||||
if total <= 1 {
|
||||
return prefix + ext
|
||||
}
|
||||
return fmt.Sprintf("%s-%d%s", prefix, index+1, ext)
|
||||
}
|
||||
|
||||
func (c *TelegramChannel) prependTelegramQuotedReply(content string, reply *telego.Message) string {
|
||||
quoted := strings.TrimSpace(telegramQuotedContent(reply))
|
||||
if quoted == "" {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
ta "github.com/mymmrac/telego/telegoapi"
|
||||
|
|
@ -1100,3 +1101,190 @@ func TestHandleMessage_EmptyContent_Ignored(t *testing.T) {
|
|||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMessage_MediaGroupCombinesCaptionMessages(t *testing.T) {
|
||||
messageBus, ch := newMediaGroupTestChannel(10 * time.Millisecond)
|
||||
base := testMediaGroupMessage("album-1")
|
||||
first := base
|
||||
first.MessageID = 1
|
||||
second := base
|
||||
second.MessageID = 2
|
||||
second.Caption = "meal caption"
|
||||
|
||||
require.NoError(t, ch.handleMessage(context.Background(), &first))
|
||||
require.NoError(t, ch.handleMessage(context.Background(), &second))
|
||||
|
||||
select {
|
||||
case inbound := <-messageBus.InboundChan():
|
||||
assert.Equal(t, "2", inbound.Context.MessageID)
|
||||
assert.Equal(t, "meal caption", inbound.Content)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for combined media group message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMessage_MediaGroupWaitsForStaggeredMessages(t *testing.T) {
|
||||
messageBus, ch := newMediaGroupTestChannel(100 * time.Millisecond)
|
||||
base := testMediaGroupMessage("album-staggered")
|
||||
first := base
|
||||
first.MessageID = 1
|
||||
first.Caption = "first caption"
|
||||
second := base
|
||||
second.MessageID = 2
|
||||
second.Caption = "second caption"
|
||||
|
||||
require.NoError(t, ch.handleMessage(context.Background(), &first))
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
require.NoError(t, ch.handleMessage(context.Background(), &second))
|
||||
|
||||
select {
|
||||
case inbound := <-messageBus.InboundChan():
|
||||
t.Fatalf("media group flushed before idle delay reset: %#v", inbound)
|
||||
case <-time.After(75 * time.Millisecond):
|
||||
}
|
||||
|
||||
select {
|
||||
case inbound := <-messageBus.InboundChan():
|
||||
assert.Equal(t, "1", inbound.Context.MessageID)
|
||||
assert.Equal(t, "first caption\nsecond caption", inbound.Content)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for staggered media group message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlushMediaGroupIgnoresStaleTimerGeneration(t *testing.T) {
|
||||
messageBus, ch := newMediaGroupTestChannel(time.Hour)
|
||||
base := testMediaGroupMessage("album-generation")
|
||||
first := base
|
||||
first.MessageID = 1
|
||||
first.Caption = "first"
|
||||
second := base
|
||||
second.MessageID = 2
|
||||
second.Caption = "second"
|
||||
key := "456:album-generation"
|
||||
|
||||
ch.mediaGroupMu.Lock()
|
||||
ch.mediaGroups[key] = &telegramMediaGroup{
|
||||
messages: []*telego.Message{&first, &second},
|
||||
generation: 2,
|
||||
}
|
||||
ch.mediaGroupMu.Unlock()
|
||||
|
||||
ch.flushMediaGroup(context.Background(), key, 1)
|
||||
|
||||
select {
|
||||
case inbound := <-messageBus.InboundChan():
|
||||
t.Fatalf("stale media group generation flushed unexpectedly: %#v", inbound)
|
||||
default:
|
||||
}
|
||||
|
||||
ch.mediaGroupMu.Lock()
|
||||
_, stillPending := ch.mediaGroups[key]
|
||||
ch.mediaGroupMu.Unlock()
|
||||
require.True(t, stillPending, "stale flush should leave the current batch pending")
|
||||
|
||||
ch.flushMediaGroup(context.Background(), key, 2)
|
||||
|
||||
select {
|
||||
case inbound := <-messageBus.InboundChan():
|
||||
assert.Equal(t, "1", inbound.Context.MessageID)
|
||||
assert.Equal(t, "first\nsecond", inbound.Content)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for current generation media group flush")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMessage_MediaGroupAfterDelayStartsNewBatch(t *testing.T) {
|
||||
messageBus, ch := newMediaGroupTestChannel(10 * time.Millisecond)
|
||||
base := testMediaGroupMessage("album-split")
|
||||
first := base
|
||||
first.MessageID = 1
|
||||
first.Caption = "first"
|
||||
second := base
|
||||
second.MessageID = 2
|
||||
second.Caption = "second"
|
||||
|
||||
require.NoError(t, ch.handleMessage(context.Background(), &first))
|
||||
select {
|
||||
case inbound := <-messageBus.InboundChan():
|
||||
assert.Equal(t, "1", inbound.Context.MessageID)
|
||||
assert.Equal(t, "first", inbound.Content)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for first media group batch")
|
||||
}
|
||||
|
||||
require.NoError(t, ch.handleMessage(context.Background(), &second))
|
||||
select {
|
||||
case inbound := <-messageBus.InboundChan():
|
||||
assert.Equal(t, "2", inbound.Context.MessageID)
|
||||
assert.Equal(t, "second", inbound.Content)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for second media group batch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopFlushesPendingMediaGroups(t *testing.T) {
|
||||
messageBus, ch := newMediaGroupTestChannel(time.Hour)
|
||||
base := testMediaGroupMessage("album-stop")
|
||||
msg := base
|
||||
msg.MessageID = 1
|
||||
msg.Caption = "caption before stop"
|
||||
|
||||
require.NoError(t, ch.handleMessage(context.Background(), &msg))
|
||||
require.NoError(t, ch.Stop(context.Background()))
|
||||
|
||||
select {
|
||||
case inbound := <-messageBus.InboundChan():
|
||||
assert.Equal(t, "1", inbound.Context.MessageID)
|
||||
assert.Equal(t, "caption before stop", inbound.Content)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for pending media group flush on stop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewTelegramChannelUsesConfiguredMediaGroupDelay(t *testing.T) {
|
||||
ch, err := NewTelegramChannel(
|
||||
&config.Channel{Type: config.ChannelTelegram, Enabled: true},
|
||||
&config.TelegramSettings{
|
||||
Token: *config.NewSecureString(testToken),
|
||||
MediaGroupDelayMS: 750,
|
||||
},
|
||||
bus.NewMessageBus(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 750*time.Millisecond, ch.mediaGroupDelay)
|
||||
|
||||
ch, err = NewTelegramChannel(
|
||||
&config.Channel{Type: config.ChannelTelegram, Enabled: true},
|
||||
&config.TelegramSettings{Token: *config.NewSecureString(testToken)},
|
||||
bus.NewMessageBus(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, defaultMediaGroupDelay, ch.mediaGroupDelay)
|
||||
}
|
||||
|
||||
func newMediaGroupTestChannel(delay time.Duration) (*bus.MessageBus, *TelegramChannel) {
|
||||
messageBus := bus.NewMessageBus()
|
||||
ch := &TelegramChannel{
|
||||
BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil),
|
||||
chatIDs: make(map[string]int64),
|
||||
ctx: context.Background(),
|
||||
mediaGroups: make(map[string]*telegramMediaGroup),
|
||||
mediaGroupDelay: delay,
|
||||
}
|
||||
return messageBus, ch
|
||||
}
|
||||
|
||||
func testMediaGroupMessage(mediaGroupID string) telego.Message {
|
||||
return telego.Message{
|
||||
Chat: telego.Chat{
|
||||
ID: 456,
|
||||
Type: "private",
|
||||
},
|
||||
From: &telego.User{
|
||||
ID: 789,
|
||||
FirstName: "User",
|
||||
},
|
||||
MediaGroupID: mediaGroupID,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -480,11 +480,12 @@ type WhatsAppSettings struct {
|
|||
}
|
||||
|
||||
type TelegramSettings struct {
|
||||
Token SecureString `json:"token,omitzero" yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
|
||||
BaseURL string `json:"base_url" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_BASE_URL"`
|
||||
Proxy string `json:"proxy" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
|
||||
Streaming StreamingConfig `json:"streaming,omitempty" yaml:"-"`
|
||||
UseMarkdownV2 bool `json:"use_markdown_v2" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_USE_MARKDOWN_V2"`
|
||||
Token SecureString `json:"token,omitzero" yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
|
||||
BaseURL string `json:"base_url" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_BASE_URL"`
|
||||
Proxy string `json:"proxy" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
|
||||
Streaming StreamingConfig `json:"streaming,omitempty" yaml:"-"`
|
||||
UseMarkdownV2 bool `json:"use_markdown_v2" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_USE_MARKDOWN_V2"`
|
||||
MediaGroupDelayMS int `json:"media_group_delay_ms" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_MEDIA_GROUP_DELAY_MS"`
|
||||
}
|
||||
|
||||
type FeishuSettings struct {
|
||||
|
|
@ -846,6 +847,13 @@ type SogouConfig struct {
|
|||
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_SOGOU_MAX_RESULTS"`
|
||||
}
|
||||
|
||||
type GeminiSearchConfig struct {
|
||||
Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_TOOLS_WEB_GEMINI_ENABLED"`
|
||||
APIKey SecureString `json:"api_key,omitzero" yaml:"api_key,omitempty" env:"PICOCLAW_TOOLS_WEB_GEMINI_API_KEY"`
|
||||
Model string `json:"model" yaml:"-" env:"PICOCLAW_TOOLS_WEB_GEMINI_MODEL"`
|
||||
MaxResults int `json:"max_results" yaml:"-" env:"PICOCLAW_TOOLS_WEB_GEMINI_MAX_RESULTS"`
|
||||
}
|
||||
|
||||
type PerplexityConfig struct {
|
||||
Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"`
|
||||
APIKeys SecureStrings `json:"api_keys,omitzero" yaml:"api_keys,omitempty" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEYS"`
|
||||
|
|
@ -889,16 +897,17 @@ type BaiduSearchConfig struct {
|
|||
}
|
||||
|
||||
type WebToolsConfig struct {
|
||||
ToolConfig ` yaml:"-" envPrefix:"PICOCLAW_TOOLS_WEB_"`
|
||||
Brave BraveConfig `yaml:"brave,omitempty" json:"brave"`
|
||||
Tavily TavilyConfig `yaml:"tavily,omitempty" json:"tavily"`
|
||||
Sogou SogouConfig `yaml:"-" json:"sogou"`
|
||||
DuckDuckGo DuckDuckGoConfig `yaml:"-" json:"duckduckgo"`
|
||||
Perplexity PerplexityConfig `yaml:"perplexity,omitempty" json:"perplexity"`
|
||||
SearXNG SearXNGConfig `yaml:"-" json:"searxng"`
|
||||
GLMSearch GLMSearchConfig `yaml:"glm_search,omitempty" json:"glm_search"`
|
||||
BaiduSearch BaiduSearchConfig `yaml:"baidu_search,omitempty" json:"baidu_search"`
|
||||
Provider string `yaml:"-" json:"provider,omitempty" env:"PICOCLAW_TOOLS_WEB_PROVIDER"`
|
||||
ToolConfig ` yaml:"-" envPrefix:"PICOCLAW_TOOLS_WEB_"`
|
||||
Brave BraveConfig `yaml:"brave,omitempty" json:"brave"`
|
||||
Tavily TavilyConfig `yaml:"tavily,omitempty" json:"tavily"`
|
||||
Sogou SogouConfig `yaml:"-" json:"sogou"`
|
||||
DuckDuckGo DuckDuckGoConfig `yaml:"-" json:"duckduckgo"`
|
||||
Gemini GeminiSearchConfig `yaml:"gemini,omitempty" json:"gemini"`
|
||||
Perplexity PerplexityConfig `yaml:"perplexity,omitempty" json:"perplexity"`
|
||||
SearXNG SearXNGConfig `yaml:"-" json:"searxng"`
|
||||
GLMSearch GLMSearchConfig `yaml:"glm_search,omitempty" json:"glm_search"`
|
||||
BaiduSearch BaiduSearchConfig `yaml:"baidu_search,omitempty" json:"baidu_search"`
|
||||
Provider string `yaml:"-" json:"provider,omitempty" env:"PICOCLAW_TOOLS_WEB_PROVIDER"`
|
||||
// PreferNative controls whether to use provider-native web search when
|
||||
// the active LLM supports it (e.g. OpenAI web_search_preview). When true,
|
||||
// the client-side web_search tool is hidden to avoid duplicate search surfaces,
|
||||
|
|
|
|||
|
|
@ -341,6 +341,11 @@ func DefaultConfig() *Config {
|
|||
Enabled: false,
|
||||
MaxResults: 5,
|
||||
},
|
||||
Gemini: GeminiSearchConfig{
|
||||
Enabled: false,
|
||||
Model: "gemini-2.5-flash",
|
||||
MaxResults: 5,
|
||||
},
|
||||
Perplexity: PerplexityConfig{
|
||||
Enabled: false,
|
||||
MaxResults: 5,
|
||||
|
|
@ -503,8 +508,9 @@ func defaultChannels() ChannelsConfig {
|
|||
"typing": map[string]any{"enabled": true},
|
||||
"placeholder": map[string]any{"enabled": true, "text": []string{"Thinking... 💭"}},
|
||||
"settings": map[string]any{
|
||||
"streaming": map[string]any{"enabled": true, "throttle_seconds": 3, "min_growth_chars": 200},
|
||||
"use_markdown_v2": false,
|
||||
"streaming": map[string]any{"enabled": true, "throttle_seconds": 3, "min_growth_chars": 200},
|
||||
"use_markdown_v2": false,
|
||||
"media_group_delay_ms": 500,
|
||||
},
|
||||
},
|
||||
"feishu": map[string]any{},
|
||||
|
|
|
|||
|
|
@ -213,14 +213,17 @@ func (p *Provider) prepareMessagesForRequest(messages []Message) []Message {
|
|||
return nil
|
||||
}
|
||||
|
||||
if p.isDeepSeekReasoningProvider() {
|
||||
return filterDeepSeekReasoningMessages(messages)
|
||||
if p.requiresToolRoundReasoningReplay() {
|
||||
return filterReasoningReplayMessages(messages)
|
||||
}
|
||||
return stripReasoningMessages(messages)
|
||||
}
|
||||
|
||||
func (p *Provider) isDeepSeekReasoningProvider() bool {
|
||||
return p.providerName == "deepseek" || isDeepSeekHost(p.apiBase)
|
||||
func (p *Provider) requiresToolRoundReasoningReplay() bool {
|
||||
return p.providerName == "deepseek" ||
|
||||
p.providerName == "mimo" ||
|
||||
isDeepSeekHost(p.apiBase) ||
|
||||
isMiMoHost(p.apiBase)
|
||||
}
|
||||
|
||||
func isDeepSeekHost(apiBase string) bool {
|
||||
|
|
@ -232,7 +235,16 @@ func isDeepSeekHost(apiBase string) bool {
|
|||
return host == "deepseek.com" || strings.HasSuffix(host, ".deepseek.com")
|
||||
}
|
||||
|
||||
func filterDeepSeekReasoningMessages(messages []Message) []Message {
|
||||
func isMiMoHost(apiBase string) bool {
|
||||
parsed, err := url.Parse(strings.TrimSpace(apiBase))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
host := strings.ToLower(strings.TrimSpace(parsed.Hostname()))
|
||||
return host == "xiaomimimo.com" || strings.HasSuffix(host, ".xiaomimimo.com")
|
||||
}
|
||||
|
||||
func filterReasoningReplayMessages(messages []Message) []Message {
|
||||
out := make([]Message, 0, len(messages))
|
||||
start := 0
|
||||
|
||||
|
|
@ -240,7 +252,7 @@ func filterDeepSeekReasoningMessages(messages []Message) []Message {
|
|||
if end <= start {
|
||||
return
|
||||
}
|
||||
out = append(out, filterDeepSeekReasoningTurn(messages[start:end])...)
|
||||
out = append(out, filterReasoningReplayTurn(messages[start:end])...)
|
||||
start = end
|
||||
}
|
||||
|
||||
|
|
@ -254,7 +266,7 @@ func filterDeepSeekReasoningMessages(messages []Message) []Message {
|
|||
return out
|
||||
}
|
||||
|
||||
func filterDeepSeekReasoningTurn(messages []Message) []Message {
|
||||
func filterReasoningReplayTurn(messages []Message) []Message {
|
||||
hasToolInteraction := false
|
||||
for _, msg := range messages {
|
||||
if msg.Role == "tool" || (msg.Role == "assistant" && len(msg.ToolCalls) > 0) {
|
||||
|
|
@ -270,10 +282,10 @@ func filterDeepSeekReasoningTurn(messages []Message) []Message {
|
|||
}
|
||||
|
||||
cloned := msg
|
||||
// DeepSeek thinking-mode replay only requires reasoning_content for
|
||||
// turns that participate in a tool interaction round. For plain
|
||||
// assistant turns between two user messages, the docs say the API will
|
||||
// ignore reasoning_content on replay, so we strip it here.
|
||||
// DeepSeek and MiMo only require reasoning_content replay for turns
|
||||
// that participate in a tool interaction round. For plain assistant
|
||||
// turns between two user messages, the reasoning trace is ignored on
|
||||
// replay, so we strip it here.
|
||||
if cloned.Role == "assistant" && strings.TrimSpace(cloned.ReasoningContent) != "" && !hasToolInteraction {
|
||||
cloned.ReasoningContent = ""
|
||||
}
|
||||
|
|
@ -419,6 +431,9 @@ func parseStreamResponse(
|
|||
onChunk func(accumulated string),
|
||||
) (*LLMResponse, error) {
|
||||
var textContent strings.Builder
|
||||
var reasoningContent strings.Builder
|
||||
var reasoning strings.Builder
|
||||
var reasoningDetails []ReasoningDetail
|
||||
var finishReason string
|
||||
var usage *UsageInfo
|
||||
|
||||
|
|
@ -430,29 +445,22 @@ func parseStreamResponse(
|
|||
}
|
||||
activeTools := map[int]*toolAccum{}
|
||||
|
||||
scanner := bufio.NewScanner(reader)
|
||||
scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024) // 1MB initial, 10MB max
|
||||
for scanner.Scan() {
|
||||
// Check for context cancellation between chunks
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
processEvent := func(data string) error {
|
||||
if strings.TrimSpace(data) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
line := scanner.Text()
|
||||
|
||||
if !strings.HasPrefix(line, "data: ") {
|
||||
continue
|
||||
}
|
||||
data := strings.TrimPrefix(line, "data: ")
|
||||
if data == "[DONE]" {
|
||||
break
|
||||
if strings.TrimSpace(data) == "[DONE]" {
|
||||
return io.EOF
|
||||
}
|
||||
|
||||
var chunk struct {
|
||||
Choices []struct {
|
||||
Delta struct {
|
||||
Content string `json:"content"`
|
||||
ToolCalls []struct {
|
||||
Content string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content"`
|
||||
Reasoning string `json:"reasoning"`
|
||||
ReasoningDetails []ReasoningDetail `json:"reasoning_details"`
|
||||
ToolCalls []struct {
|
||||
Index int `json:"index"`
|
||||
ID string `json:"id"`
|
||||
Function *struct {
|
||||
|
|
@ -467,7 +475,7 @@ func parseStreamResponse(
|
|||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||||
continue // skip malformed chunks
|
||||
return fmt.Errorf("failed to decode stream event: %w", err)
|
||||
}
|
||||
|
||||
if chunk.Usage != nil {
|
||||
|
|
@ -475,7 +483,7 @@ func parseStreamResponse(
|
|||
}
|
||||
|
||||
if len(chunk.Choices) == 0 {
|
||||
continue
|
||||
return nil
|
||||
}
|
||||
|
||||
choice := chunk.Choices[0]
|
||||
|
|
@ -487,6 +495,15 @@ func parseStreamResponse(
|
|||
onChunk(textContent.String())
|
||||
}
|
||||
}
|
||||
if choice.Delta.ReasoningContent != "" {
|
||||
reasoningContent.WriteString(choice.Delta.ReasoningContent)
|
||||
}
|
||||
if choice.Delta.Reasoning != "" {
|
||||
reasoning.WriteString(choice.Delta.Reasoning)
|
||||
}
|
||||
if len(choice.Delta.ReasoningDetails) > 0 {
|
||||
reasoningDetails = append(reasoningDetails, choice.Delta.ReasoningDetails...)
|
||||
}
|
||||
|
||||
// Accumulate tool call deltas
|
||||
for _, tc := range choice.Delta.ToolCalls {
|
||||
|
|
@ -511,11 +528,55 @@ func parseStreamResponse(
|
|||
if choice.FinishReason != nil {
|
||||
finishReason = *choice.FinishReason
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(reader)
|
||||
scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024) // 1MB initial, 10MB max
|
||||
var eventData strings.Builder
|
||||
for scanner.Scan() {
|
||||
// Check for context cancellation between chunks
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
line := scanner.Text()
|
||||
if line == "" {
|
||||
err := processEvent(eventData.String())
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
eventData.Reset()
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, ":") {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(line, "data:") {
|
||||
continue
|
||||
}
|
||||
|
||||
data := strings.TrimPrefix(line, "data:")
|
||||
data = strings.TrimPrefix(data, " ")
|
||||
if eventData.Len() > 0 {
|
||||
eventData.WriteByte('\n')
|
||||
}
|
||||
eventData.WriteString(data)
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("streaming read error: %w", err)
|
||||
}
|
||||
if eventData.Len() > 0 {
|
||||
err := processEvent(eventData.String())
|
||||
if err != nil && err != io.EOF {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Assemble tool calls from accumulated deltas
|
||||
var toolCalls []ToolCall
|
||||
|
|
@ -544,10 +605,13 @@ func parseStreamResponse(
|
|||
}
|
||||
|
||||
return &LLMResponse{
|
||||
Content: textContent.String(),
|
||||
ToolCalls: toolCalls,
|
||||
FinishReason: finishReason,
|
||||
Usage: usage,
|
||||
Content: textContent.String(),
|
||||
ReasoningContent: reasoningContent.String(),
|
||||
Reasoning: reasoning.String(),
|
||||
ReasoningDetails: reasoningDetails,
|
||||
ToolCalls: toolCalls,
|
||||
FinishReason: finishReason,
|
||||
Usage: usage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -252,9 +252,16 @@ func TestProviderChat_StripsReasoningContentForNonDeepSeekHistory(t *testing.T)
|
|||
}
|
||||
}
|
||||
|
||||
func TestProviderChat_DeepSeekOmitsReasoningContentForNonToolTurnHistory(t *testing.T) {
|
||||
var requestBody map[string]any
|
||||
func runCapturedChat(
|
||||
t *testing.T,
|
||||
providerName string,
|
||||
apiBase string,
|
||||
messages []Message,
|
||||
model string,
|
||||
) []any {
|
||||
t.Helper()
|
||||
|
||||
var requestBody map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
|
|
@ -274,21 +281,20 @@ func TestProviderChat_DeepSeekOmitsReasoningContentForNonToolTurnHistory(t *test
|
|||
defer server.Close()
|
||||
|
||||
p := NewProvider("key", server.URL, "")
|
||||
p.apiBase = "https://api.deepseek.com/v1"
|
||||
p.httpClient = &http.Client{
|
||||
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
||||
r.URL, _ = url.Parse(server.URL + r.URL.Path)
|
||||
return http.DefaultTransport.RoundTrip(r)
|
||||
}),
|
||||
if providerName != "" {
|
||||
p.SetProviderName(providerName)
|
||||
}
|
||||
if apiBase != "" {
|
||||
p.apiBase = apiBase
|
||||
p.httpClient = &http.Client{
|
||||
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
||||
r.URL, _ = url.Parse(server.URL + r.URL.Path)
|
||||
return http.DefaultTransport.RoundTrip(r)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
messages := []Message{
|
||||
{Role: "user", Content: "What is 1+1?"},
|
||||
{Role: "assistant", Content: "2", ReasoningContent: "Let me think... 1+1=2"},
|
||||
{Role: "user", Content: "What about 2+2?"},
|
||||
}
|
||||
|
||||
_, err := p.Chat(t.Context(), messages, nil, "deepseek-v4-flash", nil)
|
||||
_, err := p.Chat(t.Context(), messages, nil, model, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error = %v", err)
|
||||
}
|
||||
|
|
@ -297,18 +303,114 @@ func TestProviderChat_DeepSeekOmitsReasoningContentForNonToolTurnHistory(t *test
|
|||
if !ok {
|
||||
t.Fatalf("messages is not []any: %T", requestBody["messages"])
|
||||
}
|
||||
assistantMsg, ok := reqMessages[1].(map[string]any)
|
||||
return reqMessages
|
||||
}
|
||||
|
||||
func nonToolReplayMessages() []Message {
|
||||
return []Message{
|
||||
{Role: "user", Content: "What is 1+1?"},
|
||||
{Role: "assistant", Content: "2", ReasoningContent: "Let me think... 1+1=2"},
|
||||
{Role: "user", Content: "What about 2+2?"},
|
||||
}
|
||||
}
|
||||
|
||||
func docsReplayRequirementMessages() []Message {
|
||||
return []Message{
|
||||
{Role: "user", Content: "Who wrote The Hobbit?"},
|
||||
{Role: "assistant", Content: "J.R.R. Tolkien.", ReasoningContent: "I know this from general knowledge."},
|
||||
{Role: "user", Content: "What's the weather tomorrow?"},
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: "Let me check the date first.",
|
||||
ReasoningContent: "I need tomorrow's date before checking the weather.",
|
||||
ToolCalls: []ToolCall{{
|
||||
ID: "call_date",
|
||||
Type: "function",
|
||||
Function: &FunctionCall{
|
||||
Name: "get_date",
|
||||
Arguments: "{}",
|
||||
},
|
||||
}},
|
||||
},
|
||||
{Role: "tool", ToolCallID: "call_date", Content: "2026-04-29"},
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: "Tomorrow is 2026-04-30.",
|
||||
ReasoningContent: "Now I can continue with the weather request.",
|
||||
},
|
||||
{Role: "user", Content: "What about Guangzhou?"},
|
||||
}
|
||||
}
|
||||
|
||||
func assertAssistantReasoningOmitted(t *testing.T, reqMessages []any, index int, label string) {
|
||||
t.Helper()
|
||||
|
||||
assistantMsg, ok := reqMessages[index].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("assistant message is not map[string]any: %T", reqMessages[1])
|
||||
t.Fatalf("assistant message is not map[string]any: %T", reqMessages[index])
|
||||
}
|
||||
if _, exists := assistantMsg["reasoning_content"]; exists {
|
||||
t.Fatalf(
|
||||
"reasoning_content should be omitted for DeepSeek non-tool turns, got %v",
|
||||
"reasoning_content should be omitted for %s non-tool turns, got %v",
|
||||
label,
|
||||
assistantMsg["reasoning_content"],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func assertDocsReplayRequirements(t *testing.T, reqMessages []any, messages []Message, label string) {
|
||||
t.Helper()
|
||||
|
||||
if len(reqMessages) != len(messages) {
|
||||
t.Fatalf("len(messages) = %d, want %d", len(reqMessages), len(messages))
|
||||
}
|
||||
|
||||
plainAssistant, ok := reqMessages[1].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("plain assistant message is not map[string]any: %T", reqMessages[1])
|
||||
}
|
||||
if _, exists := plainAssistant["reasoning_content"]; exists {
|
||||
t.Fatalf(
|
||||
"plain %s turn should omit reasoning_content on replay, got %v",
|
||||
label,
|
||||
plainAssistant["reasoning_content"],
|
||||
)
|
||||
}
|
||||
|
||||
toolAssistant, ok := reqMessages[3].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("tool assistant message is not map[string]any: %T", reqMessages[3])
|
||||
}
|
||||
if toolAssistant["reasoning_content"] != "I need tomorrow's date before checking the weather." {
|
||||
t.Fatalf(
|
||||
"tool assistant reasoning_content = %v, want preserved",
|
||||
toolAssistant["reasoning_content"],
|
||||
)
|
||||
}
|
||||
|
||||
finalAssistant, ok := reqMessages[5].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("final assistant message is not map[string]any: %T", reqMessages[5])
|
||||
}
|
||||
if finalAssistant["reasoning_content"] != "Now I can continue with the weather request." {
|
||||
t.Fatalf(
|
||||
"final assistant reasoning_content = %v, want preserved",
|
||||
finalAssistant["reasoning_content"],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderChat_DeepSeekOmitsReasoningContentForNonToolTurnHistory(t *testing.T) {
|
||||
reqMessages := runCapturedChat(
|
||||
t,
|
||||
"",
|
||||
"https://api.deepseek.com/v1",
|
||||
nonToolReplayMessages(),
|
||||
"deepseek-v4-flash",
|
||||
)
|
||||
assertAssistantReasoningOmitted(t, reqMessages, 1, "DeepSeek")
|
||||
}
|
||||
|
||||
func TestProviderChat_DeepSeekPreservesReasoningContentForToolTurnHistory(t *testing.T) {
|
||||
var requestBody map[string]any
|
||||
|
||||
|
|
@ -512,6 +614,32 @@ func TestProviderChat_HistoryCanonicalizationMatrix(t *testing.T) {
|
|||
}
|
||||
})
|
||||
|
||||
t.Run("mimo", func(t *testing.T) {
|
||||
msgs := captureRequestMessages(t, "mimo")
|
||||
if len(msgs) != len(baseMessages) {
|
||||
t.Fatalf("len(messages) = %d, want %d", len(msgs), len(baseMessages))
|
||||
}
|
||||
|
||||
if _, ok := msgs[1]["reasoning_content"]; ok {
|
||||
t.Fatalf(
|
||||
"turn1 reasoning_content should be stripped for MiMo non-tool turn, got %v",
|
||||
msgs[1]["reasoning_content"],
|
||||
)
|
||||
}
|
||||
if msgs[3]["reasoning_content"] != "tool thought" {
|
||||
t.Fatalf("turn2 reasoning_content = %v, want preserved", msgs[3]["reasoning_content"])
|
||||
}
|
||||
if _, ok := msgs[6]["reasoning_content"]; ok {
|
||||
t.Fatalf("turn3 reasoning_content should be absent, got %v", msgs[6]["reasoning_content"])
|
||||
}
|
||||
if msgs[9]["reasoning_content"] != "tool mixed thought" {
|
||||
t.Fatalf("turn4 reasoning_content = %v, want preserved", msgs[9]["reasoning_content"])
|
||||
}
|
||||
if msgs[9]["content"] != "tool visible and thought" {
|
||||
t.Fatalf("turn4 content = %v, want preserved", msgs[9]["content"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-deepseek", func(t *testing.T) {
|
||||
msgs := captureRequestMessages(t, "")
|
||||
for i, msg := range msgs {
|
||||
|
|
@ -536,100 +664,29 @@ func TestProviderChat_DeepSeekDocsReplayRequirements(t *testing.T) {
|
|||
// Keep this behavior explicit here so future changes do not "fix" the
|
||||
// non-tool stripping based on issue reports that are broader than the
|
||||
// vendor documentation.
|
||||
var requestBody map[string]any
|
||||
messages := docsReplayRequirementMessages()
|
||||
reqMessages := runCapturedChat(t, "deepseek", "", messages, "deepseek-v4-flash")
|
||||
assertDocsReplayRequirements(t, reqMessages, messages, "DeepSeek")
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
resp := map[string]any{
|
||||
"choices": []map[string]any{
|
||||
{
|
||||
"message": map[string]any{"content": "ok"},
|
||||
"finish_reason": "stop",
|
||||
},
|
||||
},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
func TestProviderChat_MiMoDocsReplayRequirements(t *testing.T) {
|
||||
// MiMo documents the same replay rule as DeepSeek for thinking-mode
|
||||
// tool rounds: plain non-tool turns may omit reasoning_content on replay,
|
||||
// while tool-interaction rounds must keep it in subsequent requests.
|
||||
messages := docsReplayRequirementMessages()
|
||||
reqMessages := runCapturedChat(t, "mimo", "", messages, "mimo-2.5")
|
||||
assertDocsReplayRequirements(t, reqMessages, messages, "MiMo")
|
||||
}
|
||||
|
||||
p := NewProvider("key", server.URL, "")
|
||||
p.SetProviderName("deepseek")
|
||||
|
||||
messages := []Message{
|
||||
{Role: "user", Content: "Who wrote The Hobbit?"},
|
||||
{Role: "assistant", Content: "J.R.R. Tolkien.", ReasoningContent: "I know this from general knowledge."},
|
||||
{Role: "user", Content: "What's the weather tomorrow?"},
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: "Let me check the date first.",
|
||||
ReasoningContent: "I need tomorrow's date before checking the weather.",
|
||||
ToolCalls: []ToolCall{{
|
||||
ID: "call_date",
|
||||
Type: "function",
|
||||
Function: &FunctionCall{
|
||||
Name: "get_date",
|
||||
Arguments: "{}",
|
||||
},
|
||||
}},
|
||||
},
|
||||
{Role: "tool", ToolCallID: "call_date", Content: "2026-04-29"},
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: "Tomorrow is 2026-04-30.",
|
||||
ReasoningContent: "Now I can continue with the weather request.",
|
||||
},
|
||||
{Role: "user", Content: "What about Guangzhou?"},
|
||||
}
|
||||
|
||||
_, err := p.Chat(t.Context(), messages, nil, "deepseek-v4-flash", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error = %v", err)
|
||||
}
|
||||
|
||||
reqMessages, ok := requestBody["messages"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("messages is not []any: %T", requestBody["messages"])
|
||||
}
|
||||
if len(reqMessages) != len(messages) {
|
||||
t.Fatalf("len(messages) = %d, want %d", len(reqMessages), len(messages))
|
||||
}
|
||||
|
||||
plainAssistant, ok := reqMessages[1].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("plain assistant message is not map[string]any: %T", reqMessages[1])
|
||||
}
|
||||
if _, exists := plainAssistant["reasoning_content"]; exists {
|
||||
t.Fatalf(
|
||||
"plain DeepSeek turn should omit reasoning_content on replay, got %v",
|
||||
plainAssistant["reasoning_content"],
|
||||
)
|
||||
}
|
||||
|
||||
toolAssistant, ok := reqMessages[3].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("tool assistant message is not map[string]any: %T", reqMessages[3])
|
||||
}
|
||||
if toolAssistant["reasoning_content"] != "I need tomorrow's date before checking the weather." {
|
||||
t.Fatalf(
|
||||
"tool assistant reasoning_content = %v, want preserved",
|
||||
toolAssistant["reasoning_content"],
|
||||
)
|
||||
}
|
||||
|
||||
finalAssistant, ok := reqMessages[5].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("final assistant message is not map[string]any: %T", reqMessages[5])
|
||||
}
|
||||
if finalAssistant["reasoning_content"] != "Now I can continue with the weather request." {
|
||||
t.Fatalf(
|
||||
"final assistant reasoning_content = %v, want preserved",
|
||||
finalAssistant["reasoning_content"],
|
||||
)
|
||||
}
|
||||
func TestProviderChat_MiMoHostUsesReasoningReplayRules(t *testing.T) {
|
||||
reqMessages := runCapturedChat(
|
||||
t,
|
||||
"",
|
||||
"https://api.xiaomimimo.com/v1",
|
||||
nonToolReplayMessages(),
|
||||
"mimo-2.5",
|
||||
)
|
||||
assertAssistantReasoningOmitted(t, reqMessages, 1, "MiMo")
|
||||
}
|
||||
|
||||
func TestProviderChat_HTTPError(t *testing.T) {
|
||||
|
|
@ -1195,6 +1252,168 @@ func TestProviderChatStream_CustomHeadersInjected(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestProviderChatStream_ParsesReasoningContent(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = w.Write([]byte(
|
||||
"data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"Let me \",\"content\":\"Checking \",\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\"}}]}}]}\n\n",
|
||||
))
|
||||
_, _ = w.Write([]byte(
|
||||
"data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"think step by step.\",\"content\":\"the weather\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"Hangzhou\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":6,\"total_tokens\":16}}\n\n",
|
||||
))
|
||||
_, _ = w.Write([]byte("data: [DONE]\n\n"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
p := NewProvider("key", server.URL, "")
|
||||
out, err := p.ChatStream(
|
||||
t.Context(),
|
||||
[]Message{{Role: "user", Content: "weather?"}},
|
||||
nil,
|
||||
"deepseek-v4-flash",
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("ChatStream() error = %v", err)
|
||||
}
|
||||
if out.Content != "Checking the weather" {
|
||||
t.Fatalf("Content = %q, want %q", out.Content, "Checking the weather")
|
||||
}
|
||||
if out.ReasoningContent != "Let me think step by step." {
|
||||
t.Fatalf("ReasoningContent = %q, want %q", out.ReasoningContent, "Let me think step by step.")
|
||||
}
|
||||
if len(out.ToolCalls) != 1 {
|
||||
t.Fatalf("len(ToolCalls) = %d, want 1", len(out.ToolCalls))
|
||||
}
|
||||
if out.ToolCalls[0].ID != "call_1" {
|
||||
t.Fatalf("ToolCalls[0].ID = %q, want %q", out.ToolCalls[0].ID, "call_1")
|
||||
}
|
||||
if out.ToolCalls[0].Name != "get_weather" {
|
||||
t.Fatalf("ToolCalls[0].Name = %q, want %q", out.ToolCalls[0].Name, "get_weather")
|
||||
}
|
||||
if out.ToolCalls[0].Arguments["city"] != "Hangzhou" {
|
||||
t.Fatalf("ToolCalls[0].Arguments[city] = %v, want %q", out.ToolCalls[0].Arguments["city"], "Hangzhou")
|
||||
}
|
||||
if out.FinishReason != "tool_calls" {
|
||||
t.Fatalf("FinishReason = %q, want %q", out.FinishReason, "tool_calls")
|
||||
}
|
||||
if out.Usage == nil || out.Usage.TotalTokens != 16 {
|
||||
t.Fatalf("Usage = %#v, want total tokens 16", out.Usage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderChatStream_ParsesMultilineSSEEvent(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = w.Write([]byte(
|
||||
"data: {\"choices\":[{\"delta\":{\n" +
|
||||
"data: \"content\":\"Hello\",\"reasoning_content\":\"Thinking\",\n" +
|
||||
"data: \"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"function\":{\"name\":\"echo\",\"arguments\":\"{\\\"message\\\":\\\"hello\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}],\n" +
|
||||
"data: \"usage\":{\"prompt_tokens\":3,\"completion_tokens\":4,\"total_tokens\":7}}\n\n",
|
||||
))
|
||||
_, _ = w.Write([]byte("data: [DONE]\n\n"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
p := NewProvider("key", server.URL, "")
|
||||
out, err := p.ChatStream(
|
||||
t.Context(),
|
||||
[]Message{{Role: "user", Content: "say hello"}},
|
||||
nil,
|
||||
"gpt-4o",
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("ChatStream() error = %v", err)
|
||||
}
|
||||
if out.Content != "Hello" {
|
||||
t.Fatalf("Content = %q, want %q", out.Content, "Hello")
|
||||
}
|
||||
if out.ReasoningContent != "Thinking" {
|
||||
t.Fatalf("ReasoningContent = %q, want %q", out.ReasoningContent, "Thinking")
|
||||
}
|
||||
if len(out.ToolCalls) != 1 {
|
||||
t.Fatalf("len(ToolCalls) = %d, want 1", len(out.ToolCalls))
|
||||
}
|
||||
if out.ToolCalls[0].Name != "echo" {
|
||||
t.Fatalf("ToolCalls[0].Name = %q, want %q", out.ToolCalls[0].Name, "echo")
|
||||
}
|
||||
if out.ToolCalls[0].Arguments["message"] != "hello" {
|
||||
t.Fatalf("ToolCalls[0].Arguments[message] = %v, want %q", out.ToolCalls[0].Arguments["message"], "hello")
|
||||
}
|
||||
if out.FinishReason != "tool_calls" {
|
||||
t.Fatalf("FinishReason = %q, want %q", out.FinishReason, "tool_calls")
|
||||
}
|
||||
if out.Usage == nil || out.Usage.TotalTokens != 7 {
|
||||
t.Fatalf("Usage = %#v, want total tokens 7", out.Usage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderChatStream_ParsesReasoningVariants(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = w.Write([]byte(
|
||||
"data: {\"choices\":[{\"delta\":{\"reasoning\":\"step 1\",\"reasoning_details\":[{\"format\":\"text\",\"index\":0,\"type\":\"summary\",\"text\":\"first\"}]}}]}\n\n",
|
||||
))
|
||||
_, _ = w.Write([]byte(
|
||||
"data: {\"choices\":[{\"delta\":{\"reasoning\":\" + step 2\",\"reasoning_details\":[{\"format\":\"text\",\"index\":1,\"type\":\"summary\",\"text\":\"second\"}],\"content\":\"done\"},\"finish_reason\":\"stop\"}]}\n\n",
|
||||
))
|
||||
_, _ = w.Write([]byte("data: [DONE]\n\n"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
p := NewProvider("key", server.URL, "")
|
||||
out, err := p.ChatStream(
|
||||
t.Context(),
|
||||
[]Message{{Role: "user", Content: "think"}},
|
||||
nil,
|
||||
"gpt-4o",
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("ChatStream() error = %v", err)
|
||||
}
|
||||
if out.Content != "done" {
|
||||
t.Fatalf("Content = %q, want %q", out.Content, "done")
|
||||
}
|
||||
if out.Reasoning != "step 1 + step 2" {
|
||||
t.Fatalf("Reasoning = %q, want %q", out.Reasoning, "step 1 + step 2")
|
||||
}
|
||||
if len(out.ReasoningDetails) != 2 {
|
||||
t.Fatalf("len(ReasoningDetails) = %d, want 2", len(out.ReasoningDetails))
|
||||
}
|
||||
if out.ReasoningDetails[0].Text != "first" || out.ReasoningDetails[1].Text != "second" {
|
||||
t.Fatalf("ReasoningDetails = %#v, want texts first/second", out.ReasoningDetails)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderChatStream_InvalidEventReturnsError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = w.Write([]byte("data: {\"choices\":[\n\n"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
p := NewProvider("key", server.URL, "")
|
||||
_, err := p.ChatStream(
|
||||
t.Context(),
|
||||
[]Message{{Role: "user", Content: "hi"}},
|
||||
nil,
|
||||
"gpt-4o",
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for malformed stream event")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to decode stream event") {
|
||||
t.Fatalf("error = %v, want decode stream event error", err)
|
||||
}
|
||||
}
|
||||
|
||||
type roundTripperFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
|
|
|
|||
|
|
@ -69,10 +69,11 @@ func (t *EditFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe
|
|||
return ErrorResult("new_text is required")
|
||||
}
|
||||
|
||||
if err := editFile(t.fs, path, oldText, newText); err != nil {
|
||||
beforeContent, afterContent, err := editFile(t.fs, path, oldText, newText)
|
||||
if err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
return SilentResult(fmt.Sprintf("File edited: %s", path))
|
||||
return DiffResult(path, beforeContent, afterContent)
|
||||
}
|
||||
|
||||
type AppendFileTool struct {
|
||||
|
|
@ -131,18 +132,22 @@ func (t *AppendFileTool) Execute(ctx context.Context, args map[string]any) *Tool
|
|||
|
||||
// editFile reads the file via sysFs, performs the replacement, and writes back.
|
||||
// It uses a fileSystem interface, allowing the same logic for both restricted and unrestricted modes.
|
||||
func editFile(sysFs fileSystem, path, oldText, newText string) error {
|
||||
func editFile(sysFs fileSystem, path, oldText, newText string) ([]byte, []byte, error) {
|
||||
content, err := sysFs.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
newContent, err := replaceEditContent(content, oldText, newText)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return sysFs.WriteFile(path, newContent)
|
||||
if err := sysFs.WriteFile(path, newContent); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return content, newContent, nil
|
||||
}
|
||||
|
||||
// appendFile reads the existing content (if any) via sysFs, appends new content, and writes back.
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package fstools
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
|
@ -31,14 +32,34 @@ func TestEditTool_EditFile_Success(t *testing.T) {
|
|||
t.Errorf("Expected success, got IsError=true: %s", result.ForLLM)
|
||||
}
|
||||
|
||||
// Should return SilentResult
|
||||
if !result.Silent {
|
||||
t.Errorf("Expected Silent=true for EditFile, got false")
|
||||
// Successful edits should surface a diff to the user.
|
||||
if result.Silent {
|
||||
t.Errorf("Expected Silent=false for EditFile, got true")
|
||||
}
|
||||
|
||||
// ForUser should be empty (silent result)
|
||||
if result.ForUser != "" {
|
||||
t.Errorf("Expected ForUser to be empty for SilentResult, got: %s", result.ForUser)
|
||||
if result.ForUser == "" {
|
||||
t.Fatal("Expected ForUser to contain the diff preview")
|
||||
}
|
||||
|
||||
if result.ForLLM == result.ForUser {
|
||||
t.Fatalf("Expected ForLLM to be a compact summary, got identical outputs %q", result.ForLLM)
|
||||
}
|
||||
if result.ForLLM != fmt.Sprintf("File edited: %s", testFile) {
|
||||
t.Fatalf("Expected compact ForLLM summary, got %q", result.ForLLM)
|
||||
}
|
||||
|
||||
diffPath := strings.TrimLeft(filepath.ToSlash(testFile), "/")
|
||||
for _, want := range []string{
|
||||
fmt.Sprintf("File edited: %s", testFile),
|
||||
"```diff",
|
||||
"--- a/" + diffPath,
|
||||
"+++ b/" + diffPath,
|
||||
"-Hello World",
|
||||
"+Hello Universe",
|
||||
} {
|
||||
if !strings.Contains(result.ForUser, want) {
|
||||
t.Fatalf("Expected edit diff to contain %q, got:\n%s", want, result.ForUser)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify file was actually edited
|
||||
|
|
@ -412,7 +433,13 @@ func TestEditFileTool_Restricted_InPlaceEdit(t *testing.T) {
|
|||
|
||||
result := tool.Execute(ctx, args)
|
||||
assert.False(t, result.IsError, "Expected success, got: %s", result.ForLLM)
|
||||
assert.True(t, result.Silent)
|
||||
assert.False(t, result.Silent)
|
||||
assert.Equal(t, "File edited: edit_target.txt", result.ForLLM)
|
||||
assert.Contains(t, result.ForUser, "```diff")
|
||||
assert.Contains(t, result.ForUser, "--- a/edit_target.txt")
|
||||
assert.Contains(t, result.ForUser, "+++ b/edit_target.txt")
|
||||
assert.Contains(t, result.ForUser, "-Hello World")
|
||||
assert.Contains(t, result.ForUser, "+Hello Go")
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(workspace, testFile))
|
||||
assert.NoError(t, err)
|
||||
|
|
|
|||
|
|
@ -32,6 +32,10 @@ func SilentResult(forLLM string) *ToolResult {
|
|||
return toolshared.SilentResult(forLLM)
|
||||
}
|
||||
|
||||
func DiffResult(path string, before, after []byte) *ToolResult {
|
||||
return toolshared.DiffResult(path, before, after)
|
||||
}
|
||||
|
||||
func MediaResult(forLLM string, mediaRefs []string) *ToolResult {
|
||||
return toolshared.MediaResult(forLLM, mediaRefs)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -472,6 +472,113 @@ type SogouSearchProvider struct {
|
|||
client *http.Client
|
||||
}
|
||||
|
||||
type GeminiSearchProvider struct {
|
||||
apiKey string
|
||||
model string
|
||||
proxy string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func (p *GeminiSearchProvider) Search(
|
||||
ctx context.Context,
|
||||
query string,
|
||||
count int,
|
||||
rangeCode string,
|
||||
) (string, error) {
|
||||
if strings.TrimSpace(p.apiKey) == "" {
|
||||
return "", errors.New("no API key provided")
|
||||
}
|
||||
model := strings.TrimSpace(p.model)
|
||||
if model == "" {
|
||||
model = "gemini-2.5-flash"
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"contents": []map[string]any{{
|
||||
"parts": []map[string]string{{"text": query}},
|
||||
}},
|
||||
"tools": []map[string]any{{"google_search": map[string]any{}}},
|
||||
}
|
||||
bodyBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal payload: %w", err)
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf(
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent",
|
||||
url.PathEscape(model),
|
||||
)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBuffer(bodyBytes))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Goog-Api-Key", p.apiKey)
|
||||
req.Header.Set("User-Agent", fmt.Sprintf(userAgentHonest, config.Version))
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("gemini search api error (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var searchResp struct {
|
||||
Candidates []struct {
|
||||
Content struct {
|
||||
Parts []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"parts"`
|
||||
} `json:"content"`
|
||||
GroundingMetadata struct {
|
||||
GroundingChunks []struct {
|
||||
Web struct {
|
||||
URI string `json:"uri"`
|
||||
Title string `json:"title"`
|
||||
} `json:"web"`
|
||||
} `json:"groundingChunks"`
|
||||
} `json:"groundingMetadata"`
|
||||
} `json:"candidates"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &searchResp); err != nil {
|
||||
return "", fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
if len(searchResp.Candidates) == 0 {
|
||||
return fmt.Sprintf("No results for: %s", query), nil
|
||||
}
|
||||
|
||||
candidate := searchResp.Candidates[0]
|
||||
lines := []string{fmt.Sprintf("Results for: %s (via Gemini Google Search)", query)}
|
||||
for _, part := range candidate.Content.Parts {
|
||||
if strings.TrimSpace(part.Text) != "" {
|
||||
lines = append(lines, strings.TrimSpace(part.Text))
|
||||
}
|
||||
}
|
||||
citationCount := 0
|
||||
for _, chunk := range candidate.GroundingMetadata.GroundingChunks {
|
||||
if strings.TrimSpace(chunk.Web.URI) == "" {
|
||||
continue
|
||||
}
|
||||
citationCount++
|
||||
title := strings.TrimSpace(chunk.Web.Title)
|
||||
if title == "" {
|
||||
title = chunk.Web.URI
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("%d. %s\n %s", citationCount, title, chunk.Web.URI))
|
||||
if citationCount >= count {
|
||||
break
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n"), nil
|
||||
}
|
||||
|
||||
func (p *SogouSearchProvider) Search(
|
||||
ctx context.Context,
|
||||
query string,
|
||||
|
|
@ -1072,6 +1179,10 @@ type WebSearchToolOptions struct {
|
|||
SogouEnabled bool
|
||||
DuckDuckGoMaxResults int
|
||||
DuckDuckGoEnabled bool
|
||||
GeminiAPIKey string
|
||||
GeminiModel string
|
||||
GeminiMaxResults int
|
||||
GeminiEnabled bool
|
||||
PerplexityAPIKeys []string
|
||||
PerplexityMaxResults int
|
||||
PerplexityEnabled bool
|
||||
|
|
@ -1104,6 +1215,10 @@ func WebSearchToolOptionsFromConfig(cfg *config.Config) WebSearchToolOptions {
|
|||
SogouEnabled: cfg.Tools.Web.Sogou.Enabled,
|
||||
DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
|
||||
DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
|
||||
GeminiAPIKey: cfg.Tools.Web.Gemini.APIKey.String(),
|
||||
GeminiModel: cfg.Tools.Web.Gemini.Model,
|
||||
GeminiMaxResults: cfg.Tools.Web.Gemini.MaxResults,
|
||||
GeminiEnabled: cfg.Tools.Web.Gemini.Enabled,
|
||||
PerplexityAPIKeys: cfg.Tools.Web.Perplexity.APIKeys.Values(),
|
||||
PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
|
||||
PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
|
||||
|
|
@ -1135,6 +1250,7 @@ var (
|
|||
knownWebSearchProviders = []string{
|
||||
"sogou",
|
||||
"duckduckgo",
|
||||
"gemini",
|
||||
"brave",
|
||||
"tavily",
|
||||
"perplexity",
|
||||
|
|
@ -1142,7 +1258,7 @@ var (
|
|||
"glm_search",
|
||||
"baidu_search",
|
||||
}
|
||||
autoPrimaryWebSearchProviders = []string{"perplexity", "brave", "searxng", "tavily"}
|
||||
autoPrimaryWebSearchProviders = []string{"perplexity", "brave", "searxng", "tavily", "gemini"}
|
||||
autoFallbackWebSearchProviders = []string{"baidu_search", "glm_search"}
|
||||
)
|
||||
|
||||
|
|
@ -1162,6 +1278,8 @@ func (opts WebSearchToolOptions) providerReady(name string) bool {
|
|||
return opts.SogouEnabled
|
||||
case "duckduckgo":
|
||||
return opts.DuckDuckGoEnabled
|
||||
case "gemini":
|
||||
return opts.GeminiEnabled && strings.TrimSpace(opts.GeminiAPIKey) != ""
|
||||
case "brave":
|
||||
return opts.BraveEnabled && len(opts.BraveAPIKeys) > 0
|
||||
case "tavily":
|
||||
|
|
@ -1195,14 +1313,15 @@ func (opts WebSearchToolOptions) resolveProviderName(query string) (string, erro
|
|||
return providerName, nil
|
||||
}
|
||||
|
||||
sogouReady := opts.providerReady("sogou")
|
||||
duckReady := opts.providerReady("duckduckgo")
|
||||
|
||||
for _, name := range autoPrimaryWebSearchProviders {
|
||||
if opts.providerReady(name) {
|
||||
return name, nil
|
||||
}
|
||||
}
|
||||
|
||||
sogouReady := opts.providerReady("sogou")
|
||||
duckReady := opts.providerReady("duckduckgo")
|
||||
if sogouReady && duckReady {
|
||||
if prefersDuckDuckGoQuery(query) {
|
||||
return "duckduckgo", nil
|
||||
|
|
@ -1279,6 +1398,24 @@ func (opts WebSearchToolOptions) providerByName(name string) (SearchProvider, in
|
|||
proxy: opts.Proxy,
|
||||
client: client,
|
||||
}, maxResults, nil
|
||||
case "gemini":
|
||||
if !opts.providerReady("gemini") {
|
||||
return nil, 0, nil
|
||||
}
|
||||
client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to create HTTP client for Gemini: %w", err)
|
||||
}
|
||||
maxResults := 10
|
||||
if opts.GeminiMaxResults > 0 {
|
||||
maxResults = min(opts.GeminiMaxResults, 10)
|
||||
}
|
||||
return &GeminiSearchProvider{
|
||||
apiKey: opts.GeminiAPIKey,
|
||||
model: opts.GeminiModel,
|
||||
proxy: opts.Proxy,
|
||||
client: client,
|
||||
}, maxResults, nil
|
||||
case "searxng":
|
||||
if !opts.providerReady("searxng") {
|
||||
return nil, 0, nil
|
||||
|
|
|
|||
|
|
@ -1853,6 +1853,191 @@ func TestWebTool_AutoProviderPrefersConfiguredProvidersBeforeSogou(t *testing.T)
|
|||
}
|
||||
}
|
||||
|
||||
func TestWebTool_AutoProviderPrefersConfiguredProvidersBeforeGemini(t *testing.T) {
|
||||
opts := WebSearchToolOptions{
|
||||
GeminiEnabled: true,
|
||||
GeminiAPIKey: "google-key",
|
||||
GeminiModel: "gemini-2.5-flash",
|
||||
GeminiMaxResults: 5,
|
||||
BraveEnabled: true,
|
||||
BraveAPIKeys: []string{"brave-key"},
|
||||
BraveMaxResults: 5,
|
||||
SogouEnabled: true,
|
||||
SogouMaxResults: 5,
|
||||
DuckDuckGoEnabled: true,
|
||||
DuckDuckGoMaxResults: 5,
|
||||
}
|
||||
|
||||
name, err := ResolveWebSearchProviderName(opts, "best robotics companies")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveWebSearchProviderName() error: %v", err)
|
||||
}
|
||||
if name != "brave" {
|
||||
t.Fatalf("provider = %q, want brave", name)
|
||||
}
|
||||
|
||||
name, err = ResolveWebSearchProviderName(opts, "今天上海天气")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveWebSearchProviderName() error: %v", err)
|
||||
}
|
||||
if name != "brave" {
|
||||
t.Fatalf("provider = %q, want brave", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebTool_GeminiRequiresAPIKey(t *testing.T) {
|
||||
tool, err := NewWebSearchTool(WebSearchToolOptions{
|
||||
Provider: "gemini",
|
||||
GeminiEnabled: true,
|
||||
SogouEnabled: true,
|
||||
SogouMaxResults: 5,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewWebSearchTool() error: %v", err)
|
||||
}
|
||||
if _, ok := tool.provider.(*SogouSearchProvider); !ok {
|
||||
t.Fatalf("expected SogouSearchProvider after missing Gemini API key fallback, got %T", tool.provider)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiSearchProvider_SearchSuccess(t *testing.T) {
|
||||
provider := &GeminiSearchProvider{
|
||||
apiKey: "google-key",
|
||||
model: "gemini-2.5-flash",
|
||||
client: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.Method != http.MethodPost {
|
||||
t.Fatalf("method = %s, want POST", req.Method)
|
||||
}
|
||||
if got := req.Header.Get("X-Goog-Api-Key"); got != "google-key" {
|
||||
t.Fatalf("X-Goog-Api-Key = %q, want google-key", got)
|
||||
}
|
||||
if !strings.Contains(req.URL.String(), "/models/gemini-2.5-flash:generateContent") {
|
||||
t.Fatalf("unexpected URL: %s", req.URL.String())
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
rec.WriteHeader(http.StatusOK)
|
||||
fmt.Fprint(rec, `{
|
||||
"candidates": [
|
||||
{
|
||||
"content": {
|
||||
"parts": [
|
||||
{"text": "Answer paragraph one."},
|
||||
{"text": "Answer paragraph two."}
|
||||
]
|
||||
},
|
||||
"groundingMetadata": {
|
||||
"groundingChunks": [
|
||||
{"web": {"uri": "https://example.com/a", "title": "Result A"}},
|
||||
{"web": {"uri": "https://example.com/b", "title": "Result B"}},
|
||||
{"web": {"uri": "https://example.com/c", "title": "Result C"}}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}`)
|
||||
return rec.Result(), nil
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
out, err := provider.Search(context.Background(), "robotics", 2, "")
|
||||
if err != nil {
|
||||
t.Fatalf("Search() error: %v", err)
|
||||
}
|
||||
if !strings.Contains(out, "Results for: robotics (via Gemini Google Search)") {
|
||||
t.Fatalf("missing header in output: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Answer paragraph one.") || !strings.Contains(out, "Answer paragraph two.") {
|
||||
t.Fatalf("missing response text in output: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "1. Result A") || !strings.Contains(out, "2. Result B") {
|
||||
t.Fatalf("missing citations in output: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "Result C") {
|
||||
t.Fatalf("expected citations to be limited to count=2, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiSearchProvider_SearchIgnoresRange(t *testing.T) {
|
||||
provider := &GeminiSearchProvider{
|
||||
apiKey: "google-key",
|
||||
model: "gemini-2.5-flash",
|
||||
client: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
rec := httptest.NewRecorder()
|
||||
rec.WriteHeader(http.StatusOK)
|
||||
fmt.Fprint(rec, `{
|
||||
"candidates": [
|
||||
{
|
||||
"content": {
|
||||
"parts": [
|
||||
{"text": "Recent robotics result."}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}`)
|
||||
return rec.Result(), nil
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
out, err := provider.Search(context.Background(), "robotics", 2, "d")
|
||||
if err != nil {
|
||||
t.Fatalf("Search() error: %v", err)
|
||||
}
|
||||
if !strings.Contains(out, "Recent robotics result.") {
|
||||
t.Fatalf("missing response text in output: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiSearchProvider_SearchAPIError(t *testing.T) {
|
||||
provider := &GeminiSearchProvider{
|
||||
apiKey: "google-key",
|
||||
model: "gemini-2.5-flash",
|
||||
client: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
rec := httptest.NewRecorder()
|
||||
rec.WriteHeader(http.StatusTooManyRequests)
|
||||
fmt.Fprint(rec, `{"error":"quota exceeded"}`)
|
||||
return rec.Result(), nil
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
_, err := provider.Search(context.Background(), "robotics", 2, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "status 429") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiSearchProvider_SearchEmptyCandidates(t *testing.T) {
|
||||
provider := &GeminiSearchProvider{
|
||||
apiKey: "google-key",
|
||||
model: "gemini-2.5-flash",
|
||||
client: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
rec := httptest.NewRecorder()
|
||||
rec.WriteHeader(http.StatusOK)
|
||||
fmt.Fprint(rec, `{"candidates":[]}`)
|
||||
return rec.Result(), nil
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
out, err := provider.Search(context.Background(), "robotics", 2, "")
|
||||
if err != nil {
|
||||
t.Fatalf("Search() error: %v", err)
|
||||
}
|
||||
if out != "No results for: robotics" {
|
||||
t.Fatalf("output = %q, want %q", out, "No results for: robotics")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebTool_ExplicitProviderFallsBackWhenMissingCredentials(t *testing.T) {
|
||||
tool, err := NewWebSearchTool(WebSearchToolOptions{
|
||||
Provider: "brave",
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ type (
|
|||
TavilySearchProvider = integrationtools.TavilySearchProvider
|
||||
SogouSearchProvider = integrationtools.SogouSearchProvider
|
||||
DuckDuckGoSearchProvider = integrationtools.DuckDuckGoSearchProvider
|
||||
GeminiSearchProvider = integrationtools.GeminiSearchProvider
|
||||
PerplexitySearchProvider = integrationtools.PerplexitySearchProvider
|
||||
SearXNGSearchProvider = integrationtools.SearXNGSearchProvider
|
||||
GLMSearchProvider = integrationtools.GLMSearchProvider
|
||||
|
|
|
|||
|
|
@ -41,6 +41,53 @@ func TestSilentResult(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestDiffResult(t *testing.T) {
|
||||
result := DiffResult("pkg/tools/fs/edit.go", []byte("hello world\n"), []byte("hello universe\n"))
|
||||
|
||||
if result.Silent {
|
||||
t.Error("Expected Silent to be false")
|
||||
}
|
||||
if result.IsError {
|
||||
t.Error("Expected IsError to be false")
|
||||
}
|
||||
if result.Async {
|
||||
t.Error("Expected Async to be false")
|
||||
}
|
||||
if result.ForLLM == result.ForUser {
|
||||
t.Fatalf("Expected ForLLM to omit the full diff, got %q", result.ForLLM)
|
||||
}
|
||||
if len(result.ForLLM) >= len(result.ForUser) {
|
||||
t.Fatalf("Expected ForLLM to stay smaller than ForUser, got %d vs %d", len(result.ForLLM), len(result.ForUser))
|
||||
}
|
||||
|
||||
for _, want := range []string{
|
||||
"File edited: pkg/tools/fs/edit.go",
|
||||
"```diff",
|
||||
"--- a/pkg/tools/fs/edit.go",
|
||||
"+++ b/pkg/tools/fs/edit.go",
|
||||
"-hello world",
|
||||
"+hello universe",
|
||||
} {
|
||||
if !strings.Contains(result.ForUser, want) {
|
||||
t.Fatalf("DiffResult output missing %q:\n%s", want, result.ForUser)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffResult_NormalizesAbsolutePathsAndHandlesNoOpChanges(t *testing.T) {
|
||||
result := DiffResult("/tmp/test.txt", []byte("same\n"), []byte("same\n"))
|
||||
|
||||
if !strings.Contains(result.ForUser, "File edited: /tmp/test.txt") {
|
||||
t.Fatalf("Expected original path in output, got %q", result.ForUser)
|
||||
}
|
||||
if !strings.Contains(result.ForUser, "(no content change)") {
|
||||
t.Fatalf("Expected no-content-change marker, got %q", result.ForUser)
|
||||
}
|
||||
if !strings.Contains(result.ForLLM, "(no content change)") {
|
||||
t.Fatalf("Expected compact no-op summary in ForLLM, got %q", result.ForLLM)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsyncResult(t *testing.T) {
|
||||
result := AsyncResult("async task started")
|
||||
|
||||
|
|
|
|||
162
pkg/tools/shared/diff_result.go
Normal file
162
pkg/tools/shared/diff_result.go
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
package toolshared
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/pmezard/go-difflib/difflib"
|
||||
)
|
||||
|
||||
const (
|
||||
noContentChangeDiffMessage = "(no content change)"
|
||||
noNewlineAtEOFMarker = `\ No newline at end of file`
|
||||
diffPreviewSkippedMessage = "[diff preview skipped: file too large for inline preview]"
|
||||
diffPreviewTruncatedNote = "[diff preview truncated; call read_file for the full edited contents]"
|
||||
maxDiffInputBytes = 64 * 1024
|
||||
maxDiffInputLines = 2000
|
||||
maxUserDiffPreviewBytes = 16 * 1024
|
||||
)
|
||||
|
||||
// DiffResult creates a user-visible tool result containing a unified diff for
|
||||
// a successful file edit. The diff is included for both the LLM and the user so
|
||||
// the follow-up assistant response can reason about the resulting change set,
|
||||
// including EOF newline transitions.
|
||||
func DiffResult(path string, before, after []byte) *ToolResult {
|
||||
summary := fmt.Sprintf("File edited: %s", path)
|
||||
if exceedsDiffPreviewLimits(before, after) {
|
||||
return SilentResult(summary + "\n" + diffPreviewSkippedMessage)
|
||||
}
|
||||
|
||||
diff, err := buildUnifiedDiff(path, before, after)
|
||||
if err != nil {
|
||||
return UserResult(fmt.Sprintf("%s\n[diff unavailable: %v]", summary, err))
|
||||
}
|
||||
|
||||
userDiff, truncated := truncateDiffPreview(diff, maxUserDiffPreviewBytes)
|
||||
userContent := fmt.Sprintf("%s\n```diff\n%s\n```", summary, userDiff)
|
||||
if truncated {
|
||||
userContent += "\n" + diffPreviewTruncatedNote
|
||||
}
|
||||
|
||||
llmContent := summary
|
||||
if diff == noContentChangeDiffMessage {
|
||||
llmContent = summary + "\n" + noContentChangeDiffMessage
|
||||
} else if truncated {
|
||||
llmContent = summary + "\n" + diffPreviewTruncatedNote
|
||||
}
|
||||
|
||||
return &ToolResult{
|
||||
ForLLM: llmContent,
|
||||
ForUser: userContent,
|
||||
Silent: false,
|
||||
IsError: false,
|
||||
Async: false,
|
||||
}
|
||||
}
|
||||
|
||||
func buildUnifiedDiff(path string, before, after []byte) (string, error) {
|
||||
diff, err := difflib.GetUnifiedDiffString(difflib.UnifiedDiff{
|
||||
A: splitDiffLinesPreservingEOF(before),
|
||||
B: splitDiffLinesPreservingEOF(after),
|
||||
FromFile: "a/" + diffDisplayPath(path),
|
||||
ToFile: "b/" + diffDisplayPath(path),
|
||||
Context: 3,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
diff = strings.TrimRight(diff, "\n")
|
||||
if diff == "" {
|
||||
return noContentChangeDiffMessage, nil
|
||||
}
|
||||
|
||||
return diff, nil
|
||||
}
|
||||
|
||||
func splitDiffLinesPreservingEOF(content []byte) []string {
|
||||
if len(content) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
lines := make([]string, 0, bytes.Count(content, []byte{'\n'})+1)
|
||||
lineStart := 0
|
||||
for i, b := range content {
|
||||
if b != '\n' {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, string(content[lineStart:i+1]))
|
||||
lineStart = i + 1
|
||||
}
|
||||
if lineStart < len(content) {
|
||||
lines = append(lines, string(content[lineStart:]))
|
||||
}
|
||||
|
||||
if lacksTrailingNewline(content) {
|
||||
lines[len(lines)-1] += "\n"
|
||||
lines = append(lines, noNewlineAtEOFMarker+"\n")
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
func lacksTrailingNewline(content []byte) bool {
|
||||
return len(content) > 0 && !bytes.HasSuffix(content, []byte("\n"))
|
||||
}
|
||||
|
||||
func exceedsDiffPreviewLimits(before, after []byte) bool {
|
||||
return len(before) > maxDiffInputBytes ||
|
||||
len(after) > maxDiffInputBytes ||
|
||||
countDiffLines(before) > maxDiffInputLines ||
|
||||
countDiffLines(after) > maxDiffInputLines
|
||||
}
|
||||
|
||||
func countDiffLines(content []byte) int {
|
||||
if len(content) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
lines := bytes.Count(content, []byte{'\n'})
|
||||
if !bytes.HasSuffix(content, []byte("\n")) {
|
||||
lines++
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func truncateDiffPreview(diff string, maxBytes int) (string, bool) {
|
||||
if maxBytes <= 0 || len(diff) <= maxBytes {
|
||||
return diff, false
|
||||
}
|
||||
|
||||
truncated := diff[:maxBytes]
|
||||
for len(truncated) > 0 && !utf8.ValidString(truncated) {
|
||||
truncated = truncated[:len(truncated)-1]
|
||||
}
|
||||
|
||||
lastNewline := strings.LastIndexByte(truncated, '\n')
|
||||
if lastNewline > 0 {
|
||||
truncated = truncated[:lastNewline]
|
||||
}
|
||||
|
||||
truncated = strings.TrimRight(truncated, "\n")
|
||||
if truncated == "" {
|
||||
truncated = diff[:maxBytes]
|
||||
for len(truncated) > 0 && !utf8.ValidString(truncated) {
|
||||
truncated = truncated[:len(truncated)-1]
|
||||
}
|
||||
truncated = strings.TrimRight(truncated, "\n")
|
||||
}
|
||||
|
||||
return truncated, true
|
||||
}
|
||||
|
||||
func diffDisplayPath(path string) string {
|
||||
displayPath := strings.TrimLeft(filepath.ToSlash(path), "/")
|
||||
if displayPath == "" {
|
||||
return "file"
|
||||
}
|
||||
return displayPath
|
||||
}
|
||||
177
pkg/tools/shared/diff_result_test.go
Normal file
177
pkg/tools/shared/diff_result_test.go
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
package toolshared
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDiffResult_UserVisibleUnifiedDiff(t *testing.T) {
|
||||
result := DiffResult("/tmp/example.txt", []byte("alpha\nbeta\ngamma\n"), []byte("alpha\nbeta 2\ngamma\n"))
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("DiffResult() returned nil")
|
||||
}
|
||||
if result.Silent {
|
||||
t.Fatal("expected DiffResult to be user-visible")
|
||||
}
|
||||
if result.IsError {
|
||||
t.Fatal("expected DiffResult to be successful")
|
||||
}
|
||||
if result.ForLLM == result.ForUser {
|
||||
t.Fatal("expected compact model context instead of duplicating the full diff")
|
||||
}
|
||||
if len(result.ForLLM) >= len(result.ForUser) {
|
||||
t.Fatalf("expected ForLLM to stay smaller than ForUser, got %d vs %d", len(result.ForLLM), len(result.ForUser))
|
||||
}
|
||||
if result.ForLLM != "File edited: /tmp/example.txt" {
|
||||
t.Fatalf("expected compact summary in ForLLM, got %q", result.ForLLM)
|
||||
}
|
||||
|
||||
for _, want := range []string{
|
||||
"File edited: /tmp/example.txt",
|
||||
"```diff",
|
||||
"--- a/tmp/example.txt",
|
||||
"+++ b/tmp/example.txt",
|
||||
"@@ -1,3 +1,3 @@",
|
||||
" alpha",
|
||||
"-beta",
|
||||
"+beta 2",
|
||||
" gamma",
|
||||
} {
|
||||
if !strings.Contains(result.ForUser, want) {
|
||||
t.Fatalf("DiffResult output missing %q:\n%s", want, result.ForUser)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUnifiedDiff_NoContentChange(t *testing.T) {
|
||||
diff, err := buildUnifiedDiff("test.txt", []byte("same\n"), []byte("same\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("buildUnifiedDiff() error = %v", err)
|
||||
}
|
||||
if diff != noContentChangeDiffMessage {
|
||||
t.Fatalf("buildUnifiedDiff() = %q, want %q", diff, noContentChangeDiffMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUnifiedDiff_PreservesTrailingNewlineRemoval(t *testing.T) {
|
||||
diff, err := buildUnifiedDiff("test.txt", []byte("same\n"), []byte("same"))
|
||||
if err != nil {
|
||||
t.Fatalf("buildUnifiedDiff() error = %v", err)
|
||||
}
|
||||
|
||||
for _, want := range []string{
|
||||
"--- a/test.txt",
|
||||
"+++ b/test.txt",
|
||||
" same",
|
||||
"+" + noNewlineAtEOFMarker,
|
||||
} {
|
||||
if !strings.Contains(diff, want) {
|
||||
t.Fatalf("buildUnifiedDiff() missing %q:\n%s", want, diff)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUnifiedDiff_PreservesTrailingNewlineAddition(t *testing.T) {
|
||||
diff, err := buildUnifiedDiff("test.txt", []byte("same"), []byte("same\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("buildUnifiedDiff() error = %v", err)
|
||||
}
|
||||
|
||||
for _, want := range []string{
|
||||
"--- a/test.txt",
|
||||
"+++ b/test.txt",
|
||||
" same",
|
||||
"-" + noNewlineAtEOFMarker,
|
||||
} {
|
||||
if !strings.Contains(diff, want) {
|
||||
t.Fatalf("buildUnifiedDiff() missing %q:\n%s", want, diff)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUnifiedDiff_UsesNormalizedDisplayPaths(t *testing.T) {
|
||||
diff, err := buildUnifiedDiff("/tmp/nested/example.txt", []byte("before\n"), []byte("after\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("buildUnifiedDiff() error = %v", err)
|
||||
}
|
||||
|
||||
for _, want := range []string{
|
||||
"--- a/tmp/nested/example.txt",
|
||||
"+++ b/tmp/nested/example.txt",
|
||||
} {
|
||||
if !strings.Contains(diff, want) {
|
||||
t.Fatalf("buildUnifiedDiff() missing %q:\n%s", want, diff)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffResult_SkipsPreviewForLargeFiles(t *testing.T) {
|
||||
before := bytes.Repeat([]byte("a"), maxDiffInputBytes+1)
|
||||
after := bytes.Repeat([]byte("b"), maxDiffInputBytes+1)
|
||||
|
||||
result := DiffResult("big.txt", before, after)
|
||||
|
||||
if !result.Silent {
|
||||
t.Fatal("expected large diff previews to be skipped silently")
|
||||
}
|
||||
if result.ForUser != "" {
|
||||
t.Fatalf("expected no user-facing preview when skipped, got %q", result.ForUser)
|
||||
}
|
||||
if !strings.Contains(result.ForLLM, diffPreviewSkippedMessage) {
|
||||
t.Fatalf("expected skipped-preview note, got %q", result.ForLLM)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffResult_TruncatesLargeUserPreview(t *testing.T) {
|
||||
after := []byte(strings.Repeat("abcd", maxUserDiffPreviewBytes/4) + "\n")
|
||||
|
||||
result := DiffResult("preview.txt", []byte("before\n"), after)
|
||||
|
||||
if result.Silent {
|
||||
t.Fatal("expected preview to remain user-visible below the input caps")
|
||||
}
|
||||
if !strings.Contains(result.ForUser, diffPreviewTruncatedNote) {
|
||||
t.Fatalf("expected truncated preview note, got %q", result.ForUser)
|
||||
}
|
||||
if !strings.Contains(result.ForLLM, diffPreviewTruncatedNote) {
|
||||
t.Fatalf("expected model summary to mention truncation, got %q", result.ForLLM)
|
||||
}
|
||||
if len(result.ForLLM) >= len(result.ForUser) {
|
||||
t.Fatalf("expected ForLLM to remain smaller than ForUser, "+
|
||||
"got %d vs %d", len(result.ForLLM), len(result.ForUser))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffDisplayPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "absolute path",
|
||||
path: "/tmp/example.txt",
|
||||
want: "tmp/example.txt",
|
||||
},
|
||||
{
|
||||
name: "relative path",
|
||||
path: "pkg/tools/fs/edit.go",
|
||||
want: "pkg/tools/fs/edit.go",
|
||||
},
|
||||
{
|
||||
name: "empty path",
|
||||
path: "",
|
||||
want: "file",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := diffDisplayPath(tt.path); got != tt.want {
|
||||
t.Fatalf("diffDisplayPath(%q) = %q, want %q", tt.path, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -101,6 +101,10 @@ func SilentResult(forLLM string) *ToolResult {
|
|||
return toolshared.SilentResult(forLLM)
|
||||
}
|
||||
|
||||
func DiffResult(path string, before, after []byte) *ToolResult {
|
||||
return toolshared.DiffResult(path, before, after)
|
||||
}
|
||||
|
||||
func AsyncResult(forLLM string) *ToolResult {
|
||||
return toolshared.AsyncResult(forLLM)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ The current frontend exposes these major pages and flows:
|
|||
- `/agent/tools`
|
||||
- View tool availability and enable or disable tool switches through config-backed APIs.
|
||||
- `/config`
|
||||
- Edit agent defaults, exec controls, cron controls, heartbeat, device monitoring, launcher networking, and launch-at-login settings.
|
||||
- Edit agent defaults, self-evolution, exec controls, cron controls, heartbeat, device monitoring, launcher networking, and launch-at-login settings.
|
||||
- `/logs`
|
||||
- View the in-memory gateway log buffer and clear it.
|
||||
|
||||
|
|
|
|||
|
|
@ -48,7 +48,12 @@ func generateCatalogKey(provider, apiBase, apiKey string) string {
|
|||
return fmt.Sprintf("%s|%s|%x", provider, apiBase, hash[:6])
|
||||
}
|
||||
|
||||
// maskAPIKeyValue masks an API key for display, keeping first 4 and last 4 chars.
|
||||
// maskAPIKeyValue masks an API key for display.
|
||||
// Keys longer than 12 chars show prefix + last 4 chars: "sk-****abcd".
|
||||
// Keys 9-12 chars show prefix + last 2 chars: "sk-****cd".
|
||||
// Shorter keys are fully masked as "****".
|
||||
// Empty keys return empty string.
|
||||
// Ensure at least 40% of the key will not be displayed.
|
||||
func maskAPIKeyValue(key string) string {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
|
|
@ -57,7 +62,10 @@ func maskAPIKeyValue(key string) string {
|
|||
if len(key) <= 8 {
|
||||
return "****"
|
||||
}
|
||||
return key[:4] + "****" + key[len(key)-4:]
|
||||
if len(key) <= 12 {
|
||||
return key[:3] + "****" + key[len(key)-2:]
|
||||
}
|
||||
return key[:3] + "****" + key[len(key)-4:]
|
||||
}
|
||||
|
||||
func loadCatalogs() (*CatalogStore, error) {
|
||||
|
|
|
|||
87
web/backend/api/model_catalog_test.go
Normal file
87
web/backend/api/model_catalog_test.go
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMaskAPIKeyValue(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "empty key",
|
||||
key: "",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "whitespace only",
|
||||
key: " ",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "short key fully masked",
|
||||
key: "abcd",
|
||||
want: "****",
|
||||
},
|
||||
{
|
||||
name: "length 8 boundary fully masked",
|
||||
key: "12345678",
|
||||
want: "****",
|
||||
},
|
||||
{
|
||||
name: "length 9 boundary shows last 2",
|
||||
key: "123456789",
|
||||
want: "123****89",
|
||||
},
|
||||
{
|
||||
name: "length 10 shows last 2",
|
||||
key: "1234567890",
|
||||
want: "123****90",
|
||||
},
|
||||
{
|
||||
name: "length 12 boundary shows last 2",
|
||||
key: "abcdefghijkl",
|
||||
want: "abc****kl",
|
||||
},
|
||||
{
|
||||
name: "length 13 boundary shows last 4",
|
||||
key: "abcdefghijklm",
|
||||
want: "abc****jklm",
|
||||
},
|
||||
{
|
||||
name: "typical api key",
|
||||
key: "sk-1234567890abcd",
|
||||
want: "sk-****abcd",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := maskAPIKeyValue(tc.key)
|
||||
if got != tc.want {
|
||||
t.Fatalf("maskAPIKeyValue(%q) = %q, want %q", tc.key, got, tc.want)
|
||||
}
|
||||
|
||||
if tc.key != "" {
|
||||
displayed := strings.Replace(got, "****", "", 1)
|
||||
if len(strings.TrimSpace(tc.key)) <= 8 {
|
||||
if displayed != "" {
|
||||
t.Fatalf("maskAPIKeyValue(%q) displayed part = %q, want empty", tc.key, displayed)
|
||||
}
|
||||
} else {
|
||||
if len(displayed)*10 > len(strings.TrimSpace(tc.key))*6 {
|
||||
t.Fatalf(
|
||||
"maskAPIKeyValue(%q) displayed length = %d, want at most 60%% of %d",
|
||||
tc.key,
|
||||
len(displayed),
|
||||
len(strings.TrimSpace(tc.key)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -746,23 +746,50 @@ func fetchOpenAICompatibleModels(ctx context.Context, fetchURL, apiKey string) (
|
|||
return nil, fmt.Errorf("upstream returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var parsed struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
OwnedBy string `json:"owned_by"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
|
||||
return nil, err
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
models := make([]upstreamModel, 0, len(parsed.Data))
|
||||
for _, m := range parsed.Data {
|
||||
if m.ID != "" {
|
||||
models = append(models, upstreamModel{ID: m.ID, OwnedBy: m.OwnedBy})
|
||||
}
|
||||
type modelItem struct {
|
||||
ID string `json:"id"`
|
||||
OwnedBy string `json:"owned_by"`
|
||||
}
|
||||
return models, nil
|
||||
|
||||
// {"data": [...]} envelope. Distinguish "envelope shape with empty list"
|
||||
// from "object without a data key" via Data being non-nil after unmarshal:
|
||||
// json.Unmarshal sets Data to []modelItem{} for `{"data":[]}` but leaves
|
||||
// it as nil when "data" is absent or null.
|
||||
var envelope struct {
|
||||
Data []modelItem `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &envelope); err == nil && envelope.Data != nil {
|
||||
models := make([]upstreamModel, 0, len(envelope.Data))
|
||||
for _, m := range envelope.Data {
|
||||
if m.ID != "" {
|
||||
models = append(models, upstreamModel{ID: m.ID, OwnedBy: m.OwnedBy})
|
||||
}
|
||||
}
|
||||
return models, nil
|
||||
}
|
||||
|
||||
// Bare-array shape, including `[]`.
|
||||
var arr []modelItem
|
||||
if err := json.Unmarshal(body, &arr); err == nil {
|
||||
models := make([]upstreamModel, 0, len(arr))
|
||||
for _, m := range arr {
|
||||
if m.ID != "" {
|
||||
models = append(models, upstreamModel{ID: m.ID, OwnedBy: m.OwnedBy})
|
||||
}
|
||||
}
|
||||
return models, nil
|
||||
}
|
||||
|
||||
preview := body
|
||||
if len(preview) > 256 {
|
||||
preview = preview[:256]
|
||||
}
|
||||
return nil, fmt.Errorf("decode response: unrecognized shape: %s", strings.TrimSpace(string(preview)))
|
||||
}
|
||||
|
||||
func fetchOllamaModels(ctx context.Context, fetchURL string) ([]upstreamModel, error) {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package api
|
|||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
|
|
@ -2219,3 +2220,174 @@ func TestMaskAPIKey(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchOpenAICompatibleModels_ResponseShapes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
apiKey string
|
||||
wantLen int
|
||||
wantFirst struct {
|
||||
id, ownedBy string
|
||||
}
|
||||
wantSecond struct {
|
||||
id, ownedBy string
|
||||
}
|
||||
}{
|
||||
{
|
||||
name: "envelope shape",
|
||||
response: `{"data":[{"id":"gpt-4o","owned_by":"openai"},{"id":"gpt-4o-mini","owned_by":"openai"}]}`,
|
||||
apiKey: "test-key",
|
||||
wantLen: 2,
|
||||
wantFirst: struct {
|
||||
id, ownedBy string
|
||||
}{id: "gpt-4o", ownedBy: "openai"},
|
||||
wantSecond: struct {
|
||||
id, ownedBy string
|
||||
}{id: "gpt-4o-mini", ownedBy: "openai"},
|
||||
},
|
||||
{
|
||||
name: "bare array shape",
|
||||
response: `[{"id":"qwen-max","owned_by":"qwen"},{"id":"qwen-plus","owned_by":"qwen"}]`,
|
||||
apiKey: "",
|
||||
wantLen: 2,
|
||||
wantFirst: struct {
|
||||
id, ownedBy string
|
||||
}{id: "qwen-max", ownedBy: "qwen"},
|
||||
wantSecond: struct {
|
||||
id, ownedBy string
|
||||
}{id: "qwen-plus", ownedBy: "qwen"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, tt.response)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
models, err := fetchOpenAICompatibleModels(t.Context(), srv.URL+"/models", tt.apiKey)
|
||||
if err != nil {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if len(models) != tt.wantLen {
|
||||
t.Fatalf("len(models) = %d, want %d", len(models), tt.wantLen)
|
||||
}
|
||||
if models[0].ID != tt.wantFirst.id || models[0].OwnedBy != tt.wantFirst.ownedBy {
|
||||
t.Fatalf("models[0] = %+v, want {ID:%s OwnedBy:%s}", models[0], tt.wantFirst.id, tt.wantFirst.ownedBy)
|
||||
}
|
||||
if models[1].ID != tt.wantSecond.id || models[1].OwnedBy != tt.wantSecond.ownedBy {
|
||||
t.Fatalf("models[1] = %+v, want {ID:%s OwnedBy:%s}", models[1], tt.wantSecond.id, tt.wantSecond.ownedBy)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchOpenAICompatibleModels_EmptyEnvelopeReturnsEmptySlice(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"data":[]}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
models, err := fetchOpenAICompatibleModels(t.Context(), srv.URL+"/models", "k")
|
||||
if err != nil {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if len(models) != 0 {
|
||||
t.Fatalf("len(models) = %d, want 0", len(models))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchOpenAICompatibleModels_EmptyBareArrayReturnsEmptySlice(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `[]`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
models, err := fetchOpenAICompatibleModels(t.Context(), srv.URL+"/models", "k")
|
||||
if err != nil {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if len(models) != 0 {
|
||||
t.Fatalf("len(models) = %d, want 0", len(models))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchOpenAICompatibleModels_UnrecognizedShape(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"models":[],"error":"unsupported"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := fetchOpenAICompatibleModels(t.Context(), srv.URL+"/models", "k")
|
||||
if err == nil {
|
||||
t.Fatal("error = nil, want unrecognized shape error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unrecognized shape") {
|
||||
t.Fatalf("error = %q, want it to contain 'unrecognized shape'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchOpenAICompatibleModels_FiltersEmptyIDs(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"data":[`+
|
||||
`{"id":"gpt-4o","owned_by":"openai"},`+
|
||||
`{"id":"","owned_by":"openai"},`+
|
||||
`{"id":"gpt-4o-mini"}]}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
models, err := fetchOpenAICompatibleModels(t.Context(), srv.URL+"/models", "k")
|
||||
if err != nil {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if len(models) != 2 {
|
||||
t.Fatalf("len(models) = %d, want 2 (empty IDs should be filtered)", len(models))
|
||||
}
|
||||
if models[0].ID != "gpt-4o" {
|
||||
t.Fatalf("models[0].ID = %q, want %q", models[0].ID, "gpt-4o")
|
||||
}
|
||||
if models[1].ID != "gpt-4o-mini" {
|
||||
t.Fatalf("models[1].ID = %q, want %q", models[1].ID, "gpt-4o-mini")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchOpenAICompatibleModels_SetsAuthorizationHeader(t *testing.T) {
|
||||
var gotAuth string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"data":[{"id":"m1"}]}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
if _, err := fetchOpenAICompatibleModels(t.Context(), srv.URL+"/models", "my-secret-key"); err != nil {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if gotAuth != "Bearer my-secret-key" {
|
||||
t.Fatalf("Authorization = %q, want %q", gotAuth, "Bearer my-secret-key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchOpenAICompatibleModels_NoAuthHeaderWhenKeyEmpty(t *testing.T) {
|
||||
var gotAuth string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `[{"id":"m1"}]`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
if _, err := fetchOpenAICompatibleModels(t.Context(), srv.URL+"/models", ""); err != nil {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if gotAuth != "" {
|
||||
t.Fatalf("Authorization = %q, want empty", gotAuth)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ type webSearchProviderConfig struct {
|
|||
BaseURL string `json:"base_url,omitempty"`
|
||||
APIKey string `json:"api_key,omitempty"`
|
||||
APIKeys []string `json:"api_keys,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
APIKeySet bool `json:"api_key_set,omitempty"`
|
||||
}
|
||||
|
||||
|
|
@ -446,6 +447,14 @@ func (h *Handler) handleUpdateWebSearchConfig(w http.ResponseWriter, r *http.Req
|
|||
cfg.Tools.Web.DuckDuckGo.Enabled = settings.Enabled
|
||||
cfg.Tools.Web.DuckDuckGo.MaxResults = settings.MaxResults
|
||||
}
|
||||
if settings, ok := req.Settings["gemini"]; ok {
|
||||
cfg.Tools.Web.Gemini.Enabled = settings.Enabled
|
||||
cfg.Tools.Web.Gemini.MaxResults = settings.MaxResults
|
||||
cfg.Tools.Web.Gemini.Model = strings.TrimSpace(settings.Model)
|
||||
if key := strings.TrimSpace(settings.APIKey); key != "" {
|
||||
cfg.Tools.Web.Gemini.APIKey = *config.NewSecureString(key)
|
||||
}
|
||||
}
|
||||
if settings, ok := req.Settings["brave"]; ok {
|
||||
cfg.Tools.Web.Brave.Enabled = settings.Enabled
|
||||
cfg.Tools.Web.Brave.MaxResults = settings.MaxResults
|
||||
|
|
@ -505,7 +514,7 @@ func normalizeWebSearchProvider(provider string) string {
|
|||
switch strings.ToLower(strings.TrimSpace(provider)) {
|
||||
case "", "auto":
|
||||
return "auto"
|
||||
case "sogou", "brave", "tavily", "duckduckgo", "perplexity", "searxng", "glm_search", "baidu_search":
|
||||
case "sogou", "brave", "tavily", "duckduckgo", "gemini", "perplexity", "searxng", "glm_search", "baidu_search":
|
||||
return strings.ToLower(strings.TrimSpace(provider))
|
||||
default:
|
||||
return ""
|
||||
|
|
@ -549,6 +558,12 @@ func buildWebSearchConfigResponse(cfg *config.Config) webSearchConfigResponse {
|
|||
Enabled: cfg.Tools.Web.DuckDuckGo.Enabled,
|
||||
MaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
|
||||
},
|
||||
"gemini": {
|
||||
Enabled: cfg.Tools.Web.Gemini.Enabled,
|
||||
MaxResults: cfg.Tools.Web.Gemini.MaxResults,
|
||||
Model: cfg.Tools.Web.Gemini.Model,
|
||||
APIKeySet: cfg.Tools.Web.Gemini.APIKey.String() != "",
|
||||
},
|
||||
"brave": {
|
||||
Enabled: cfg.Tools.Web.Brave.Enabled,
|
||||
MaxResults: cfg.Tools.Web.Brave.MaxResults,
|
||||
|
|
@ -604,6 +619,13 @@ func buildWebSearchConfigResponse(cfg *config.Config) webSearchConfigResponse {
|
|||
Configured: picotools.WebSearchProviderReady(opts, "duckduckgo"),
|
||||
Current: current == "duckduckgo",
|
||||
},
|
||||
{
|
||||
ID: "gemini",
|
||||
Label: "Gemini (Google Search)",
|
||||
Configured: picotools.WebSearchProviderReady(opts, "gemini"),
|
||||
Current: current == "gemini",
|
||||
RequiresAuth: true,
|
||||
},
|
||||
{
|
||||
ID: "brave",
|
||||
Label: "Brave Search",
|
||||
|
|
|
|||
|
|
@ -540,7 +540,7 @@ func TestHandleUpdateWebSearchConfig_PreservesAndReplacesMultiKeys(t *testing.T)
|
|||
}
|
||||
}
|
||||
|
||||
func TestResolveCurrentWebSearchProvider_PrefersConfiguredProvidersBeforeSogou(t *testing.T) {
|
||||
func TestResolveCurrentWebSearchProvider_PrefersConfiguredProvidersInAutoMode(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Tools.Web.Provider = "auto"
|
||||
cfg.Tools.Web.Sogou.Enabled = true
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@tabler/icons-react": "^3.43.0",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@tanstack/react-query": "^5.99.0",
|
||||
"@tanstack/react-router": "^1.169.2",
|
||||
"@tanstack/react-router-devtools": "^1.166.13",
|
||||
|
|
@ -31,7 +31,7 @@
|
|||
"highlight.js": "^11.11.1",
|
||||
"i18next": "^26.0.10",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"jotai": "^2.19.1",
|
||||
"jotai": "^2.20.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.5",
|
||||
"react-dom": "19.2.5",
|
||||
|
|
@ -45,7 +45,7 @@
|
|||
"shadcn": "^4.7.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"wrap-ansi": "^10.0.0"
|
||||
},
|
||||
|
|
@ -65,9 +65,9 @@
|
|||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.59.1",
|
||||
"vite": "^8.0.10"
|
||||
"typescript-eslint": "^8.59.3",
|
||||
"vite": "^8.0.13"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
511
web/frontend/pnpm-lock.yaml
generated
511
web/frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -30,6 +30,7 @@ export interface WebSearchProviderConfig {
|
|||
max_results: number
|
||||
base_url?: string
|
||||
api_key?: string
|
||||
model?: string
|
||||
api_key_set?: boolean
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,10 +30,13 @@ const apiKeyProviders = new Set([
|
|||
"brave",
|
||||
"tavily",
|
||||
"perplexity",
|
||||
"gemini",
|
||||
"glm_search",
|
||||
"baidu_search",
|
||||
])
|
||||
|
||||
const modelProviders = new Set(["gemini"])
|
||||
|
||||
export function WebSearchProviderSettings({
|
||||
providerLabelMap,
|
||||
settings,
|
||||
|
|
@ -226,6 +229,27 @@ function ProviderCard({
|
|||
/>
|
||||
</ProviderField>
|
||||
)}
|
||||
|
||||
{modelProviders.has(providerId) && (
|
||||
<ProviderField
|
||||
label={t("pages.agent.tools.web_search.model", "Model")}
|
||||
>
|
||||
<Input
|
||||
value={settings.model ?? ""}
|
||||
onChange={(event) =>
|
||||
updateSettings((current) => ({
|
||||
...current,
|
||||
model: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={t(
|
||||
"pages.agent.tools.web_search.model_placeholder",
|
||||
"Optional model override",
|
||||
)}
|
||||
className="bg-muted/40 hover:bg-muted/60 focus:bg-background focus:ring-primary/20 h-10 rounded-xl border-transparent shadow-none transition-colors"
|
||||
/>
|
||||
</ProviderField>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
AgentDefaultsSection,
|
||||
CronSection,
|
||||
DevicesSection,
|
||||
EvolutionSection,
|
||||
ExecSection,
|
||||
LauncherSection,
|
||||
MCPSection,
|
||||
|
|
@ -33,6 +34,7 @@ import {
|
|||
type MCPServerForm,
|
||||
buildFormFromConfig,
|
||||
parseCIDRText,
|
||||
parseFloatField,
|
||||
parseIntField,
|
||||
parseJSONObjectField,
|
||||
parseMultilineList,
|
||||
|
|
@ -281,6 +283,16 @@ export function ConfigPage() {
|
|||
"Cron exec timeout",
|
||||
{ min: 0 },
|
||||
)
|
||||
const evolutionMinTaskCount = parseIntField(
|
||||
form.evolutionMinTaskCount,
|
||||
"Evolution minimum task count",
|
||||
{ min: 1 },
|
||||
)
|
||||
const evolutionMinSuccessRatio = parseFloatField(
|
||||
form.evolutionMinSuccessRatio,
|
||||
"Evolution minimum success ratio",
|
||||
{ min: 0.01, max: 1 },
|
||||
)
|
||||
const mcpDiscoveryValidationEnabled =
|
||||
form.mcpEnabled && form.mcpDiscoveryEnabled
|
||||
const mcpDiscoveryPatch: Record<string, unknown> = {
|
||||
|
|
@ -500,6 +512,20 @@ export function ConfigPage() {
|
|||
session: {
|
||||
dm_scope: dmScope,
|
||||
},
|
||||
evolution: {
|
||||
enabled: form.evolutionEnabled,
|
||||
mode: form.evolutionMode,
|
||||
state_dir:
|
||||
form.evolutionStateDir.trim() === ""
|
||||
? null
|
||||
: form.evolutionStateDir.trim(),
|
||||
min_task_count: evolutionMinTaskCount,
|
||||
min_success_ratio: evolutionMinSuccessRatio,
|
||||
cold_path_trigger: form.evolutionColdPathTrigger,
|
||||
cold_path_times: parseMultilineList(
|
||||
form.evolutionColdPathTimesText,
|
||||
),
|
||||
},
|
||||
tools: {
|
||||
cron: {
|
||||
allow_command: form.allowCommand,
|
||||
|
|
@ -661,6 +687,8 @@ export function ConfigPage() {
|
|||
|
||||
<RuntimeSection form={form} onFieldChange={updateField} />
|
||||
|
||||
<EvolutionSection form={form} onFieldChange={updateField} />
|
||||
|
||||
<MCPSection
|
||||
form={form}
|
||||
onFieldChange={updateField}
|
||||
|
|
|
|||
|
|
@ -236,6 +236,152 @@ interface MCPSectionProps {
|
|||
) => void
|
||||
}
|
||||
|
||||
interface EvolutionSectionProps {
|
||||
form: CoreConfigForm
|
||||
onFieldChange: UpdateCoreField
|
||||
}
|
||||
|
||||
export function EvolutionSection({
|
||||
form,
|
||||
onFieldChange,
|
||||
}: EvolutionSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<ConfigSectionCard
|
||||
title={t("pages.config.sections.evolution")}
|
||||
description={t("pages.config.evolution_section_hint")}
|
||||
>
|
||||
<SwitchCardField
|
||||
label={t("pages.config.evolution_enabled")}
|
||||
hint={t("pages.config.evolution_enabled_hint")}
|
||||
layout="setting-row"
|
||||
checked={form.evolutionEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onFieldChange("evolutionEnabled", checked)
|
||||
}
|
||||
/>
|
||||
|
||||
<Field
|
||||
label={t("pages.config.evolution_mode")}
|
||||
hint={t("pages.config.evolution_mode_hint")}
|
||||
layout="setting-row"
|
||||
>
|
||||
<Select
|
||||
value={form.evolutionMode}
|
||||
onValueChange={(value) => onFieldChange("evolutionMode", value)}
|
||||
>
|
||||
<SelectTrigger aria-label={t("pages.config.evolution_mode")}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="observe">
|
||||
{t("pages.config.evolution_mode_observe")}
|
||||
</SelectItem>
|
||||
<SelectItem value="draft">
|
||||
{t("pages.config.evolution_mode_draft")}
|
||||
</SelectItem>
|
||||
<SelectItem value="apply">
|
||||
{t("pages.config.evolution_mode_apply")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("pages.config.evolution_state_dir")}
|
||||
hint={t("pages.config.evolution_state_dir_hint")}
|
||||
layout="setting-row"
|
||||
>
|
||||
<Input
|
||||
value={form.evolutionStateDir}
|
||||
onChange={(e) => onFieldChange("evolutionStateDir", e.target.value)}
|
||||
placeholder="e.g. /var/lib/picoclaw/evolution"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("pages.config.evolution_min_task_count")}
|
||||
hint={t("pages.config.evolution_min_task_count_hint")}
|
||||
layout="setting-row"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.evolutionMinTaskCount}
|
||||
onChange={(e) =>
|
||||
onFieldChange("evolutionMinTaskCount", e.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("pages.config.evolution_min_success_ratio")}
|
||||
hint={t("pages.config.evolution_min_success_ratio_hint")}
|
||||
layout="setting-row"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={0.01}
|
||||
max={1}
|
||||
step="0.05"
|
||||
value={form.evolutionMinSuccessRatio}
|
||||
onChange={(e) =>
|
||||
onFieldChange("evolutionMinSuccessRatio", e.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("pages.config.evolution_cold_path_trigger")}
|
||||
hint={t("pages.config.evolution_cold_path_trigger_hint")}
|
||||
layout="setting-row"
|
||||
>
|
||||
<Select
|
||||
value={form.evolutionColdPathTrigger}
|
||||
onValueChange={(value) =>
|
||||
onFieldChange("evolutionColdPathTrigger", value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label={t("pages.config.evolution_cold_path_trigger")}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="after_turn">
|
||||
{t("pages.config.evolution_cold_path_after_turn")}
|
||||
</SelectItem>
|
||||
<SelectItem value="scheduled">
|
||||
{t("pages.config.evolution_cold_path_scheduled")}
|
||||
</SelectItem>
|
||||
<SelectItem value="manual">
|
||||
{t("pages.config.evolution_cold_path_manual")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
{form.evolutionColdPathTrigger === "scheduled" && (
|
||||
<Field
|
||||
label={t("pages.config.evolution_cold_path_times")}
|
||||
hint={t("pages.config.evolution_cold_path_times_hint")}
|
||||
layout="setting-row"
|
||||
>
|
||||
<Textarea
|
||||
value={form.evolutionColdPathTimesText}
|
||||
placeholder={"03:00\n15:30"}
|
||||
className="min-h-[88px] font-mono text-xs"
|
||||
onChange={(e) =>
|
||||
onFieldChange("evolutionColdPathTimesText", e.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</ConfigSectionCard>
|
||||
)
|
||||
}
|
||||
|
||||
export function MCPSection({
|
||||
form,
|
||||
onFieldChange,
|
||||
|
|
|
|||
|
|
@ -32,6 +32,13 @@ export interface CoreConfigForm {
|
|||
mcpDiscoveryUseBM25: boolean
|
||||
mcpDiscoveryUseRegex: boolean
|
||||
mcpServers: MCPServerForm[]
|
||||
evolutionEnabled: boolean
|
||||
evolutionMode: string
|
||||
evolutionStateDir: string
|
||||
evolutionMinTaskCount: string
|
||||
evolutionMinSuccessRatio: string
|
||||
evolutionColdPathTrigger: string
|
||||
evolutionColdPathTimesText: string
|
||||
}
|
||||
|
||||
export type MCPServerType = "http" | "sse" | "stdio"
|
||||
|
|
@ -121,6 +128,13 @@ export const EMPTY_FORM: CoreConfigForm = {
|
|||
mcpDiscoveryUseBM25: true,
|
||||
mcpDiscoveryUseRegex: false,
|
||||
mcpServers: [],
|
||||
evolutionEnabled: false,
|
||||
evolutionMode: "observe",
|
||||
evolutionStateDir: "",
|
||||
evolutionMinTaskCount: "2",
|
||||
evolutionMinSuccessRatio: "0.7",
|
||||
evolutionColdPathTrigger: "after_turn",
|
||||
evolutionColdPathTimesText: "",
|
||||
}
|
||||
|
||||
export const EMPTY_LAUNCHER_FORM: LauncherForm = {
|
||||
|
|
@ -215,6 +229,7 @@ export function buildFormFromConfig(config: unknown): CoreConfigForm {
|
|||
const session = asRecord(root.session)
|
||||
const heartbeat = asRecord(root.heartbeat)
|
||||
const devices = asRecord(root.devices)
|
||||
const evolution = asRecord(root.evolution)
|
||||
const tools = asRecord(root.tools)
|
||||
const mcp = asRecord(tools.mcp)
|
||||
const mcpDiscovery = asRecord(mcp.discovery)
|
||||
|
|
@ -335,6 +350,29 @@ export function buildFormFromConfig(config: unknown): CoreConfigForm {
|
|||
? EMPTY_FORM.mcpDiscoveryUseRegex
|
||||
: asBool(mcpDiscovery.use_regex),
|
||||
mcpServers: mapMCPServers(mcp.servers),
|
||||
evolutionEnabled:
|
||||
evolution.enabled === undefined
|
||||
? EMPTY_FORM.evolutionEnabled
|
||||
: asBool(evolution.enabled),
|
||||
evolutionMode: asString(evolution.mode) || EMPTY_FORM.evolutionMode,
|
||||
evolutionStateDir:
|
||||
asString(evolution.state_dir) || EMPTY_FORM.evolutionStateDir,
|
||||
evolutionMinTaskCount: asNumberString(
|
||||
evolution.min_task_count,
|
||||
EMPTY_FORM.evolutionMinTaskCount,
|
||||
),
|
||||
evolutionMinSuccessRatio: asNumberString(
|
||||
evolution.min_success_ratio,
|
||||
EMPTY_FORM.evolutionMinSuccessRatio,
|
||||
),
|
||||
evolutionColdPathTrigger:
|
||||
asString(evolution.cold_path_trigger) ||
|
||||
EMPTY_FORM.evolutionColdPathTrigger,
|
||||
evolutionColdPathTimesText: Array.isArray(evolution.cold_path_times)
|
||||
? evolution.cold_path_times
|
||||
.filter((value): value is string => typeof value === "string")
|
||||
.join("\n")
|
||||
: EMPTY_FORM.evolutionColdPathTimesText,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -356,6 +394,24 @@ export function parseIntField(
|
|||
return value
|
||||
}
|
||||
|
||||
export function parseFloatField(
|
||||
rawValue: string,
|
||||
label: string,
|
||||
options: { min?: number; max?: number } = {},
|
||||
): number {
|
||||
const value = Number(rawValue)
|
||||
if (!Number.isFinite(value)) {
|
||||
throw new Error(`${label} must be a number.`)
|
||||
}
|
||||
if (options.min !== undefined && value < options.min) {
|
||||
throw new Error(`${label} must be >= ${options.min}.`)
|
||||
}
|
||||
if (options.max !== undefined && value > options.max) {
|
||||
throw new Error(`${label} must be <= ${options.max}.`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function parseCIDRText(raw: string): string[] {
|
||||
if (!raw.trim()) {
|
||||
return []
|
||||
|
|
|
|||
|
|
@ -802,6 +802,27 @@
|
|||
"allowed_cidrs": "Allowed Network CIDRs",
|
||||
"allowed_cidrs_hint": "Only clients from these CIDR ranges can access the service. One per line or comma-separated. Leave empty to allow all.",
|
||||
"allowed_cidrs_placeholder": "192.168.1.0/24\n10.0.0.0/8",
|
||||
"evolution_section_hint": "Let the agent learn from completed turns and prepare skill improvements.",
|
||||
"evolution_enabled": "Enable Evolution",
|
||||
"evolution_enabled_hint": "Record learning data for completed turns. Draft and apply modes can also generate skill updates.",
|
||||
"evolution_mode": "Evolution Mode",
|
||||
"evolution_mode_hint": "Observe only records data, Draft prepares candidate skills, Apply can write accepted drafts into workspace skills.",
|
||||
"evolution_mode_observe": "Observe",
|
||||
"evolution_mode_draft": "Draft",
|
||||
"evolution_mode_apply": "Apply",
|
||||
"evolution_state_dir": "State Directory",
|
||||
"evolution_state_dir_hint": "Optional directory for evolution state. Leave empty to use the workspace default.",
|
||||
"evolution_min_task_count": "Minimum Task Count",
|
||||
"evolution_min_task_count_hint": "Minimum related tasks required before a pattern can produce a draft.",
|
||||
"evolution_min_success_ratio": "Minimum Success Ratio",
|
||||
"evolution_min_success_ratio_hint": "Required success ratio for clustered tasks. Use a value greater than 0 and up to 1.",
|
||||
"evolution_cold_path_trigger": "Cold Path Trigger",
|
||||
"evolution_cold_path_trigger_hint": "Choose when draft generation runs for eligible learning records.",
|
||||
"evolution_cold_path_after_turn": "After each turn",
|
||||
"evolution_cold_path_scheduled": "Scheduled",
|
||||
"evolution_cold_path_manual": "Off",
|
||||
"evolution_cold_path_times": "Scheduled Times",
|
||||
"evolution_cold_path_times_hint": "Run times for scheduled cold-path processing. Enter one HH:MM value per line.",
|
||||
"mcp_section_hint": "Configure MCP servers without editing config.json manually.",
|
||||
"mcp_enabled": "Enable MCP",
|
||||
"mcp_enabled_hint": "Turn MCP server integration on or off.",
|
||||
|
|
@ -835,6 +856,7 @@
|
|||
"sections": {
|
||||
"agent": "Agent",
|
||||
"runtime": "Runtime",
|
||||
"evolution": "Evolution",
|
||||
"mcp": "MCP",
|
||||
"exec": "Run Commands",
|
||||
"cron": "Cron Tasks",
|
||||
|
|
|
|||
|
|
@ -700,9 +700,31 @@
|
|||
"allowed_cidrs": "CIDRs de Rede Permitidos",
|
||||
"allowed_cidrs_hint": "Apenas clientes destes intervalos CIDR podem acessar o serviço. Um por linha ou separados por vírgula. Deixe vazio para permitir todos.",
|
||||
"allowed_cidrs_placeholder": "192.168.1.0/24\n10.0.0.0/8",
|
||||
"evolution_section_hint": "Permite que o agente aprenda com turnos concluídos e prepare melhorias de skills.",
|
||||
"evolution_enabled": "Habilitar Evolução",
|
||||
"evolution_enabled_hint": "Registrar dados de aprendizado para turnos concluídos. Os modos Draft e Apply também podem gerar atualizações de skills.",
|
||||
"evolution_mode": "Modo de Evolução",
|
||||
"evolution_mode_hint": "Observe apenas registra dados, Draft prepara skills candidatas, Apply pode gravar drafts aceitos nas skills do workspace.",
|
||||
"evolution_mode_observe": "Observe",
|
||||
"evolution_mode_draft": "Draft",
|
||||
"evolution_mode_apply": "Apply",
|
||||
"evolution_state_dir": "Diretório de Estado",
|
||||
"evolution_state_dir_hint": "Diretório opcional para o estado de evolução. Deixe vazio para usar o padrão do workspace.",
|
||||
"evolution_min_task_count": "Contagem Mínima de Tarefas",
|
||||
"evolution_min_task_count_hint": "Número mínimo de tarefas relacionadas antes que um padrão possa produzir um draft.",
|
||||
"evolution_min_success_ratio": "Taxa Mínima de Sucesso",
|
||||
"evolution_min_success_ratio_hint": "Taxa de sucesso exigida para tarefas agrupadas. Use um valor maior que 0 e até 1.",
|
||||
"evolution_cold_path_trigger": "Acionador Cold Path",
|
||||
"evolution_cold_path_trigger_hint": "Escolha quando a geração de drafts roda para registros de aprendizado elegíveis.",
|
||||
"evolution_cold_path_after_turn": "Após cada turno",
|
||||
"evolution_cold_path_scheduled": "Agendado",
|
||||
"evolution_cold_path_manual": "Desligado",
|
||||
"evolution_cold_path_times": "Horários Agendados",
|
||||
"evolution_cold_path_times_hint": "Horários para o processamento cold-path agendado. Insira um valor HH:MM por linha.",
|
||||
"sections": {
|
||||
"agent": "Agente",
|
||||
"runtime": "Runtime",
|
||||
"evolution": "Evolução",
|
||||
"exec": "Execução de Comandos",
|
||||
"cron": "Tarefas Agendadas",
|
||||
"launcher": "Launcher",
|
||||
|
|
|
|||
|
|
@ -803,6 +803,27 @@
|
|||
"allowed_cidrs": "允许访问网段",
|
||||
"allowed_cidrs_hint": "仅允许这些 CIDR 网段的客户端访问服务。可按行或逗号分隔;留空表示允许所有来源",
|
||||
"allowed_cidrs_placeholder": "192.168.1.0/24\n10.0.0.0/8",
|
||||
"evolution_section_hint": "让 Agent 从已完成的回合中学习,并准备技能改进。",
|
||||
"evolution_enabled": "启用自进化",
|
||||
"evolution_enabled_hint": "记录已完成回合的学习数据。Draft 和 Apply 模式还可以生成技能更新。",
|
||||
"evolution_mode": "自进化模式",
|
||||
"evolution_mode_hint": "Observe 只记录数据,Draft 生成候选技能,Apply 可将接受的草稿写入工作区技能。",
|
||||
"evolution_mode_observe": "observe",
|
||||
"evolution_mode_draft": "draft",
|
||||
"evolution_mode_apply": "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": "通过可视化界面配置 MCP 服务器,无需手动编辑 config.json",
|
||||
"mcp_enabled": "启用 MCP",
|
||||
"mcp_enabled_hint": "开启或关闭 MCP 服务集成",
|
||||
|
|
@ -836,6 +857,7 @@
|
|||
"sections": {
|
||||
"agent": "智能体",
|
||||
"runtime": "运行时",
|
||||
"evolution": "自进化",
|
||||
"mcp": "MCP",
|
||||
"exec": "运行命令",
|
||||
"cron": "定时任务",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue