Merge remote-tracking branch 'upstream/main'
This commit is contained in:
commit
c299163ed3
150 changed files with 3333 additions and 2095 deletions
5
.github/workflows/build.yml
vendored
5
.github/workflows/build.yml
vendored
|
|
@ -16,10 +16,5 @@ jobs:
|
|||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: fmt
|
||||
run: |
|
||||
make fmt
|
||||
git diff --exit-code || (echo "::error::Code is not formatted. Run 'make fmt' and commit the changes." && exit 1)
|
||||
|
||||
- name: Build
|
||||
run: make build-all
|
||||
|
|
|
|||
39
.github/workflows/pr.yml
vendored
39
.github/workflows/pr.yml
vendored
|
|
@ -24,48 +24,9 @@ jobs:
|
|||
with:
|
||||
version: v2.10.1
|
||||
|
||||
# TODO: Remove once linter is properly configured
|
||||
fmt-check:
|
||||
name: Formatting
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Check formatting
|
||||
run: |
|
||||
make fmt
|
||||
git diff --exit-code || (echo "::error::Code is not formatted. Run 'make fmt' and commit the changes." && exit 1)
|
||||
|
||||
# TODO: Remove once linter is properly configured
|
||||
vet:
|
||||
name: Vet
|
||||
runs-on: ubuntu-latest
|
||||
needs: fmt-check
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Run go generate
|
||||
run: go generate ./...
|
||||
|
||||
- name: Run go vet
|
||||
run: go vet ./...
|
||||
|
||||
test:
|
||||
name: Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: fmt-check
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
|
|
|||
|
|
@ -47,7 +47,6 @@ linters:
|
|||
- godox
|
||||
- goprintffuncname
|
||||
- gosec
|
||||
- govet
|
||||
- ineffassign
|
||||
- lll
|
||||
- maintidx
|
||||
|
|
@ -160,12 +159,11 @@ issues:
|
|||
|
||||
formatters:
|
||||
enable:
|
||||
- gci
|
||||
- gofmt
|
||||
- gofumpt
|
||||
- goimports
|
||||
# TODO: Disabled, because they are failing at the moment, we should fix them and enable (step by step)
|
||||
# - gci
|
||||
# - gofmt
|
||||
# - gofumpt
|
||||
# - golines
|
||||
- golines
|
||||
settings:
|
||||
gci:
|
||||
sections:
|
||||
|
|
|
|||
302
CONTRIBUTING.md
Normal file
302
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
# Contributing to PicoClaw
|
||||
|
||||
Thank you for your interest in contributing to PicoClaw! This project is a community-driven effort to build the lightweight and versatile personal AI assistant. We welcome contributions of all kinds: bug fixes, features, documentation, translations, and testing.
|
||||
|
||||
PicoClaw itself was substantially developed with AI assistance — we embrace this approach and have built our contribution process around it.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Code of Conduct](#code-of-conduct)
|
||||
- [Ways to Contribute](#ways-to-contribute)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Development Setup](#development-setup)
|
||||
- [Making Changes](#making-changes)
|
||||
- [AI-Assisted Contributions](#ai-assisted-contributions)
|
||||
- [Pull Request Process](#pull-request-process)
|
||||
- [Branch Strategy](#branch-strategy)
|
||||
- [Code Review](#code-review)
|
||||
- [Communication](#communication)
|
||||
|
||||
---
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
We are committed to maintaining a welcoming and respectful community. Be kind, constructive, and assume good faith. Harassment or discrimination of any kind will not be tolerated.
|
||||
|
||||
---
|
||||
|
||||
## Ways to Contribute
|
||||
|
||||
- **Bug reports** — Open an issue using the bug report template.
|
||||
- **Feature requests** — Open an issue using the feature request template; discuss before implementing.
|
||||
- **Code** — Fix bugs or implement features. See the workflow below.
|
||||
- **Documentation** — Improve READMEs, docs, inline comments, or translations.
|
||||
- **Testing** — Run PicoClaw on new hardware, channels, or LLM providers and report your results.
|
||||
|
||||
For substantial new features, please open an issue first to discuss the design before writing code. This prevents wasted effort and ensures alignment with the project's direction.
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. **Fork** the repository on GitHub.
|
||||
2. **Clone** your fork locally:
|
||||
```bash
|
||||
git clone https://github.com/<your-username>/picoclaw.git
|
||||
cd picoclaw
|
||||
```
|
||||
3. Add the upstream remote:
|
||||
```bash
|
||||
git remote add upstream https://github.com/sipeed/picoclaw.git
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Go 1.25 or later
|
||||
- `make`
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
make build # Build binary (runs go generate first)
|
||||
make generate # Run go generate only
|
||||
make check # Full pre-commit check: deps + fmt + vet + test
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
make test # Run all tests
|
||||
go test -run TestName -v ./pkg/session/ # Run a single test
|
||||
go test -bench=. -benchmem -run='^$' ./... # Run benchmarks
|
||||
```
|
||||
|
||||
### Code Style
|
||||
|
||||
```bash
|
||||
make fmt # Format code
|
||||
make vet # Static analysis
|
||||
make lint # Full linter run
|
||||
```
|
||||
|
||||
All CI checks must pass before a PR can be merged. Run `make check` locally before pushing to catch issues early.
|
||||
|
||||
---
|
||||
|
||||
## Making Changes
|
||||
|
||||
### Branching
|
||||
|
||||
Always branch off `main` and target `main` in your PR. Never push directly to `main` or any `release/*` branch:
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
git pull upstream main
|
||||
git checkout -b your-feature-branch
|
||||
```
|
||||
|
||||
Use descriptive branch names, e.g. `fix/telegram-timeout`, `feat/ollama-provider`, `docs/contributing-guide`.
|
||||
|
||||
### Commits
|
||||
|
||||
- Write clear, concise commit messages in English.
|
||||
- Use the imperative mood: "Add retry logic" not "Added retry logic".
|
||||
- Reference the related issue when relevant: `Fix session leak (#123)`.
|
||||
- Keep commits focused. One logical change per commit is preferred.
|
||||
- For minor cleanups or typo fixes, squash them into a single commit before opening a PR.
|
||||
- Refer to https://www.conventionalcommits.org/zh-hans/v1.0.0/
|
||||
|
||||
### Keeping Up to Date
|
||||
|
||||
Rebase your branch onto upstream `main` before opening a PR:
|
||||
|
||||
```bash
|
||||
git fetch upstream
|
||||
git rebase upstream/main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## AI-Assisted Contributions
|
||||
|
||||
PicoClaw was built with substantial AI assistance, and we fully embrace AI-assisted development. However, contributors must understand their responsibilities when using AI tools.
|
||||
|
||||
### Disclosure Is Required
|
||||
|
||||
Every PR must disclose AI involvement using the PR template's **🤖 AI Code Generation** section. There are three levels:
|
||||
|
||||
| Level | Description |
|
||||
|---|---|
|
||||
| 🤖 Fully AI-generated | AI wrote the code; contributor reviewed and validated it |
|
||||
| 🛠️ Mostly AI-generated | AI produced the draft; contributor made significant modifications |
|
||||
| 👨💻 Mostly Human-written | Contributor led; AI provided suggestions or none at all |
|
||||
|
||||
Honest disclosure is expected. There is no stigma attached to any level — what matters is the quality of the contribution.
|
||||
|
||||
### You Are Responsible for What You Submit
|
||||
|
||||
Using AI to generate code does not reduce your responsibility as the contributor. Before opening a PR with AI-generated code, you must:
|
||||
|
||||
- **Read and understand** every line of the generated code.
|
||||
- **Test it** in a real environment (see the Test Environment section of the PR template).
|
||||
- **Check for security issues** — AI models can generate subtly insecure code (e.g., path traversal, injection, credential exposure). Review carefully.
|
||||
- **Verify correctness** — AI-generated logic can be plausible-sounding but wrong. Validate the behavior, not just the syntax.
|
||||
|
||||
PRs where it is clear the contributor has not read or tested the AI-generated code will be closed without review.
|
||||
|
||||
### AI-Generated Code Quality Standards
|
||||
|
||||
AI-generated contributions are held to the **same quality bar** as human-written code:
|
||||
|
||||
- It must pass all CI checks (`make check`).
|
||||
- It must be idiomatic Go and consistent with the existing codebase style.
|
||||
- It must not introduce unnecessary abstractions, dead code, or over-engineering.
|
||||
- It must include or update tests where appropriate.
|
||||
|
||||
### Security Review
|
||||
|
||||
AI-generated code requires extra security scrutiny. Pay special attention to:
|
||||
|
||||
- File path handling and sandbox escapes (see commit `244eb0b` for a real example)
|
||||
- External input validation in channel handlers and tool implementations
|
||||
- Credential or secret handling
|
||||
- Command execution (`exec.Command`, shell invocations)
|
||||
|
||||
If you are unsure whether a piece of AI-generated code is safe, say so in the PR — reviewers will help.
|
||||
|
||||
---
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
### Before Opening a PR
|
||||
|
||||
- [ ] Run `make check` and ensure it passes locally.
|
||||
- [ ] Fill in the PR template completely, including the AI disclosure section.
|
||||
- [ ] Link any related issue(s) in the PR description.
|
||||
- [ ] Keep the PR focused. Avoid bundling unrelated changes together.
|
||||
|
||||
### PR Template Sections
|
||||
|
||||
The PR template asks for:
|
||||
|
||||
- **Description** — What does this change do and why?
|
||||
- **Type of Change** — Bug fix, feature, docs, or refactor.
|
||||
- **AI Code Generation** — Disclosure of AI involvement (required).
|
||||
- **Related Issue** — Link to the issue this addresses.
|
||||
- **Technical Context** — Reference URLs and reasoning (skip for pure docs PRs).
|
||||
- **Test Environment** — Hardware, OS, model/provider, and channels used for testing.
|
||||
- **Evidence** — Optional logs or screenshots demonstrating the change works.
|
||||
- **Checklist** — Self-review confirmation.
|
||||
|
||||
### PR Size
|
||||
|
||||
Prefer small, reviewable PRs. A PR that changes 200 lines across 5 files is much easier to review than one that changes 2000 lines across 30 files. If your feature is large, consider splitting it into a series of smaller, logically complete PRs.
|
||||
|
||||
---
|
||||
|
||||
## Branch Strategy
|
||||
|
||||
### Long-Lived Branches
|
||||
|
||||
- **`main`** — the active development branch. All feature PRs target `main`. The branch is protected: direct pushes are not permitted, and at least one maintainer approval is required before merging.
|
||||
- **`release/x.y`** — stable release branches, cut from `main` when a version is ready to ship. These branches are more strictly protected than `main`.
|
||||
|
||||
### Requirements to Merge into `main`
|
||||
|
||||
A PR can only be merged when all of the following are satisfied:
|
||||
|
||||
1. **CI passes** — All GitHub Actions workflows (lint, test, build) must be green.
|
||||
2. **Reviewer approval** — At least one maintainer has approved the PR.
|
||||
3. **No unresolved review comments** — All review threads must be resolved.
|
||||
4. **PR template is complete** — Including AI disclosure and test environment.
|
||||
|
||||
### Who Can Merge
|
||||
|
||||
Only maintainers can merge PRs. Contributors cannot merge their own PRs, even if they have write access.
|
||||
|
||||
### Merge Strategy
|
||||
|
||||
We use **squash merge** for most PRs to keep the `main` history clean and readable. Each merged PR becomes a single commit referencing the PR number, e.g.:
|
||||
|
||||
```
|
||||
feat: Add Ollama provider support (#491)
|
||||
```
|
||||
|
||||
If a PR consists of multiple independent, well-separated commits that tell a clear story, a regular merge may be used at the maintainer's discretion.
|
||||
|
||||
### Release Branches
|
||||
|
||||
When a version is ready, maintainers cut a `release/x.y` branch from `main`. After that point:
|
||||
|
||||
- **New features are not backported.** The release branch receives no new functionality after it is cut.
|
||||
- **Security fixes and critical bug fixes are cherry-picked.** If a fix in `main` qualifies (security vulnerability, data loss, crash), maintainers will cherry-pick the relevant commit(s) onto the affected `release/x.y` branch and issue a patch release.
|
||||
|
||||
If you believe a fix in `main` should be backported to a release branch, note it in the PR description or open a separate issue. The decision rests with the maintainers.
|
||||
|
||||
Release branches have stricter protections than `main` and are never directly pushed to under any circumstances.
|
||||
|
||||
---
|
||||
|
||||
## Code Review
|
||||
|
||||
### For Contributors
|
||||
|
||||
- Respond to review comments within a reasonable time. If you need more time, say so.
|
||||
- When you update a PR in response to feedback, briefly note what changed (e.g., "Updated to use `sync.RWMutex` as suggested").
|
||||
- If you disagree with feedback, engage respectfully. Explain your reasoning; reviewers can be wrong too.
|
||||
- Do not force-push after a review has started — it makes it harder for reviewers to see what changed. Use additional commits instead; the maintainer will squash on merge.
|
||||
|
||||
### For Reviewers
|
||||
|
||||
Review for:
|
||||
|
||||
1. **Correctness** — Does the code do what it claims? Are there edge cases?
|
||||
2. **Security** — Especially for AI-generated code, tool implementations, and channel handlers.
|
||||
3. **Architecture** — Is the approach consistent with the existing design?
|
||||
4. **Simplicity** — Is there a simpler solution? Does this add unnecessary complexity?
|
||||
5. **Tests** — Are the changes covered by tests? Are existing tests still meaningful?
|
||||
|
||||
Be constructive and specific. "This could have a race condition if two goroutines call this concurrently — consider using a mutex here" is better than "this looks wrong".
|
||||
|
||||
|
||||
### Reviewer List
|
||||
Once your PR is submitted, you can reach out to the assigned reviewers listed in the following table.
|
||||
|
||||
|Function| Reviewer|
|
||||
|--- |--- |
|
||||
|Provider|@yinwm |
|
||||
|Channel |@yinwm |
|
||||
|Agent |@lxowalle|
|
||||
|Tools |@lxowalle|
|
||||
|SKill ||
|
||||
|MCP ||
|
||||
|Optimization|@lxowalle|
|
||||
|Security||
|
||||
|AI CI |@imguoguo|
|
||||
|UX ||
|
||||
|Document||
|
||||
|
||||
---
|
||||
|
||||
## Communication
|
||||
|
||||
- **GitHub Issues** — Bug reports, feature requests, design discussions.
|
||||
- **GitHub Discussions** — General questions, ideas, community conversation.
|
||||
- **Pull Request comments** — Code-specific feedback.
|
||||
- **Wechat&Discord** — We will invite you when you have at least one merged PR
|
||||
|
||||
When in doubt, open an issue before writing code. It costs little and prevents wasted effort.
|
||||
|
||||
---
|
||||
|
||||
## A Note on the Project's AI-Driven Origin
|
||||
|
||||
PicoClaw's architecture was substantially designed and implemented with AI assistance, guided by human oversight. If you find something that looks odd or over-engineered, it may be an artifact of that process — opening an issue to discuss it is always welcome.
|
||||
|
||||
We believe AI-assisted development done responsibly produces great results. We also believe humans must remain accountable for what they ship. These two beliefs are not in conflict.
|
||||
|
||||
Thank you for contributing!
|
||||
303
CONTRIBUTING.zh.md
Normal file
303
CONTRIBUTING.zh.md
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
# 参与贡献 PicoClaw
|
||||
|
||||
感谢你对 PicoClaw 的关注!本项目是一个社区驱动的开源项目,目标是构建 轻量灵活,人人可用 的个人AI助手。我们欢迎一切形式的贡献:Bug 修复、新功能、文档、翻译和测试。
|
||||
|
||||
PicoClaw 本身在很大程度上是借助 AI 辅助开发的——我们拥抱这种方式,并围绕它构建了贡献流程。
|
||||
|
||||
## 目录
|
||||
|
||||
- [行为准则](#行为准则)
|
||||
- [贡献方式](#贡献方式)
|
||||
- [快速开始](#快速开始)
|
||||
- [开发环境配置](#开发环境配置)
|
||||
- [提交修改](#提交修改)
|
||||
- [AI 辅助贡献](#ai-辅助贡献)
|
||||
- [Pull Request 流程](#pull-request-流程)
|
||||
- [分支策略](#分支策略)
|
||||
- [代码审查](#代码审查)
|
||||
- [沟通渠道](#沟通渠道)
|
||||
|
||||
---
|
||||
|
||||
## 行为准则
|
||||
|
||||
我们致力于维护一个友好、互相尊重的社区环境。请保持善意、建设性的态度,并善意地理解他人。任何形式的骚扰或歧视均不被接受。
|
||||
|
||||
---
|
||||
|
||||
## 贡献方式
|
||||
|
||||
- **Bug 反馈** — 使用 Bug 报告模板提交 Issue。
|
||||
- **功能建议** — 使用功能请求模板提交 Issue,建议在开始实现前先进行讨论。
|
||||
- **代码贡献** — 修复 Bug 或实现新功能,参见下方工作流程。
|
||||
- **文档改进** — 完善 README、文档、代码注释或翻译。
|
||||
- **测试与验证** — 在新硬件、新渠道或新 LLM 提供商上运行 PicoClaw 并反馈结果。
|
||||
|
||||
对于较大的新功能,请先提交 Issue 讨论设计方案,再动手写代码。这能避免无效投入,也确保与项目方向保持一致。
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
1. 在 GitHub 上 **Fork** 本仓库。
|
||||
2. 将你的 Fork **克隆**到本地:
|
||||
```bash
|
||||
git clone https://github.com/<你的用户名>/picoclaw.git
|
||||
cd picoclaw
|
||||
```
|
||||
3. 添加上游远程仓库:
|
||||
```bash
|
||||
git remote add upstream https://github.com/sipeed/picoclaw.git
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 开发环境配置
|
||||
|
||||
### 前置依赖
|
||||
|
||||
- Go 1.25 或更高版本
|
||||
- `make`
|
||||
|
||||
### 构建
|
||||
|
||||
```bash
|
||||
make build # 构建二进制文件(会先执行 go generate)
|
||||
make generate # 仅执行 go generate
|
||||
make check # 完整的提交前检查:deps + fmt + vet + test
|
||||
```
|
||||
|
||||
### 运行测试
|
||||
|
||||
```bash
|
||||
make test # 运行所有测试
|
||||
go test -run TestName -v ./pkg/session/ # 运行单个测试
|
||||
go test -bench=. -benchmem -run='^$' ./... # 运行基准测试
|
||||
```
|
||||
|
||||
### 代码风格
|
||||
|
||||
```bash
|
||||
make fmt # 格式化代码
|
||||
make vet # 静态分析
|
||||
make lint # 完整的 lint 检查
|
||||
```
|
||||
|
||||
所有 CI 检查通过后 PR 才能被合并。推送代码前请先在本地运行 `make check`,提前发现问题。
|
||||
|
||||
---
|
||||
|
||||
## 提交修改
|
||||
|
||||
### 分支管理
|
||||
|
||||
始终从 `main` 分支切出,并在 PR 中以 `main` 为目标分支。不要直接向 `main` 或任何 `release/*` 分支推送代码:
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
git pull upstream main
|
||||
git checkout -b 你的功能分支名
|
||||
```
|
||||
|
||||
请使用描述性的分支名,例如:`fix/telegram-timeout`、`feat/ollama-provider`、`docs/contributing-guide`。
|
||||
|
||||
### Commit 规范
|
||||
|
||||
- 使用英文撰写清晰、简洁的 commit 信息。
|
||||
- 使用祈使句:写 "Add retry logic",而不是 "Added retry logic"。
|
||||
- 有关联 Issue 时请引用:`Fix session leak (#123)`。
|
||||
- 保持 commit 专注,每个 commit 只做一件事。
|
||||
- 对于小的清理或拼写修正,提 PR 前请将其合并为一个 commit。
|
||||
- 按照 https://www.conventionalcommits.org/zh-hans/v1.0.0/ 规范来撰写
|
||||
|
||||
### 保持与上游同步
|
||||
|
||||
提 PR 前,请将你的分支变基到上游 `main`:
|
||||
|
||||
```bash
|
||||
git fetch upstream
|
||||
git rebase upstream/main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## AI 辅助贡献
|
||||
|
||||
PicoClaw 在很大程度上借助 AI 辅助开发,我们完全拥抱这种开发方式。但贡献者必须清楚地了解自己在使用 AI 工具时所承担的责任。
|
||||
|
||||
### 必须披露 AI 使用情况
|
||||
|
||||
每个 PR 都必须通过 PR 模板中的 **🤖 AI 代码生成** 部分披露 AI 参与情况,共分三个级别:
|
||||
|
||||
| 级别 | 说明 |
|
||||
|---|---|
|
||||
| 🤖 完全由 AI 生成 | AI 编写代码,贡献者负责审查和验证 |
|
||||
| 🛠️ 主要由 AI 生成 | AI 起草,贡献者做了较大修改 |
|
||||
| 👨💻 主要由人工编写 | 贡献者主导,AI 仅提供辅助或未使用 AI |
|
||||
|
||||
我们期望你诚实填写。三种级别均可接受,没有任何歧视——重要的是贡献的质量。
|
||||
|
||||
### 你对提交的代码负全责
|
||||
|
||||
使用 AI 生成代码并不能减轻你作为贡献者的责任。在提交含有 AI 生成代码的 PR 之前,你必须:
|
||||
|
||||
- **逐行阅读并理解**生成的代码。
|
||||
- **在真实环境中测试**(参见 PR 模板中的测试环境部分)。
|
||||
- **检查安全问题** — AI 模型可能生成存在安全隐患的代码(如路径穿越、注入攻击、凭据泄露等),请仔细审查。
|
||||
- **验证正确性** — AI 生成的逻辑可能听起来合理但实际上是错误的,请验证行为,而不仅仅是语法。
|
||||
|
||||
如果明显可以看出贡献者没有阅读或测试 AI 生成的代码,该 PR 将被直接关闭,不予审查。
|
||||
|
||||
### AI 生成代码的质量标准
|
||||
|
||||
AI 生成的代码与人工编写的代码遵循**相同的质量要求**:
|
||||
|
||||
- 必须通过所有 CI 检查(`make check`)。
|
||||
- 必须符合 Go 惯用写法,并与现有代码库的风格保持一致。
|
||||
- 不得引入不必要的抽象、死代码或过度设计。
|
||||
- 须在适当的地方包含或更新测试。
|
||||
|
||||
### 安全审查
|
||||
|
||||
AI 生成的代码需要格外仔细的安全审查。请特别关注以下方面:
|
||||
|
||||
- 文件路径处理与沙箱逃逸(项目历史中的 commit `244eb0b` 就是真实案例)
|
||||
- channel 处理器和 tool 实现中的外部输入校验
|
||||
- 凭据或密钥的处理
|
||||
- 命令执行(`exec.Command`、shell 调用等)
|
||||
|
||||
如果你不确定某段 AI 生成代码是否安全,请在 PR 中说明——审查者会帮助判断。
|
||||
|
||||
---
|
||||
|
||||
## Pull Request 流程
|
||||
|
||||
### 提 PR 前的检查
|
||||
|
||||
- [ ] 在本地运行 `make check` 并确认通过。
|
||||
- [ ] 完整填写 PR 模板,包括 AI 披露部分。
|
||||
- [ ] 在 PR 描述中关联相关 Issue。
|
||||
- [ ] 保持 PR 专注,避免将不相关的修改混在一起。
|
||||
|
||||
### PR 模板各部分说明
|
||||
|
||||
PR 模板要求填写:
|
||||
|
||||
- **描述** — 这个改动做了什么,为什么要做?
|
||||
- **变更类型** — Bug 修复、新功能、文档或重构。
|
||||
- **AI 代码生成** — AI 参与情况披露(必填)。
|
||||
- **关联 Issue** — 此 PR 解决的 Issue 链接。
|
||||
- **技术背景** — 参考链接和设计理由(纯文档类 PR 可跳过)。
|
||||
- **测试环境** — 用于测试的硬件、操作系统、模型/提供商和渠道。
|
||||
- **验证证据** — 可选的日志或截图,用于证明改动有效。
|
||||
- **检查清单** — 自我审查确认。
|
||||
|
||||
### PR 规模
|
||||
|
||||
请尽量提交小而易于审查的 PR。一个涉及 5 个文件共 200 行改动的 PR,远比涉及 30 个文件共 2000 行改动的 PR 容易审查。如果你的功能较大,可以考虑将其拆分为一系列逻辑完整的小 PR。
|
||||
|
||||
---
|
||||
|
||||
## 分支策略
|
||||
|
||||
### 长期分支
|
||||
|
||||
- **`main`** — 活跃开发分支。所有功能 PR 均以 `main` 为目标。该分支受保护:禁止直接推送,合并前必须获得至少一名维护者的批准。
|
||||
- **`release/x.y`** — 稳定发布分支,在某个版本准备发布时从 `main` 切出。这些分支的保护级别高于 `main`。
|
||||
|
||||
### 合并到 `main` 的前提条件
|
||||
|
||||
PR 必须同时满足以下所有条件,才能被合并:
|
||||
|
||||
1. **CI 全部通过** — 所有 GitHub Actions 工作流(lint、test、build)均为绿色。
|
||||
2. **获得审查者批准** — 至少一名维护者已批准该 PR。
|
||||
3. **无未解决的审查意见** — 所有审查讨论线程均已关闭。
|
||||
4. **PR 模板填写完整** — 包括 AI 披露和测试环境信息。
|
||||
|
||||
### 谁可以合并
|
||||
|
||||
只有维护者才能合并 PR。贡献者不能合并自己的 PR,即使拥有写权限也不行。
|
||||
|
||||
### 合并策略
|
||||
|
||||
为保持 `main` 历史清晰可读,我们对大多数 PR 使用 **Squash Merge**。每个合并的 PR 变为一个包含 PR 编号的单独 commit,例如:
|
||||
|
||||
```
|
||||
feat: Add Ollama provider support (#491)
|
||||
```
|
||||
|
||||
如果一个 PR 包含多个独立、结构清晰、能讲述完整故事的 commit,维护者可视情况使用普通 merge。
|
||||
|
||||
### Release 分支
|
||||
|
||||
当某个版本准备就绪时,维护者会从 `main` 切出 `release/x.y` 分支。此后:
|
||||
|
||||
- **新功能不会被回溯(backport)。** Release 分支切出后,不再接收任何新功能。
|
||||
- **安全修复和关键 Bug 修复会被 cherry-pick 进来。** 若 `main` 上的某个修复属于安全漏洞、数据丢失或崩溃类问题,维护者会将相关 commit cherry-pick 到受影响的 `release/x.y` 分支,并发布补丁版本。
|
||||
|
||||
如果你认为 `main` 上的某个修复应该被回溯到某个 release 分支,请在 PR 描述中注明,或单独开一个 Issue 说明。最终决定由维护者做出。
|
||||
|
||||
Release 分支的保护级别高于 `main`,在任何情况下均不允许直接推送。
|
||||
|
||||
---
|
||||
|
||||
## 代码审查
|
||||
|
||||
### 对贡献者的建议
|
||||
|
||||
- 在合理时间内回复审查意见。如果需要更多时间,请告知。
|
||||
- 更新 PR 以响应反馈时,简要说明改动内容(例如:"按建议改用了 `sync.RWMutex`")。
|
||||
- 如果你不同意某条反馈,请礼貌地阐述你的理由——审查者也可能有判断失误的时候。
|
||||
- 审查开始后请不要 force push——这会让审查者难以追踪变化。请使用额外的 commit,维护者在合并时会进行 squash。
|
||||
|
||||
### 对审查者的建议
|
||||
|
||||
审查重点:
|
||||
|
||||
1. **正确性** — 代码是否实现了其声称的功能?是否存在边界情况?
|
||||
2. **安全性** — 对 AI 生成代码、tool 实现和 channel 处理器尤其需要关注。
|
||||
3. **架构** — 实现方式是否与现有设计一致?
|
||||
4. **简洁性** — 是否有更简单的方案?是否引入了不必要的复杂度?
|
||||
5. **测试** — 改动是否有测试覆盖?现有测试是否仍然有意义?
|
||||
|
||||
请给出建设性且具体的反馈。"如果两个 goroutine 同时调用这个函数可能会有竞态条件,建议在这里加一个 mutex" 远比 "这里看起来有问题" 更有帮助。
|
||||
|
||||
### 审查者列表
|
||||
提交对应PR后,可以参考下表联系对应的审查人员沟通
|
||||
|
||||
|Function| Reviewer|
|
||||
|--- |--- |
|
||||
|Provider|@yinwm |
|
||||
|Channel |@yinwm |
|
||||
|Agent |@lxowalle|
|
||||
|Tools |@lxowalle|
|
||||
|SKill ||
|
||||
|MCP ||
|
||||
|Optimization|@lxowalle|
|
||||
|Security||
|
||||
|AI CI |@imguoguo|
|
||||
|UX ||
|
||||
|Document||
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 沟通渠道
|
||||
|
||||
- **GitHub Issues** — Bug 报告、功能建议、设计讨论。
|
||||
- **GitHub Discussions** — 一般性问题、想法交流、社区讨论。
|
||||
- **Pull Request 评论** — 与具体代码相关的反馈。
|
||||
- **Wechat&Discord** — 当你有至少一个已合并的PR后,我们会邀请你加入开发者交流群
|
||||
|
||||
有疑问时,请先开 Issue 讨论,再动手写代码。这几乎没有成本,却能避免大量无效投入。
|
||||
|
||||
---
|
||||
|
||||
## 关于本项目的 AI 驱动起源
|
||||
|
||||
PicoClaw 的架构在人工监督下,经由 AI 辅助完成了大量设计和实现工作。如果你发现某处看起来奇怪或过度设计,这可能是该过程留下的痕迹——欢迎提 Issue 讨论。
|
||||
|
||||
我们相信,负责任地使用 AI 辅助开发能产生优秀的成果。我们同样相信,人类必须对自己提交的内容负责。这两点并不矛盾。
|
||||
|
||||
感谢你的贡献!
|
||||
11
Makefile
11
Makefile
|
|
@ -17,6 +17,9 @@ LDFLAGS=-ldflags "-X main.version=$(VERSION) -X main.gitCommit=$(GIT_COMMIT) -X
|
|||
GO?=go
|
||||
GOFLAGS?=-v -tags stdjson
|
||||
|
||||
# Golangci-lint
|
||||
GOLANGCI_LINT?=golangci-lint
|
||||
|
||||
# Installation
|
||||
INSTALL_PREFIX?=$(HOME)/.local
|
||||
INSTALL_BIN_DIR=$(INSTALL_PREFIX)/bin
|
||||
|
|
@ -126,13 +129,17 @@ clean:
|
|||
vet:
|
||||
@$(GO) vet ./...
|
||||
|
||||
## fmt: Format Go code
|
||||
## test: Test Go code
|
||||
test:
|
||||
@$(GO) test ./...
|
||||
|
||||
## fmt: Format Go code
|
||||
fmt:
|
||||
@$(GO) fmt ./...
|
||||
@$(GOLANGCI_LINT) fmt
|
||||
|
||||
## lint: Run linters
|
||||
lint:
|
||||
@$(GOLANGCI_LINT) run
|
||||
|
||||
## deps: Download dependencies
|
||||
deps:
|
||||
|
|
|
|||
31
README.fr.md
31
README.fr.md
|
|
@ -212,19 +212,24 @@ picoclaw onboard
|
|||
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt4",
|
||||
"model": "openai/gpt-5.2",
|
||||
"api_key": "sk-your-openai-key",
|
||||
"api_base": "https://api.openai.com/v1"
|
||||
}
|
||||
],
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "~/.picoclaw/workspace",
|
||||
"model": "glm-4.7",
|
||||
"max_tokens": 8192,
|
||||
"temperature": 0.7,
|
||||
"max_tool_iterations": 20
|
||||
"model": "gpt4"
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"api_key": "xxx",
|
||||
"api_base": "https://openrouter.ai/api/v1"
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"token": "VOTRE_TOKEN_BOT",
|
||||
"allow_from": ["VOTRE_USER_ID"]
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
|
|
@ -290,7 +295,7 @@ Discutez avec votre PicoClaw via Telegram, Discord, DingTalk, LINE ou WeCom
|
|||
"telegram": {
|
||||
"enabled": true,
|
||||
"token": "VOTRE_TOKEN_BOT",
|
||||
"allowFrom": ["VOTRE_USER_ID"]
|
||||
"allow_from": ["VOTRE_USER_ID"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -333,7 +338,7 @@ picoclaw gateway
|
|||
"discord": {
|
||||
"enabled": true,
|
||||
"token": "VOTRE_TOKEN_BOT",
|
||||
"allowFrom": ["VOTRE_USER_ID"]
|
||||
"allow_from": ["VOTRE_USER_ID"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -765,6 +770,8 @@ Le sous-agent a accès aux outils (message, web_search, etc.) et peut communique
|
|||
| `anthropic` (À tester) | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
|
||||
| `openai` (À tester) | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
|
||||
| `deepseek` (À tester) | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
|
||||
| `qwen` | LLM (Alibaba Qwen) | [dashscope.aliyuncs.com](https://dashscope.aliyuncs.com/compatible-mode/v1) |
|
||||
| `cerebras` | LLM (Cerebras) | [cerebras.ai](https://api.cerebras.ai/v1) |
|
||||
| `groq` | LLM + **Transcription vocale** (Whisper) | [console.groq.com](https://console.groq.com) |
|
||||
|
||||
<details>
|
||||
|
|
@ -1087,7 +1094,7 @@ Ajoutez la clé dans `~/.picoclaw/config.json` si vous utilisez Brave :
|
|||
"tools": {
|
||||
"web": {
|
||||
"brave": {
|
||||
"enabled": true,
|
||||
"enabled": false,
|
||||
"api_key": "VOTRE_CLE_API_BRAVE",
|
||||
"max_results": 5
|
||||
},
|
||||
|
|
|
|||
61
README.ja.md
61
README.ja.md
|
|
@ -178,35 +178,25 @@ picoclaw onboard
|
|||
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt4",
|
||||
"model": "openai/gpt-5.2",
|
||||
"api_key": "sk-your-openai-key",
|
||||
"api_base": "https://api.openai.com/v1"
|
||||
}
|
||||
],
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "~/.picoclaw/workspace",
|
||||
"model": "glm-4.7",
|
||||
"max_tokens": 8192,
|
||||
"temperature": 0.7,
|
||||
"max_tool_iterations": 20
|
||||
"model": "gpt4"
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"api_key": "xxx",
|
||||
"api_base": "https://openrouter.ai/api/v1"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"web": {
|
||||
"search": {
|
||||
"api_key": "YOUR_BRAVE_API_KEY",
|
||||
"max_results": 5
|
||||
}
|
||||
},
|
||||
"cron": {
|
||||
"exec_timeout_minutes": 5
|
||||
}
|
||||
},
|
||||
"heartbeat": {
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"interval": 30
|
||||
"token": "YOUR_TELEGRAM_BOT_TOKEN",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
@ -218,7 +208,7 @@ picoclaw onboard
|
|||
|
||||
> **注意**: 完全な設定テンプレートは `config.example.json` を参照してください。
|
||||
|
||||
**3. チャット**
|
||||
**4. チャット**
|
||||
|
||||
```bash
|
||||
picoclaw agent -m "What is 2+2?"
|
||||
|
|
@ -768,10 +758,10 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
|
|||
},
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"apiKey": "sk-or-v1-xxx"
|
||||
"api_key": "sk-or-v1-xxx"
|
||||
},
|
||||
"groq": {
|
||||
"apiKey": "gsk_xxx"
|
||||
"api_key": "gsk_xxx"
|
||||
}
|
||||
},
|
||||
"channels": {
|
||||
|
|
@ -790,17 +780,17 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
|
|||
},
|
||||
"feishu": {
|
||||
"enabled": false,
|
||||
"appId": "cli_xxx",
|
||||
"appSecret": "xxx",
|
||||
"encryptKey": "",
|
||||
"verificationToken": "",
|
||||
"app_id": "cli_xxx",
|
||||
"app_secret": "xxx",
|
||||
"encrypt_key": "",
|
||||
"verification_token": "",
|
||||
"allow_from": []
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"web": {
|
||||
"search": {
|
||||
"apiKey": "BSA..."
|
||||
"api_key": "BSA..."
|
||||
}
|
||||
},
|
||||
"cron": {
|
||||
|
|
@ -1005,9 +995,14 @@ Web 検索を有効にするには:
|
|||
{
|
||||
"tools": {
|
||||
"web": {
|
||||
"search": {
|
||||
"brave": {
|
||||
"enabled": true,
|
||||
"api_key": "YOUR_BRAVE_API_KEY",
|
||||
"max_results": 5
|
||||
},
|
||||
"duckduckgo": {
|
||||
"enabled": true,
|
||||
"max_results": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
10
README.md
10
README.md
|
|
@ -247,7 +247,7 @@ picoclaw onboard
|
|||
}
|
||||
```
|
||||
|
||||
> **New**: The `model_list` configuration format allows zero-code provider addition. See [Model Configuration](#-model-configuration) for details.
|
||||
> **New**: The `model_list` configuration format allows zero-code provider addition. See [Model Configuration](#model-configuration-model_list) for details.
|
||||
|
||||
**3. Get API Keys**
|
||||
|
||||
|
|
@ -422,7 +422,7 @@ picoclaw gateway
|
|||
}
|
||||
```
|
||||
|
||||
> Set `allow_from` to empty to allow all users, or specify QQ numbers to restrict access.
|
||||
> Set `allow_from` to empty to allow all users, or specify DingTalk user IDs to restrict access.
|
||||
|
||||
**3. Run**
|
||||
|
||||
|
|
@ -871,15 +871,15 @@ This design also enables **multi-agent support** with flexible provider selectio
|
|||
}
|
||||
```
|
||||
|
||||
**Anthropic (with OAuth)**
|
||||
**Anthropic (with API key)**
|
||||
```json
|
||||
{
|
||||
"model_name": "claude-sonnet-4.6",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"auth_method": "oauth"
|
||||
"api_key": "sk-ant-your-key"
|
||||
}
|
||||
```
|
||||
> Run `picoclaw auth login --provider anthropic` to set up OAuth credentials.
|
||||
> Run `picoclaw auth login --provider anthropic` to paste your API token.
|
||||
|
||||
**Ollama (local)**
|
||||
```json
|
||||
|
|
|
|||
|
|
@ -217,19 +217,17 @@ picoclaw onboard
|
|||
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt4",
|
||||
"model": "openai/gpt-5.2",
|
||||
"api_key": "sk-your-openai-key",
|
||||
"api_base": "https://api.openai.com/v1"
|
||||
}
|
||||
],
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "~/.picoclaw/workspace",
|
||||
"model": "glm-4.7",
|
||||
"max_tokens": 8192,
|
||||
"temperature": 0.7,
|
||||
"max_tool_iterations": 20
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"api_key": "xxx",
|
||||
"api_base": "https://openrouter.ai/api/v1"
|
||||
"model": "gpt4"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
|
|
@ -295,7 +293,7 @@ Converse com seu PicoClaw via Telegram, Discord, DingTalk, LINE ou WeCom.
|
|||
"telegram": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_BOT_TOKEN",
|
||||
"allowFrom": ["YOUR_USER_ID"]
|
||||
"allow_from": ["YOUR_USER_ID"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -338,7 +336,7 @@ picoclaw gateway
|
|||
"discord": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_BOT_TOKEN",
|
||||
"allowFrom": ["YOUR_USER_ID"]
|
||||
"allow_from": ["YOUR_USER_ID"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -770,6 +768,8 @@ O subagente tem acesso às ferramentas (message, web_search, etc.) e pode se com
|
|||
| `anthropic` (Em teste) | LLM (Claude direto) | [console.anthropic.com](https://console.anthropic.com) |
|
||||
| `openai` (Em teste) | LLM (GPT direto) | [platform.openai.com](https://platform.openai.com) |
|
||||
| `deepseek` (Em teste) | LLM (DeepSeek direto) | [platform.deepseek.com](https://platform.deepseek.com) |
|
||||
| `qwen` | Alibaba Qwen | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
|
||||
| `cerebras` | Cerebras | [cerebras.ai](https://cerebras.ai) |
|
||||
| `groq` | LLM + **Transcrição de voz** (Whisper) | [console.groq.com](https://console.groq.com) |
|
||||
|
||||
<details>
|
||||
|
|
@ -1092,7 +1092,7 @@ Adicione a key em `~/.picoclaw/config.json` se usar o Brave:
|
|||
"tools": {
|
||||
"web": {
|
||||
"brave": {
|
||||
"enabled": true,
|
||||
"enabled": false,
|
||||
"api_key": "YOUR_BRAVE_API_KEY",
|
||||
"max_results": 5
|
||||
},
|
||||
|
|
@ -1123,3 +1123,4 @@ Isso acontece quando outra instância do bot está em execução. Certifique-se
|
|||
| **Zhipu** | 200K tokens/mês | Melhor para usuários chineses |
|
||||
| **Brave Search** | 2000 consultas/mês | Funcionalidade de busca web |
|
||||
| **Groq** | Plano gratuito disponível | Inferência ultra-rápida (Llama, Mixtral) |
|
||||
| **Cerebras** | Plano gratuito disponível | Inferência ultra-rápida (Llama 3.3 70B) |
|
||||
|
|
|
|||
38
README.vi.md
38
README.vi.md
|
|
@ -193,32 +193,24 @@ picoclaw onboard
|
|||
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt4",
|
||||
"model": "openai/gpt-5.2",
|
||||
"api_key": "sk-your-openai-key",
|
||||
"api_base": "https://api.openai.com/v1"
|
||||
}
|
||||
],
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "~/.picoclaw/workspace",
|
||||
"model": "glm-4.7",
|
||||
"max_tokens": 8192,
|
||||
"temperature": 0.7,
|
||||
"max_tool_iterations": 20
|
||||
"model": "gpt4"
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"api_key": "xxx",
|
||||
"api_base": "https://openrouter.ai/api/v1"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"web": {
|
||||
"brave": {
|
||||
"enabled": false,
|
||||
"api_key": "YOUR_BRAVE_API_KEY",
|
||||
"max_results": 5
|
||||
},
|
||||
"duckduckgo": {
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"max_results": 5
|
||||
}
|
||||
"token": "YOUR_TELEGRAM_BOT_TOKEN",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -747,6 +739,8 @@ Subagent có quyền truy cập các công cụ (message, web_search, v.v.) và
|
|||
| `openai` (Đang thử nghiệm) | LLM (GPT trực tiếp) | [platform.openai.com](https://platform.openai.com) |
|
||||
| `deepseek` (Đang thử nghiệm) | LLM (DeepSeek trực tiếp) | [platform.deepseek.com](https://platform.deepseek.com) |
|
||||
| `groq` | LLM + **Chuyển giọng nói** (Whisper) | [console.groq.com](https://console.groq.com) |
|
||||
| `qwen` | LLM (Qwen trực tiếp) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
|
||||
| `cerebras` | LLM (Cerebras trực tiếp) | [cerebras.ai](https://cerebras.ai) |
|
||||
|
||||
<details>
|
||||
<summary><b>Cấu hình Zhipu</b></summary>
|
||||
|
|
@ -1065,7 +1059,7 @@ Thêm key vào `~/.picoclaw/config.json` nếu dùng Brave:
|
|||
"tools": {
|
||||
"web": {
|
||||
"brave": {
|
||||
"enabled": true,
|
||||
"enabled": false,
|
||||
"api_key": "YOUR_BRAVE_API_KEY",
|
||||
"max_results": 5
|
||||
},
|
||||
|
|
|
|||
373
README.zh.md
373
README.zh.md
|
|
@ -15,6 +15,7 @@
|
|||
</p>
|
||||
|
||||
**中文** | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
|
@ -42,14 +43,15 @@
|
|||
|
||||
> [!CAUTION]
|
||||
> **🚨 SECURITY & OFFICIAL CHANNELS / 安全声明**
|
||||
> * **无加密货币 (NO CRYPTO):** PicoClaw **没有** 发行任何官方代币、Token 或虚拟货币。所有在 `pump.fun` 或其他交易平台上的相关声称均为 **诈骗**。
|
||||
> * **官方域名:** 唯一的官方网站是 **[picoclaw.io](https://picoclaw.io)**,公司官网是 **[sipeed.com](https://sipeed.com)**。
|
||||
> * **警惕:** 许多 `.ai/.org/.com/.net/...` 后缀的域名被第三方抢注,请勿轻信。
|
||||
> * **注意:** picoclaw正在初期的快速功能开发阶段,可能有尚未修复的网络安全问题,在1.0正式版发布前,请不要将其部署到生产环境中
|
||||
> * **注意:** picoclaw最近合并了大量PRs,近期版本可能内存占用较大(10~20MB),我们将在功能较为收敛后进行资源占用优化.
|
||||
|
||||
>
|
||||
> - **无加密货币 (NO CRYPTO):** PicoClaw **没有** 发行任何官方代币、Token 或虚拟货币。所有在 `pump.fun` 或其他交易平台上的相关声称均为 **诈骗**。
|
||||
> - **官方域名:** 唯一的官方网站是 **[picoclaw.io](https://picoclaw.io)**,公司官网是 **[sipeed.com](https://sipeed.com)**。
|
||||
> - **警惕:** 许多 `.ai/.org/.com/.net/...` 后缀的域名被第三方抢注,请勿轻信。
|
||||
> - **注意:** picoclaw正在初期的快速功能开发阶段,可能有尚未修复的网络安全问题,在1.0正式版发布前,请不要将其部署到生产环境中
|
||||
> - **注意:** picoclaw最近合并了大量PRs,近期版本可能内存占用较大(10~20MB),我们将在功能较为收敛后进行资源占用优化.
|
||||
|
||||
## 📢 新闻 (News)
|
||||
|
||||
2026-02-16 🎉 PicoClaw 在一周内突破了12K star! 感谢大家的关注!PicoClaw 的成长速度超乎我们预期. 由于PR数量的快速膨胀,我们亟需社区开发者参与维护. 我们需要的志愿者角色和roadmap已经发布到了[这里](docs/picoclaw_community_roadmap_260216.md), 期待你的参与!
|
||||
|
||||
2026-02-13 🎉 **PicoClaw 在 4 天内突破 5000 Stars!** 感谢社区的支持!由于正值中国春节假期,PR 和 Issue 涌入较多,我们正在利用这段时间敲定 **项目路线图 (Roadmap)** 并组建 **开发者群组**,以便加速 PicoClaw 的开发。
|
||||
|
|
@ -70,7 +72,7 @@
|
|||
🤖 **AI 自举**: 纯 Go 语言原生实现 — 95% 的核心代码由 Agent 生成,并经由“人机回环 (Human-in-the-loop)”微调。
|
||||
|
||||
| | OpenClaw | NanoBot | **PicoClaw** |
|
||||
| --- | --- | --- | --- |
|
||||
| ------------------------------ | ------------- | ------------------------ | -------------------------------------- |
|
||||
| **语言** | TypeScript | Python | **Go** |
|
||||
| **RAM** | >1GB | >100MB | **< 10MB** |
|
||||
| **启动时间**</br>(0.8GHz core) | >500s | >30s | **<1s** |
|
||||
|
|
@ -101,9 +103,12 @@
|
|||
</table>
|
||||
|
||||
### 📱 在手机上轻松运行
|
||||
|
||||
picoclaw 可以将你10年前的老旧手机废物利用,变身成为你的AI助理!快速指南:
|
||||
|
||||
1. 先去应用商店下载安装Termux
|
||||
2. 打开后执行指令
|
||||
|
||||
```bash
|
||||
# 注意: 下面的v0.1.1 可以换为你实际看到的最新版本
|
||||
wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64
|
||||
|
|
@ -111,19 +116,17 @@ chmod +x picoclaw-linux-arm64
|
|||
pkg install proot
|
||||
termux-chroot ./picoclaw-linux-arm64 onboard
|
||||
```
|
||||
|
||||
然后跟随下面的“快速开始”章节继续配置picoclaw即可使用!
|
||||
<img src="assets/termux.jpg" alt="PicoClaw" width="512">
|
||||
|
||||
|
||||
|
||||
|
||||
### 🐜 创新的低占用部署
|
||||
|
||||
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://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),用于智能监控。
|
||||
|
||||
[https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4](https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4)
|
||||
|
||||
|
|
@ -242,9 +245,14 @@ picoclaw onboard
|
|||
],
|
||||
"tools": {
|
||||
"web": {
|
||||
"search": {
|
||||
"brave": {
|
||||
"enabled": false,
|
||||
"api_key": "YOUR_BRAVE_API_KEY",
|
||||
"max_results": 5
|
||||
},
|
||||
"duckduckgo": {
|
||||
"enabled": true,
|
||||
"max_results": 5
|
||||
}
|
||||
},
|
||||
"cron": {
|
||||
|
|
@ -252,15 +260,14 @@ picoclaw onboard
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
> **新功能**: `model_list` 配置格式支持零代码添加 provider。详见[模型配置](#-模型配置-model_list)章节。
|
||||
> **新功能**: `model_list` 配置格式支持零代码添加 provider。详见[模型配置](#模型配置-model_list)章节。
|
||||
|
||||
**3. 获取 API Key**
|
||||
|
||||
* **LLM 提供商**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
||||
* **网络搜索** (可选): [Brave Search](https://brave.com/search/api) - 提供免费层级 (2000 请求/月)
|
||||
- **LLM 提供商**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
||||
- **网络搜索** (可选): [Brave Search](https://brave.com/search/api) - 提供免费层级 (2000 请求/月)
|
||||
|
||||
> **注意**: 完整的配置模板请参考 `config.example.json`。
|
||||
|
||||
|
|
@ -277,259 +284,28 @@ picoclaw agent -m "2+2 等于几?"
|
|||
|
||||
## 💬 聊天应用集成 (Chat Apps)
|
||||
|
||||
通过 Telegram, Discord, 钉钉或企业微信与您的 PicoClaw 对话。
|
||||
|
||||
| 渠道 | 设置难度 |
|
||||
| --- | --- |
|
||||
| **Telegram** | 简单 (仅需 token) |
|
||||
| **Discord** | 简单 (bot token + intents) |
|
||||
| **QQ** | 简单 (AppID + AppSecret) |
|
||||
| **钉钉 (DingTalk)** | 中等 (应用凭证) |
|
||||
| **企业微信 (WeCom)** | 中等 (企业ID + Webhook配置) |
|
||||
|
||||
<details>
|
||||
<summary><b>Telegram</b> (推荐)</summary>
|
||||
|
||||
**1. 创建机器人**
|
||||
|
||||
* 打开 Telegram,搜索 `@BotFather`
|
||||
* 发送 `/newbot`,按照提示操作
|
||||
* 复制 token
|
||||
|
||||
**2. 配置**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_BOT_TOKEN",
|
||||
"allow_from": ["YOUR_USER_ID"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
> 从 Telegram 上的 `@userinfobot` 获取您的用户 ID。
|
||||
|
||||
**3. 运行**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Discord</b></summary>
|
||||
|
||||
**1. 创建机器人**
|
||||
|
||||
* 前往 [https://discord.com/developers/applications](https://discord.com/developers/applications)
|
||||
* Create an application → Bot → Add Bot
|
||||
* 复制 bot token
|
||||
|
||||
**2. 开启 Intents**
|
||||
|
||||
* 在 Bot 设置中,开启 **MESSAGE CONTENT INTENT**
|
||||
* (可选) 如果计划基于成员数据使用白名单,开启 **SERVER MEMBERS INTENT**
|
||||
|
||||
**3. 获取您的 User ID**
|
||||
|
||||
* Discord 设置 → Advanced → 开启 **Developer Mode**
|
||||
* 右键点击您的头像 → **Copy User ID**
|
||||
|
||||
**4. 配置**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"discord": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_BOT_TOKEN",
|
||||
"allow_from": ["YOUR_USER_ID"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**5. 邀请机器人**
|
||||
|
||||
* OAuth2 → URL Generator
|
||||
* Scopes: `bot`
|
||||
* Bot Permissions: `Send Messages`, `Read Message History`
|
||||
* 打开生成的邀请 URL,将机器人添加到您的服务器
|
||||
|
||||
**6. 运行**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>QQ</b></summary>
|
||||
|
||||
**1. 创建机器人**
|
||||
|
||||
* 前往 [QQ 开放平台](https://q.qq.com/#)
|
||||
* 创建应用 → 获取 **AppID** 和 **AppSecret**
|
||||
|
||||
**2. 配置**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"qq": {
|
||||
"enabled": true,
|
||||
"app_id": "YOUR_APP_ID",
|
||||
"app_secret": "YOUR_APP_SECRET",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
> 将 `allow_from` 设为空以允许所有用户,或指定 QQ 号以限制访问。
|
||||
|
||||
**3. 运行**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>钉钉 (DingTalk)</b></summary>
|
||||
|
||||
**1. 创建机器人**
|
||||
|
||||
* 前往 [开放平台](https://open.dingtalk.com/)
|
||||
* 创建内部应用
|
||||
* 复制 Client ID 和 Client Secret
|
||||
|
||||
**2. 配置**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"dingtalk": {
|
||||
"enabled": true,
|
||||
"client_id": "YOUR_CLIENT_ID",
|
||||
"client_secret": "YOUR_CLIENT_SECRET",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
> 将 `allow_from` 设为空以允许所有用户,或指定 ID 以限制访问。
|
||||
|
||||
**3. 运行**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>企业微信 (WeCom)</b></summary>
|
||||
|
||||
PicoClaw 支持两种企业微信集成方式:
|
||||
|
||||
**选项1: 智能机器人 (WeCom Bot)** - 设置更简单,支持群聊
|
||||
**选项2: 自建应用 (WeCom App)** - 功能更丰富,支持主动推送消息
|
||||
|
||||
详见 [企业微信自建应用配置指南](docs/wecom-app-configuration.md)。
|
||||
|
||||
**快速设置 - 智能机器人:**
|
||||
|
||||
**1. 创建机器人**
|
||||
|
||||
* 前往企业微信管理后台 → 群聊 → 添加群机器人
|
||||
* 复制 Webhook URL (格式: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`)
|
||||
|
||||
**2. 配置**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"wecom": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_TOKEN",
|
||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
|
||||
"webhook_host": "0.0.0.0",
|
||||
"webhook_port": 18793,
|
||||
"webhook_path": "/webhook/wecom",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**快速设置 - 自建应用:**
|
||||
|
||||
**1. 创建应用**
|
||||
|
||||
* 前往企业微信管理后台 → 应用管理 → 创建应用
|
||||
* 复制 **AgentId** 和 **Secret**
|
||||
* 前往"我的企业"页面,复制 **CorpID**
|
||||
|
||||
**2. 配置接收消息**
|
||||
|
||||
* 在应用详情页,点击"接收消息" → "设置API"
|
||||
* 设置 URL 为 `http://your-server:18792/webhook/wecom-app`
|
||||
* 生成 **Token** 和 **EncodingAESKey**
|
||||
|
||||
**3. 配置**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"wecom_app": {
|
||||
"enabled": true,
|
||||
"corp_id": "wwxxxxxxxxxxxxxxxx",
|
||||
"corp_secret": "YOUR_CORP_SECRET",
|
||||
"agent_id": 1000002,
|
||||
"token": "YOUR_TOKEN",
|
||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||
"webhook_host": "0.0.0.0",
|
||||
"webhook_port": 18792,
|
||||
"webhook_path": "/webhook/wecom-app",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**4. 运行**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
|
||||
```
|
||||
|
||||
> **注意**: 自建应用需要开放 18792 端口用于接收 Webhook 回调。生产环境建议使用反向代理配置 HTTPS。
|
||||
|
||||
</details>
|
||||
PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方。
|
||||
|
||||
### 核心渠道
|
||||
|
||||
| 渠道 | 设置难度 | 特性说明 | 文档链接 |
|
||||
| -------------------- | ----------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
|
||||
| **Telegram** | ⭐ 简单 | 推荐,支持语音转文字,长轮询无需公网 | [查看文档](docs/channels/telegram/README.zh.md) |
|
||||
| **Discord** | ⭐ 简单 | Socket Mode,支持群组/私信,Bot 生态成熟 | [查看文档](docs/channels/discord/README.zh.md) |
|
||||
| **Slack** | ⭐ 简单 | **Socket Mode** (无需公网 IP),企业级支持 | [查看文档](docs/channels/slack/README.zh.md) |
|
||||
| **QQ** | ⭐⭐ 中等 | 官方机器人 API,适合国内社群 | [查看文档](docs/channels/qq/README.zh.md) |
|
||||
| **钉钉 (DingTalk)** | ⭐⭐ 中等 | Stream 模式无需公网,企业办公首选 | [查看文档](docs/channels/dingtalk/README.zh.md) |
|
||||
| **企业微信 (WeCom)** | ⭐⭐⭐ 较难 | 支持群机器人(Webhook)和自建应用(API) | [Bot 文档](docs/channels/wecom/wecom_bot/README.zh.md) / [App 文档](docs/channels/wecom/wecom_app/README.zh.md) |
|
||||
| **飞书 (Feishu)** | ⭐⭐⭐ 较难 | 企业级协作,功能丰富 | [查看文档](docs/channels/feishu/README.zh.md) |
|
||||
| **Line** | ⭐⭐⭐ 较难 | 需要 HTTPS Webhook | [查看文档](docs/channels/line/README.zh.md) |
|
||||
| **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP,社区生态丰富 | [查看文档](docs/channels/onebot/README.zh.md) |
|
||||
| **MaixCam** | ⭐ 简单 | 专为 AI 摄像头设计的硬件集成通道 | [查看文档](docs/channels/maixcam/README.zh.md) |
|
||||
|
||||
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> 加入 Agent 社交网络
|
||||
|
||||
只需通过 CLI 或任何集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。
|
||||
|
||||
**阅读 `https://clawdchat.ai/skill.md` 并按照说明加入 [ClawdChat.ai**](https://clawdchat.ai)
|
||||
\*\*阅读 `https://clawdchat.ai/skill.md` 并按照说明加入 [ClawdChat.ai](https://clawdchat.ai)
|
||||
|
||||
## ⚙️ 配置详解
|
||||
|
||||
|
|
@ -565,7 +341,6 @@ PicoClaw 可以自动执行周期性任务。在工作区创建 `HEARTBEAT.md`
|
|||
- Check my email for important messages
|
||||
- Review my calendar for upcoming events
|
||||
- Check the weather forecast
|
||||
|
||||
```
|
||||
|
||||
Agent 将每隔 30 分钟(可配置)读取此文件,并使用可用工具执行任务。
|
||||
|
|
@ -578,18 +353,19 @@ Agent 将每隔 30 分钟(可配置)读取此文件,并使用可用工具
|
|||
# Periodic Tasks
|
||||
|
||||
## Quick Tasks (respond directly)
|
||||
|
||||
- Report current time
|
||||
|
||||
## Long Tasks (use spawn for async)
|
||||
|
||||
- Search the web for AI news and summarize
|
||||
- Check email and report important messages
|
||||
|
||||
```
|
||||
|
||||
**关键行为:**
|
||||
|
||||
| 特性 | 描述 |
|
||||
| --- | --- |
|
||||
| ---------------- | ---------------------------------------- |
|
||||
| **spawn** | 创建异步子 Agent,不阻塞主心跳进程 |
|
||||
| **独立上下文** | 子 Agent 拥有独立上下文,无会话历史 |
|
||||
| **message tool** | 子 Agent 通过 message 工具直接与用户通信 |
|
||||
|
|
@ -623,18 +399,17 @@ Agent 读取 HEARTBEAT.md
|
|||
"interval": 30
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
| 选项 | 默认值 | 描述 |
|
||||
| --- | --- | --- |
|
||||
| ---------- | ------ | ---------------------------- |
|
||||
| `enabled` | `true` | 启用/禁用心跳 |
|
||||
| `interval` | `30` | 检查间隔,单位分钟 (最小: 5) |
|
||||
|
||||
**环境变量:**
|
||||
|
||||
* `PICOCLAW_HEARTBEAT_ENABLED=false` 禁用
|
||||
* `PICOCLAW_HEARTBEAT_INTERVAL=60` 更改间隔
|
||||
- `PICOCLAW_HEARTBEAT_ENABLED=false` 禁用
|
||||
- `PICOCLAW_HEARTBEAT_INTERVAL=60` 更改间隔
|
||||
|
||||
### 提供商 (Providers)
|
||||
|
||||
|
|
@ -642,7 +417,7 @@ Agent 读取 HEARTBEAT.md
|
|||
> Groq 通过 Whisper 提供免费的语音转录。如果配置了 Groq,Telegram 语音消息将被自动转录为文字。
|
||||
|
||||
| 提供商 | 用途 | 获取 API Key |
|
||||
| --- | --- | --- |
|
||||
| -------------------- | ---------------------------- | -------------------------------------------------------------------- |
|
||||
| `gemini` | LLM (Gemini 直连) | [aistudio.google.com](https://aistudio.google.com) |
|
||||
| `zhipu` | LLM (智谱直连) | [bigmodel.cn](bigmodel.cn) |
|
||||
| `openrouter(待测试)` | LLM (推荐,可访问所有模型) | [openrouter.ai](https://openrouter.ai) |
|
||||
|
|
@ -667,7 +442,7 @@ Agent 读取 HEARTBEAT.md
|
|||
#### 📋 所有支持的厂商
|
||||
|
||||
| 厂商 | `model` 前缀 | 默认 API Base | 协议 | 获取 API Key |
|
||||
|------|-------------|---------------|------|--------------|
|
||||
| ------------------- | ----------------- | --------------------------------------------------- | --------- | ----------------------------------------------------------------- |
|
||||
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [获取密钥](https://platform.openai.com) |
|
||||
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [获取密钥](https://console.anthropic.com) |
|
||||
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [获取密钥](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
||||
|
|
@ -718,6 +493,7 @@ Agent 读取 HEARTBEAT.md
|
|||
#### 各厂商配置示例
|
||||
|
||||
**OpenAI**
|
||||
|
||||
```json
|
||||
{
|
||||
"model_name": "gpt-5.2",
|
||||
|
|
@ -727,6 +503,7 @@ Agent 读取 HEARTBEAT.md
|
|||
```
|
||||
|
||||
**智谱 AI (GLM)**
|
||||
|
||||
```json
|
||||
{
|
||||
"model_name": "glm-4.7",
|
||||
|
|
@ -736,6 +513,7 @@ Agent 读取 HEARTBEAT.md
|
|||
```
|
||||
|
||||
**DeepSeek**
|
||||
|
||||
```json
|
||||
{
|
||||
"model_name": "deepseek-chat",
|
||||
|
|
@ -745,6 +523,7 @@ Agent 读取 HEARTBEAT.md
|
|||
```
|
||||
|
||||
**Anthropic (使用 OAuth)**
|
||||
|
||||
```json
|
||||
{
|
||||
"model_name": "claude-sonnet-4.6",
|
||||
|
|
@ -752,9 +531,11 @@ Agent 读取 HEARTBEAT.md
|
|||
"auth_method": "oauth"
|
||||
}
|
||||
```
|
||||
|
||||
> 运行 `picoclaw auth login --provider anthropic` 来设置 OAuth 凭证。
|
||||
|
||||
**Ollama (本地)**
|
||||
|
||||
```json
|
||||
{
|
||||
"model_name": "llama3",
|
||||
|
|
@ -763,6 +544,7 @@ Agent 读取 HEARTBEAT.md
|
|||
```
|
||||
|
||||
**自定义代理/API**
|
||||
|
||||
```json
|
||||
{
|
||||
"model_name": "my-custom-model",
|
||||
|
|
@ -800,6 +582,7 @@ Agent 读取 HEARTBEAT.md
|
|||
旧的 `providers` 配置格式**已弃用**,但为向后兼容仍支持。
|
||||
|
||||
**旧配置(已弃用):**
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
|
|
@ -818,6 +601,7 @@ Agent 读取 HEARTBEAT.md
|
|||
```
|
||||
|
||||
**新配置(推荐):**
|
||||
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
|
|
@ -842,7 +626,7 @@ Agent 读取 HEARTBEAT.md
|
|||
|
||||
**1. 获取 API key 和 base URL**
|
||||
|
||||
* 获取 [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)
|
||||
- 获取 [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)
|
||||
|
||||
**2. 配置**
|
||||
|
||||
|
|
@ -861,10 +645,9 @@ Agent 读取 HEARTBEAT.md
|
|||
"zhipu": {
|
||||
"api_key": "Your API Key",
|
||||
"api_base": "https://open.bigmodel.cn/api/paas/v4"
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**3. 运行**
|
||||
|
|
@ -925,8 +708,14 @@ picoclaw agent -m "你好"
|
|||
},
|
||||
"tools": {
|
||||
"web": {
|
||||
"search": {
|
||||
"api_key": "BSA..."
|
||||
"brave": {
|
||||
"enabled": false,
|
||||
"api_key": "YOUR_BRAVE_API_KEY",
|
||||
"max_results": 5
|
||||
},
|
||||
"duckduckgo": {
|
||||
"enabled": true,
|
||||
"max_results": 5
|
||||
}
|
||||
},
|
||||
"cron": {
|
||||
|
|
@ -938,7 +727,6 @@ picoclaw agent -m "你好"
|
|||
"interval": 30
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
</details>
|
||||
|
|
@ -946,7 +734,7 @@ picoclaw agent -m "你好"
|
|||
## CLI 命令行参考
|
||||
|
||||
| 命令 | 描述 |
|
||||
| --- | --- |
|
||||
| ------------------------- | ------------------ |
|
||||
| `picoclaw onboard` | 初始化配置和工作区 |
|
||||
| `picoclaw agent -m "..."` | 与 Agent 对话 |
|
||||
| `picoclaw agent` | 交互式聊天模式 |
|
||||
|
|
@ -959,9 +747,9 @@ picoclaw agent -m "你好"
|
|||
|
||||
PicoClaw 通过 `cron` 工具支持定时提醒和重复任务:
|
||||
|
||||
* **一次性提醒**: "Remind me in 10 minutes" (10分钟后提醒我) → 10分钟后触发一次
|
||||
* **重复任务**: "Remind me every 2 hours" (每2小时提醒我) → 每2小时触发
|
||||
* **Cron 表达式**: "Remind me at 9am daily" (每天上午9点提醒我) → 使用 cron 表达式
|
||||
- **一次性提醒**: "Remind me in 10 minutes" (10分钟后提醒我) → 10分钟后触发一次
|
||||
- **重复任务**: "Remind me every 2 hours" (每2小时提醒我) → 每2小时触发
|
||||
- **Cron 表达式**: "Remind me at 9am daily" (每天上午9点提醒我) → 使用 cron 表达式
|
||||
|
||||
任务存储在 `~/.picoclaw/workspace/cron/` 中并自动处理。
|
||||
|
||||
|
|
@ -989,22 +777,25 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
|
|||
|
||||
1. 在 [https://brave.com/search/api](https://brave.com/search/api) 获取免费 API Key (每月 2000 次免费查询)
|
||||
2. 添加到 `~/.picoclaw/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"web": {
|
||||
"search": {
|
||||
"brave": {
|
||||
"enabled": false,
|
||||
"api_key": "YOUR_BRAVE_API_KEY",
|
||||
"max_results": 5
|
||||
},
|
||||
"duckduckgo": {
|
||||
"enabled": true,
|
||||
"max_results": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
|
||||
|
||||
### 遇到内容过滤错误 (Content Filtering Errors)
|
||||
|
||||
某些提供商(如智谱)有严格的内容过滤。尝试改写您的问题或使用其他模型。
|
||||
|
|
@ -1018,7 +809,7 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
|
|||
## 📝 API Key 对比
|
||||
|
||||
| 服务 | 免费层级 | 适用场景 |
|
||||
| --- | --- | --- |
|
||||
| ---------------- | -------------- | ----------------------------- |
|
||||
| **OpenRouter** | 200K tokens/月 | 多模型聚合 (Claude, GPT-4 等) |
|
||||
| **智谱 (Zhipu)** | 200K tokens/月 | 最适合中国用户 |
|
||||
| **Brave Search** | 2000 次查询/月 | 网络搜索功能 |
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 141 KiB |
|
|
@ -13,6 +13,7 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/chzyer/readline"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/agent"
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
|
|
@ -74,10 +75,10 @@ func agentCmd() {
|
|||
// Print agent startup info (only for interactive mode)
|
||||
startupInfo := agentLoop.GetStartupInfo()
|
||||
logger.InfoCF("agent", "Agent initialized",
|
||||
map[string]interface{}{
|
||||
"tools_count": startupInfo["tools"].(map[string]interface{})["count"],
|
||||
"skills_total": startupInfo["skills"].(map[string]interface{})["total"],
|
||||
"skills_available": startupInfo["skills"].(map[string]interface{})["available"],
|
||||
map[string]any{
|
||||
"tools_count": startupInfo["tools"].(map[string]any)["count"],
|
||||
"skills_total": startupInfo["skills"].(map[string]any)["total"],
|
||||
"skills_available": startupInfo["skills"].(map[string]any)["available"],
|
||||
})
|
||||
|
||||
if message != "" {
|
||||
|
|
@ -104,7 +105,6 @@ func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
|
|||
InterruptPrompt: "^C",
|
||||
EOFPrompt: "exit",
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("Error initializing readline: %v\n", err)
|
||||
fmt.Println("Falling back to simple input mode...")
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ func authLoginOpenAI(useDeviceCode bool) {
|
|||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := auth.SetCredential("openai", cred); err != nil {
|
||||
if err = auth.SetCredential("openai", cred); err != nil {
|
||||
fmt.Printf("Failed to save credentials: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
|
@ -188,7 +188,7 @@ func authLoginGoogleAntigravity() {
|
|||
fmt.Printf("Project: %s\n", projectID)
|
||||
}
|
||||
|
||||
if err := auth.SetCredential("google-antigravity", cred); err != nil {
|
||||
if err = auth.SetCredential("google-antigravity", cred); err != nil {
|
||||
fmt.Printf("Failed to save credentials: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
|
@ -265,7 +265,7 @@ func authLoginPasteToken(provider string) {
|
|||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := auth.SetCredential(provider, cred); err != nil {
|
||||
if err = auth.SetCredential(provider, cred); err != nil {
|
||||
fmt.Printf("Failed to save credentials: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/agent"
|
||||
|
|
@ -60,8 +61,8 @@ func gatewayCmd() {
|
|||
// Print agent startup info
|
||||
fmt.Println("\n📦 Agent Status:")
|
||||
startupInfo := agentLoop.GetStartupInfo()
|
||||
toolsInfo := startupInfo["tools"].(map[string]interface{})
|
||||
skillsInfo := startupInfo["skills"].(map[string]interface{})
|
||||
toolsInfo := startupInfo["tools"].(map[string]any)
|
||||
skillsInfo := startupInfo["skills"].(map[string]any)
|
||||
fmt.Printf(" • Tools: %d loaded\n", toolsInfo["count"])
|
||||
fmt.Printf(" • Skills: %d/%d available\n",
|
||||
skillsInfo["available"],
|
||||
|
|
@ -69,7 +70,7 @@ func gatewayCmd() {
|
|||
|
||||
// Log to file as well
|
||||
logger.InfoCF("agent", "Agent initialized",
|
||||
map[string]interface{}{
|
||||
map[string]any{
|
||||
"tools_count": toolsInfo["count"],
|
||||
"skills_total": skillsInfo["total"],
|
||||
"skills_available": skillsInfo["available"],
|
||||
|
|
@ -77,7 +78,14 @@ func gatewayCmd() {
|
|||
|
||||
// Setup cron tool and service
|
||||
execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute
|
||||
cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath(), cfg.Agents.Defaults.RestrictToWorkspace, execTimeout, cfg)
|
||||
cronService := setupCronTool(
|
||||
agentLoop,
|
||||
msgBus,
|
||||
cfg.WorkspacePath(),
|
||||
cfg.Agents.Defaults.RestrictToWorkspace,
|
||||
execTimeout,
|
||||
cfg,
|
||||
)
|
||||
|
||||
heartbeatService := heartbeat.NewHeartbeatService(
|
||||
cfg.WorkspacePath(),
|
||||
|
|
@ -91,7 +99,8 @@ func gatewayCmd() {
|
|||
channel, chatID = "cli", "direct"
|
||||
}
|
||||
// Use ProcessHeartbeat - no session history, each heartbeat is independent
|
||||
response, err := agentLoop.ProcessHeartbeat(context.Background(), prompt, channel, chatID)
|
||||
var response string
|
||||
response, err = agentLoop.ProcessHeartbeat(context.Background(), prompt, channel, chatID)
|
||||
if err != nil {
|
||||
return tools.ErrorResult(fmt.Sprintf("Heartbeat error: %v", err))
|
||||
}
|
||||
|
|
@ -113,8 +122,17 @@ func gatewayCmd() {
|
|||
agentLoop.SetChannelManager(channelManager)
|
||||
|
||||
var transcriber *voice.GroqTranscriber
|
||||
if cfg.Providers.Groq.APIKey != "" {
|
||||
transcriber = voice.NewGroqTranscriber(cfg.Providers.Groq.APIKey)
|
||||
groqAPIKey := cfg.Providers.Groq.APIKey
|
||||
if groqAPIKey == "" {
|
||||
for _, mc := range cfg.ModelList {
|
||||
if strings.HasPrefix(mc.Model, "groq/") && mc.APIKey != "" {
|
||||
groqAPIKey = mc.APIKey
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if groqAPIKey != "" {
|
||||
transcriber = voice.NewGroqTranscriber(groqAPIKey)
|
||||
logger.InfoC("voice", "Groq voice transcription enabled")
|
||||
}
|
||||
|
||||
|
|
@ -181,7 +199,7 @@ func gatewayCmd() {
|
|||
healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
|
||||
go func() {
|
||||
if err := healthServer.Start(); err != nil && err != http.ErrServerClosed {
|
||||
logger.ErrorCF("health", "Health server error", map[string]interface{}{"error": err.Error()})
|
||||
logger.ErrorCF("health", "Health server error", map[string]any{"error": err.Error()})
|
||||
}
|
||||
}()
|
||||
fmt.Printf("✓ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port)
|
||||
|
|
@ -203,7 +221,14 @@ func gatewayCmd() {
|
|||
fmt.Println("✓ Gateway stopped")
|
||||
}
|
||||
|
||||
func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration, cfg *config.Config) *cron.CronService {
|
||||
func setupCronTool(
|
||||
agentLoop *agent.AgentLoop,
|
||||
msgBus *bus.MessageBus,
|
||||
workspace string,
|
||||
restrict bool,
|
||||
execTimeout time.Duration,
|
||||
cfg *config.Config,
|
||||
) *cron.CronService {
|
||||
cronStorePath := filepath.Join(workspace, "cron", "jobs.json")
|
||||
|
||||
// Create cron service
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ func onboard() {
|
|||
|
||||
func copyEmbeddedToTarget(targetDir string) error {
|
||||
// Ensure target directory exists
|
||||
if err := os.MkdirAll(targetDir, 0755); err != nil {
|
||||
if err := os.MkdirAll(targetDir, 0o755); err != nil {
|
||||
return fmt.Errorf("Failed to create target directory: %w", err)
|
||||
}
|
||||
|
||||
|
|
@ -85,12 +85,12 @@ func copyEmbeddedToTarget(targetDir string) error {
|
|||
targetPath := filepath.Join(targetDir, new_path)
|
||||
|
||||
// Ensure target file's directory exists
|
||||
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
|
||||
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
|
||||
return fmt.Errorf("Failed to create directory %s: %w", filepath.Dir(targetPath), err)
|
||||
}
|
||||
|
||||
// Write file
|
||||
if err := os.WriteFile(targetPath, data, 0644); err != nil {
|
||||
if err := os.WriteFile(targetPath, data, 0o644); err != nil {
|
||||
return fmt.Errorf("Failed to write file %s: %w", targetPath, err)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) {
|
|||
workspace := cfg.WorkspacePath()
|
||||
targetDir := filepath.Join(workspace, "skills", slug)
|
||||
|
||||
if _, err := os.Stat(targetDir); err == nil {
|
||||
if _, err = os.Stat(targetDir); err == nil {
|
||||
fmt.Printf("\u2717 Skill '%s' already installed at %s\n", slug, targetDir)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
|
@ -126,7 +126,7 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) {
|
|||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := os.MkdirAll(filepath.Join(workspace, "skills"), 0755); err != nil {
|
||||
if err = os.MkdirAll(filepath.Join(workspace, "skills"), 0o755); err != nil {
|
||||
fmt.Printf("\u2717 Failed to create skills directory: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
|
@ -193,7 +193,7 @@ func skillsInstallBuiltinCmd(workspace string) {
|
|||
continue
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(workspacePath, 0755); err != nil {
|
||||
if err := os.MkdirAll(workspacePath, 0o755); err != nil {
|
||||
fmt.Printf("✗ Failed to create directory for %s: %v\n", skillName, err)
|
||||
continue
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ services:
|
|||
container_name: picoclaw-agent
|
||||
profiles:
|
||||
- agent
|
||||
# Uncomment to access host network; leave commented unless needed.
|
||||
#extra_hosts:
|
||||
# - "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
- ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro
|
||||
- picoclaw-workspace:/home/picoclaw/.picoclaw/workspace
|
||||
|
|
@ -29,6 +32,9 @@ services:
|
|||
restart: unless-stopped
|
||||
profiles:
|
||||
- gateway
|
||||
# Uncomment to access host network; leave commented unless needed.
|
||||
#extra_hosts:
|
||||
# - "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
# Configuration file
|
||||
- ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro
|
||||
|
|
|
|||
|
|
@ -378,7 +378,7 @@ const antigravityPlugin = {
|
|||
description: "OAuth flow for Google Antigravity (Cloud Code Assist)",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
|
||||
register(api: OpenClawPluginApi) {
|
||||
register(api: PicoClawPluginApi) {
|
||||
api.registerProvider({
|
||||
id: "google-antigravity",
|
||||
label: "Google Antigravity",
|
||||
|
|
@ -405,7 +405,7 @@ const antigravityPlugin = {
|
|||
|
||||
```typescript
|
||||
type ProviderAuthContext = {
|
||||
config: OpenClawConfig;
|
||||
config: PicoClawConfig;
|
||||
agentDir?: string;
|
||||
workspaceDir?: string;
|
||||
prompter: WizardPrompter; // UI prompts/notifications
|
||||
|
|
@ -426,7 +426,7 @@ type ProviderAuthResult = {
|
|||
profileId: string;
|
||||
credential: AuthProfileCredential;
|
||||
}>;
|
||||
configPatch?: Partial<OpenClawConfig>;
|
||||
configPatch?: Partial<PicoClawConfig>;
|
||||
defaultModel?: string;
|
||||
notes?: string[];
|
||||
};
|
||||
|
|
@ -438,10 +438,9 @@ type ProviderAuthResult = {
|
|||
|
||||
### 1. Required Environment/Dependencies
|
||||
|
||||
- Node.js ≥ 22
|
||||
- OpenClaw plugin-sdk
|
||||
- crypto module (built-in)
|
||||
- http module (built-in)
|
||||
- Go ≥ 1.21
|
||||
- PicoClaw codebase (`pkg/providers/` and `pkg/auth/`)
|
||||
- `crypto` and `net/http` standard library packages
|
||||
|
||||
### 2. Required Headers for API Calls
|
||||
|
||||
|
|
@ -572,36 +571,40 @@ Each SSE message (`data: {...}`) is wrapped in a `response` field:
|
|||
|
||||
## Configuration
|
||||
|
||||
### openclaw.json Configuration
|
||||
### config.json Configuration
|
||||
|
||||
```json5
|
||||
```json
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "google-antigravity/claude-opus-4-6-thinking",
|
||||
},
|
||||
},
|
||||
},
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gemini-flash",
|
||||
"model": "antigravity/gemini-3-flash",
|
||||
"auth_method": "oauth"
|
||||
}
|
||||
],
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "gemini-flash"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Auth Profile Storage
|
||||
|
||||
Auth profiles are stored in `~/.openclaw/agent/auth-profiles.json`:
|
||||
Auth profiles are stored in `~/.picoclaw/auth.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"profiles": {
|
||||
"google-antigravity:user@example.com": {
|
||||
"type": "oauth",
|
||||
"credentials": {
|
||||
"google-antigravity": {
|
||||
"access_token": "ya29...",
|
||||
"refresh_token": "1//...",
|
||||
"expires_at": "2026-01-01T00:00:00Z",
|
||||
"provider": "google-antigravity",
|
||||
"access": "ya29...",
|
||||
"refresh": "1//...",
|
||||
"expires": 1704067200000,
|
||||
"auth_method": "oauth",
|
||||
"email": "user@example.com",
|
||||
"projectId": "my-project-id"
|
||||
"project_id": "my-project-id"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -611,277 +614,85 @@ Auth profiles are stored in `~/.openclaw/agent/auth-profiles.json`:
|
|||
|
||||
## Creating a New Provider in PicoClaw
|
||||
|
||||
PicoClaw providers are implemented as Go packages under `pkg/providers/`. To add a new provider:
|
||||
|
||||
### Step-by-Step Implementation
|
||||
|
||||
#### 1. Create Plugin Structure
|
||||
#### 1. Create Provider File
|
||||
|
||||
Create a new Go file in `pkg/providers/`:
|
||||
|
||||
```
|
||||
extensions/
|
||||
└── your-provider-auth/
|
||||
├── openclaw.plugin.json
|
||||
├── package.json
|
||||
├── README.md
|
||||
└── index.ts
|
||||
pkg/providers/
|
||||
└── your_provider.go
|
||||
```
|
||||
|
||||
#### 2. Define Plugin Manifest
|
||||
#### 2. Implement the Provider Interface
|
||||
|
||||
Your provider must implement the `Provider` interface defined in `pkg/providers/types.go`:
|
||||
|
||||
```go
|
||||
package providers
|
||||
|
||||
type YourProvider struct {
|
||||
apiKey string
|
||||
apiBase string
|
||||
}
|
||||
|
||||
func NewYourProvider(apiKey, apiBase, proxy string) *YourProvider {
|
||||
if apiBase == "" {
|
||||
apiBase = "https://api.your-provider.com/v1"
|
||||
}
|
||||
return &YourProvider{apiKey: apiKey, apiBase: apiBase}
|
||||
}
|
||||
|
||||
func (p *YourProvider) Chat(ctx context.Context, messages []Message, tools []Tool, cb StreamCallback) error {
|
||||
// Implement chat completion with streaming
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Register in the Factory
|
||||
|
||||
Add your provider to the protocol switch in `pkg/providers/factory.go`:
|
||||
|
||||
```go
|
||||
case "your-provider":
|
||||
return NewYourProvider(sel.apiKey, sel.apiBase, sel.proxy), nil
|
||||
```
|
||||
|
||||
#### 4. Add Default Config (Optional)
|
||||
|
||||
Add a default entry in `pkg/config/defaults.go`:
|
||||
|
||||
```go
|
||||
{
|
||||
ModelName: "your-model",
|
||||
Model: "your-provider/model-name",
|
||||
APIKey: "",
|
||||
},
|
||||
```
|
||||
|
||||
#### 5. Add Auth Support (Optional)
|
||||
|
||||
If your provider requires OAuth or special authentication, add a case to `cmd/picoclaw/cmd_auth.go`:
|
||||
|
||||
```go
|
||||
case "your-provider":
|
||||
authLoginYourProvider()
|
||||
```
|
||||
|
||||
#### 6. Configure via `config.json`
|
||||
|
||||
**openclaw.plugin.json:**
|
||||
```json
|
||||
{
|
||||
"id": "your-provider-auth",
|
||||
"providers": ["your-provider"],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**package.json:**
|
||||
```json
|
||||
"model_list": [
|
||||
{
|
||||
"name": "@openclaw/your-provider-auth",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Your Provider OAuth plugin",
|
||||
"type": "module"
|
||||
"model_name": "your-model",
|
||||
"model": "your-provider/model-name",
|
||||
"api_key": "your-api-key",
|
||||
"api_base": "https://api.your-provider.com/v1"
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Implement OAuth Flow
|
||||
|
||||
```typescript
|
||||
import {
|
||||
buildOauthProviderAuthResult,
|
||||
emptyPluginConfigSchema,
|
||||
type OpenClawPluginApi,
|
||||
type ProviderAuthContext,
|
||||
} from "openclaw/plugin-sdk";
|
||||
|
||||
const YOUR_CLIENT_ID = "your-client-id";
|
||||
const YOUR_CLIENT_SECRET = "your-client-secret";
|
||||
const AUTH_URL = "https://provider.com/oauth/authorize";
|
||||
const TOKEN_URL = "https://provider.com/oauth/token";
|
||||
const REDIRECT_URI = "http://localhost:PORT/oauth-callback";
|
||||
|
||||
async function loginYourProvider(params: {
|
||||
isRemote: boolean;
|
||||
openUrl: (url: string) => Promise<void>;
|
||||
prompt: (message: string) => Promise<string>;
|
||||
note: (message: string, title?: string) => Promise<void>;
|
||||
log: (message: string) => void;
|
||||
progress: { update: (msg: string) => void; stop: (msg?: string) => void };
|
||||
}) {
|
||||
// 1. Generate PKCE
|
||||
const { verifier, challenge } = generatePkce();
|
||||
const state = randomBytes(16).toString("hex");
|
||||
|
||||
// 2. Build auth URL
|
||||
const authUrl = buildAuthUrl({ challenge, state });
|
||||
|
||||
// 3. Start callback server (if not remote)
|
||||
const callbackServer = !params.isRemote
|
||||
? await startCallbackServer({ timeoutMs: 5 * 60 * 1000 })
|
||||
: null;
|
||||
|
||||
// 4. Open browser or show URL
|
||||
if (callbackServer) {
|
||||
await params.openUrl(authUrl);
|
||||
const callback = await callbackServer.waitForCallback();
|
||||
code = callback.searchParams.get("code");
|
||||
} else {
|
||||
await params.note(`Auth URL: ${authUrl}`, "OAuth");
|
||||
const input = await params.prompt("Paste redirect URL:");
|
||||
const parsed = parseCallbackInput(input);
|
||||
code = parsed.code;
|
||||
}
|
||||
|
||||
// 5. Exchange code for tokens
|
||||
const tokens = await exchangeCode({ code, verifier });
|
||||
|
||||
// 6. Fetch additional user data
|
||||
const email = await fetchUserEmail(tokens.access);
|
||||
|
||||
return { ...tokens, email };
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. Register Provider
|
||||
|
||||
```typescript
|
||||
const yourProviderPlugin = {
|
||||
id: "your-provider-auth",
|
||||
name: "Your Provider Auth",
|
||||
description: "OAuth for Your Provider",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
|
||||
register(api: OpenClawPluginApi) {
|
||||
api.registerProvider({
|
||||
id: "your-provider",
|
||||
label: "Your Provider",
|
||||
docsPath: "/providers/models",
|
||||
aliases: ["yp"],
|
||||
|
||||
auth: [
|
||||
{
|
||||
id: "oauth",
|
||||
label: "OAuth Login",
|
||||
hint: "Browser-based authentication",
|
||||
kind: "oauth",
|
||||
|
||||
run: async (ctx: ProviderAuthContext) => {
|
||||
const spin = ctx.prompter.progress("Starting OAuth...");
|
||||
|
||||
try {
|
||||
const result = await loginYourProvider({
|
||||
isRemote: ctx.isRemote,
|
||||
openUrl: ctx.openUrl,
|
||||
prompt: async (msg) => String(await ctx.prompter.text({ message: msg })),
|
||||
note: ctx.prompter.note,
|
||||
log: (msg) => ctx.runtime.log(msg),
|
||||
progress: spin,
|
||||
});
|
||||
|
||||
return buildOauthProviderAuthResult({
|
||||
providerId: "your-provider",
|
||||
defaultModel: "your-provider/model-name",
|
||||
access: result.access,
|
||||
refresh: result.refresh,
|
||||
expires: result.expires,
|
||||
email: result.email,
|
||||
notes: ["Provider-specific notes"],
|
||||
});
|
||||
} catch (err) {
|
||||
spin.stop("OAuth failed");
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default yourProviderPlugin;
|
||||
```
|
||||
|
||||
#### 5. Implement Usage Tracking (Optional)
|
||||
|
||||
```typescript
|
||||
// src/infra/provider-usage.fetch.your-provider.ts
|
||||
export async function fetchYourProviderUsage(
|
||||
token: string,
|
||||
timeoutMs: number,
|
||||
fetchFn: typeof fetch
|
||||
): Promise<ProviderUsageSnapshot> {
|
||||
// Fetch usage data from provider API
|
||||
const response = await fetchFn("https://api.provider.com/usage", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return {
|
||||
provider: "your-provider",
|
||||
displayName: "Your Provider",
|
||||
windows: [
|
||||
{ label: "Credits", usedPercent: data.usedPercent },
|
||||
],
|
||||
plan: data.planName,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
#### 6. Register Usage Fetcher
|
||||
|
||||
```typescript
|
||||
// src/infra/provider-usage.load.ts
|
||||
case "your-provider":
|
||||
return await fetchYourProviderUsage(auth.token, timeoutMs, fetchFn);
|
||||
```
|
||||
|
||||
#### 7. Add Provider to Type Definitions
|
||||
|
||||
```typescript
|
||||
// src/infra/provider-usage.types.ts
|
||||
export type SupportedProvider =
|
||||
| "anthropic"
|
||||
| "github-copilot"
|
||||
| "google-gemini-cli"
|
||||
| "google-antigravity"
|
||||
| "your-provider" // Add here
|
||||
| "minimax"
|
||||
| "openai-codex";
|
||||
```
|
||||
|
||||
#### 8. Add Auth Choice Handler
|
||||
|
||||
```typescript
|
||||
// src/commands/auth-choice.apply.your-provider.ts
|
||||
import { applyAuthChoicePluginProvider } from "./auth-choice.apply.plugin-provider.js";
|
||||
|
||||
export async function applyAuthChoiceYourProvider(
|
||||
params: ApplyAuthChoiceParams
|
||||
): Promise<ApplyAuthChoiceResult | null> {
|
||||
return await applyAuthChoicePluginProvider(params, {
|
||||
authChoice: "your-provider",
|
||||
pluginId: "your-provider-auth",
|
||||
providerId: "your-provider",
|
||||
methodId: "oauth",
|
||||
label: "Your Provider",
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
#### 9. Export from Main Index
|
||||
|
||||
```typescript
|
||||
// src/commands/auth-choice.apply.ts
|
||||
import { applyAuthChoiceYourProvider } from "./auth-choice.apply.your-provider.js";
|
||||
|
||||
// In the switch statement:
|
||||
case "your-provider":
|
||||
return await applyAuthChoiceYourProvider(params);
|
||||
```
|
||||
|
||||
### Helper Utilities
|
||||
|
||||
#### PKCE Generation
|
||||
```typescript
|
||||
function generatePkce(): { verifier: string; challenge: string } {
|
||||
const verifier = randomBytes(32).toString("hex");
|
||||
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
||||
return { verifier, challenge };
|
||||
}
|
||||
```
|
||||
|
||||
#### Callback Server
|
||||
```typescript
|
||||
async function startCallbackServer(params: { timeoutMs: number }) {
|
||||
const port = 51121; // Your port
|
||||
|
||||
const server = createServer((request, response) => {
|
||||
const url = new URL(request.url!, `http://localhost:${port}`);
|
||||
|
||||
if (url.pathname === "/oauth-callback") {
|
||||
response.writeHead(200, { "Content-Type": "text/html" });
|
||||
response.end("<h1>Authentication complete</h1>");
|
||||
resolveCallback(url);
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
server.listen(port, "127.0.0.1", resolve);
|
||||
server.once("error", reject);
|
||||
});
|
||||
|
||||
return {
|
||||
waitForCallback: () => callbackPromise,
|
||||
close: () => new Promise((resolve) => server.close(resolve)),
|
||||
};
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -892,33 +703,27 @@ async function startCallbackServer(params: { timeoutMs: number }) {
|
|||
### CLI Commands
|
||||
|
||||
```bash
|
||||
# Enable the plugin
|
||||
openclaw plugins enable your-provider-auth
|
||||
# Authenticate with a provider
|
||||
picoclaw auth login --provider your-provider
|
||||
|
||||
# Restart gateway
|
||||
openclaw gateway restart
|
||||
# List models (for Antigravity)
|
||||
picoclaw auth models
|
||||
|
||||
# Authenticate
|
||||
openclaw models auth login --provider your-provider --set-default
|
||||
# Start the gateway
|
||||
picoclaw gateway
|
||||
|
||||
# List models
|
||||
openclaw models list
|
||||
|
||||
# Set model
|
||||
openclaw models set your-provider/model-name
|
||||
|
||||
# Check usage
|
||||
openclaw models usage
|
||||
# Run an agent with a specific model
|
||||
picoclaw agent -m "Hello" --model your-model
|
||||
```
|
||||
|
||||
### Environment Variables for Testing
|
||||
|
||||
```bash
|
||||
# Test specific providers only
|
||||
export OPENCLAW_LIVE_PROVIDERS="your-provider,google-antigravity"
|
||||
# Override default model
|
||||
export PICOCLAW_AGENTS_DEFAULTS_MODEL=your-model
|
||||
|
||||
# Test with specific models
|
||||
export OPENCLAW_LIVE_GATEWAY_MODELS="your-provider/model-name"
|
||||
# Override provider settings
|
||||
export PICOCLAW_MODEL_LIST='[{"model_name":"your-model","model":"your-provider/model-name","api_key":"..."}]'
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -926,16 +731,16 @@ export OPENCLAW_LIVE_GATEWAY_MODELS="your-provider/model-name"
|
|||
## References
|
||||
|
||||
- **Source Files:**
|
||||
- `extensions/google-antigravity-auth/index.ts` - Full OAuth implementation
|
||||
- `src/infra/provider-usage.fetch.antigravity.ts` - Usage fetching
|
||||
- `src/agents/pi-embedded-runner/google.ts` - Model sanitization
|
||||
- `src/agents/model-forward-compat.ts` - Forward compatibility
|
||||
- `src/plugin-sdk/provider-auth-result.ts` - Auth result builder
|
||||
- `src/plugins/types.ts` - Plugin type definitions
|
||||
- `pkg/providers/antigravity_provider.go` - Antigravity provider implementation
|
||||
- `pkg/auth/oauth.go` - OAuth flow implementation
|
||||
- `pkg/auth/store.go` - Auth credential storage (`~/.picoclaw/auth.json`)
|
||||
- `pkg/providers/factory.go` - Provider factory and protocol routing
|
||||
- `pkg/providers/types.go` - Provider interface definitions
|
||||
- `cmd/picoclaw/cmd_auth.go` - Auth CLI commands
|
||||
|
||||
- **Documentation:**
|
||||
- `docs/concepts/model-providers.md` - Provider overview
|
||||
- `docs/concepts/usage-tracking.md` - Usage tracking
|
||||
- `docs/ANTIGRAVITY_USAGE.md` - Antigravity usage guide
|
||||
- `docs/migration/model-list-migration.md` - Migration guide
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -987,7 +792,7 @@ Some models might show up in the available models list but return an empty respo
|
|||
## Troubleshooting
|
||||
|
||||
### "Token expired"
|
||||
- Refresh OAuth tokens: `openclaw models auth login --provider google-antigravity`
|
||||
- Refresh OAuth tokens: `picoclaw auth login --provider antigravity`
|
||||
|
||||
### "Gemini for Google Cloud is not enabled"
|
||||
- Enable the API in your Google Cloud Console
|
||||
|
|
@ -998,5 +803,5 @@ Some models might show up in the available models list but return an empty respo
|
|||
|
||||
### Models not appearing in list
|
||||
- Verify OAuth authentication completed successfully
|
||||
- Check auth profile storage: `~/.openclaw/agent/auth-profiles.json`
|
||||
- Ensure the plugin is enabled: `openclaw plugins list`
|
||||
- Check auth profile storage: `~/.picoclaw/auth.json`
|
||||
- Re-run `picoclaw auth login --provider antigravity`
|
||||
|
|
|
|||
|
|
@ -47,14 +47,12 @@ picoclaw agent -m "Hello" --model claude-opus-4-6-thinking
|
|||
|
||||
If you are deploying via Coolify or Docker, follow these steps to test:
|
||||
|
||||
1. **Branch**: Use the `feat/antigravity-provider` branch.
|
||||
2. **Environment Variables**:
|
||||
* `PICOCLAW_AGENTS_DEFAULTS_PROVIDER=antigravity`
|
||||
* `PICOCLAW_AGENTS_DEFAULTS_MODEL=gemini-3-flash`
|
||||
3. **Authentication persistence**:
|
||||
1. **Environment Variables**:
|
||||
* `PICOCLAW_AGENTS_DEFAULTS_MODEL=gemini-flash`
|
||||
2. **Authentication persistence**:
|
||||
If you've logged in locally, you can copy your credentials to the server:
|
||||
```bash
|
||||
scp ~/.picoclaw/auth-profiles.json user@your-server:~/.picoclaw/
|
||||
scp ~/.picoclaw/auth.json user@your-server:~/.picoclaw/
|
||||
```
|
||||
*Alternatively*, run the `auth login` command once on the server if you have terminal access.
|
||||
|
||||
|
|
|
|||
33
docs/channels/dingtalk/README.zh.md
Normal file
33
docs/channels/dingtalk/README.zh.md
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# 钉钉
|
||||
|
||||
钉钉是阿里巴巴的企业通讯平台,在中国职场中广受欢迎。它采用流式 SDK 来维持持久连接。
|
||||
|
||||
## 配置
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"dingtalk": {
|
||||
"enabled": true,
|
||||
"client_id": "YOUR_CLIENT_ID",
|
||||
"client_secret": "YOUR_CLIENT_SECRET",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 必填 | 描述 |
|
||||
| ------------- | ------ | ---- | -------------------------------- |
|
||||
| enabled | bool | 是 | 是否启用钉钉频道 |
|
||||
| client_id | string | 是 | 钉钉应用的 Client ID |
|
||||
| client_secret | string | 是 | 钉钉应用的 Client Secret |
|
||||
| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
|
||||
|
||||
## 设置流程
|
||||
|
||||
1. 前往 [钉钉开放平台](https://open.dingtalk.com/)
|
||||
2. 创建一个企业内部应用
|
||||
3. 从应用设置中获取 Client ID 和 Client Secret
|
||||
4. 配置OAuth和事件订阅(如需要)
|
||||
5. 将 Client ID 和 Client Secret 填入配置文件中
|
||||
35
docs/channels/discord/README.zh.md
Normal file
35
docs/channels/discord/README.zh.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# Discord
|
||||
|
||||
Discord 是一个专为社区设计的免费语音、视频和文本聊天应用。PicoClaw 通过 Discord Bot API 连接到 Discord 服务器,支持接收和发送消息。
|
||||
|
||||
## 配置
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"discord": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_BOT_TOKEN",
|
||||
"allow_from": ["YOUR_USER_ID"],
|
||||
"mention_only": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 必填 | 描述 |
|
||||
| ------------ | ------ | ---- | -------------------------------- |
|
||||
| enabled | bool | 是 | 是否启用 Discord 频道 |
|
||||
| token | string | 是 | Discord 机器人 Token |
|
||||
| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
|
||||
| mention_only | bool | 否 | 是否仅响应提及机器人的消息 |
|
||||
|
||||
## 设置流程
|
||||
|
||||
1. 前往 [Discord 开发者门户](https://discord.com/developers/applications) 创建一个新的应用
|
||||
2. 启用 Intents:
|
||||
- Message Content Intent
|
||||
- Server Members Intent
|
||||
3. 获取 Bot Token
|
||||
4. 将 Bot Token 填入配置文件中
|
||||
5. 邀请机器人加入服务器并授予必要权限(例如发送消息、读取消息历史等)
|
||||
37
docs/channels/feishu/README.zh.md
Normal file
37
docs/channels/feishu/README.zh.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# 飞书
|
||||
|
||||
飞书(国际版名称:Lark)是字节跳动旗下的企业协作平台。它通过事件驱动的 Webhook 同时支持中国和全球市场。
|
||||
|
||||
## 配置
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"feishu": {
|
||||
"enabled": true,
|
||||
"app_id": "cli_xxx",
|
||||
"app_secret": "xxx",
|
||||
"encrypt_key": "",
|
||||
"verification_token": "",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 必填 | 描述 |
|
||||
| ------------------ | ------ | ---- | -------------------------------- |
|
||||
| enabled | bool | 是 | 是否启用飞书频道 |
|
||||
| app_id | string | 是 | 飞书应用的 App ID(以cli\_开头) |
|
||||
| app_secret | string | 是 | 飞书应用的 App Secret |
|
||||
| encrypt_key | string | 否 | 事件回调加密密钥 |
|
||||
| verification_token | string | 否 | 用于Webhook事件验证的Token |
|
||||
| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
|
||||
|
||||
## 设置流程
|
||||
|
||||
1. 前往 [飞书开放平台](https://open.feishu.cn/)创建应用程序
|
||||
2. 获取 App ID 和 App Secret
|
||||
3. 配置事件订阅和Webhook URL
|
||||
4. 设置加密(可选,生产环境建议启用)
|
||||
5. 将 App ID、App Secret、Encrypt Key 和 Verification Token(如果启用加密) 填入配置文件中
|
||||
41
docs/channels/line/README.zh.md
Normal file
41
docs/channels/line/README.zh.md
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# Line
|
||||
|
||||
PicoClaw 通过 LINE Messaging API 配合 Webhook 回调功能实现对 LINE 的支持。
|
||||
|
||||
## 配置
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"line": {
|
||||
"enabled": true,
|
||||
"channel_secret": "YOUR_CHANNEL_SECRET",
|
||||
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
|
||||
"webhook_host": "0.0.0.0",
|
||||
"webhook_port": 18791,
|
||||
"webhook_path": "/webhook/line",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 必填 | 描述 |
|
||||
| -------------------- | ------ | ---- | ------------------------------------------ |
|
||||
| enabled | bool | 是 | 是否启用 LINE Channel |
|
||||
| channel_secret | string | 是 | LINE Messaging API 的 Channel Secret |
|
||||
| channel_access_token | string | 是 | LINE Messaging API 的 Channel Access Token |
|
||||
| webhook_host | string | 是 | Webhook 监听的主机地址 (通常为 0.0.0.0) |
|
||||
| webhook_port | int | 是 | Webhook 监听的端口 (默认为 18791) |
|
||||
| webhook_path | string | 是 | Webhook 的路径 (默认为 /webhook/line) |
|
||||
| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
|
||||
|
||||
## 设置流程
|
||||
|
||||
1. 前往 [LINE Developers Console](https://developers.line.biz/console/) 创建一个服务提供商和一个 Messaging API Channel
|
||||
2. 获取 Channel Secret 和 Channel Access Token
|
||||
3. 配置Webhook:
|
||||
- Line要求Webhook必须使用HTTPS协议,因此需要部署一个支持HTTPS的服务器,或者使用反向代理工具如ngrok将本地服务器暴露到公网
|
||||
- 将 Webhook URL 设置为 `https://your-domain.com/webhook/line`
|
||||
- 启用 Webhook 并验证 URL
|
||||
4. 将 Channel Secret 和 Channel Access Token 填入配置文件中
|
||||
31
docs/channels/maixcam/README.zh.md
Normal file
31
docs/channels/maixcam/README.zh.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# MaixCam
|
||||
|
||||
MaixCam 是专用于连接矽速科技 MaixCAM 与 MaixCAM2 AI 摄像设备的通道。它采用 TCP 套接字实现双向通信,支持边缘 AI 部署场景。
|
||||
|
||||
## 配置
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"maixcam": {
|
||||
"enabled": true,
|
||||
"server_address": "0.0.0.0:8899",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 必填 | 描述 |
|
||||
| -------------- | ------ | ---- | -------------------------------- |
|
||||
| enabled | bool | 是 | 是否启用 MaixCam 频道 |
|
||||
| server_address | string | 是 | TCP 服务器监听地址和端口 |
|
||||
| allow_from | array | 否 | 设备ID白名单,空表示允许所有设备 |
|
||||
|
||||
## 使用场景
|
||||
|
||||
MaixCam 通道使 PicoClaw 能够作为边缘设备的 AI 后端运行:
|
||||
|
||||
- **智能监控** :MaixCAM 发送图像帧,PicoClaw 通过视觉模型进行分析
|
||||
- **物联网控制** :设备发送传感器数据,PicoClaw 协调响应
|
||||
- **离线AI** :在本地网络部署 PicoClaw 实现低延迟推理
|
||||
31
docs/channels/onebot/README.zh.md
Normal file
31
docs/channels/onebot/README.zh.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# OneBot
|
||||
|
||||
OneBot 是一个面向 QQ 机器人的开放协议标准,为多种 QQ 机器人实现(例如 go-cqhttp、Mirai)提供了统一的接口。它使用 WebSocket 进行通信。
|
||||
|
||||
## 配置
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"onebot": {
|
||||
"enabled": true,
|
||||
"ws_url": "ws://localhost:8080",
|
||||
"access_token": "",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 必填 | 描述 |
|
||||
| ------------ | ------ | ---- | -------------------------------- |
|
||||
| enabled | bool | 是 | 是否启用 OneBot 频道 |
|
||||
| ws_url | string | 是 | OneBot 服务器的 WebSocket URL |
|
||||
| access_token | string | 否 | 连接 OneBot 服务器的访问令牌 |
|
||||
| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
|
||||
|
||||
## 设置流程
|
||||
|
||||
1. 部署一个 OneBot 兼容的实现(例如napcat)
|
||||
2. 配置 OneBot 实现以启用 WebSocket 服务并设置访问令牌(如果需要)
|
||||
3. 将 WebSocket URL 和访问令牌填入配置文件中
|
||||
32
docs/channels/qq/README.zh.md
Normal file
32
docs/channels/qq/README.zh.md
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# QQ
|
||||
|
||||
PicoClaw 通过 QQ 开放平台的官方机器人 API 提供对 QQ 的支持。
|
||||
|
||||
## 配置
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"qq": {
|
||||
"enabled": true,
|
||||
"app_id": "YOUR_APP_ID",
|
||||
"app_secret": "YOUR_APP_SECRET",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 必填 | 描述 |
|
||||
| ---------- | ------ | ---- | -------------------------------- |
|
||||
| enabled | bool | 是 | 是否启用 QQ Channel |
|
||||
| app_id | string | 是 | QQ 机器人应用的 App ID |
|
||||
| app_secret | string | 是 | QQ 机器人应用的 App Secret |
|
||||
| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
|
||||
|
||||
## 设置流程
|
||||
|
||||
1. 前往 [QQ 开放平台](https://q.qq.com/) 创建一个机器人
|
||||
2. 通过仪表盘获取 App ID 和 App Secret
|
||||
3. 开启机器人沙箱模式, 将用户和群添加到沙箱中
|
||||
4. 将 App ID 和 App Secret 填入配置文件中
|
||||
33
docs/channels/slack/README.zh.md
Normal file
33
docs/channels/slack/README.zh.md
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# Slack
|
||||
|
||||
Slack 是全球领先的企业级即时通讯平台。PicoClaw 采用 Slack 的 Socket Mode 实现实时双向通信,无需配置公开的 Webhook 端点。
|
||||
|
||||
## 配置
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"slack": {
|
||||
"enabled": true,
|
||||
"bot_token": "xoxb-...",
|
||||
"app_token": "xapp-...",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 必填 | 描述 |
|
||||
| ---------- | ------ | ---- | -------------------------------------------------------- |
|
||||
| enabled | bool | 是 | 是否启用 Slack 频道 |
|
||||
| bot_token | string | 是 | Slack 机器人的 Bot User OAuth Token (以 xoxb- 开头) |
|
||||
| app_token | string | 是 | Slack 应用的 Socket Mode App Level Token (以 xapp- 开头) |
|
||||
| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
|
||||
|
||||
## 设置流程
|
||||
|
||||
1. 前往 [Slack API](https://api.slack.com/) 创建一个新的 Slack 应用
|
||||
2. 启用 Socket Mode 并获取 App Level Token
|
||||
3. 添加 Bot Token Scopes(例如`chat:write`、`im:history`等)
|
||||
4. 安装应用到工作区并获取 Bot User OAuth Token
|
||||
5. 将 Bot Token 和 App Token 填入配置文件中
|
||||
33
docs/channels/telegram/README.zh.md
Normal file
33
docs/channels/telegram/README.zh.md
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# Telegram
|
||||
|
||||
Telegram Channel 通过 Telegram 机器人 API 使用长轮询实现基于机器人的通信。它支持文本消息、媒体附件(照片、语音、音频、文档)、通过 Groq Whisper 进行语音转录以及内置命令处理器。
|
||||
|
||||
## 配置
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz",
|
||||
"allow_from": ["123456789"],
|
||||
"proxy": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 必填 | 描述 |
|
||||
| ---------- | ------ | ---- | --------------------------------------------------------- |
|
||||
| enabled | bool | 是 | 是否启用 Telegram 频道 |
|
||||
| token | string | 是 | Telegram 机器人 API Token |
|
||||
| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
|
||||
| proxy | string | 否 | 连接 Telegram API 的代理 URL (例如 http://127.0.0.1:7890) |
|
||||
|
||||
## 设置流程
|
||||
|
||||
1. 在 Telegram 中搜索 `@BotFather`
|
||||
2. 发送 `/newbot` 命令并按照提示创建新机器人
|
||||
3. 获取 HTTP API Token
|
||||
4. 将 Token 填入配置文件中
|
||||
5. (可选) 配置 `allow_from` 以限制允许互动的用户 ID (可通过 `@userinfobot` 获取 ID)
|
||||
47
docs/channels/wecom/wecom_app/README.zh.md
Normal file
47
docs/channels/wecom/wecom_app/README.zh.md
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# 企业微信自建应用
|
||||
|
||||
企业微信自建应用是指企业在企业微信中创建的应用,主要用于企业内部使用。通过企业微信自建应用,企业可以实现与员工的高效沟通和协作,提高工作效率。
|
||||
|
||||
## 配置
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"wecom_app": {
|
||||
"enabled": true,
|
||||
"corp_id": "wwxxxxxxxxxxxxxxxx",
|
||||
"corp_secret": "YOUR_CORP_SECRET",
|
||||
"agent_id": 1000002,
|
||||
"token": "YOUR_TOKEN",
|
||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||
"webhook_host": "0.0.0.0",
|
||||
"webhook_port": 18792,
|
||||
"webhook_path": "/webhook/wecom-app",
|
||||
"allow_from": [],
|
||||
"reply_timeout": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 必填 | 描述 |
|
||||
| ---------------- | ------ | ---- | ---------------------------------------- |
|
||||
| corp_id | string | 是 | 企业 ID |
|
||||
| corp_secret | string | 是 | 应用程序密钥 |
|
||||
| agent_id | int | 是 | 应用程序代理 ID |
|
||||
| token | string | 是 | 回调验证令牌 |
|
||||
| encoding_aes_key | string | 是 | 43 字符 AES 密钥 |
|
||||
| webhook_host | string | 否 | HTTP 服务器绑定地址 |
|
||||
| webhook_port | int | 否 | HTTP 服务器端口(默认:18792) |
|
||||
| webhook_path | string | 否 | Webhook 路径(默认:/webhook/wecom-app) |
|
||||
| allow_from | array | 否 | 用户 ID 白名单 |
|
||||
| reply_timeout | int | 否 | 回复超时时间(秒) |
|
||||
|
||||
## 设置流程
|
||||
|
||||
1. 登录 [企业微信管理后台](https://work.weixin.qq.com/)
|
||||
2. 进入“应用管理” -> “创建应用”
|
||||
3. 获取企业 ID (CorpID) 和应用 Secret
|
||||
4. 在应用设置中配置“接收消息”,获取 Token 和 EncodingAESKey
|
||||
5. 设置回调 URL 为 `http://<your-server-ip>:<port>/webhook/wecom-app`
|
||||
6. 将 CorpID, Secret, AgentID 等信息填入配置文件
|
||||
41
docs/channels/wecom/wecom_bot/README.zh.md
Normal file
41
docs/channels/wecom/wecom_bot/README.zh.md
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# 企业微信机器人
|
||||
|
||||
企业微信机器人是企业微信提供的一种快速接入方式,可以通过 Webhook URL 接收消息。
|
||||
|
||||
## 配置
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"wecom": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_TOKEN",
|
||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
|
||||
"webhook_host": "0.0.0.0",
|
||||
"webhook_port": 18793,
|
||||
"webhook_path": "/webhook/wecom",
|
||||
"allow_from": [],
|
||||
"reply_timeout": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 必填 | 描述 |
|
||||
| ---------------- | ------ | ---- | -------------------------------------------- |
|
||||
| token | string | 是 | 签名验证代币 |
|
||||
| encoding_aes_key | string | 是 | 用于解密的 43 字符 AES 密钥 |
|
||||
| webhook_url | string | 是 | 用于发送回复的企业微信群聊机器人 Webhook URL |
|
||||
| webhook_host | string | 否 | HTTP 服务器绑定地址(默认:0.0.0.0) |
|
||||
| webhook_port | int | 否 | HTTP 服务器端口(默认:18793) |
|
||||
| webhook_path | string | 否 | Webhook 端点路径(默认:/webhook/wecom) |
|
||||
| allow_from | array | 否 | 用户 ID 白名单(空值 = 允许所有用户) |
|
||||
| reply_timeout | int | 否 | 回复超时时间(单位:秒,默认值:5) |
|
||||
|
||||
## 设置流程
|
||||
|
||||
1. 在企业微信群中添加机器人
|
||||
2. 获取 Webhook URL
|
||||
3. (如需接收消息) 在机器人配置页面设置接收消息的 API 地址(回调地址)以及 Token 和 EncodingAESKey
|
||||
4. 将相关信息填入配置文件
|
||||
|
|
@ -1,7 +1,5 @@
|
|||
# Provider Architecture Refactoring - Test Suite Summary
|
||||
|
||||
> PRD: `tasks/prd-provider-refactoring.md`
|
||||
|
||||
This document summarizes the complete test suite designed for the Provider architecture refactoring.
|
||||
|
||||
## Test File Structure
|
||||
|
|
@ -12,10 +10,8 @@ pkg/
|
|||
│ ├── model_config_test.go # US-001, US-002: ModelConfig struct and GetModelConfig tests
|
||||
│ └── migration_test.go # US-003: Backward compatibility and migration tests
|
||||
├── providers/
|
||||
│ ├── registry_test.go # US-006: Load balancing tests
|
||||
│ ├── integration_test.go # E2E integration tests
|
||||
│ └── factory/
|
||||
│ └── factory_test.go # US-004, US-005: Provider factory tests
|
||||
│ ├── factory_test.go # US-004, US-005: Provider factory tests
|
||||
│ └── factory_provider_test.go # Factory provider integration tests
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -122,7 +118,6 @@ go test ./pkg/... -race
|
|||
# Run specific package tests
|
||||
go test ./pkg/config -v
|
||||
go test ./pkg/providers -v
|
||||
go test ./pkg/providers/factory -v
|
||||
|
||||
# Run E2E tests
|
||||
go test ./pkg/providers -run TestE2E -v
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ The `model` field uses a protocol prefix format: `[protocol/]model-identifier`
|
|||
| `openai/` | OpenAI API (default) | `openai/gpt-5.2` |
|
||||
| `anthropic/` | Anthropic API | `anthropic/claude-opus-4` |
|
||||
| `antigravity/` | Google via Antigravity OAuth | `antigravity/gemini-2.0-flash` |
|
||||
| `gemini/` | Google Gemini API | `gemini/gemini-2.0-flash-exp` |
|
||||
| `claude-cli/` | Claude CLI (local) | `claude-cli/claude-sonnet-4.6` |
|
||||
| `codex-cli/` | Codex CLI (local) | `codex-cli/codex-4` |
|
||||
| `github-copilot/` | GitHub Copilot | `github-copilot/gpt-4o` |
|
||||
|
|
@ -93,6 +94,13 @@ The `model` field uses a protocol prefix format: `[protocol/]model-identifier`
|
|||
| `deepseek/` | DeepSeek API | `deepseek/deepseek-chat` |
|
||||
| `cerebras/` | Cerebras API | `cerebras/llama-3.3-70b` |
|
||||
| `qwen/` | Alibaba Qwen | `qwen/qwen-max` |
|
||||
| `zhipu/` | Zhipu AI | `zhipu/glm-4` |
|
||||
| `nvidia/` | NVIDIA NIM | `nvidia/llama-3.1-nemotron-70b` |
|
||||
| `ollama/` | Ollama (local) | `ollama/llama3` |
|
||||
| `vllm/` | vLLM (local) | `vllm/my-model` |
|
||||
| `moonshot/` | Moonshot AI | `moonshot/moonshot-v1-8k` |
|
||||
| `shengsuanyun/` | ShengSuanYun | `shengsuanyun/deepseek-v3` |
|
||||
| `volcengine/` | Volcengine | `volcengine/doubao-pro-32k` |
|
||||
|
||||
**Note**: If no prefix is specified, `openai/` is used as the default.
|
||||
|
||||
|
|
|
|||
|
|
@ -71,14 +71,14 @@ Interested in a specific feature? You can "claim" these tasks and start building
|
|||
* Support for OneBot, additional platforms
|
||||
* attachments (images, audio, video, files).
|
||||
* **Skills:**
|
||||
* Implementing `find_skill` to discover tools via [openclaw/skills](https://github.com/openclaw/skills) and other platforms.
|
||||
* Implementing `find_skill` to discover tools via [ClawhHub](https://clawhub.ai) and other platforms.
|
||||
* **Operations:** * MCP Support.
|
||||
* Android operations (e.g., botdrop).
|
||||
* Browser automation via CDP or ActionBook.
|
||||
|
||||
|
||||
* **Multi-Agent Ecosystem:**
|
||||
* **Basic Model-Agnet** S
|
||||
* **Basic Model-Agent**
|
||||
* **Model Routing:** Small models for easy tasks, large models for hard ones (to save tokens).
|
||||
* **Swarm Mode.**
|
||||
* **AIEOS Integration.**
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ PicoClaw's tools configuration is located in the `tools` field of `config.json`.
|
|||
"tools": {
|
||||
"web": { ... },
|
||||
"exec": { ... },
|
||||
"approval": { ... },
|
||||
"cron": { ... }
|
||||
"cron": { ... },
|
||||
"skills": { ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
@ -83,25 +83,12 @@ By default, PicoClaw blocks the following dangerous commands:
|
|||
"custom_deny_patterns": [
|
||||
"\\brm\\s+-r\\b",
|
||||
"\\bkillall\\s+python"
|
||||
],
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Approval Tool
|
||||
|
||||
The approval tool controls permissions for dangerous operations.
|
||||
|
||||
| Config | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `enabled` | bool | true | Enable approval functionality |
|
||||
| `write_file` | bool | true | Require approval for file writes |
|
||||
| `edit_file` | bool | true | Require approval for file edits |
|
||||
| `append_file` | bool | true | Require approval for file appends |
|
||||
| `exec` | bool | true | Require approval for command execution |
|
||||
| `timeout_minutes` | int | 5 | Approval timeout in minutes |
|
||||
|
||||
## Cron Tool
|
||||
|
||||
The cron tool is used for scheduling periodic tasks.
|
||||
|
|
@ -110,6 +97,40 @@ The cron tool is used for scheduling periodic tasks.
|
|||
|--------|------|---------|-------------|
|
||||
| `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit |
|
||||
|
||||
## Skills Tool
|
||||
|
||||
The skills tool configures skill discovery and installation via registries like ClawHub.
|
||||
|
||||
### Registries
|
||||
|
||||
| Config | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `registries.clawhub.enabled` | bool | true | Enable ClawHub registry |
|
||||
| `registries.clawhub.base_url` | string | `https://clawhub.ai` | ClawHub base URL |
|
||||
| `registries.clawhub.search_path` | string | `/api/v1/search` | Search API path |
|
||||
| `registries.clawhub.skills_path` | string | `/api/v1/skills` | Skills API path |
|
||||
| `registries.clawhub.download_path` | string | `/api/v1/download` | Download API path |
|
||||
|
||||
### Configuration Example
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"skills": {
|
||||
"registries": {
|
||||
"clawhub": {
|
||||
"enabled": true,
|
||||
"base_url": "https://clawhub.ai",
|
||||
"search_path": "/api/v1/search",
|
||||
"skills_path": "/api/v1/skills",
|
||||
"download_path": "/api/v1/download"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
All configuration options can be overridden via environment variables with the format `PICOCLAW_TOOLS_<SECTION>_<KEY>`:
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ Your workspace is at: %s
|
|||
|
||||
2. **Be helpful and accurate** - When using tools, briefly explain what you're doing.
|
||||
|
||||
3. **Memory** - When remembering something, write to %s/memory/MEMORY.md`,
|
||||
3. **Memory** - When interacting with me if something seems memorable, update %s/memory/MEMORY.md`,
|
||||
now, runtime, workspacePath, workspacePath, workspacePath, workspacePath, toolsSection, workspacePath)
|
||||
}
|
||||
|
||||
|
|
@ -96,7 +96,9 @@ func (cb *ContextBuilder) buildToolsSection() string {
|
|||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("## Available Tools\n\n")
|
||||
sb.WriteString("**CRITICAL**: You MUST use tools to perform actions. Do NOT pretend to execute commands or schedule tasks.\n\n")
|
||||
sb.WriteString(
|
||||
"**CRITICAL**: You MUST use tools to perform actions. Do NOT pretend to execute commands or schedule tasks.\n\n",
|
||||
)
|
||||
sb.WriteString("You have access to the following tools:\n\n")
|
||||
for _, s := range summaries {
|
||||
sb.WriteString(s)
|
||||
|
|
@ -146,18 +148,24 @@ func (cb *ContextBuilder) LoadBootstrapFiles() string {
|
|||
"IDENTITY.md",
|
||||
}
|
||||
|
||||
var result string
|
||||
var sb strings.Builder
|
||||
for _, filename := range bootstrapFiles {
|
||||
filePath := filepath.Join(cb.workspace, filename)
|
||||
if data, err := os.ReadFile(filePath); err == nil {
|
||||
result += fmt.Sprintf("## %s\n\n%s\n\n", filename, string(data))
|
||||
fmt.Fprintf(&sb, "## %s\n\n%s\n\n", filename, data)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary string, currentMessage string, media []string, channel, chatID string) []providers.Message {
|
||||
func (cb *ContextBuilder) BuildMessages(
|
||||
history []providers.Message,
|
||||
summary string,
|
||||
currentMessage string,
|
||||
media []string,
|
||||
channel, chatID string,
|
||||
) []providers.Message {
|
||||
messages := []providers.Message{}
|
||||
|
||||
systemPrompt := cb.BuildSystemPrompt()
|
||||
|
|
@ -169,7 +177,7 @@ func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary str
|
|||
|
||||
// Log system prompt summary for debugging (debug mode only)
|
||||
logger.DebugCF("agent", "System prompt built",
|
||||
map[string]interface{}{
|
||||
map[string]any{
|
||||
"total_chars": len(systemPrompt),
|
||||
"total_lines": strings.Count(systemPrompt, "\n") + 1,
|
||||
"section_count": strings.Count(systemPrompt, "\n\n---\n\n") + 1,
|
||||
|
|
@ -181,7 +189,7 @@ func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary str
|
|||
preview = preview[:500] + "... (truncated)"
|
||||
}
|
||||
logger.DebugCF("agent", "System prompt preview",
|
||||
map[string]interface{}{
|
||||
map[string]any{
|
||||
"preview": preview,
|
||||
})
|
||||
|
||||
|
|
@ -218,12 +226,12 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message
|
|||
switch msg.Role {
|
||||
case "tool":
|
||||
if len(sanitized) == 0 {
|
||||
logger.DebugCF("agent", "Dropping orphaned leading tool message", map[string]interface{}{})
|
||||
logger.DebugCF("agent", "Dropping orphaned leading tool message", map[string]any{})
|
||||
continue
|
||||
}
|
||||
last := sanitized[len(sanitized)-1]
|
||||
if last.Role != "assistant" || len(last.ToolCalls) == 0 {
|
||||
logger.DebugCF("agent", "Dropping orphaned tool message", map[string]interface{}{})
|
||||
logger.DebugCF("agent", "Dropping orphaned tool message", map[string]any{})
|
||||
continue
|
||||
}
|
||||
sanitized = append(sanitized, msg)
|
||||
|
|
@ -231,12 +239,16 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message
|
|||
case "assistant":
|
||||
if len(msg.ToolCalls) > 0 {
|
||||
if len(sanitized) == 0 {
|
||||
logger.DebugCF("agent", "Dropping assistant tool-call turn at history start", map[string]interface{}{})
|
||||
logger.DebugCF("agent", "Dropping assistant tool-call turn at history start", map[string]any{})
|
||||
continue
|
||||
}
|
||||
prev := sanitized[len(sanitized)-1]
|
||||
if prev.Role != "user" && prev.Role != "tool" {
|
||||
logger.DebugCF("agent", "Dropping assistant tool-call turn with invalid predecessor", map[string]interface{}{"prev_role": prev.Role})
|
||||
logger.DebugCF(
|
||||
"agent",
|
||||
"Dropping assistant tool-call turn with invalid predecessor",
|
||||
map[string]any{"prev_role": prev.Role},
|
||||
)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
|
@ -250,7 +262,10 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message
|
|||
return sanitized
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) AddToolResult(messages []providers.Message, toolCallID, toolName, result string) []providers.Message {
|
||||
func (cb *ContextBuilder) AddToolResult(
|
||||
messages []providers.Message,
|
||||
toolCallID, toolName, result string,
|
||||
) []providers.Message {
|
||||
messages = append(messages, providers.Message{
|
||||
Role: "tool",
|
||||
Content: result,
|
||||
|
|
@ -259,7 +274,11 @@ func (cb *ContextBuilder) AddToolResult(messages []providers.Message, toolCallID
|
|||
return messages
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) AddAssistantMessage(messages []providers.Message, content string, toolCalls []map[string]interface{}) []providers.Message {
|
||||
func (cb *ContextBuilder) AddAssistantMessage(
|
||||
messages []providers.Message,
|
||||
content string,
|
||||
toolCalls []map[string]any,
|
||||
) []providers.Message {
|
||||
msg := providers.Message{
|
||||
Role: "assistant",
|
||||
Content: content,
|
||||
|
|
@ -289,13 +308,13 @@ func (cb *ContextBuilder) loadSkills() string {
|
|||
}
|
||||
|
||||
// GetSkillsInfo returns information about loaded skills.
|
||||
func (cb *ContextBuilder) GetSkillsInfo() map[string]interface{} {
|
||||
func (cb *ContextBuilder) GetSkillsInfo() map[string]any {
|
||||
allSkills := cb.skillsLoader.ListSkills()
|
||||
skillNames := make([]string, 0, len(allSkills))
|
||||
for _, s := range allSkills {
|
||||
skillNames = append(skillNames, s.Name)
|
||||
}
|
||||
return map[string]interface{}{
|
||||
return map[string]any{
|
||||
"total": len(allSkills),
|
||||
"available": len(allSkills),
|
||||
"names": skillNames,
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ func NewAgentInstance(
|
|||
provider providers.LLMProvider,
|
||||
) *AgentInstance {
|
||||
workspace := resolveAgentWorkspace(agentCfg, defaults)
|
||||
os.MkdirAll(workspace, 0755)
|
||||
os.MkdirAll(workspace, 0o755)
|
||||
|
||||
model := resolveAgentModel(agentCfg, defaults)
|
||||
fallbacks := resolveAgentFallbacks(agentCfg, defaults)
|
||||
|
|
|
|||
|
|
@ -80,7 +80,12 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers
|
|||
}
|
||||
|
||||
// registerSharedTools registers tools that are shared across all agents (web, message, spawn).
|
||||
func registerSharedTools(cfg *config.Config, msgBus *bus.MessageBus, registry *AgentRegistry, provider providers.LLMProvider) {
|
||||
func registerSharedTools(
|
||||
cfg *config.Config,
|
||||
msgBus *bus.MessageBus,
|
||||
registry *AgentRegistry,
|
||||
provider providers.LLMProvider,
|
||||
) {
|
||||
for _, agentID := range registry.ListAgentIDs() {
|
||||
agent, ok := registry.GetAgent(agentID)
|
||||
if !ok {
|
||||
|
|
@ -123,7 +128,10 @@ func registerSharedTools(cfg *config.Config, msgBus *bus.MessageBus, registry *A
|
|||
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
|
||||
ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub),
|
||||
})
|
||||
searchCache := skills.NewSearchCache(cfg.Tools.Skills.SearchCache.MaxSize, time.Duration(cfg.Tools.Skills.SearchCache.TTLSeconds)*time.Second)
|
||||
searchCache := skills.NewSearchCache(
|
||||
cfg.Tools.Skills.SearchCache.MaxSize,
|
||||
time.Duration(cfg.Tools.Skills.SearchCache.TTLSeconds)*time.Second,
|
||||
)
|
||||
agent.Tools.Register(tools.NewFindSkillsTool(registryMgr, searchCache))
|
||||
agent.Tools.Register(tools.NewInstallSkillTool(registryMgr, agent.Workspace))
|
||||
|
||||
|
|
@ -226,7 +234,10 @@ func (al *AgentLoop) ProcessDirect(ctx context.Context, content, sessionKey stri
|
|||
return al.ProcessDirectWithChannel(ctx, content, sessionKey, "cli", "direct")
|
||||
}
|
||||
|
||||
func (al *AgentLoop) ProcessDirectWithChannel(ctx context.Context, content, sessionKey, channel, chatID string) (string, error) {
|
||||
func (al *AgentLoop) ProcessDirectWithChannel(
|
||||
ctx context.Context,
|
||||
content, sessionKey, channel, chatID string,
|
||||
) (string, error) {
|
||||
msg := bus.InboundMessage{
|
||||
Channel: channel,
|
||||
SenderID: "cron",
|
||||
|
|
@ -263,7 +274,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
|||
logContent = utils.Truncate(msg.Content, 80)
|
||||
}
|
||||
logger.InfoCF("agent", fmt.Sprintf("Processing message from %s:%s: %s", msg.Channel, msg.SenderID, logContent),
|
||||
map[string]interface{}{
|
||||
map[string]any{
|
||||
"channel": msg.Channel,
|
||||
"chat_id": msg.ChatID,
|
||||
"sender_id": msg.SenderID,
|
||||
|
|
@ -302,7 +313,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
|||
}
|
||||
|
||||
logger.InfoCF("agent", "Routed message",
|
||||
map[string]interface{}{
|
||||
map[string]any{
|
||||
"agent_id": agent.ID,
|
||||
"session_key": sessionKey,
|
||||
"matched_by": route.MatchedBy,
|
||||
|
|
@ -325,7 +336,7 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe
|
|||
}
|
||||
|
||||
logger.InfoCF("agent", "Processing system message",
|
||||
map[string]interface{}{
|
||||
map[string]any{
|
||||
"sender_id": msg.SenderID,
|
||||
"chat_id": msg.ChatID,
|
||||
})
|
||||
|
|
@ -350,7 +361,7 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe
|
|||
// Skip internal channels - only log, don't send to user
|
||||
if constants.IsInternalChannel(originChannel) {
|
||||
logger.InfoCF("agent", "Subagent completed (internal channel)",
|
||||
map[string]interface{}{
|
||||
map[string]any{
|
||||
"sender_id": msg.SenderID,
|
||||
"content_len": len(content),
|
||||
"channel": originChannel,
|
||||
|
|
@ -383,7 +394,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
|
|||
if !constants.IsInternalChannel(opts.Channel) {
|
||||
channelKey := fmt.Sprintf("%s:%s", opts.Channel, opts.ChatID)
|
||||
if err := al.RecordLastChannel(channelKey); err != nil {
|
||||
logger.WarnCF("agent", "Failed to record last channel", map[string]interface{}{"error": err.Error()})
|
||||
logger.WarnCF("agent", "Failed to record last channel", map[string]any{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -445,7 +456,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
|
|||
// 9. Log response
|
||||
responsePreview := utils.Truncate(finalContent, 120)
|
||||
logger.InfoCF("agent", fmt.Sprintf("Response: %s", responsePreview),
|
||||
map[string]interface{}{
|
||||
map[string]any{
|
||||
"agent_id": agent.ID,
|
||||
"session_key": opts.SessionKey,
|
||||
"iterations": iteration,
|
||||
|
|
@ -456,7 +467,12 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
|
|||
}
|
||||
|
||||
// runLLMIteration executes the LLM call loop with tool handling.
|
||||
func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, messages []providers.Message, opts processOptions) (string, int, error) {
|
||||
func (al *AgentLoop) runLLMIteration(
|
||||
ctx context.Context,
|
||||
agent *AgentInstance,
|
||||
messages []providers.Message,
|
||||
opts processOptions,
|
||||
) (string, int, error) {
|
||||
iteration := 0
|
||||
var finalContent string
|
||||
|
||||
|
|
@ -464,7 +480,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
|
|||
iteration++
|
||||
|
||||
logger.DebugCF("agent", "LLM iteration",
|
||||
map[string]interface{}{
|
||||
map[string]any{
|
||||
"agent_id": agent.ID,
|
||||
"iteration": iteration,
|
||||
"max": agent.MaxIterations,
|
||||
|
|
@ -475,7 +491,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
|
|||
|
||||
// Log LLM request details
|
||||
logger.DebugCF("agent", "LLM request",
|
||||
map[string]interface{}{
|
||||
map[string]any{
|
||||
"agent_id": agent.ID,
|
||||
"iteration": iteration,
|
||||
"model": agent.Model,
|
||||
|
|
@ -488,7 +504,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
|
|||
|
||||
// Log full messages (detailed)
|
||||
logger.DebugCF("agent", "Full LLM request",
|
||||
map[string]interface{}{
|
||||
map[string]any{
|
||||
"iteration": iteration,
|
||||
"messages_json": formatMessagesForLog(messages),
|
||||
"tools_json": formatToolsForLog(providerToolDefs),
|
||||
|
|
@ -502,7 +518,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
|
|||
if len(agent.Candidates) > 1 && al.fallback != nil {
|
||||
fbResult, fbErr := al.fallback.Execute(ctx, agent.Candidates,
|
||||
func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) {
|
||||
return agent.Provider.Chat(ctx, messages, providerToolDefs, model, map[string]interface{}{
|
||||
return agent.Provider.Chat(ctx, messages, providerToolDefs, model, map[string]any{
|
||||
"max_tokens": agent.MaxTokens,
|
||||
"temperature": agent.Temperature,
|
||||
})
|
||||
|
|
@ -514,11 +530,11 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
|
|||
if fbResult.Provider != "" && len(fbResult.Attempts) > 0 {
|
||||
logger.InfoCF("agent", fmt.Sprintf("Fallback: succeeded with %s/%s after %d attempts",
|
||||
fbResult.Provider, fbResult.Model, len(fbResult.Attempts)+1),
|
||||
map[string]interface{}{"agent_id": agent.ID, "iteration": iteration})
|
||||
map[string]any{"agent_id": agent.ID, "iteration": iteration})
|
||||
}
|
||||
return fbResult.Response, nil
|
||||
}
|
||||
return agent.Provider.Chat(ctx, messages, providerToolDefs, agent.Model, map[string]interface{}{
|
||||
return agent.Provider.Chat(ctx, messages, providerToolDefs, agent.Model, map[string]any{
|
||||
"max_tokens": agent.MaxTokens,
|
||||
"temperature": agent.Temperature,
|
||||
})
|
||||
|
|
@ -539,7 +555,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
|
|||
strings.Contains(errMsg, "length")
|
||||
|
||||
if isContextError && retry < maxRetries {
|
||||
logger.WarnCF("agent", "Context window error detected, attempting compression", map[string]interface{}{
|
||||
logger.WarnCF("agent", "Context window error detected, attempting compression", map[string]any{
|
||||
"error": err.Error(),
|
||||
"retry": retry,
|
||||
})
|
||||
|
|
@ -566,7 +582,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
|
|||
|
||||
if err != nil {
|
||||
logger.ErrorCF("agent", "LLM call failed",
|
||||
map[string]interface{}{
|
||||
map[string]any{
|
||||
"agent_id": agent.ID,
|
||||
"iteration": iteration,
|
||||
"error": err.Error(),
|
||||
|
|
@ -578,7 +594,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
|
|||
if len(response.ToolCalls) == 0 {
|
||||
finalContent = response.Content
|
||||
logger.InfoCF("agent", "LLM response without tool calls (direct answer)",
|
||||
map[string]interface{}{
|
||||
map[string]any{
|
||||
"agent_id": agent.ID,
|
||||
"iteration": iteration,
|
||||
"content_chars": len(finalContent),
|
||||
|
|
@ -597,7 +613,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
|
|||
toolNames = append(toolNames, tc.Name)
|
||||
}
|
||||
logger.InfoCF("agent", "LLM requested tool calls",
|
||||
map[string]interface{}{
|
||||
map[string]any{
|
||||
"agent_id": agent.ID,
|
||||
"tools": toolNames,
|
||||
"count": len(normalizedToolCalls),
|
||||
|
|
@ -641,7 +657,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
|
|||
argsJSON, _ := json.Marshal(tc.Arguments)
|
||||
argsPreview := utils.Truncate(string(argsJSON), 200)
|
||||
logger.InfoCF("agent", fmt.Sprintf("Tool call: %s(%s)", tc.Name, argsPreview),
|
||||
map[string]interface{}{
|
||||
map[string]any{
|
||||
"agent_id": agent.ID,
|
||||
"tool": tc.Name,
|
||||
"iteration": iteration,
|
||||
|
|
@ -656,14 +672,21 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
|
|||
// The agent will handle user notification via processSystemMessage
|
||||
if !result.Silent && result.ForUser != "" {
|
||||
logger.InfoCF("agent", "Async tool completed, agent will handle notification",
|
||||
map[string]interface{}{
|
||||
map[string]any{
|
||||
"tool": tc.Name,
|
||||
"content_len": len(result.ForUser),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
toolResult := agent.Tools.ExecuteWithContext(ctx, tc.Name, tc.Arguments, opts.Channel, opts.ChatID, asyncCallback)
|
||||
toolResult := agent.Tools.ExecuteWithContext(
|
||||
ctx,
|
||||
tc.Name,
|
||||
tc.Arguments,
|
||||
opts.Channel,
|
||||
opts.ChatID,
|
||||
asyncCallback,
|
||||
)
|
||||
|
||||
// Send ForUser content to user immediately if not Silent
|
||||
if !toolResult.Silent && toolResult.ForUser != "" && opts.SendResponse {
|
||||
|
|
@ -673,7 +696,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
|
|||
Content: toolResult.ForUser,
|
||||
})
|
||||
logger.DebugCF("agent", "Sent tool result to user",
|
||||
map[string]interface{}{
|
||||
map[string]any{
|
||||
"tool": tc.Name,
|
||||
"content_len": len(toolResult.ForUser),
|
||||
})
|
||||
|
|
@ -775,7 +798,10 @@ func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) {
|
|||
|
||||
// Append compression note to the original system prompt instead of adding a new system message
|
||||
// This avoids having two consecutive system messages which some APIs (like Zhipu) reject
|
||||
compressionNote := fmt.Sprintf("\n\n[System Note: Emergency compression dropped %d oldest messages due to context limit]", droppedCount)
|
||||
compressionNote := fmt.Sprintf(
|
||||
"\n\n[System Note: Emergency compression dropped %d oldest messages due to context limit]",
|
||||
droppedCount,
|
||||
)
|
||||
enhancedSystemPrompt := history[0]
|
||||
enhancedSystemPrompt.Content = enhancedSystemPrompt.Content + compressionNote
|
||||
newHistory = append(newHistory, enhancedSystemPrompt)
|
||||
|
|
@ -787,7 +813,7 @@ func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) {
|
|||
agent.Sessions.SetHistory(sessionKey, newHistory)
|
||||
agent.Sessions.Save(sessionKey)
|
||||
|
||||
logger.WarnCF("agent", "Forced compression executed", map[string]interface{}{
|
||||
logger.WarnCF("agent", "Forced compression executed", map[string]any{
|
||||
"session_key": sessionKey,
|
||||
"dropped_msgs": droppedCount,
|
||||
"new_count": len(newHistory),
|
||||
|
|
@ -795,8 +821,8 @@ func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) {
|
|||
}
|
||||
|
||||
// GetStartupInfo returns information about loaded tools and skills for logging.
|
||||
func (al *AgentLoop) GetStartupInfo() map[string]interface{} {
|
||||
info := make(map[string]interface{})
|
||||
func (al *AgentLoop) GetStartupInfo() map[string]any {
|
||||
info := make(map[string]any)
|
||||
|
||||
agent := al.registry.GetDefaultAgent()
|
||||
if agent == nil {
|
||||
|
|
@ -805,7 +831,7 @@ func (al *AgentLoop) GetStartupInfo() map[string]interface{} {
|
|||
|
||||
// Tools info
|
||||
toolsList := agent.Tools.List()
|
||||
info["tools"] = map[string]interface{}{
|
||||
info["tools"] = map[string]any{
|
||||
"count": len(toolsList),
|
||||
"names": toolsList,
|
||||
}
|
||||
|
|
@ -814,7 +840,7 @@ func (al *AgentLoop) GetStartupInfo() map[string]interface{} {
|
|||
info["skills"] = agent.ContextBuilder.GetSkillsInfo()
|
||||
|
||||
// Agents info
|
||||
info["agents"] = map[string]interface{}{
|
||||
info["agents"] = map[string]any{
|
||||
"count": len(al.registry.ListAgentIDs()),
|
||||
"ids": al.registry.ListAgentIDs(),
|
||||
}
|
||||
|
|
@ -828,49 +854,49 @@ func formatMessagesForLog(messages []providers.Message) string {
|
|||
return "[]"
|
||||
}
|
||||
|
||||
var result string
|
||||
result += "[\n"
|
||||
var sb strings.Builder
|
||||
sb.WriteString("[\n")
|
||||
for i, msg := range messages {
|
||||
result += fmt.Sprintf(" [%d] Role: %s\n", i, msg.Role)
|
||||
fmt.Fprintf(&sb, " [%d] Role: %s\n", i, msg.Role)
|
||||
if len(msg.ToolCalls) > 0 {
|
||||
result += " ToolCalls:\n"
|
||||
sb.WriteString(" ToolCalls:\n")
|
||||
for _, tc := range msg.ToolCalls {
|
||||
result += fmt.Sprintf(" - ID: %s, Type: %s, Name: %s\n", tc.ID, tc.Type, tc.Name)
|
||||
fmt.Fprintf(&sb, " - ID: %s, Type: %s, Name: %s\n", tc.ID, tc.Type, tc.Name)
|
||||
if tc.Function != nil {
|
||||
result += fmt.Sprintf(" Arguments: %s\n", utils.Truncate(tc.Function.Arguments, 200))
|
||||
fmt.Fprintf(&sb, " Arguments: %s\n", utils.Truncate(tc.Function.Arguments, 200))
|
||||
}
|
||||
}
|
||||
}
|
||||
if msg.Content != "" {
|
||||
content := utils.Truncate(msg.Content, 200)
|
||||
result += fmt.Sprintf(" Content: %s\n", content)
|
||||
fmt.Fprintf(&sb, " Content: %s\n", content)
|
||||
}
|
||||
if msg.ToolCallID != "" {
|
||||
result += fmt.Sprintf(" ToolCallID: %s\n", msg.ToolCallID)
|
||||
fmt.Fprintf(&sb, " ToolCallID: %s\n", msg.ToolCallID)
|
||||
}
|
||||
result += "\n"
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
result += "]"
|
||||
return result
|
||||
sb.WriteString("]")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// formatToolsForLog formats tool definitions for logging
|
||||
func formatToolsForLog(tools []providers.ToolDefinition) string {
|
||||
if len(tools) == 0 {
|
||||
func formatToolsForLog(toolDefs []providers.ToolDefinition) string {
|
||||
if len(toolDefs) == 0 {
|
||||
return "[]"
|
||||
}
|
||||
|
||||
var result string
|
||||
result += "[\n"
|
||||
for i, tool := range tools {
|
||||
result += fmt.Sprintf(" [%d] Type: %s, Name: %s\n", i, tool.Type, tool.Function.Name)
|
||||
result += fmt.Sprintf(" Description: %s\n", tool.Function.Description)
|
||||
var sb strings.Builder
|
||||
sb.WriteString("[\n")
|
||||
for i, tool := range toolDefs {
|
||||
fmt.Fprintf(&sb, " [%d] Type: %s, Name: %s\n", i, tool.Type, tool.Function.Name)
|
||||
fmt.Fprintf(&sb, " Description: %s\n", tool.Function.Description)
|
||||
if len(tool.Function.Parameters) > 0 {
|
||||
result += fmt.Sprintf(" Parameters: %s\n", utils.Truncate(fmt.Sprintf("%v", tool.Function.Parameters), 200))
|
||||
fmt.Fprintf(&sb, " Parameters: %s\n", utils.Truncate(fmt.Sprintf("%v", tool.Function.Parameters), 200))
|
||||
}
|
||||
}
|
||||
result += "]"
|
||||
return result
|
||||
sb.WriteString("]")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// summarizeSession summarizes the conversation history for a session.
|
||||
|
|
@ -919,11 +945,21 @@ func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) {
|
|||
s1, _ := al.summarizeBatch(ctx, agent, part1, "")
|
||||
s2, _ := al.summarizeBatch(ctx, agent, part2, "")
|
||||
|
||||
mergePrompt := fmt.Sprintf("Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s", s1, s2)
|
||||
resp, err := agent.Provider.Chat(ctx, []providers.Message{{Role: "user", Content: mergePrompt}}, nil, agent.Model, map[string]interface{}{
|
||||
mergePrompt := fmt.Sprintf(
|
||||
"Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s",
|
||||
s1,
|
||||
s2,
|
||||
)
|
||||
resp, err := agent.Provider.Chat(
|
||||
ctx,
|
||||
[]providers.Message{{Role: "user", Content: mergePrompt}},
|
||||
nil,
|
||||
agent.Model,
|
||||
map[string]any{
|
||||
"max_tokens": 1024,
|
||||
"temperature": 0.3,
|
||||
})
|
||||
},
|
||||
)
|
||||
if err == nil {
|
||||
finalSummary = resp.Content
|
||||
} else {
|
||||
|
|
@ -945,20 +981,35 @@ func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) {
|
|||
}
|
||||
|
||||
// summarizeBatch summarizes a batch of messages.
|
||||
func (al *AgentLoop) summarizeBatch(ctx context.Context, agent *AgentInstance, batch []providers.Message, existingSummary string) (string, error) {
|
||||
prompt := "Provide a concise summary of this conversation segment, preserving core context and key points.\n"
|
||||
func (al *AgentLoop) summarizeBatch(
|
||||
ctx context.Context,
|
||||
agent *AgentInstance,
|
||||
batch []providers.Message,
|
||||
existingSummary string,
|
||||
) (string, error) {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("Provide a concise summary of this conversation segment, preserving core context and key points.\n")
|
||||
if existingSummary != "" {
|
||||
prompt += "Existing context: " + existingSummary + "\n"
|
||||
sb.WriteString("Existing context: ")
|
||||
sb.WriteString(existingSummary)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
prompt += "\nCONVERSATION:\n"
|
||||
sb.WriteString("\nCONVERSATION:\n")
|
||||
for _, m := range batch {
|
||||
prompt += fmt.Sprintf("%s: %s\n", m.Role, m.Content)
|
||||
fmt.Fprintf(&sb, "%s: %s\n", m.Role, m.Content)
|
||||
}
|
||||
prompt := sb.String()
|
||||
|
||||
response, err := agent.Provider.Chat(ctx, []providers.Message{{Role: "user", Content: prompt}}, nil, agent.Model, map[string]interface{}{
|
||||
response, err := agent.Provider.Chat(
|
||||
ctx,
|
||||
[]providers.Message{{Role: "user", Content: prompt}},
|
||||
nil,
|
||||
agent.Model,
|
||||
map[string]any{
|
||||
"max_tokens": 1024,
|
||||
"temperature": 0.3,
|
||||
})
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -171,7 +171,7 @@ func TestToolRegistry_ToolRegistration(t *testing.T) {
|
|||
// Verify tool is registered by checking it doesn't panic on GetStartupInfo
|
||||
// (actual tool retrieval is tested in tools package tests)
|
||||
info := al.GetStartupInfo()
|
||||
toolsInfo := info["tools"].(map[string]interface{})
|
||||
toolsInfo := info["tools"].(map[string]any)
|
||||
toolsList := toolsInfo["names"].([]string)
|
||||
|
||||
// Check that our custom tool name is in the list
|
||||
|
|
@ -246,7 +246,7 @@ func TestToolRegistry_GetDefinitions(t *testing.T) {
|
|||
al.RegisterTool(testTool)
|
||||
|
||||
info := al.GetStartupInfo()
|
||||
toolsInfo := info["tools"].(map[string]interface{})
|
||||
toolsInfo := info["tools"].(map[string]any)
|
||||
toolsList := toolsInfo["names"].([]string)
|
||||
|
||||
// Check that our custom tool name is in the list
|
||||
|
|
@ -293,7 +293,7 @@ func TestAgentLoop_GetStartupInfo(t *testing.T) {
|
|||
t.Fatal("Expected 'tools' key in startup info")
|
||||
}
|
||||
|
||||
toolsMap, ok := toolsInfo.(map[string]interface{})
|
||||
toolsMap, ok := toolsInfo.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("Expected 'tools' to be a map")
|
||||
}
|
||||
|
|
@ -349,7 +349,13 @@ type simpleMockProvider struct {
|
|||
response string
|
||||
}
|
||||
|
||||
func (m *simpleMockProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, opts map[string]interface{}) (*providers.LLMResponse, error) {
|
||||
func (m *simpleMockProvider) Chat(
|
||||
ctx context.Context,
|
||||
messages []providers.Message,
|
||||
tools []providers.ToolDefinition,
|
||||
model string,
|
||||
opts map[string]any,
|
||||
) (*providers.LLMResponse, error) {
|
||||
return &providers.LLMResponse{
|
||||
Content: m.response,
|
||||
ToolCalls: []providers.ToolCall{},
|
||||
|
|
@ -371,14 +377,14 @@ func (m *mockCustomTool) Description() string {
|
|||
return "Mock custom tool for testing"
|
||||
}
|
||||
|
||||
func (m *mockCustomTool) Parameters() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
func (m *mockCustomTool) Parameters() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
"properties": map[string]any{},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockCustomTool) Execute(ctx context.Context, args map[string]interface{}) *tools.ToolResult {
|
||||
func (m *mockCustomTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult {
|
||||
return tools.SilentResult("Custom tool executed")
|
||||
}
|
||||
|
||||
|
|
@ -396,14 +402,14 @@ func (m *mockContextualTool) Description() string {
|
|||
return "Mock contextual tool"
|
||||
}
|
||||
|
||||
func (m *mockContextualTool) Parameters() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
func (m *mockContextualTool) Parameters() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
"properties": map[string]any{},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockContextualTool) Execute(ctx context.Context, args map[string]interface{}) *tools.ToolResult {
|
||||
func (m *mockContextualTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult {
|
||||
return tools.SilentResult("Contextual tool executed")
|
||||
}
|
||||
|
||||
|
|
@ -523,7 +529,13 @@ type failFirstMockProvider struct {
|
|||
successResp string
|
||||
}
|
||||
|
||||
func (m *failFirstMockProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, opts map[string]interface{}) (*providers.LLMResponse, error) {
|
||||
func (m *failFirstMockProvider) Chat(
|
||||
ctx context.Context,
|
||||
messages []providers.Message,
|
||||
tools []providers.ToolDefinition,
|
||||
model string,
|
||||
opts map[string]any,
|
||||
) (*providers.LLMResponse, error) {
|
||||
m.currentCall++
|
||||
if m.currentCall <= m.failures {
|
||||
return nil, m.failError
|
||||
|
|
@ -588,7 +600,13 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) {
|
|||
|
||||
// Call ProcessDirectWithChannel
|
||||
// Note: ProcessDirectWithChannel calls processMessage which will execute runLLMIteration
|
||||
response, err := al.ProcessDirectWithChannel(context.Background(), "Trigger message", sessionKey, "test", "test-chat")
|
||||
response, err := al.ProcessDirectWithChannel(
|
||||
context.Background(),
|
||||
"Trigger message",
|
||||
sessionKey,
|
||||
"test",
|
||||
"test-chat",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected success after retry, got error: %v", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
|
|
@ -29,7 +30,7 @@ func NewMemoryStore(workspace string) *MemoryStore {
|
|||
memoryFile := filepath.Join(memoryDir, "MEMORY.md")
|
||||
|
||||
// Ensure memory directory exists
|
||||
os.MkdirAll(memoryDir, 0755)
|
||||
os.MkdirAll(memoryDir, 0o755)
|
||||
|
||||
return &MemoryStore{
|
||||
workspace: workspace,
|
||||
|
|
@ -57,7 +58,7 @@ func (ms *MemoryStore) ReadLongTerm() string {
|
|||
|
||||
// WriteLongTerm writes content to the long-term memory file (MEMORY.md).
|
||||
func (ms *MemoryStore) WriteLongTerm(content string) error {
|
||||
return os.WriteFile(ms.memoryFile, []byte(content), 0644)
|
||||
return os.WriteFile(ms.memoryFile, []byte(content), 0o644)
|
||||
}
|
||||
|
||||
// ReadToday reads today's daily note.
|
||||
|
|
@ -77,7 +78,7 @@ func (ms *MemoryStore) AppendToday(content string) error {
|
|||
|
||||
// Ensure month directory exists
|
||||
monthDir := filepath.Dir(todayFile)
|
||||
os.MkdirAll(monthDir, 0755)
|
||||
os.MkdirAll(monthDir, 0o755)
|
||||
|
||||
var existingContent string
|
||||
if data, err := os.ReadFile(todayFile); err == nil {
|
||||
|
|
@ -94,13 +95,14 @@ func (ms *MemoryStore) AppendToday(content string) error {
|
|||
newContent = existingContent + "\n" + content
|
||||
}
|
||||
|
||||
return os.WriteFile(todayFile, []byte(newContent), 0644)
|
||||
return os.WriteFile(todayFile, []byte(newContent), 0o644)
|
||||
}
|
||||
|
||||
// GetRecentDailyNotes returns daily notes from the last N days.
|
||||
// Contents are joined with "---" separator.
|
||||
func (ms *MemoryStore) GetRecentDailyNotes(days int) string {
|
||||
var notes []string
|
||||
var sb strings.Builder
|
||||
first := true
|
||||
|
||||
for i := 0; i < days; i++ {
|
||||
date := time.Now().AddDate(0, 0, -i)
|
||||
|
|
@ -109,53 +111,41 @@ func (ms *MemoryStore) GetRecentDailyNotes(days int) string {
|
|||
filePath := filepath.Join(ms.memoryDir, monthDir, dateStr+".md")
|
||||
|
||||
if data, err := os.ReadFile(filePath); err == nil {
|
||||
notes = append(notes, string(data))
|
||||
if !first {
|
||||
sb.WriteString("\n\n---\n\n")
|
||||
}
|
||||
sb.Write(data)
|
||||
first = false
|
||||
}
|
||||
}
|
||||
|
||||
if len(notes) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Join with separator
|
||||
var result string
|
||||
for i, note := range notes {
|
||||
if i > 0 {
|
||||
result += "\n\n---\n\n"
|
||||
}
|
||||
result += note
|
||||
}
|
||||
return result
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// GetMemoryContext returns formatted memory context for the agent prompt.
|
||||
// Includes long-term memory and recent daily notes.
|
||||
func (ms *MemoryStore) GetMemoryContext() string {
|
||||
var parts []string
|
||||
|
||||
// Long-term memory
|
||||
longTerm := ms.ReadLongTerm()
|
||||
if longTerm != "" {
|
||||
parts = append(parts, "## Long-term Memory\n\n"+longTerm)
|
||||
}
|
||||
|
||||
// Recent daily notes (last 3 days)
|
||||
recentNotes := ms.GetRecentDailyNotes(3)
|
||||
if recentNotes != "" {
|
||||
parts = append(parts, "## Recent Daily Notes\n\n"+recentNotes)
|
||||
}
|
||||
|
||||
if len(parts) == 0 {
|
||||
if longTerm == "" && recentNotes == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Join parts with separator
|
||||
var result string
|
||||
for i, part := range parts {
|
||||
if i > 0 {
|
||||
result += "\n\n---\n\n"
|
||||
var sb strings.Builder
|
||||
|
||||
if longTerm != "" {
|
||||
sb.WriteString("## Long-term Memory\n\n")
|
||||
sb.WriteString(longTerm)
|
||||
}
|
||||
result += part
|
||||
|
||||
if recentNotes != "" {
|
||||
if longTerm != "" {
|
||||
sb.WriteString("\n\n---\n\n")
|
||||
}
|
||||
return fmt.Sprintf("# Memory\n\n%s", result)
|
||||
sb.WriteString("## Recent Daily Notes\n\n")
|
||||
sb.WriteString(recentNotes)
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,13 @@ import (
|
|||
|
||||
type mockProvider struct{}
|
||||
|
||||
func (m *mockProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, opts map[string]interface{}) (*providers.LLMResponse, error) {
|
||||
func (m *mockProvider) Chat(
|
||||
ctx context.Context,
|
||||
messages []providers.Message,
|
||||
tools []providers.ToolDefinition,
|
||||
model string,
|
||||
opts map[string]any,
|
||||
) (*providers.LLMResponse, error) {
|
||||
return &providers.LLMResponse{
|
||||
Content: "Mock response",
|
||||
ToolCalls: []providers.ToolCall{},
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ func NewAgentRegistry(
|
|||
instance := NewAgentInstance(ac, &cfg.Agents.Defaults, cfg, provider)
|
||||
registry.agents[id] = instance
|
||||
logger.InfoCF("agent", "Registered agent",
|
||||
map[string]interface{}{
|
||||
map[string]any{
|
||||
"agent_id": id,
|
||||
"name": ac.Name,
|
||||
"workspace": instance.Workspace,
|
||||
|
|
|
|||
|
|
@ -10,7 +10,13 @@ import (
|
|||
|
||||
type mockRegistryProvider struct{}
|
||||
|
||||
func (m *mockRegistryProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, options map[string]interface{}) (*providers.LLMResponse, error) {
|
||||
func (m *mockRegistryProvider) Chat(
|
||||
ctx context.Context,
|
||||
messages []providers.Message,
|
||||
tools []providers.ToolDefinition,
|
||||
model string,
|
||||
options map[string]any,
|
||||
) (*providers.LLMResponse, error) {
|
||||
return &providers.LLMResponse{Content: "mock", FinishReason: "stop"}, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -44,7 +44,9 @@ func OpenAIOAuthConfig() OAuthProviderConfig {
|
|||
// Client credentials are the same ones used by OpenCode/pi-ai for Cloud Code Assist access.
|
||||
func GoogleAntigravityOAuthConfig() OAuthProviderConfig {
|
||||
// These are the same client credentials used by the OpenCode antigravity plugin.
|
||||
clientID := decodeBase64("MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==")
|
||||
clientID := decodeBase64(
|
||||
"MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==",
|
||||
)
|
||||
clientSecret := decodeBase64("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY=")
|
||||
return OAuthProviderConfig{
|
||||
Issuer: "https://accounts.google.com/o/oauth2/v2",
|
||||
|
|
@ -129,8 +131,13 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
|
|||
fmt.Printf("Could not open browser automatically.\nPlease open this URL manually:\n\n%s\n\n", authURL)
|
||||
}
|
||||
|
||||
fmt.Printf("Wait! If you are in a headless environment (like Coolify/VPS) and cannot reach localhost:%d,\n", cfg.Port)
|
||||
fmt.Println("please complete the login in your local browser and then PASTE the final redirect URL (or just the code) here.")
|
||||
fmt.Printf(
|
||||
"Wait! If you are in a headless environment (like Coolify/VPS) and cannot reach localhost:%d,\n",
|
||||
cfg.Port,
|
||||
)
|
||||
fmt.Println(
|
||||
"please complete the login in your local browser and then PASTE the final redirect URL (or just the code) here.",
|
||||
)
|
||||
fmt.Println("Waiting for authentication (browser or manual paste)...")
|
||||
|
||||
// Start manual input in a goroutine
|
||||
|
|
@ -253,8 +260,11 @@ func LoginDeviceCode(cfg OAuthProviderConfig) (*AuthCredential, error) {
|
|||
deviceResp.Interval = 5
|
||||
}
|
||||
|
||||
fmt.Printf("\nTo authenticate, open this URL in your browser:\n\n %s/codex/device\n\nThen enter this code: %s\n\nWaiting for authentication...\n",
|
||||
cfg.Issuer, deviceResp.UserCode)
|
||||
fmt.Printf(
|
||||
"\nTo authenticate, open this URL in your browser:\n\n %s/codex/device\n\nThen enter this code: %s\n\nWaiting for authentication...\n",
|
||||
cfg.Issuer,
|
||||
deviceResp.UserCode,
|
||||
)
|
||||
|
||||
deadline := time.After(15 * time.Minute)
|
||||
ticker := time.NewTicker(time.Duration(deviceResp.Interval) * time.Second)
|
||||
|
|
@ -491,15 +501,15 @@ func extractAccountID(token string) string {
|
|||
return accountID
|
||||
}
|
||||
|
||||
if authClaim, ok := claims["https://api.openai.com/auth"].(map[string]interface{}); ok {
|
||||
if authClaim, ok := claims["https://api.openai.com/auth"].(map[string]any); ok {
|
||||
if accountID, ok := authClaim["chatgpt_account_id"].(string); ok && accountID != "" {
|
||||
return accountID
|
||||
}
|
||||
}
|
||||
|
||||
if orgs, ok := claims["organizations"].([]interface{}); ok {
|
||||
if orgs, ok := claims["organizations"].([]any); ok {
|
||||
for _, org := range orgs {
|
||||
if orgMap, ok := org.(map[string]interface{}); ok {
|
||||
if orgMap, ok := org.(map[string]any); ok {
|
||||
if accountID, ok := orgMap["id"].(string); ok && accountID != "" {
|
||||
return accountID
|
||||
}
|
||||
|
|
@ -510,7 +520,7 @@ func extractAccountID(token string) string {
|
|||
return ""
|
||||
}
|
||||
|
||||
func parseJWTClaims(token string) (map[string]interface{}, error) {
|
||||
func parseJWTClaims(token string) (map[string]any, error) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) < 2 {
|
||||
return nil, fmt.Errorf("token is not a JWT")
|
||||
|
|
@ -529,7 +539,7 @@ func parseJWTClaims(token string) (map[string]interface{}, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
var claims map[string]interface{}
|
||||
var claims map[string]any
|
||||
if err := json.Unmarshal(decoded, &claims); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import (
|
|||
"testing"
|
||||
)
|
||||
|
||||
func makeJWTForClaims(t *testing.T, claims map[string]interface{}) string {
|
||||
func makeJWTForClaims(t *testing.T, claims map[string]any) string {
|
||||
t.Helper()
|
||||
|
||||
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`))
|
||||
|
|
@ -89,7 +89,7 @@ func TestBuildAuthorizeURLOpenAIExtras(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestParseTokenResponse(t *testing.T) {
|
||||
resp := map[string]interface{}{
|
||||
resp := map[string]any{
|
||||
"access_token": "test-access-token",
|
||||
"refresh_token": "test-refresh-token",
|
||||
"expires_in": 3600,
|
||||
|
|
@ -120,8 +120,8 @@ func TestParseTokenResponse(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestParseTokenResponseExtractsAccountIDFromIDToken(t *testing.T) {
|
||||
idToken := makeJWTForClaims(t, map[string]interface{}{"chatgpt_account_id": "acc-id-from-id-token"})
|
||||
resp := map[string]interface{}{
|
||||
idToken := makeJWTForClaims(t, map[string]any{"chatgpt_account_id": "acc-id-from-id-token"})
|
||||
resp := map[string]any{
|
||||
"access_token": "opaque-access-token",
|
||||
"refresh_token": "test-refresh-token",
|
||||
"expires_in": 3600,
|
||||
|
|
@ -139,9 +139,9 @@ func TestParseTokenResponseExtractsAccountIDFromIDToken(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestExtractAccountIDFromOrganizationsFallback(t *testing.T) {
|
||||
token := makeJWTForClaims(t, map[string]interface{}{
|
||||
"organizations": []interface{}{
|
||||
map[string]interface{}{"id": "org_from_orgs"},
|
||||
token := makeJWTForClaims(t, map[string]any{
|
||||
"organizations": []any{
|
||||
map[string]any{"id": "org_from_orgs"},
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -160,7 +160,7 @@ func TestParseTokenResponseNoAccessToken(t *testing.T) {
|
|||
|
||||
func TestParseTokenResponseAccountIDFromIDToken(t *testing.T) {
|
||||
idToken := makeJWTWithAccountID("acc-from-id")
|
||||
resp := map[string]interface{}{
|
||||
resp := map[string]any{
|
||||
"access_token": "not-a-jwt",
|
||||
"refresh_token": "test-refresh-token",
|
||||
"expires_in": 3600,
|
||||
|
|
@ -180,7 +180,9 @@ func TestParseTokenResponseAccountIDFromIDToken(t *testing.T) {
|
|||
|
||||
func makeJWTWithAccountID(accountID string) string {
|
||||
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`))
|
||||
payload := base64.RawURLEncoding.EncodeToString([]byte(`{"https://api.openai.com/auth":{"chatgpt_account_id":"` + accountID + `"}}`))
|
||||
payload := base64.RawURLEncoding.EncodeToString(
|
||||
[]byte(`{"https://api.openai.com/auth":{"chatgpt_account_id":"` + accountID + `"}}`),
|
||||
)
|
||||
return header + "." + payload + ".sig"
|
||||
}
|
||||
|
||||
|
|
@ -201,7 +203,7 @@ func TestExchangeCodeForTokens(t *testing.T) {
|
|||
return
|
||||
}
|
||||
|
||||
resp := map[string]interface{}{
|
||||
resp := map[string]any{
|
||||
"access_token": "mock-access-token",
|
||||
"refresh_token": "mock-refresh-token",
|
||||
"expires_in": 3600,
|
||||
|
|
@ -240,7 +242,7 @@ func TestRefreshAccessToken(t *testing.T) {
|
|||
return
|
||||
}
|
||||
|
||||
resp := map[string]interface{}{
|
||||
resp := map[string]any{
|
||||
"access_token": "refreshed-access-token",
|
||||
"refresh_token": "refreshed-refresh-token",
|
||||
"expires_in": 3600,
|
||||
|
|
@ -290,7 +292,7 @@ func TestRefreshAccessTokenNoRefreshToken(t *testing.T) {
|
|||
|
||||
func TestRefreshAccessTokenPreservesRefreshAndAccountID(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
resp := map[string]interface{}{
|
||||
resp := map[string]any{
|
||||
"access_token": "new-access-token-only",
|
||||
"expires_in": 3600,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ func LoadStore() (*AuthStore, error) {
|
|||
func SaveStore(store *AuthStore) error {
|
||||
path := authFilePath()
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
@ -72,7 +72,7 @@ func SaveStore(store *AuthStore) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0600)
|
||||
return os.WriteFile(path, data, 0o600)
|
||||
}
|
||||
|
||||
func GetCredential(provider string) (*AuthCredential, error) {
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ func TestStoreFilePermissions(t *testing.T) {
|
|||
t.Fatalf("Stat() error: %v", err)
|
||||
}
|
||||
perm := info.Mode().Perm()
|
||||
if perm != 0600 {
|
||||
if perm != 0o600 {
|
||||
t.Errorf("file permissions = %o, want 0600", perm)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,14 +17,14 @@ type Channel interface {
|
|||
}
|
||||
|
||||
type BaseChannel struct {
|
||||
config interface{}
|
||||
config any
|
||||
bus *bus.MessageBus
|
||||
running bool
|
||||
name string
|
||||
allowList []string
|
||||
}
|
||||
|
||||
func NewBaseChannel(name string, config interface{}, bus *bus.MessageBus, allowList []string) *BaseChannel {
|
||||
func NewBaseChannel(name string, config any, bus *bus.MessageBus, allowList []string) *BaseChannel {
|
||||
return &BaseChannel{
|
||||
config: config,
|
||||
bus: bus,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
|
||||
"github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot"
|
||||
"github.com/open-dingtalk/dingtalk-stream-sdk-go/client"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
|
|
@ -108,7 +109,7 @@ func (c *DingTalkChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
|
|||
return fmt.Errorf("invalid session_webhook type for chat %s", msg.ChatID)
|
||||
}
|
||||
|
||||
logger.DebugCF("dingtalk", "Sending message", map[string]interface{}{
|
||||
logger.DebugCF("dingtalk", "Sending message", map[string]any{
|
||||
"chat_id": msg.ChatID,
|
||||
"preview": utils.Truncate(msg.Content, 100),
|
||||
})
|
||||
|
|
@ -120,12 +121,15 @@ func (c *DingTalkChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
|
|||
// onChatBotMessageReceived implements the IChatBotMessageHandler function signature
|
||||
// This is called by the Stream SDK when a new message arrives
|
||||
// IChatBotMessageHandler is: func(c context.Context, data *chatbot.BotCallbackDataModel) ([]byte, error)
|
||||
func (c *DingTalkChannel) onChatBotMessageReceived(ctx context.Context, data *chatbot.BotCallbackDataModel) ([]byte, error) {
|
||||
func (c *DingTalkChannel) onChatBotMessageReceived(
|
||||
ctx context.Context,
|
||||
data *chatbot.BotCallbackDataModel,
|
||||
) ([]byte, error) {
|
||||
// Extract message content from Text field
|
||||
content := data.Text.Content
|
||||
if content == "" {
|
||||
// Try to extract from Content interface{} if Text is empty
|
||||
if contentMap, ok := data.Content.(map[string]interface{}); ok {
|
||||
if contentMap, ok := data.Content.(map[string]any); ok {
|
||||
if textContent, ok := contentMap["content"].(string); ok {
|
||||
content = textContent
|
||||
}
|
||||
|
|
@ -163,7 +167,7 @@ func (c *DingTalkChannel) onChatBotMessageReceived(ctx context.Context, data *ch
|
|||
metadata["peer_id"] = data.ConversationId
|
||||
}
|
||||
|
||||
logger.DebugCF("dingtalk", "Received message", map[string]interface{}{
|
||||
logger.DebugCF("dingtalk", "Received message", map[string]any{
|
||||
"sender_nick": senderNick,
|
||||
"sender_id": senderID,
|
||||
"preview": utils.Truncate(content, 50),
|
||||
|
|
@ -192,7 +196,6 @@ func (c *DingTalkChannel) SendDirectReply(ctx context.Context, sessionWebhook, c
|
|||
titleBytes,
|
||||
contentBytes,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send reply: %w", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
|
|
@ -321,7 +322,7 @@ func (c *DiscordChannel) startTyping(chatID string) {
|
|||
|
||||
go func() {
|
||||
if err := c.session.ChannelTyping(chatID); err != nil {
|
||||
logger.DebugCF("discord", "ChannelTyping error", map[string]interface{}{"chatID": chatID, "err": err})
|
||||
logger.DebugCF("discord", "ChannelTyping error", map[string]any{"chatID": chatID, "err": err})
|
||||
}
|
||||
ticker := time.NewTicker(8 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
|
@ -336,7 +337,7 @@ func (c *DiscordChannel) startTyping(chatID string) {
|
|||
return
|
||||
case <-ticker.C:
|
||||
if err := c.session.ChannelTyping(chatID); err != nil {
|
||||
logger.DebugCF("discord", "ChannelTyping error", map[string]interface{}{"chatID": chatID, "err": err})
|
||||
logger.DebugCF("discord", "ChannelTyping error", map[string]any{"chatID": chatID, "err": err})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,9 @@ type FeishuChannel struct {
|
|||
|
||||
// NewFeishuChannel returns an error on 32-bit architectures where the Feishu SDK is not supported
|
||||
func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) {
|
||||
return nil, errors.New("feishu channel is not supported on 32-bit architectures (armv7l, 386, etc.). Please use a 64-bit system or disable feishu in your config")
|
||||
return nil, errors.New(
|
||||
"feishu channel is not supported on 32-bit architectures (armv7l, 386, etc.). Please use a 64-bit system or disable feishu in your config",
|
||||
)
|
||||
}
|
||||
|
||||
// Start is a stub method to satisfy the Channel interface
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ func (c *FeishuChannel) Start(ctx context.Context) error {
|
|||
|
||||
go func() {
|
||||
if err := wsClient.Start(runCtx); err != nil {
|
||||
logger.ErrorCF("feishu", "Feishu websocket stopped with error", map[string]interface{}{
|
||||
logger.ErrorCF("feishu", "Feishu websocket stopped with error", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
|
@ -121,7 +121,7 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
|
|||
return fmt.Errorf("feishu api error: code=%d msg=%s", resp.Code, resp.Msg)
|
||||
}
|
||||
|
||||
logger.DebugCF("feishu", "Feishu message sent", map[string]interface{}{
|
||||
logger.DebugCF("feishu", "Feishu message sent", map[string]any{
|
||||
"chat_id": msg.ChatID,
|
||||
})
|
||||
|
||||
|
|
@ -174,7 +174,7 @@ func (c *FeishuChannel) handleMessageReceive(_ context.Context, event *larkim.P2
|
|||
metadata["peer_id"] = chatID
|
||||
}
|
||||
|
||||
logger.InfoCF("feishu", "Feishu message received", map[string]interface{}{
|
||||
logger.InfoCF("feishu", "Feishu message received", map[string]any{
|
||||
"sender_id": senderID,
|
||||
"chat_id": chatID,
|
||||
"preview": utils.Truncate(content, 80),
|
||||
|
|
|
|||
|
|
@ -75,11 +75,11 @@ func (c *LINEChannel) Start(ctx context.Context) error {
|
|||
|
||||
// Fetch bot profile to get bot's userId for mention detection
|
||||
if err := c.fetchBotInfo(); err != nil {
|
||||
logger.WarnCF("line", "Failed to fetch bot info (mention detection disabled)", map[string]interface{}{
|
||||
logger.WarnCF("line", "Failed to fetch bot info (mention detection disabled)", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
logger.InfoCF("line", "Bot info fetched", map[string]interface{}{
|
||||
logger.InfoCF("line", "Bot info fetched", map[string]any{
|
||||
"bot_user_id": c.botUserID,
|
||||
"basic_id": c.botBasicID,
|
||||
"display_name": c.botDisplayName,
|
||||
|
|
@ -100,12 +100,12 @@ func (c *LINEChannel) Start(ctx context.Context) error {
|
|||
}
|
||||
|
||||
go func() {
|
||||
logger.InfoCF("line", "LINE webhook server listening", map[string]interface{}{
|
||||
logger.InfoCF("line", "LINE webhook server listening", map[string]any{
|
||||
"addr": addr,
|
||||
"path": path,
|
||||
})
|
||||
if err := c.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
logger.ErrorCF("line", "Webhook server error", map[string]interface{}{
|
||||
logger.ErrorCF("line", "Webhook server error", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
|
@ -162,7 +162,7 @@ func (c *LINEChannel) Stop(ctx context.Context) error {
|
|||
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
if err := c.httpServer.Shutdown(shutdownCtx); err != nil {
|
||||
logger.ErrorCF("line", "Webhook server shutdown error", map[string]interface{}{
|
||||
logger.ErrorCF("line", "Webhook server shutdown error", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
|
@ -182,7 +182,7 @@ func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
logger.ErrorCF("line", "Failed to read request body", map[string]interface{}{
|
||||
logger.ErrorCF("line", "Failed to read request body", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||
|
|
@ -200,7 +200,7 @@ func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) {
|
|||
Events []lineEvent `json:"events"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
logger.ErrorCF("line", "Failed to parse webhook payload", map[string]interface{}{
|
||||
logger.ErrorCF("line", "Failed to parse webhook payload", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||
|
|
@ -266,7 +266,7 @@ type lineMentionee struct {
|
|||
|
||||
func (c *LINEChannel) processEvent(event lineEvent) {
|
||||
if event.Type != "message" {
|
||||
logger.DebugCF("line", "Ignoring non-message event", map[string]interface{}{
|
||||
logger.DebugCF("line", "Ignoring non-message event", map[string]any{
|
||||
"type": event.Type,
|
||||
})
|
||||
return
|
||||
|
|
@ -278,7 +278,7 @@ func (c *LINEChannel) processEvent(event lineEvent) {
|
|||
|
||||
var msg lineMessage
|
||||
if err := json.Unmarshal(event.Message, &msg); err != nil {
|
||||
logger.ErrorCF("line", "Failed to parse message", map[string]interface{}{
|
||||
logger.ErrorCF("line", "Failed to parse message", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
|
|
@ -286,7 +286,7 @@ func (c *LINEChannel) processEvent(event lineEvent) {
|
|||
|
||||
// In group chats, only respond when the bot is mentioned
|
||||
if isGroup && !c.isBotMentioned(msg) {
|
||||
logger.DebugCF("line", "Ignoring group message without mention", map[string]interface{}{
|
||||
logger.DebugCF("line", "Ignoring group message without mention", map[string]any{
|
||||
"chat_id": chatID,
|
||||
})
|
||||
return
|
||||
|
|
@ -312,7 +312,7 @@ func (c *LINEChannel) processEvent(event lineEvent) {
|
|||
defer func() {
|
||||
for _, file := range localFiles {
|
||||
if err := os.Remove(file); err != nil {
|
||||
logger.DebugCF("line", "Failed to cleanup temp file", map[string]interface{}{
|
||||
logger.DebugCF("line", "Failed to cleanup temp file", map[string]any{
|
||||
"file": file,
|
||||
"error": err.Error(),
|
||||
})
|
||||
|
|
@ -374,7 +374,7 @@ func (c *LINEChannel) processEvent(event lineEvent) {
|
|||
metadata["peer_id"] = senderID
|
||||
}
|
||||
|
||||
logger.DebugCF("line", "Received message", map[string]interface{}{
|
||||
logger.DebugCF("line", "Received message", map[string]any{
|
||||
"sender_id": senderID,
|
||||
"chat_id": chatID,
|
||||
"message_type": msg.Type,
|
||||
|
|
@ -505,7 +505,7 @@ func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
|||
tokenEntry := entry.(replyTokenEntry)
|
||||
if time.Since(tokenEntry.timestamp) < lineReplyTokenMaxAge {
|
||||
if err := c.sendReply(ctx, tokenEntry.token, msg.Content, quoteToken); err == nil {
|
||||
logger.DebugCF("line", "Message sent via Reply API", map[string]interface{}{
|
||||
logger.DebugCF("line", "Message sent via Reply API", map[string]any{
|
||||
"chat_id": msg.ChatID,
|
||||
"quoted": quoteToken != "",
|
||||
})
|
||||
|
|
@ -533,7 +533,7 @@ func buildTextMessage(content, quoteToken string) map[string]string {
|
|||
|
||||
// sendReply sends a message using the LINE Reply API.
|
||||
func (c *LINEChannel) sendReply(ctx context.Context, replyToken, content, quoteToken string) error {
|
||||
payload := map[string]interface{}{
|
||||
payload := map[string]any{
|
||||
"replyToken": replyToken,
|
||||
"messages": []map[string]string{buildTextMessage(content, quoteToken)},
|
||||
}
|
||||
|
|
@ -543,7 +543,7 @@ func (c *LINEChannel) sendReply(ctx context.Context, replyToken, content, quoteT
|
|||
|
||||
// sendPush sends a message using the LINE Push API.
|
||||
func (c *LINEChannel) sendPush(ctx context.Context, to, content, quoteToken string) error {
|
||||
payload := map[string]interface{}{
|
||||
payload := map[string]any{
|
||||
"to": to,
|
||||
"messages": []map[string]string{buildTextMessage(content, quoteToken)},
|
||||
}
|
||||
|
|
@ -553,19 +553,19 @@ func (c *LINEChannel) sendPush(ctx context.Context, to, content, quoteToken stri
|
|||
|
||||
// sendLoading sends a loading animation indicator to the chat.
|
||||
func (c *LINEChannel) sendLoading(chatID string) {
|
||||
payload := map[string]interface{}{
|
||||
payload := map[string]any{
|
||||
"chatId": chatID,
|
||||
"loadingSeconds": 60,
|
||||
}
|
||||
if err := c.callAPI(c.ctx, lineLoadingEndpoint, payload); err != nil {
|
||||
logger.DebugCF("line", "Failed to send loading indicator", map[string]interface{}{
|
||||
logger.DebugCF("line", "Failed to send loading indicator", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// callAPI makes an authenticated POST request to the LINE API.
|
||||
func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload interface{}) error {
|
||||
func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload any) error {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal payload: %w", err)
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ type MaixCamMessage struct {
|
|||
Type string `json:"type"`
|
||||
Tips string `json:"tips"`
|
||||
Timestamp float64 `json:"timestamp"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
|
||||
func NewMaixCamChannel(cfg config.MaixCamConfig, bus *bus.MessageBus) (*MaixCamChannel, error) {
|
||||
|
|
@ -49,7 +49,7 @@ func (c *MaixCamChannel) Start(ctx context.Context) error {
|
|||
c.listener = listener
|
||||
c.setRunning(true)
|
||||
|
||||
logger.InfoCF("maixcam", "MaixCam server listening", map[string]interface{}{
|
||||
logger.InfoCF("maixcam", "MaixCam server listening", map[string]any{
|
||||
"host": c.config.Host,
|
||||
"port": c.config.Port,
|
||||
})
|
||||
|
|
@ -71,14 +71,14 @@ func (c *MaixCamChannel) acceptConnections(ctx context.Context) {
|
|||
conn, err := c.listener.Accept()
|
||||
if err != nil {
|
||||
if c.running {
|
||||
logger.ErrorCF("maixcam", "Failed to accept connection", map[string]interface{}{
|
||||
logger.ErrorCF("maixcam", "Failed to accept connection", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
logger.InfoCF("maixcam", "New connection from MaixCam device", map[string]interface{}{
|
||||
logger.InfoCF("maixcam", "New connection from MaixCam device", map[string]any{
|
||||
"remote_addr": conn.RemoteAddr().String(),
|
||||
})
|
||||
|
||||
|
|
@ -112,7 +112,7 @@ func (c *MaixCamChannel) handleConnection(conn net.Conn, ctx context.Context) {
|
|||
var msg MaixCamMessage
|
||||
if err := decoder.Decode(&msg); err != nil {
|
||||
if err.Error() != "EOF" {
|
||||
logger.ErrorCF("maixcam", "Failed to decode message", map[string]interface{}{
|
||||
logger.ErrorCF("maixcam", "Failed to decode message", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
|
@ -133,14 +133,14 @@ func (c *MaixCamChannel) processMessage(msg MaixCamMessage, conn net.Conn) {
|
|||
case "status":
|
||||
c.handleStatusUpdate(msg)
|
||||
default:
|
||||
logger.WarnCF("maixcam", "Unknown message type", map[string]interface{}{
|
||||
logger.WarnCF("maixcam", "Unknown message type", map[string]any{
|
||||
"type": msg.Type,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (c *MaixCamChannel) handlePersonDetection(msg MaixCamMessage) {
|
||||
logger.InfoCF("maixcam", "", map[string]interface{}{
|
||||
logger.InfoCF("maixcam", "", map[string]any{
|
||||
"timestamp": msg.Timestamp,
|
||||
"data": msg.Data,
|
||||
})
|
||||
|
|
@ -178,7 +178,7 @@ func (c *MaixCamChannel) handlePersonDetection(msg MaixCamMessage) {
|
|||
}
|
||||
|
||||
func (c *MaixCamChannel) handleStatusUpdate(msg MaixCamMessage) {
|
||||
logger.InfoCF("maixcam", "Status update from MaixCam", map[string]interface{}{
|
||||
logger.InfoCF("maixcam", "Status update from MaixCam", map[string]any{
|
||||
"status": msg.Data,
|
||||
})
|
||||
}
|
||||
|
|
@ -216,7 +216,7 @@ func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
|
|||
return fmt.Errorf("no connected MaixCam devices")
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
response := map[string]any{
|
||||
"type": "command",
|
||||
"timestamp": float64(0),
|
||||
"message": msg.Content,
|
||||
|
|
@ -231,7 +231,7 @@ func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
|
|||
var sendErr error
|
||||
for conn := range c.clients {
|
||||
if _, err := conn.Write(data); err != nil {
|
||||
logger.ErrorCF("maixcam", "Failed to send to client", map[string]interface{}{
|
||||
logger.ErrorCF("maixcam", "Failed to send to client", map[string]any{
|
||||
"client": conn.RemoteAddr().String(),
|
||||
"error": err.Error(),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ func (m *Manager) initChannels() error {
|
|||
logger.DebugC("channels", "Attempting to initialize Telegram channel")
|
||||
telegram, err := NewTelegramChannel(m.config, m.bus)
|
||||
if err != nil {
|
||||
logger.ErrorCF("channels", "Failed to initialize Telegram channel", map[string]interface{}{
|
||||
logger.ErrorCF("channels", "Failed to initialize Telegram channel", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
|
|
@ -63,7 +63,7 @@ func (m *Manager) initChannels() error {
|
|||
logger.DebugC("channels", "Attempting to initialize WhatsApp channel")
|
||||
whatsapp, err := NewWhatsAppChannel(m.config.Channels.WhatsApp, m.bus)
|
||||
if err != nil {
|
||||
logger.ErrorCF("channels", "Failed to initialize WhatsApp channel", map[string]interface{}{
|
||||
logger.ErrorCF("channels", "Failed to initialize WhatsApp channel", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
|
|
@ -76,7 +76,7 @@ func (m *Manager) initChannels() error {
|
|||
logger.DebugC("channels", "Attempting to initialize Feishu channel")
|
||||
feishu, err := NewFeishuChannel(m.config.Channels.Feishu, m.bus)
|
||||
if err != nil {
|
||||
logger.ErrorCF("channels", "Failed to initialize Feishu channel", map[string]interface{}{
|
||||
logger.ErrorCF("channels", "Failed to initialize Feishu channel", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
|
|
@ -89,7 +89,7 @@ func (m *Manager) initChannels() error {
|
|||
logger.DebugC("channels", "Attempting to initialize Discord channel")
|
||||
discord, err := NewDiscordChannel(m.config.Channels.Discord, m.bus)
|
||||
if err != nil {
|
||||
logger.ErrorCF("channels", "Failed to initialize Discord channel", map[string]interface{}{
|
||||
logger.ErrorCF("channels", "Failed to initialize Discord channel", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
|
|
@ -102,7 +102,7 @@ func (m *Manager) initChannels() error {
|
|||
logger.DebugC("channels", "Attempting to initialize MaixCam channel")
|
||||
maixcam, err := NewMaixCamChannel(m.config.Channels.MaixCam, m.bus)
|
||||
if err != nil {
|
||||
logger.ErrorCF("channels", "Failed to initialize MaixCam channel", map[string]interface{}{
|
||||
logger.ErrorCF("channels", "Failed to initialize MaixCam channel", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
|
|
@ -115,7 +115,7 @@ func (m *Manager) initChannels() error {
|
|||
logger.DebugC("channels", "Attempting to initialize QQ channel")
|
||||
qq, err := NewQQChannel(m.config.Channels.QQ, m.bus)
|
||||
if err != nil {
|
||||
logger.ErrorCF("channels", "Failed to initialize QQ channel", map[string]interface{}{
|
||||
logger.ErrorCF("channels", "Failed to initialize QQ channel", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
|
|
@ -128,7 +128,7 @@ func (m *Manager) initChannels() error {
|
|||
logger.DebugC("channels", "Attempting to initialize DingTalk channel")
|
||||
dingtalk, err := NewDingTalkChannel(m.config.Channels.DingTalk, m.bus)
|
||||
if err != nil {
|
||||
logger.ErrorCF("channels", "Failed to initialize DingTalk channel", map[string]interface{}{
|
||||
logger.ErrorCF("channels", "Failed to initialize DingTalk channel", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
|
|
@ -141,7 +141,7 @@ func (m *Manager) initChannels() error {
|
|||
logger.DebugC("channels", "Attempting to initialize Slack channel")
|
||||
slackCh, err := NewSlackChannel(m.config.Channels.Slack, m.bus)
|
||||
if err != nil {
|
||||
logger.ErrorCF("channels", "Failed to initialize Slack channel", map[string]interface{}{
|
||||
logger.ErrorCF("channels", "Failed to initialize Slack channel", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
|
|
@ -154,7 +154,7 @@ func (m *Manager) initChannels() error {
|
|||
logger.DebugC("channels", "Attempting to initialize LINE channel")
|
||||
line, err := NewLINEChannel(m.config.Channels.LINE, m.bus)
|
||||
if err != nil {
|
||||
logger.ErrorCF("channels", "Failed to initialize LINE channel", map[string]interface{}{
|
||||
logger.ErrorCF("channels", "Failed to initialize LINE channel", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
|
|
@ -167,7 +167,7 @@ func (m *Manager) initChannels() error {
|
|||
logger.DebugC("channels", "Attempting to initialize OneBot channel")
|
||||
onebot, err := NewOneBotChannel(m.config.Channels.OneBot, m.bus)
|
||||
if err != nil {
|
||||
logger.ErrorCF("channels", "Failed to initialize OneBot channel", map[string]interface{}{
|
||||
logger.ErrorCF("channels", "Failed to initialize OneBot channel", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
|
|
@ -180,7 +180,7 @@ func (m *Manager) initChannels() error {
|
|||
logger.DebugC("channels", "Attempting to initialize WeCom channel")
|
||||
wecom, err := NewWeComBotChannel(m.config.Channels.WeCom, m.bus)
|
||||
if err != nil {
|
||||
logger.ErrorCF("channels", "Failed to initialize WeCom channel", map[string]interface{}{
|
||||
logger.ErrorCF("channels", "Failed to initialize WeCom channel", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
|
|
@ -193,7 +193,7 @@ func (m *Manager) initChannels() error {
|
|||
logger.DebugC("channels", "Attempting to initialize WeCom App channel")
|
||||
wecomApp, err := NewWeComAppChannel(m.config.Channels.WeComApp, m.bus)
|
||||
if err != nil {
|
||||
logger.ErrorCF("channels", "Failed to initialize WeCom App channel", map[string]interface{}{
|
||||
logger.ErrorCF("channels", "Failed to initialize WeCom App channel", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
|
|
@ -202,7 +202,7 @@ func (m *Manager) initChannels() error {
|
|||
}
|
||||
}
|
||||
|
||||
logger.InfoCF("channels", "Channel initialization completed", map[string]interface{}{
|
||||
logger.InfoCF("channels", "Channel initialization completed", map[string]any{
|
||||
"enabled_channels": len(m.channels),
|
||||
})
|
||||
|
||||
|
|
@ -226,11 +226,11 @@ func (m *Manager) StartAll(ctx context.Context) error {
|
|||
go m.dispatchOutbound(dispatchCtx)
|
||||
|
||||
for name, channel := range m.channels {
|
||||
logger.InfoCF("channels", "Starting channel", map[string]interface{}{
|
||||
logger.InfoCF("channels", "Starting channel", map[string]any{
|
||||
"channel": name,
|
||||
})
|
||||
if err := channel.Start(ctx); err != nil {
|
||||
logger.ErrorCF("channels", "Failed to start channel", map[string]interface{}{
|
||||
logger.ErrorCF("channels", "Failed to start channel", map[string]any{
|
||||
"channel": name,
|
||||
"error": err.Error(),
|
||||
})
|
||||
|
|
@ -253,11 +253,11 @@ func (m *Manager) StopAll(ctx context.Context) error {
|
|||
}
|
||||
|
||||
for name, channel := range m.channels {
|
||||
logger.InfoCF("channels", "Stopping channel", map[string]interface{}{
|
||||
logger.InfoCF("channels", "Stopping channel", map[string]any{
|
||||
"channel": name,
|
||||
})
|
||||
if err := channel.Stop(ctx); err != nil {
|
||||
logger.ErrorCF("channels", "Error stopping channel", map[string]interface{}{
|
||||
logger.ErrorCF("channels", "Error stopping channel", map[string]any{
|
||||
"channel": name,
|
||||
"error": err.Error(),
|
||||
})
|
||||
|
|
@ -292,14 +292,14 @@ func (m *Manager) dispatchOutbound(ctx context.Context) {
|
|||
m.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
logger.WarnCF("channels", "Unknown channel for outbound message", map[string]interface{}{
|
||||
logger.WarnCF("channels", "Unknown channel for outbound message", map[string]any{
|
||||
"channel": msg.Channel,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if err := channel.Send(ctx, msg); err != nil {
|
||||
logger.ErrorCF("channels", "Error sending message to channel", map[string]interface{}{
|
||||
logger.ErrorCF("channels", "Error sending message to channel", map[string]any{
|
||||
"channel": msg.Channel,
|
||||
"error": err.Error(),
|
||||
})
|
||||
|
|
@ -315,13 +315,13 @@ func (m *Manager) GetChannel(name string) (Channel, bool) {
|
|||
return channel, ok
|
||||
}
|
||||
|
||||
func (m *Manager) GetStatus() map[string]interface{} {
|
||||
func (m *Manager) GetStatus() map[string]any {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
status := make(map[string]interface{})
|
||||
status := make(map[string]any)
|
||||
for name, channel := range m.channels {
|
||||
status[name] = map[string]interface{}{
|
||||
status[name] = map[string]any{
|
||||
"enabled": true,
|
||||
"running": channel.IsRunning(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,13 +88,13 @@ type oneBotSender struct {
|
|||
|
||||
type oneBotAPIRequest struct {
|
||||
Action string `json:"action"`
|
||||
Params interface{} `json:"params"`
|
||||
Params any `json:"params"`
|
||||
Echo string `json:"echo,omitempty"`
|
||||
}
|
||||
|
||||
type oneBotMessageSegment struct {
|
||||
Type string `json:"type"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
|
||||
func NewOneBotChannel(cfg config.OneBotConfig, messageBus *bus.MessageBus) (*OneBotChannel, error) {
|
||||
|
|
@ -117,13 +117,13 @@ func (c *OneBotChannel) SetTranscriber(transcriber *voice.GroqTranscriber) {
|
|||
|
||||
func (c *OneBotChannel) setMsgEmojiLike(messageID string, emojiID int, set bool) {
|
||||
go func() {
|
||||
_, err := c.sendAPIRequest("set_msg_emoji_like", map[string]interface{}{
|
||||
_, err := c.sendAPIRequest("set_msg_emoji_like", map[string]any{
|
||||
"message_id": messageID,
|
||||
"emoji_id": emojiID,
|
||||
"set": set,
|
||||
}, 5*time.Second)
|
||||
if err != nil {
|
||||
logger.DebugCF("onebot", "Failed to set emoji like", map[string]interface{}{
|
||||
logger.DebugCF("onebot", "Failed to set emoji like", map[string]any{
|
||||
"message_id": messageID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
|
|
@ -136,14 +136,14 @@ func (c *OneBotChannel) Start(ctx context.Context) error {
|
|||
return fmt.Errorf("OneBot ws_url not configured")
|
||||
}
|
||||
|
||||
logger.InfoCF("onebot", "Starting OneBot channel", map[string]interface{}{
|
||||
logger.InfoCF("onebot", "Starting OneBot channel", map[string]any{
|
||||
"ws_url": c.config.WSUrl,
|
||||
})
|
||||
|
||||
c.ctx, c.cancel = context.WithCancel(ctx)
|
||||
|
||||
if err := c.connect(); err != nil {
|
||||
logger.WarnCF("onebot", "Initial connection failed, will retry in background", map[string]interface{}{
|
||||
logger.WarnCF("onebot", "Initial connection failed, will retry in background", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
|
|
@ -208,7 +208,7 @@ func (c *OneBotChannel) pinger(conn *websocket.Conn) {
|
|||
err := conn.WriteMessage(websocket.PingMessage, nil)
|
||||
c.writeMu.Unlock()
|
||||
if err != nil {
|
||||
logger.DebugCF("onebot", "Ping write failed, stopping pinger", map[string]interface{}{
|
||||
logger.DebugCF("onebot", "Ping write failed, stopping pinger", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
|
|
@ -220,7 +220,7 @@ func (c *OneBotChannel) pinger(conn *websocket.Conn) {
|
|||
func (c *OneBotChannel) fetchSelfID() {
|
||||
resp, err := c.sendAPIRequest("get_login_info", nil, 5*time.Second)
|
||||
if err != nil {
|
||||
logger.WarnCF("onebot", "Failed to get_login_info", map[string]interface{}{
|
||||
logger.WarnCF("onebot", "Failed to get_login_info", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
|
|
@ -250,7 +250,7 @@ func (c *OneBotChannel) fetchSelfID() {
|
|||
}
|
||||
if uid, err := parseJSONInt64(info.UserID); err == nil && uid > 0 {
|
||||
atomic.StoreInt64(&c.selfID, uid)
|
||||
logger.InfoCF("onebot", "Bot self ID retrieved", map[string]interface{}{
|
||||
logger.InfoCF("onebot", "Bot self ID retrieved", map[string]any{
|
||||
"self_id": uid,
|
||||
"nickname": info.Nickname,
|
||||
})
|
||||
|
|
@ -258,12 +258,12 @@ func (c *OneBotChannel) fetchSelfID() {
|
|||
}
|
||||
}
|
||||
|
||||
logger.WarnCF("onebot", "Could not parse self ID from get_login_info response", map[string]interface{}{
|
||||
logger.WarnCF("onebot", "Could not parse self ID from get_login_info response", map[string]any{
|
||||
"response": string(resp),
|
||||
})
|
||||
}
|
||||
|
||||
func (c *OneBotChannel) sendAPIRequest(action string, params interface{}, timeout time.Duration) (json.RawMessage, error) {
|
||||
func (c *OneBotChannel) sendAPIRequest(action string, params any, timeout time.Duration) (json.RawMessage, error) {
|
||||
c.mu.Lock()
|
||||
conn := c.conn
|
||||
c.mu.Unlock()
|
||||
|
|
@ -332,7 +332,7 @@ func (c *OneBotChannel) reconnectLoop() {
|
|||
if conn == nil {
|
||||
logger.InfoC("onebot", "Attempting to reconnect...")
|
||||
if err := c.connect(); err != nil {
|
||||
logger.ErrorCF("onebot", "Reconnect failed", map[string]interface{}{
|
||||
logger.ErrorCF("onebot", "Reconnect failed", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
|
|
@ -405,7 +405,7 @@ func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
|
|||
c.writeMu.Unlock()
|
||||
|
||||
if err != nil {
|
||||
logger.ErrorCF("onebot", "Failed to send message", map[string]interface{}{
|
||||
logger.ErrorCF("onebot", "Failed to send message", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return err
|
||||
|
|
@ -427,20 +427,20 @@ func (c *OneBotChannel) buildMessageSegments(chatID, content string) []oneBotMes
|
|||
if msgID, ok := lastMsgID.(string); ok && msgID != "" {
|
||||
segments = append(segments, oneBotMessageSegment{
|
||||
Type: "reply",
|
||||
Data: map[string]interface{}{"id": msgID},
|
||||
Data: map[string]any{"id": msgID},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
segments = append(segments, oneBotMessageSegment{
|
||||
Type: "text",
|
||||
Data: map[string]interface{}{"text": content},
|
||||
Data: map[string]any{"text": content},
|
||||
})
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
func (c *OneBotChannel) buildSendRequest(msg bus.OutboundMessage) (string, interface{}, error) {
|
||||
func (c *OneBotChannel) buildSendRequest(msg bus.OutboundMessage) (string, any, error) {
|
||||
chatID := msg.ChatID
|
||||
segments := c.buildMessageSegments(chatID, msg.Content)
|
||||
|
||||
|
|
@ -458,7 +458,7 @@ func (c *OneBotChannel) buildSendRequest(msg bus.OutboundMessage) (string, inter
|
|||
if err != nil {
|
||||
return "", nil, fmt.Errorf("invalid %s in chatID: %s", idKey, chatID)
|
||||
}
|
||||
return action, map[string]interface{}{idKey: id, "message": segments}, nil
|
||||
return action, map[string]any{idKey: id, "message": segments}, nil
|
||||
}
|
||||
|
||||
func (c *OneBotChannel) listen() {
|
||||
|
|
@ -478,7 +478,7 @@ func (c *OneBotChannel) listen() {
|
|||
default:
|
||||
_, message, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
logger.ErrorCF("onebot", "WebSocket read error", map[string]interface{}{
|
||||
logger.ErrorCF("onebot", "WebSocket read error", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
c.mu.Lock()
|
||||
|
|
@ -494,14 +494,14 @@ func (c *OneBotChannel) listen() {
|
|||
|
||||
var raw oneBotRawEvent
|
||||
if err := json.Unmarshal(message, &raw); err != nil {
|
||||
logger.WarnCF("onebot", "Failed to unmarshal raw event", map[string]interface{}{
|
||||
logger.WarnCF("onebot", "Failed to unmarshal raw event", map[string]any{
|
||||
"error": err.Error(),
|
||||
"payload": string(message),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
logger.DebugCF("onebot", "WebSocket event", map[string]interface{}{
|
||||
logger.DebugCF("onebot", "WebSocket event", map[string]any{
|
||||
"length": len(message),
|
||||
"post_type": raw.PostType,
|
||||
"sub_type": raw.SubType,
|
||||
|
|
@ -518,7 +518,7 @@ func (c *OneBotChannel) listen() {
|
|||
default:
|
||||
}
|
||||
} else {
|
||||
logger.DebugCF("onebot", "Received API response (no waiter)", map[string]interface{}{
|
||||
logger.DebugCF("onebot", "Received API response (no waiter)", map[string]any{
|
||||
"echo": raw.Echo,
|
||||
"status": string(raw.Status),
|
||||
})
|
||||
|
|
@ -527,7 +527,7 @@ func (c *OneBotChannel) listen() {
|
|||
}
|
||||
|
||||
if isAPIResponse(raw.Status) {
|
||||
logger.DebugCF("onebot", "Received API response without echo, skipping", map[string]interface{}{
|
||||
logger.DebugCF("onebot", "Received API response without echo, skipping", map[string]any{
|
||||
"status": string(raw.Status),
|
||||
})
|
||||
continue
|
||||
|
|
@ -594,7 +594,7 @@ func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64)
|
|||
return parseMessageResult{Text: s, IsBotMentioned: mentioned}
|
||||
}
|
||||
|
||||
var segments []map[string]interface{}
|
||||
var segments []map[string]any
|
||||
if err := json.Unmarshal(raw, &segments); err != nil {
|
||||
return parseMessageResult{}
|
||||
}
|
||||
|
|
@ -608,7 +608,7 @@ func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64)
|
|||
|
||||
for _, seg := range segments {
|
||||
segType, _ := seg["type"].(string)
|
||||
data, _ := seg["data"].(map[string]interface{})
|
||||
data, _ := seg["data"].(map[string]any)
|
||||
|
||||
switch segType {
|
||||
case "text":
|
||||
|
|
@ -662,7 +662,7 @@ func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64)
|
|||
result, err := c.transcriber.Transcribe(tctx, localPath)
|
||||
tcancel()
|
||||
if err != nil {
|
||||
logger.WarnCF("onebot", "Voice transcription failed", map[string]interface{}{
|
||||
logger.WarnCF("onebot", "Voice transcription failed", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
textParts = append(textParts, "[voice (transcription failed)]")
|
||||
|
|
@ -713,7 +713,7 @@ func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) {
|
|||
case "message":
|
||||
if userID, err := parseJSONInt64(raw.UserID); err == nil && userID > 0 {
|
||||
if !c.IsAllowed(strconv.FormatInt(userID, 10)) {
|
||||
logger.DebugCF("onebot", "Message rejected by allowlist", map[string]interface{}{
|
||||
logger.DebugCF("onebot", "Message rejected by allowlist", map[string]any{
|
||||
"user_id": userID,
|
||||
})
|
||||
return
|
||||
|
|
@ -722,7 +722,7 @@ func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) {
|
|||
c.handleMessage(raw)
|
||||
|
||||
case "message_sent":
|
||||
logger.DebugCF("onebot", "Bot sent message event", map[string]interface{}{
|
||||
logger.DebugCF("onebot", "Bot sent message event", map[string]any{
|
||||
"message_type": raw.MessageType,
|
||||
"message_id": parseJSONString(raw.MessageID),
|
||||
})
|
||||
|
|
@ -734,18 +734,18 @@ func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) {
|
|||
c.handleNoticeEvent(raw)
|
||||
|
||||
case "request":
|
||||
logger.DebugCF("onebot", "Request event received", map[string]interface{}{
|
||||
logger.DebugCF("onebot", "Request event received", map[string]any{
|
||||
"sub_type": raw.SubType,
|
||||
})
|
||||
|
||||
case "":
|
||||
logger.DebugCF("onebot", "Event with empty post_type (possibly API response)", map[string]interface{}{
|
||||
logger.DebugCF("onebot", "Event with empty post_type (possibly API response)", map[string]any{
|
||||
"echo": raw.Echo,
|
||||
"status": raw.Status,
|
||||
})
|
||||
|
||||
default:
|
||||
logger.DebugCF("onebot", "Unknown post_type", map[string]interface{}{
|
||||
logger.DebugCF("onebot", "Unknown post_type", map[string]any{
|
||||
"post_type": raw.PostType,
|
||||
})
|
||||
}
|
||||
|
|
@ -753,14 +753,14 @@ func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) {
|
|||
|
||||
func (c *OneBotChannel) handleMetaEvent(raw *oneBotRawEvent) {
|
||||
if raw.MetaEventType == "lifecycle" {
|
||||
logger.InfoCF("onebot", "Lifecycle event", map[string]interface{}{"sub_type": raw.SubType})
|
||||
logger.InfoCF("onebot", "Lifecycle event", map[string]any{"sub_type": raw.SubType})
|
||||
} else if raw.MetaEventType != "heartbeat" {
|
||||
logger.DebugCF("onebot", "Meta event: "+raw.MetaEventType, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *OneBotChannel) handleNoticeEvent(raw *oneBotRawEvent) {
|
||||
fields := map[string]interface{}{
|
||||
fields := map[string]any{
|
||||
"notice_type": raw.NoticeType,
|
||||
"sub_type": raw.SubType,
|
||||
"group_id": parseJSONString(raw.GroupID),
|
||||
|
|
@ -780,7 +780,7 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
|
|||
// Parse fields from raw event
|
||||
userID, err := parseJSONInt64(raw.UserID)
|
||||
if err != nil {
|
||||
logger.WarnCF("onebot", "Failed to parse user_id", map[string]interface{}{
|
||||
logger.WarnCF("onebot", "Failed to parse user_id", map[string]any{
|
||||
"error": err.Error(),
|
||||
"raw": string(raw.UserID),
|
||||
})
|
||||
|
|
@ -817,7 +817,7 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
|
|||
var sender oneBotSender
|
||||
if len(raw.Sender) > 0 {
|
||||
if err := json.Unmarshal(raw.Sender, &sender); err != nil {
|
||||
logger.WarnCF("onebot", "Failed to parse sender", map[string]interface{}{
|
||||
logger.WarnCF("onebot", "Failed to parse sender", map[string]any{
|
||||
"error": err.Error(),
|
||||
"sender": string(raw.Sender),
|
||||
})
|
||||
|
|
@ -829,7 +829,7 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
|
|||
defer func() {
|
||||
for _, f := range parsed.LocalFiles {
|
||||
if err := os.Remove(f); err != nil {
|
||||
logger.DebugCF("onebot", "Failed to remove temp file", map[string]interface{}{
|
||||
logger.DebugCF("onebot", "Failed to remove temp file", map[string]any{
|
||||
"path": f,
|
||||
"error": err.Error(),
|
||||
})
|
||||
|
|
@ -839,14 +839,14 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
|
|||
}
|
||||
|
||||
if c.isDuplicate(messageID) {
|
||||
logger.DebugCF("onebot", "Duplicate message, skipping", map[string]interface{}{
|
||||
logger.DebugCF("onebot", "Duplicate message, skipping", map[string]any{
|
||||
"message_id": messageID,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if content == "" {
|
||||
logger.DebugCF("onebot", "Received empty message, ignoring", map[string]interface{}{
|
||||
logger.DebugCF("onebot", "Received empty message, ignoring", map[string]any{
|
||||
"message_id": messageID,
|
||||
})
|
||||
return
|
||||
|
|
@ -889,7 +889,7 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
|
|||
|
||||
triggered, strippedContent := c.checkGroupTrigger(content, isBotMentioned)
|
||||
if !triggered {
|
||||
logger.DebugCF("onebot", "Group message ignored (no trigger)", map[string]interface{}{
|
||||
logger.DebugCF("onebot", "Group message ignored (no trigger)", map[string]any{
|
||||
"sender": senderID,
|
||||
"group": groupIDStr,
|
||||
"is_mentioned": isBotMentioned,
|
||||
|
|
@ -900,7 +900,7 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
|
|||
content = strippedContent
|
||||
|
||||
default:
|
||||
logger.WarnCF("onebot", "Unknown message type, cannot route", map[string]interface{}{
|
||||
logger.WarnCF("onebot", "Unknown message type, cannot route", map[string]any{
|
||||
"type": raw.MessageType,
|
||||
"message_id": messageID,
|
||||
"user_id": userID,
|
||||
|
|
@ -908,7 +908,7 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
|
|||
return
|
||||
}
|
||||
|
||||
logger.InfoCF("onebot", "Received "+raw.MessageType+" message", map[string]interface{}{
|
||||
logger.InfoCF("onebot", "Received "+raw.MessageType+" message", map[string]any{
|
||||
"sender": senderID,
|
||||
"chat_id": chatID,
|
||||
"message_id": messageID,
|
||||
|
|
@ -961,7 +961,10 @@ func truncate(s string, n int) string {
|
|||
return string(runes[:n]) + "..."
|
||||
}
|
||||
|
||||
func (c *OneBotChannel) checkGroupTrigger(content string, isBotMentioned bool) (triggered bool, strippedContent string) {
|
||||
func (c *OneBotChannel) checkGroupTrigger(
|
||||
content string,
|
||||
isBotMentioned bool,
|
||||
) (triggered bool, strippedContent string) {
|
||||
if isBotMentioned {
|
||||
return true, strings.TrimSpace(content)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ func (c *QQChannel) Start(ctx context.Context) error {
|
|||
return fmt.Errorf("failed to get websocket info: %w", err)
|
||||
}
|
||||
|
||||
logger.InfoCF("qq", "Got WebSocket info", map[string]interface{}{
|
||||
logger.InfoCF("qq", "Got WebSocket info", map[string]any{
|
||||
"shards": wsInfo.Shards,
|
||||
})
|
||||
|
||||
|
|
@ -87,7 +87,7 @@ func (c *QQChannel) Start(ctx context.Context) error {
|
|||
// 在 goroutine 中启动 WebSocket 连接,避免阻塞
|
||||
go func() {
|
||||
if err := c.sessionManager.Start(wsInfo, c.tokenSource, &intent); err != nil {
|
||||
logger.ErrorCF("qq", "WebSocket session error", map[string]interface{}{
|
||||
logger.ErrorCF("qq", "WebSocket session error", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
c.setRunning(false)
|
||||
|
|
@ -124,7 +124,7 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
|||
// C2C 消息发送
|
||||
_, err := c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate)
|
||||
if err != nil {
|
||||
logger.ErrorCF("qq", "Failed to send C2C message", map[string]interface{}{
|
||||
logger.ErrorCF("qq", "Failed to send C2C message", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return err
|
||||
|
|
@ -157,7 +157,7 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
|
|||
return nil
|
||||
}
|
||||
|
||||
logger.InfoCF("qq", "Received C2C message", map[string]interface{}{
|
||||
logger.InfoCF("qq", "Received C2C message", map[string]any{
|
||||
"sender": senderID,
|
||||
"length": len(content),
|
||||
})
|
||||
|
|
@ -199,7 +199,7 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
|
|||
return nil
|
||||
}
|
||||
|
||||
logger.InfoCF("qq", "Received group AT message", map[string]interface{}{
|
||||
logger.InfoCF("qq", "Received group AT message", map[string]any{
|
||||
"sender": senderID,
|
||||
"group": data.GroupID,
|
||||
"length": len(content),
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ func (c *SlackChannel) Start(ctx context.Context) error {
|
|||
c.botUserID = authResp.UserID
|
||||
c.teamID = authResp.TeamID
|
||||
|
||||
logger.InfoCF("slack", "Slack bot connected", map[string]interface{}{
|
||||
logger.InfoCF("slack", "Slack bot connected", map[string]any{
|
||||
"bot_user_id": c.botUserID,
|
||||
"team": authResp.Team,
|
||||
})
|
||||
|
|
@ -85,7 +85,7 @@ func (c *SlackChannel) Start(ctx context.Context) error {
|
|||
go func() {
|
||||
if err := c.socketClient.RunContext(c.ctx); err != nil {
|
||||
if c.ctx.Err() == nil {
|
||||
logger.ErrorCF("slack", "Socket Mode connection error", map[string]interface{}{
|
||||
logger.ErrorCF("slack", "Socket Mode connection error", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
|
@ -140,7 +140,7 @@ func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
|
|||
})
|
||||
}
|
||||
|
||||
logger.DebugCF("slack", "Message sent", map[string]interface{}{
|
||||
logger.DebugCF("slack", "Message sent", map[string]any{
|
||||
"channel_id": channelID,
|
||||
"thread_ts": threadTS,
|
||||
})
|
||||
|
|
@ -202,7 +202,7 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
|
|||
|
||||
// 检查白名单,避免为被拒绝的用户下载附件
|
||||
if !c.IsAllowed(ev.User) {
|
||||
logger.DebugCF("slack", "Message rejected by allowlist", map[string]interface{}{
|
||||
logger.DebugCF("slack", "Message rejected by allowlist", map[string]any{
|
||||
"user_id": ev.User,
|
||||
})
|
||||
return
|
||||
|
|
@ -238,7 +238,7 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
|
|||
defer func() {
|
||||
for _, file := range localFiles {
|
||||
if err := os.Remove(file); err != nil {
|
||||
logger.DebugCF("slack", "Failed to cleanup temp file", map[string]interface{}{
|
||||
logger.DebugCF("slack", "Failed to cleanup temp file", map[string]any{
|
||||
"file": file,
|
||||
"error": err.Error(),
|
||||
})
|
||||
|
|
@ -261,7 +261,7 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
|
|||
result, err := c.transcriber.Transcribe(ctx, localPath)
|
||||
|
||||
if err != nil {
|
||||
logger.ErrorCF("slack", "Voice transcription failed", map[string]interface{}{"error": err.Error()})
|
||||
logger.ErrorCF("slack", "Voice transcription failed", map[string]any{"error": err.Error()})
|
||||
content += fmt.Sprintf("\n[audio: %s (transcription failed)]", file.Name)
|
||||
} else {
|
||||
content += fmt.Sprintf("\n[voice transcription: %s]", result.Text)
|
||||
|
|
@ -293,7 +293,7 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
|
|||
"team_id": c.teamID,
|
||||
}
|
||||
|
||||
logger.DebugCF("slack", "Received message", map[string]interface{}{
|
||||
logger.DebugCF("slack", "Received message", map[string]any{
|
||||
"sender_id": senderID,
|
||||
"chat_id": chatID,
|
||||
"preview": utils.Truncate(content, 50),
|
||||
|
|
@ -309,7 +309,7 @@ func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) {
|
|||
}
|
||||
|
||||
if !c.IsAllowed(ev.User) {
|
||||
logger.DebugCF("slack", "Mention rejected by allowlist", map[string]interface{}{
|
||||
logger.DebugCF("slack", "Mention rejected by allowlist", map[string]any{
|
||||
"user_id": ev.User,
|
||||
})
|
||||
return
|
||||
|
|
@ -375,7 +375,7 @@ func (c *SlackChannel) handleSlashCommand(event socketmode.Event) {
|
|||
}
|
||||
|
||||
if !c.IsAllowed(cmd.UserID) {
|
||||
logger.DebugCF("slack", "Slash command rejected by allowlist", map[string]interface{}{
|
||||
logger.DebugCF("slack", "Slash command rejected by allowlist", map[string]any{
|
||||
"user_id": cmd.UserID,
|
||||
})
|
||||
return
|
||||
|
|
@ -400,7 +400,7 @@ func (c *SlackChannel) handleSlashCommand(event socketmode.Event) {
|
|||
"team_id": c.teamID,
|
||||
}
|
||||
|
||||
logger.DebugCF("slack", "Slash command received", map[string]interface{}{
|
||||
logger.DebugCF("slack", "Slash command received", map[string]any{
|
||||
"sender_id": senderID,
|
||||
"command": cmd.Command,
|
||||
"text": utils.Truncate(content, 50),
|
||||
|
|
@ -415,7 +415,7 @@ func (c *SlackChannel) downloadSlackFile(file slack.File) string {
|
|||
downloadURL = file.URLPrivate
|
||||
}
|
||||
if downloadURL == "" {
|
||||
logger.ErrorCF("slack", "No download URL for file", map[string]interface{}{"file_id": file.ID})
|
||||
logger.ErrorCF("slack", "No download URL for file", map[string]any{"file_id": file.ID})
|
||||
return ""
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,10 +11,9 @@ import (
|
|||
"sync"
|
||||
"time"
|
||||
|
||||
th "github.com/mymmrac/telego/telegohandler"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
"github.com/mymmrac/telego/telegohandler"
|
||||
th "github.com/mymmrac/telego/telegohandler"
|
||||
tu "github.com/mymmrac/telego/telegoutil"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
|
|
@ -127,7 +126,7 @@ func (c *TelegramChannel) Start(ctx context.Context) error {
|
|||
}, th.AnyMessage())
|
||||
|
||||
c.setRunning(true)
|
||||
logger.InfoCF("telegram", "Telegram bot connected", map[string]interface{}{
|
||||
logger.InfoCF("telegram", "Telegram bot connected", map[string]any{
|
||||
"username": c.bot.Username(),
|
||||
})
|
||||
|
||||
|
|
@ -140,6 +139,7 @@ func (c *TelegramChannel) Start(ctx context.Context) error {
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *TelegramChannel) Stop(ctx context.Context) error {
|
||||
logger.InfoC("telegram", "Stopping Telegram bot...")
|
||||
c.setRunning(false)
|
||||
|
|
@ -182,7 +182,7 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
|
|||
tgMsg.ParseMode = telego.ModeHTML
|
||||
|
||||
if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil {
|
||||
logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]interface{}{
|
||||
logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
tgMsg.ParseMode = ""
|
||||
|
|
@ -210,7 +210,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
|||
|
||||
// 检查白名单,避免为被拒绝的用户下载附件
|
||||
if !c.IsAllowed(senderID) {
|
||||
logger.DebugCF("telegram", "Message rejected by allowlist", map[string]interface{}{
|
||||
logger.DebugCF("telegram", "Message rejected by allowlist", map[string]any{
|
||||
"user_id": senderID,
|
||||
})
|
||||
return nil
|
||||
|
|
@ -227,7 +227,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
|||
defer func() {
|
||||
for _, file := range localFiles {
|
||||
if err := os.Remove(file); err != nil {
|
||||
logger.DebugCF("telegram", "Failed to cleanup temp file", map[string]interface{}{
|
||||
logger.DebugCF("telegram", "Failed to cleanup temp file", map[string]any{
|
||||
"file": file,
|
||||
"error": err.Error(),
|
||||
})
|
||||
|
|
@ -267,19 +267,19 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
|||
|
||||
transcribedText := ""
|
||||
if c.transcriber != nil && c.transcriber.IsAvailable() {
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
transcriberCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
result, err := c.transcriber.Transcribe(ctx, voicePath)
|
||||
result, err := c.transcriber.Transcribe(transcriberCtx, voicePath)
|
||||
if err != nil {
|
||||
logger.ErrorCF("telegram", "Voice transcription failed", map[string]interface{}{
|
||||
logger.ErrorCF("telegram", "Voice transcription failed", map[string]any{
|
||||
"error": err.Error(),
|
||||
"path": voicePath,
|
||||
})
|
||||
transcribedText = "[voice (transcription failed)]"
|
||||
} else {
|
||||
transcribedText = fmt.Sprintf("[voice transcription: %s]", result.Text)
|
||||
logger.InfoCF("telegram", "Voice transcribed successfully", map[string]interface{}{
|
||||
logger.InfoCF("telegram", "Voice transcribed successfully", map[string]any{
|
||||
"text": result.Text,
|
||||
})
|
||||
}
|
||||
|
|
@ -322,7 +322,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
|||
content = "[empty message]"
|
||||
}
|
||||
|
||||
logger.DebugCF("telegram", "Received message", map[string]interface{}{
|
||||
logger.DebugCF("telegram", "Received message", map[string]any{
|
||||
"sender_id": senderID,
|
||||
"chat_id": fmt.Sprintf("%d", chatID),
|
||||
"preview": utils.Truncate(content, 50),
|
||||
|
|
@ -331,7 +331,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
|||
// Thinking indicator
|
||||
err := c.bot.SendChatAction(ctx, tu.ChatAction(tu.ID(chatID), telego.ChatActionTyping))
|
||||
if err != nil {
|
||||
logger.ErrorCF("telegram", "Failed to send chat action", map[string]interface{}{
|
||||
logger.ErrorCF("telegram", "Failed to send chat action", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
|
@ -378,7 +378,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
|||
func (c *TelegramChannel) downloadPhoto(ctx context.Context, fileID string) string {
|
||||
file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID})
|
||||
if err != nil {
|
||||
logger.ErrorCF("telegram", "Failed to get photo file", map[string]interface{}{
|
||||
logger.ErrorCF("telegram", "Failed to get photo file", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return ""
|
||||
|
|
@ -393,7 +393,7 @@ func (c *TelegramChannel) downloadFileWithInfo(file *telego.File, ext string) st
|
|||
}
|
||||
|
||||
url := c.bot.FileDownloadURL(file.FilePath)
|
||||
logger.DebugCF("telegram", "File URL", map[string]interface{}{"url": url})
|
||||
logger.DebugCF("telegram", "File URL", map[string]any{"url": url})
|
||||
|
||||
// Use FilePath as filename for better identification
|
||||
filename := file.FilePath + ext
|
||||
|
|
@ -405,7 +405,7 @@ func (c *TelegramChannel) downloadFileWithInfo(file *telego.File, ext string) st
|
|||
func (c *TelegramChannel) downloadFile(ctx context.Context, fileID, ext string) string {
|
||||
file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID})
|
||||
if err != nil {
|
||||
logger.ErrorCF("telegram", "Failed to get file", map[string]interface{}{
|
||||
logger.ErrorCF("telegram", "Failed to get file", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return ""
|
||||
|
|
@ -463,7 +463,11 @@ func markdownToTelegramHTML(text string) string {
|
|||
|
||||
for i, code := range codeBlocks.codes {
|
||||
escaped := escapeHTML(code)
|
||||
text = strings.ReplaceAll(text, fmt.Sprintf("\x00CB%d\x00", i), fmt.Sprintf("<pre><code>%s</code></pre>", escaped))
|
||||
text = strings.ReplaceAll(
|
||||
text,
|
||||
fmt.Sprintf("\x00CB%d\x00", i),
|
||||
fmt.Sprintf("<pre><code>%s</code></pre>", escaped),
|
||||
)
|
||||
}
|
||||
|
||||
return text
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
|
|
@ -35,6 +36,7 @@ func commandArgs(text string) string {
|
|||
}
|
||||
return strings.TrimSpace(parts[1])
|
||||
}
|
||||
|
||||
func (c *cmd) Help(ctx context.Context, message telego.Message) error {
|
||||
msg := `/start - Start the bot
|
||||
/help - Show this help message
|
||||
|
|
@ -96,6 +98,7 @@ func (c *cmd) Show(ctx context.Context, message telego.Message) error {
|
|||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *cmd) List(ctx context.Context, message telego.Message) error {
|
||||
args := commandArgs(message.Text)
|
||||
if args == "" {
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ func (c *WeComBotChannel) Start(ctx context.Context) error {
|
|||
}
|
||||
|
||||
c.setRunning(true)
|
||||
logger.InfoCF("wecom", "WeCom Bot channel started", map[string]interface{}{
|
||||
logger.InfoCF("wecom", "WeCom Bot channel started", map[string]any{
|
||||
"address": addr,
|
||||
"path": webhookPath,
|
||||
})
|
||||
|
|
@ -142,7 +142,7 @@ func (c *WeComBotChannel) Start(ctx context.Context) error {
|
|||
// Start server in goroutine
|
||||
go func() {
|
||||
if err := c.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
logger.ErrorCF("wecom", "HTTP server error", map[string]interface{}{
|
||||
logger.ErrorCF("wecom", "HTTP server error", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
|
@ -178,7 +178,7 @@ func (c *WeComBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
|
|||
return fmt.Errorf("wecom channel not running")
|
||||
}
|
||||
|
||||
logger.DebugCF("wecom", "Sending message via webhook", map[string]interface{}{
|
||||
logger.DebugCF("wecom", "Sending message via webhook", map[string]any{
|
||||
"chat_id": msg.ChatID,
|
||||
"preview": utils.Truncate(msg.Content, 100),
|
||||
})
|
||||
|
|
@ -230,7 +230,7 @@ func (c *WeComBotChannel) handleVerification(ctx context.Context, w http.Respons
|
|||
// Reference: https://developer.work.weixin.qq.com/document/path/101033
|
||||
decryptedEchoStr, err := WeComDecryptMessageWithVerify(echostr, c.config.EncodingAESKey, "")
|
||||
if err != nil {
|
||||
logger.ErrorCF("wecom", "Failed to decrypt echostr", map[string]interface{}{
|
||||
logger.ErrorCF("wecom", "Failed to decrypt echostr", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
http.Error(w, "Decryption failed", http.StatusInternalServerError)
|
||||
|
|
@ -272,8 +272,8 @@ func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.Resp
|
|||
AgentID string `xml:"AgentID"`
|
||||
}
|
||||
|
||||
if err := xml.Unmarshal(body, &encryptedMsg); err != nil {
|
||||
logger.ErrorCF("wecom", "Failed to parse XML", map[string]interface{}{
|
||||
if err = xml.Unmarshal(body, &encryptedMsg); err != nil {
|
||||
logger.ErrorCF("wecom", "Failed to parse XML", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
http.Error(w, "Invalid XML", http.StatusBadRequest)
|
||||
|
|
@ -292,7 +292,7 @@ func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.Resp
|
|||
// Reference: https://developer.work.weixin.qq.com/document/path/101033
|
||||
decryptedMsg, err := WeComDecryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, "")
|
||||
if err != nil {
|
||||
logger.ErrorCF("wecom", "Failed to decrypt message", map[string]interface{}{
|
||||
logger.ErrorCF("wecom", "Failed to decrypt message", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
http.Error(w, "Decryption failed", http.StatusInternalServerError)
|
||||
|
|
@ -302,7 +302,7 @@ func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.Resp
|
|||
// Parse decrypted JSON message (AIBOT uses JSON format)
|
||||
var msg WeComBotMessage
|
||||
if err := json.Unmarshal([]byte(decryptedMsg), &msg); err != nil {
|
||||
logger.ErrorCF("wecom", "Failed to parse decrypted message", map[string]interface{}{
|
||||
logger.ErrorCF("wecom", "Failed to parse decrypted message", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
http.Error(w, "Invalid message format", http.StatusBadRequest)
|
||||
|
|
@ -320,8 +320,9 @@ func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.Resp
|
|||
// processMessage processes the received message
|
||||
func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessage) {
|
||||
// Skip unsupported message types
|
||||
if msg.MsgType != "text" && msg.MsgType != "image" && msg.MsgType != "voice" && msg.MsgType != "file" && msg.MsgType != "mixed" {
|
||||
logger.DebugCF("wecom", "Skipping non-supported message type", map[string]interface{}{
|
||||
if msg.MsgType != "text" && msg.MsgType != "image" && msg.MsgType != "voice" && msg.MsgType != "file" &&
|
||||
msg.MsgType != "mixed" {
|
||||
logger.DebugCF("wecom", "Skipping non-supported message type", map[string]any{
|
||||
"msg_type": msg.MsgType,
|
||||
})
|
||||
return
|
||||
|
|
@ -332,7 +333,7 @@ func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessag
|
|||
c.msgMu.Lock()
|
||||
if c.processedMsgs[msgID] {
|
||||
c.msgMu.Unlock()
|
||||
logger.DebugCF("wecom", "Skipping duplicate message", map[string]interface{}{
|
||||
logger.DebugCF("wecom", "Skipping duplicate message", map[string]any{
|
||||
"msg_id": msgID,
|
||||
})
|
||||
return
|
||||
|
|
@ -399,7 +400,7 @@ func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessag
|
|||
metadata["sender_id"] = senderID
|
||||
}
|
||||
|
||||
logger.DebugCF("wecom", "Received message", map[string]interface{}{
|
||||
logger.DebugCF("wecom", "Received message", map[string]any{
|
||||
"sender_id": senderID,
|
||||
"msg_type": msg.MsgType,
|
||||
"peer_kind": peerKind,
|
||||
|
|
@ -468,7 +469,7 @@ func (c *WeComBotChannel) sendWebhookReply(ctx context.Context, userID, content
|
|||
|
||||
// handleHealth handles health check requests
|
||||
func (c *WeComBotChannel) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
status := map[string]interface{}{
|
||||
status := map[string]any{
|
||||
"status": "ok",
|
||||
"running": c.IsRunning(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -145,7 +145,7 @@ func (c *WeComAppChannel) Start(ctx context.Context) error {
|
|||
|
||||
// Get initial access token
|
||||
if err := c.refreshAccessToken(); err != nil {
|
||||
logger.WarnCF("wecom_app", "Failed to get initial access token", map[string]interface{}{
|
||||
logger.WarnCF("wecom_app", "Failed to get initial access token", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
|
@ -171,7 +171,7 @@ func (c *WeComAppChannel) Start(ctx context.Context) error {
|
|||
}
|
||||
|
||||
c.setRunning(true)
|
||||
logger.InfoCF("wecom_app", "WeCom App channel started", map[string]interface{}{
|
||||
logger.InfoCF("wecom_app", "WeCom App channel started", map[string]any{
|
||||
"address": addr,
|
||||
"path": webhookPath,
|
||||
})
|
||||
|
|
@ -179,7 +179,7 @@ func (c *WeComAppChannel) Start(ctx context.Context) error {
|
|||
// Start server in goroutine
|
||||
go func() {
|
||||
if err := c.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
logger.ErrorCF("wecom_app", "HTTP server error", map[string]interface{}{
|
||||
logger.ErrorCF("wecom_app", "HTTP server error", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
|
@ -218,7 +218,7 @@ func (c *WeComAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
|
|||
return fmt.Errorf("no valid access token available")
|
||||
}
|
||||
|
||||
logger.DebugCF("wecom_app", "Sending message", map[string]interface{}{
|
||||
logger.DebugCF("wecom_app", "Sending message", map[string]any{
|
||||
"chat_id": msg.ChatID,
|
||||
"preview": utils.Truncate(msg.Content, 100),
|
||||
})
|
||||
|
|
@ -231,7 +231,7 @@ func (c *WeComAppChannel) handleWebhook(w http.ResponseWriter, r *http.Request)
|
|||
ctx := r.Context()
|
||||
|
||||
// Log all incoming requests for debugging
|
||||
logger.DebugCF("wecom_app", "Received webhook request", map[string]interface{}{
|
||||
logger.DebugCF("wecom_app", "Received webhook request", map[string]any{
|
||||
"method": r.Method,
|
||||
"url": r.URL.String(),
|
||||
"path": r.URL.Path,
|
||||
|
|
@ -250,7 +250,7 @@ func (c *WeComAppChannel) handleWebhook(w http.ResponseWriter, r *http.Request)
|
|||
return
|
||||
}
|
||||
|
||||
logger.WarnCF("wecom_app", "Method not allowed", map[string]interface{}{
|
||||
logger.WarnCF("wecom_app", "Method not allowed", map[string]any{
|
||||
"method": r.Method,
|
||||
})
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
|
|
@ -264,7 +264,7 @@ func (c *WeComAppChannel) handleVerification(ctx context.Context, w http.Respons
|
|||
nonce := query.Get("nonce")
|
||||
echostr := query.Get("echostr")
|
||||
|
||||
logger.DebugCF("wecom_app", "Handling verification request", map[string]interface{}{
|
||||
logger.DebugCF("wecom_app", "Handling verification request", map[string]any{
|
||||
"msg_signature": msgSignature,
|
||||
"timestamp": timestamp,
|
||||
"nonce": nonce,
|
||||
|
|
@ -280,7 +280,7 @@ func (c *WeComAppChannel) handleVerification(ctx context.Context, w http.Respons
|
|||
|
||||
// Verify signature
|
||||
if !WeComVerifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) {
|
||||
logger.WarnCF("wecom_app", "Signature verification failed", map[string]interface{}{
|
||||
logger.WarnCF("wecom_app", "Signature verification failed", map[string]any{
|
||||
"token": c.config.Token,
|
||||
"msg_signature": msgSignature,
|
||||
"timestamp": timestamp,
|
||||
|
|
@ -294,13 +294,13 @@ func (c *WeComAppChannel) handleVerification(ctx context.Context, w http.Respons
|
|||
|
||||
// Decrypt echostr with CorpID verification
|
||||
// For WeCom App (自建应用), receiveid should be corp_id
|
||||
logger.DebugCF("wecom_app", "Attempting to decrypt echostr", map[string]interface{}{
|
||||
logger.DebugCF("wecom_app", "Attempting to decrypt echostr", map[string]any{
|
||||
"encoding_aes_key": c.config.EncodingAESKey,
|
||||
"corp_id": c.config.CorpID,
|
||||
})
|
||||
decryptedEchoStr, err := WeComDecryptMessageWithVerify(echostr, c.config.EncodingAESKey, c.config.CorpID)
|
||||
if err != nil {
|
||||
logger.ErrorCF("wecom_app", "Failed to decrypt echostr", map[string]interface{}{
|
||||
logger.ErrorCF("wecom_app", "Failed to decrypt echostr", map[string]any{
|
||||
"error": err.Error(),
|
||||
"encoding_aes_key": c.config.EncodingAESKey,
|
||||
"corp_id": c.config.CorpID,
|
||||
|
|
@ -309,7 +309,7 @@ func (c *WeComAppChannel) handleVerification(ctx context.Context, w http.Respons
|
|||
return
|
||||
}
|
||||
|
||||
logger.DebugCF("wecom_app", "Successfully decrypted echostr", map[string]interface{}{
|
||||
logger.DebugCF("wecom_app", "Successfully decrypted echostr", map[string]any{
|
||||
"decrypted": decryptedEchoStr,
|
||||
})
|
||||
|
||||
|
|
@ -348,8 +348,8 @@ func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.Resp
|
|||
AgentID string `xml:"AgentID"`
|
||||
}
|
||||
|
||||
if err := xml.Unmarshal(body, &encryptedMsg); err != nil {
|
||||
logger.ErrorCF("wecom_app", "Failed to parse XML", map[string]interface{}{
|
||||
if err = xml.Unmarshal(body, &encryptedMsg); err != nil {
|
||||
logger.ErrorCF("wecom_app", "Failed to parse XML", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
http.Error(w, "Invalid XML", http.StatusBadRequest)
|
||||
|
|
@ -367,7 +367,7 @@ func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.Resp
|
|||
// For WeCom App (自建应用), receiveid should be corp_id
|
||||
decryptedMsg, err := WeComDecryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, c.config.CorpID)
|
||||
if err != nil {
|
||||
logger.ErrorCF("wecom_app", "Failed to decrypt message", map[string]interface{}{
|
||||
logger.ErrorCF("wecom_app", "Failed to decrypt message", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
http.Error(w, "Decryption failed", http.StatusInternalServerError)
|
||||
|
|
@ -377,7 +377,7 @@ func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.Resp
|
|||
// Parse decrypted XML message
|
||||
var msg WeComXMLMessage
|
||||
if err := xml.Unmarshal([]byte(decryptedMsg), &msg); err != nil {
|
||||
logger.ErrorCF("wecom_app", "Failed to parse decrypted message", map[string]interface{}{
|
||||
logger.ErrorCF("wecom_app", "Failed to parse decrypted message", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
http.Error(w, "Invalid message format", http.StatusBadRequest)
|
||||
|
|
@ -396,7 +396,7 @@ func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.Resp
|
|||
func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessage) {
|
||||
// Skip non-text messages for now (can be extended)
|
||||
if msg.MsgType != "text" && msg.MsgType != "image" && msg.MsgType != "voice" {
|
||||
logger.DebugCF("wecom_app", "Skipping non-supported message type", map[string]interface{}{
|
||||
logger.DebugCF("wecom_app", "Skipping non-supported message type", map[string]any{
|
||||
"msg_type": msg.MsgType,
|
||||
})
|
||||
return
|
||||
|
|
@ -408,7 +408,7 @@ func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessag
|
|||
c.msgMu.Lock()
|
||||
if c.processedMsgs[msgID] {
|
||||
c.msgMu.Unlock()
|
||||
logger.DebugCF("wecom_app", "Skipping duplicate message", map[string]interface{}{
|
||||
logger.DebugCF("wecom_app", "Skipping duplicate message", map[string]any{
|
||||
"msg_id": msgID,
|
||||
})
|
||||
return
|
||||
|
|
@ -441,7 +441,7 @@ func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessag
|
|||
|
||||
content := msg.Content
|
||||
|
||||
logger.DebugCF("wecom_app", "Received message", map[string]interface{}{
|
||||
logger.DebugCF("wecom_app", "Received message", map[string]any{
|
||||
"sender_id": senderID,
|
||||
"msg_type": msg.MsgType,
|
||||
"preview": utils.Truncate(content, 50),
|
||||
|
|
@ -462,7 +462,7 @@ func (c *WeComAppChannel) tokenRefreshLoop() {
|
|||
return
|
||||
case <-ticker.C:
|
||||
if err := c.refreshAccessToken(); err != nil {
|
||||
logger.ErrorCF("wecom_app", "Failed to refresh access token", map[string]interface{}{
|
||||
logger.ErrorCF("wecom_app", "Failed to refresh access token", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
|
@ -628,7 +628,7 @@ func (c *WeComAppChannel) sendMarkdownMessage(ctx context.Context, accessToken,
|
|||
|
||||
// handleHealth handles health check requests
|
||||
func (c *WeComAppChannel) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
status := map[string]interface{}{
|
||||
status := map[string]any{
|
||||
"status": "ok",
|
||||
"running": c.IsRunning(),
|
||||
"has_token": c.getAccessToken() != "",
|
||||
|
|
|
|||
|
|
@ -399,7 +399,11 @@ func TestWeComAppHandleVerification(t *testing.T) {
|
|||
nonce := "test_nonce"
|
||||
signature := generateSignatureApp("test_token", timestamp, nonce, encryptedEchostr)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr, nil)
|
||||
req := httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr,
|
||||
nil,
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ch.handleVerification(context.Background(), w, req)
|
||||
|
|
@ -429,7 +433,11 @@ func TestWeComAppHandleVerification(t *testing.T) {
|
|||
timestamp := "1234567890"
|
||||
nonce := "test_nonce"
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/webhook/wecom-app?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr, nil)
|
||||
req := httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/webhook/wecom-app?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr,
|
||||
nil,
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ch.handleVerification(context.Background(), w, req)
|
||||
|
|
@ -481,7 +489,11 @@ func TestWeComAppHandleMessageCallback(t *testing.T) {
|
|||
nonce := "test_nonce"
|
||||
signature := generateSignatureApp("test_token", timestamp, nonce, encrypted)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData))
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce,
|
||||
bytes.NewReader(wrapperData),
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ch.handleMessageCallback(context.Background(), w, req)
|
||||
|
|
@ -510,7 +522,11 @@ func TestWeComAppHandleMessageCallback(t *testing.T) {
|
|||
nonce := "test_nonce"
|
||||
signature := generateSignatureApp("test_token", timestamp, nonce, "")
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, strings.NewReader("invalid xml"))
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce,
|
||||
strings.NewReader("invalid xml"),
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ch.handleMessageCallback(context.Background(), w, req)
|
||||
|
|
@ -532,7 +548,11 @@ func TestWeComAppHandleMessageCallback(t *testing.T) {
|
|||
timestamp := "1234567890"
|
||||
nonce := "test_nonce"
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/webhook/wecom-app?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData))
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/webhook/wecom-app?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce,
|
||||
bytes.NewReader(wrapperData),
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ch.handleMessageCallback(context.Background(), w, req)
|
||||
|
|
@ -646,7 +666,11 @@ func TestWeComAppHandleWebhook(t *testing.T) {
|
|||
nonce := "test_nonce"
|
||||
signature := generateSignatureApp("test_token", timestamp, nonce, encoded)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encoded, nil)
|
||||
req := httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encoded,
|
||||
nil,
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ch.handleWebhook(w, req)
|
||||
|
|
@ -669,7 +693,11 @@ func TestWeComAppHandleWebhook(t *testing.T) {
|
|||
nonce := "test_nonce"
|
||||
signature := generateSignatureApp("test_token", timestamp, nonce, encryptedWrapper.Encrypt)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData))
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce,
|
||||
bytes.NewReader(wrapperData),
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ch.handleWebhook(w, req)
|
||||
|
|
@ -824,19 +852,6 @@ func TestWeComAppMessageStructures(t *testing.T) {
|
|||
}
|
||||
})
|
||||
|
||||
t.Run("WeComImageMessage structure", func(t *testing.T) {
|
||||
msg := WeComImageMessage{
|
||||
ToUser: "user123",
|
||||
MsgType: "image",
|
||||
AgentID: 1000002,
|
||||
}
|
||||
msg.Image.MediaID = "media_123456"
|
||||
|
||||
if msg.Image.MediaID != "media_123456" {
|
||||
t.Errorf("Image.MediaID = %q, want %q", msg.Image.MediaID, "media_123456")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("WeComAccessTokenResponse structure", func(t *testing.T) {
|
||||
jsonData := `{
|
||||
"errcode": 0,
|
||||
|
|
|
|||
|
|
@ -198,9 +198,7 @@ func TestWeComBotVerifySignature(t *testing.T) {
|
|||
Token: "",
|
||||
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||
}
|
||||
base := NewBaseChannel("wecom", cfgEmpty, msgBus, cfgEmpty.AllowFrom)
|
||||
chEmpty := &WeComBotChannel{
|
||||
BaseChannel: base,
|
||||
config: cfgEmpty,
|
||||
}
|
||||
|
||||
|
|
@ -358,7 +356,11 @@ func TestWeComBotHandleVerification(t *testing.T) {
|
|||
nonce := "test_nonce"
|
||||
signature := generateSignature("test_token", timestamp, nonce, encryptedEchostr)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr, nil)
|
||||
req := httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr,
|
||||
nil,
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ch.handleVerification(context.Background(), w, req)
|
||||
|
|
@ -388,7 +390,11 @@ func TestWeComBotHandleVerification(t *testing.T) {
|
|||
timestamp := "1234567890"
|
||||
nonce := "test_nonce"
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/webhook/wecom?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr, nil)
|
||||
req := httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/webhook/wecom?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr,
|
||||
nil,
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ch.handleVerification(context.Background(), w, req)
|
||||
|
|
@ -437,7 +443,11 @@ func TestWeComBotHandleMessageCallback(t *testing.T) {
|
|||
nonce := "test_nonce"
|
||||
signature := generateSignature("test_token", timestamp, nonce, encrypted)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData))
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce,
|
||||
bytes.NewReader(wrapperData),
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ch.handleMessageCallback(context.Background(), w, req)
|
||||
|
|
@ -479,7 +489,11 @@ func TestWeComBotHandleMessageCallback(t *testing.T) {
|
|||
nonce := "test_nonce"
|
||||
signature := generateSignature("test_token", timestamp, nonce, encrypted)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData))
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce,
|
||||
bytes.NewReader(wrapperData),
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ch.handleMessageCallback(context.Background(), w, req)
|
||||
|
|
@ -508,7 +522,11 @@ func TestWeComBotHandleMessageCallback(t *testing.T) {
|
|||
nonce := "test_nonce"
|
||||
signature := generateSignature("test_token", timestamp, nonce, "")
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, strings.NewReader("invalid xml"))
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce,
|
||||
strings.NewReader("invalid xml"),
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ch.handleMessageCallback(context.Background(), w, req)
|
||||
|
|
@ -530,7 +548,11 @@ func TestWeComBotHandleMessageCallback(t *testing.T) {
|
|||
timestamp := "1234567890"
|
||||
nonce := "test_nonce"
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/webhook/wecom?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData))
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/webhook/wecom?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce,
|
||||
bytes.NewReader(wrapperData),
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ch.handleMessageCallback(context.Background(), w, req)
|
||||
|
|
@ -625,7 +647,11 @@ func TestWeComBotHandleWebhook(t *testing.T) {
|
|||
nonce := "test_nonce"
|
||||
signature := generateSignature("test_token", timestamp, nonce, encoded)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encoded, nil)
|
||||
req := httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encoded,
|
||||
nil,
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ch.handleWebhook(w, req)
|
||||
|
|
@ -648,7 +674,11 @@ func TestWeComBotHandleWebhook(t *testing.T) {
|
|||
nonce := "test_nonce"
|
||||
signature := generateSignature("test_token", timestamp, nonce, encryptedWrapper.Encrypt)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData))
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce,
|
||||
bytes.NewReader(wrapperData),
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ch.handleWebhook(w, req)
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
|
|||
return fmt.Errorf("whatsapp connection not established")
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
payload := map[string]any{
|
||||
"type": "message",
|
||||
"to": msg.ChatID,
|
||||
"content": msg.Content,
|
||||
|
|
@ -126,7 +126,7 @@ func (c *WhatsAppChannel) listen(ctx context.Context) {
|
|||
continue
|
||||
}
|
||||
|
||||
var msg map[string]interface{}
|
||||
var msg map[string]any
|
||||
if err := json.Unmarshal(message, &msg); err != nil {
|
||||
log.Printf("Failed to unmarshal WhatsApp message: %v", err)
|
||||
continue
|
||||
|
|
@ -144,7 +144,7 @@ func (c *WhatsAppChannel) listen(ctx context.Context) {
|
|||
}
|
||||
}
|
||||
|
||||
func (c *WhatsAppChannel) handleIncomingMessage(msg map[string]interface{}) {
|
||||
func (c *WhatsAppChannel) handleIncomingMessage(msg map[string]any) {
|
||||
senderID, ok := msg["from"].(string)
|
||||
if !ok {
|
||||
return
|
||||
|
|
@ -161,7 +161,7 @@ func (c *WhatsAppChannel) handleIncomingMessage(msg map[string]interface{}) {
|
|||
}
|
||||
|
||||
var mediaPaths []string
|
||||
if mediaData, ok := msg["media"].([]interface{}); ok {
|
||||
if mediaData, ok := msg["media"].([]any); ok {
|
||||
mediaPaths = make([]string, 0, len(mediaData))
|
||||
for _, m := range mediaData {
|
||||
if path, ok := m.(string); ok {
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
|
|||
}
|
||||
|
||||
// Try []interface{} to handle mixed types
|
||||
var raw []interface{}
|
||||
var raw []any
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -517,11 +517,11 @@ func SaveConfig(path string, cfg *Config) error {
|
|||
}
|
||||
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(path, data, 0600)
|
||||
return os.WriteFile(path, data, 0o600)
|
||||
}
|
||||
|
||||
func (c *Config) WorkspacePath() string {
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ func TestAgentModelConfig_MarshalObject(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var result map[string]interface{}
|
||||
var result map[string]any
|
||||
json.Unmarshal(data, &result)
|
||||
if result["primary"] != "claude-opus" {
|
||||
t.Errorf("primary = %v", result["primary"])
|
||||
|
|
@ -319,7 +319,7 @@ func TestSaveConfig_FilePermissions(t *testing.T) {
|
|||
}
|
||||
|
||||
perm := info.Mode().Perm()
|
||||
if perm != 0600 {
|
||||
if perm != 0o600 {
|
||||
t.Errorf("config file has permission %04o, want 0600", perm)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -361,7 +361,10 @@ func TestConvertProvidersToModelList_ProviderNameAliases(t *testing.T) {
|
|||
Agents: AgentsConfig{
|
||||
Defaults: AgentDefaults{
|
||||
Provider: tt.providerAlias,
|
||||
Model: strings.TrimPrefix(tt.expectedModel, tt.expectedModel[:strings.Index(tt.expectedModel, "/")+1]),
|
||||
Model: strings.TrimPrefix(
|
||||
tt.expectedModel,
|
||||
tt.expectedModel[:strings.Index(tt.expectedModel, "/")+1],
|
||||
),
|
||||
},
|
||||
},
|
||||
Providers: ProvidersConfig{},
|
||||
|
|
@ -382,7 +385,10 @@ func TestConvertProvidersToModelList_ProviderNameAliases(t *testing.T) {
|
|||
}
|
||||
|
||||
// Need to fix the model name in config
|
||||
cfg.Agents.Defaults.Model = strings.TrimPrefix(tt.expectedModel, tt.expectedModel[:strings.Index(tt.expectedModel, "/")+1])
|
||||
cfg.Agents.Defaults.Model = strings.TrimPrefix(
|
||||
tt.expectedModel,
|
||||
tt.expectedModel[:strings.Index(tt.expectedModel, "/")+1],
|
||||
)
|
||||
|
||||
result := ConvertProvidersToModelList(cfg)
|
||||
if len(result) != 1 {
|
||||
|
|
@ -515,7 +521,11 @@ func TestBuildModelWithProtocol_AlreadyHasPrefix(t *testing.T) {
|
|||
func TestBuildModelWithProtocol_DifferentPrefix(t *testing.T) {
|
||||
result := buildModelWithProtocol("anthropic", "openrouter/claude-sonnet-4.6")
|
||||
if result != "openrouter/claude-sonnet-4.6" {
|
||||
t.Errorf("buildModelWithProtocol(anthropic, openrouter/claude-sonnet-4.6) = %q, want %q", result, "openrouter/claude-sonnet-4.6")
|
||||
t.Errorf(
|
||||
"buildModelWithProtocol(anthropic, openrouter/claude-sonnet-4.6) = %q, want %q",
|
||||
result,
|
||||
"openrouter/claude-sonnet-4.6",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -331,7 +331,7 @@ func (cs *CronService) loadStore() error {
|
|||
|
||||
func (cs *CronService) saveStoreUnsafe() error {
|
||||
dir := filepath.Dir(cs.storePath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
@ -340,10 +340,16 @@ func (cs *CronService) saveStoreUnsafe() error {
|
|||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(cs.storePath, data, 0600)
|
||||
return os.WriteFile(cs.storePath, data, 0o600)
|
||||
}
|
||||
|
||||
func (cs *CronService) AddJob(name string, schedule CronSchedule, message string, deliver bool, channel, to string) (*CronJob, error) {
|
||||
func (cs *CronService) AddJob(
|
||||
name string,
|
||||
schedule CronSchedule,
|
||||
message string,
|
||||
deliver bool,
|
||||
channel, to string,
|
||||
) (*CronJob, error) {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
|
||||
|
|
@ -465,7 +471,7 @@ func (cs *CronService) ListJobs(includeDisabled bool) []CronJob {
|
|||
return enabled
|
||||
}
|
||||
|
||||
func (cs *CronService) Status() map[string]interface{} {
|
||||
func (cs *CronService) Status() map[string]any {
|
||||
cs.mu.RLock()
|
||||
defer cs.mu.RUnlock()
|
||||
|
||||
|
|
@ -476,7 +482,7 @@ func (cs *CronService) Status() map[string]interface{} {
|
|||
}
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
return map[string]any{
|
||||
"enabled": cs.running,
|
||||
"jobs": len(cs.store.Jobs),
|
||||
"nextWakeAtMS": cs.getNextWakeMS(),
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ func TestSaveStore_FilePermissions(t *testing.T) {
|
|||
}
|
||||
|
||||
perm := info.Mode().Perm()
|
||||
if perm != 0600 {
|
||||
if perm != 0o600 {
|
||||
t.Errorf("cron store has permission %04o, want 0600", perm)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,14 +63,14 @@ func (s *Service) Start(ctx context.Context) error {
|
|||
for _, src := range s.sources {
|
||||
eventCh, err := src.Start(s.ctx)
|
||||
if err != nil {
|
||||
logger.ErrorCF("devices", "Failed to start source", map[string]interface{}{
|
||||
logger.ErrorCF("devices", "Failed to start source", map[string]any{
|
||||
"kind": src.Kind(),
|
||||
"error": err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
go s.handleEvents(src.Kind(), eventCh)
|
||||
logger.InfoCF("devices", "Device source started", map[string]interface{}{
|
||||
logger.InfoCF("devices", "Device source started", map[string]any{
|
||||
"kind": src.Kind(),
|
||||
})
|
||||
}
|
||||
|
|
@ -115,7 +115,7 @@ func (s *Service) sendNotification(ev *events.DeviceEvent) {
|
|||
|
||||
lastChannel := s.state.GetLastChannel()
|
||||
if lastChannel == "" {
|
||||
logger.DebugCF("devices", "No last channel, skipping notification", map[string]interface{}{
|
||||
logger.DebugCF("devices", "No last channel, skipping notification", map[string]any{
|
||||
"event": ev.FormatMessage(),
|
||||
})
|
||||
return
|
||||
|
|
@ -133,7 +133,7 @@ func (s *Service) sendNotification(ev *events.DeviceEvent) {
|
|||
Content: msg,
|
||||
})
|
||||
|
||||
logger.InfoCF("devices", "Device notification sent", map[string]interface{}{
|
||||
logger.InfoCF("devices", "Device notification sent", map[string]any{
|
||||
"kind": ev.Kind,
|
||||
"action": ev.Action,
|
||||
"to": platform,
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ func (m *USBMonitor) Start(ctx context.Context) (<-chan *events.DeviceEvent, err
|
|||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
logger.ErrorCF("devices", "udevadm scan error", map[string]interface{}{"error": err.Error()})
|
||||
logger.ErrorCF("devices", "udevadm scan error", map[string]any{"error": err.Error()})
|
||||
}
|
||||
cmd.Wait()
|
||||
}()
|
||||
|
|
|
|||
|
|
@ -193,7 +193,7 @@ func (hs *HeartbeatService) executeHeartbeat() {
|
|||
if result.Async {
|
||||
hs.logInfo("Async task started: %s", result.ForLLM)
|
||||
logger.InfoCF("heartbeat", "Async heartbeat task started",
|
||||
map[string]interface{}{
|
||||
map[string]any{
|
||||
"message": result.ForLLM,
|
||||
})
|
||||
return
|
||||
|
|
@ -275,7 +275,7 @@ This file contains tasks for the heartbeat service to check periodically.
|
|||
Add your heartbeat tasks below this line:
|
||||
`
|
||||
|
||||
if err := os.WriteFile(heartbeatPath, []byte(defaultContent), 0644); err != nil {
|
||||
if err := os.WriteFile(heartbeatPath, []byte(defaultContent), 0o644); err != nil {
|
||||
hs.logError("Failed to create default HEARTBEAT.md: %v", err)
|
||||
} else {
|
||||
hs.logInfo("Created default HEARTBEAT.md template")
|
||||
|
|
@ -354,7 +354,7 @@ func (hs *HeartbeatService) logError(format string, args ...any) {
|
|||
// log writes a message to the heartbeat log file
|
||||
func (hs *HeartbeatService) log(level, format string, args ...any) {
|
||||
logFile := filepath.Join(hs.workspace, "heartbeat.log")
|
||||
f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ func TestExecuteHeartbeat_Async(t *testing.T) {
|
|||
})
|
||||
|
||||
// Create HEARTBEAT.md
|
||||
os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0644)
|
||||
os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0o644)
|
||||
|
||||
// Execute heartbeat directly (internal method for testing)
|
||||
hs.executeHeartbeat()
|
||||
|
|
@ -68,7 +68,7 @@ func TestExecuteHeartbeat_Error(t *testing.T) {
|
|||
})
|
||||
|
||||
// Create HEARTBEAT.md
|
||||
os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0644)
|
||||
os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0o644)
|
||||
|
||||
hs.executeHeartbeat()
|
||||
|
||||
|
|
@ -106,7 +106,7 @@ func TestExecuteHeartbeat_Silent(t *testing.T) {
|
|||
})
|
||||
|
||||
// Create HEARTBEAT.md
|
||||
os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0644)
|
||||
os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0o644)
|
||||
|
||||
hs.executeHeartbeat()
|
||||
|
||||
|
|
@ -174,7 +174,7 @@ func TestExecuteHeartbeat_NilResult(t *testing.T) {
|
|||
})
|
||||
|
||||
// Create HEARTBEAT.md
|
||||
os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0644)
|
||||
os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0o644)
|
||||
|
||||
// Should not panic with nil result
|
||||
hs.executeHeartbeat()
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ type LogEntry struct {
|
|||
Timestamp string `json:"timestamp"`
|
||||
Component string `json:"component,omitempty"`
|
||||
Message string `json:"message"`
|
||||
Fields map[string]interface{} `json:"fields,omitempty"`
|
||||
Fields map[string]any `json:"fields,omitempty"`
|
||||
Caller string `json:"caller,omitempty"`
|
||||
}
|
||||
|
||||
|
|
@ -71,7 +71,7 @@ func EnableFileLogging(filePath string) error {
|
|||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
file, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||
file, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open log file: %w", err)
|
||||
}
|
||||
|
|
@ -96,7 +96,7 @@ func DisableFileLogging() {
|
|||
}
|
||||
}
|
||||
|
||||
func logMessage(level LogLevel, component string, message string, fields map[string]interface{}) {
|
||||
func logMessage(level LogLevel, component string, message string, fields map[string]any) {
|
||||
if level < currentLevel {
|
||||
return
|
||||
}
|
||||
|
|
@ -150,7 +150,7 @@ func formatComponent(component string) string {
|
|||
return fmt.Sprintf(" %s:", component)
|
||||
}
|
||||
|
||||
func formatFields(fields map[string]interface{}) string {
|
||||
func formatFields(fields map[string]any) string {
|
||||
var parts []string
|
||||
for k, v := range fields {
|
||||
parts = append(parts, fmt.Sprintf("%s=%v", k, v))
|
||||
|
|
@ -166,11 +166,11 @@ func DebugC(component string, message string) {
|
|||
logMessage(DEBUG, component, message, nil)
|
||||
}
|
||||
|
||||
func DebugF(message string, fields map[string]interface{}) {
|
||||
func DebugF(message string, fields map[string]any) {
|
||||
logMessage(DEBUG, "", message, fields)
|
||||
}
|
||||
|
||||
func DebugCF(component string, message string, fields map[string]interface{}) {
|
||||
func DebugCF(component string, message string, fields map[string]any) {
|
||||
logMessage(DEBUG, component, message, fields)
|
||||
}
|
||||
|
||||
|
|
@ -182,11 +182,11 @@ func InfoC(component string, message string) {
|
|||
logMessage(INFO, component, message, nil)
|
||||
}
|
||||
|
||||
func InfoF(message string, fields map[string]interface{}) {
|
||||
func InfoF(message string, fields map[string]any) {
|
||||
logMessage(INFO, "", message, fields)
|
||||
}
|
||||
|
||||
func InfoCF(component string, message string, fields map[string]interface{}) {
|
||||
func InfoCF(component string, message string, fields map[string]any) {
|
||||
logMessage(INFO, component, message, fields)
|
||||
}
|
||||
|
||||
|
|
@ -198,11 +198,11 @@ func WarnC(component string, message string) {
|
|||
logMessage(WARN, component, message, nil)
|
||||
}
|
||||
|
||||
func WarnF(message string, fields map[string]interface{}) {
|
||||
func WarnF(message string, fields map[string]any) {
|
||||
logMessage(WARN, "", message, fields)
|
||||
}
|
||||
|
||||
func WarnCF(component string, message string, fields map[string]interface{}) {
|
||||
func WarnCF(component string, message string, fields map[string]any) {
|
||||
logMessage(WARN, component, message, fields)
|
||||
}
|
||||
|
||||
|
|
@ -214,11 +214,11 @@ func ErrorC(component string, message string) {
|
|||
logMessage(ERROR, component, message, nil)
|
||||
}
|
||||
|
||||
func ErrorF(message string, fields map[string]interface{}) {
|
||||
func ErrorF(message string, fields map[string]any) {
|
||||
logMessage(ERROR, "", message, fields)
|
||||
}
|
||||
|
||||
func ErrorCF(component string, message string, fields map[string]interface{}) {
|
||||
func ErrorCF(component string, message string, fields map[string]any) {
|
||||
logMessage(ERROR, component, message, fields)
|
||||
}
|
||||
|
||||
|
|
@ -230,10 +230,10 @@ func FatalC(component string, message string) {
|
|||
logMessage(FATAL, component, message, nil)
|
||||
}
|
||||
|
||||
func FatalF(message string, fields map[string]interface{}) {
|
||||
func FatalF(message string, fields map[string]any) {
|
||||
logMessage(FATAL, "", message, fields)
|
||||
}
|
||||
|
||||
func FatalCF(component string, message string, fields map[string]interface{}) {
|
||||
func FatalCF(component string, message string, fields map[string]any) {
|
||||
logMessage(FATAL, component, message, fields)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,11 +54,11 @@ func TestLoggerWithComponent(t *testing.T) {
|
|||
name string
|
||||
component string
|
||||
message string
|
||||
fields map[string]interface{}
|
||||
fields map[string]any
|
||||
}{
|
||||
{"Simple message", "test", "Hello, world!", nil},
|
||||
{"Message with component", "discord", "Discord message", nil},
|
||||
{"Message with fields", "telegram", "Telegram message", map[string]interface{}{
|
||||
{"Message with fields", "telegram", "Telegram message", map[string]any{
|
||||
"user_id": "12345",
|
||||
"count": 42,
|
||||
}},
|
||||
|
|
@ -128,12 +128,12 @@ func TestLoggerHelperFunctions(t *testing.T) {
|
|||
Error("This should log")
|
||||
|
||||
InfoC("test", "Component message")
|
||||
InfoF("Fields message", map[string]interface{}{"key": "value"})
|
||||
InfoF("Fields message", map[string]any{"key": "value"})
|
||||
|
||||
WarnC("test", "Warning with component")
|
||||
ErrorF("Error with fields", map[string]interface{}{"error": "test"})
|
||||
ErrorF("Error with fields", map[string]any{"error": "test"})
|
||||
|
||||
SetLevel(DEBUG)
|
||||
DebugC("test", "Debug with component")
|
||||
WarnF("Warning with fields", map[string]interface{}{"key": "value"})
|
||||
WarnF("Warning with fields", map[string]any{"key": "value"})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,26 +47,26 @@ func findOpenClawConfig(openclawHome string) (string, error) {
|
|||
return "", fmt.Errorf("no config file found in %s (tried openclaw.json, config.json)", openclawHome)
|
||||
}
|
||||
|
||||
func LoadOpenClawConfig(configPath string) (map[string]interface{}, error) {
|
||||
func LoadOpenClawConfig(configPath string) (map[string]any, error) {
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading OpenClaw config: %w", err)
|
||||
}
|
||||
|
||||
var raw map[string]interface{}
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, fmt.Errorf("parsing OpenClaw config: %w", err)
|
||||
}
|
||||
|
||||
converted := convertKeysToSnake(raw)
|
||||
result, ok := converted.(map[string]interface{})
|
||||
result, ok := converted.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected config format")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func ConvertConfig(data map[string]interface{}) (*config.Config, []string, error) {
|
||||
func ConvertConfig(data map[string]any) (*config.Config, []string, error) {
|
||||
cfg := config.DefaultConfig()
|
||||
var warnings []string
|
||||
|
||||
|
|
@ -92,7 +92,7 @@ func ConvertConfig(data map[string]interface{}) (*config.Config, []string, error
|
|||
|
||||
if providers, ok := getMap(data, "providers"); ok {
|
||||
for name, val := range providers {
|
||||
pMap, ok := val.(map[string]interface{})
|
||||
pMap, ok := val.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
|
@ -131,7 +131,7 @@ func ConvertConfig(data map[string]interface{}) (*config.Config, []string, error
|
|||
|
||||
if channels, ok := getMap(data, "channels"); ok {
|
||||
for name, val := range channels {
|
||||
cMap, ok := val.(map[string]interface{})
|
||||
cMap, ok := val.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
|
@ -318,16 +318,16 @@ func camelToSnake(s string) string {
|
|||
return result.String()
|
||||
}
|
||||
|
||||
func convertKeysToSnake(data interface{}) interface{} {
|
||||
func convertKeysToSnake(data any) any {
|
||||
switch v := data.(type) {
|
||||
case map[string]interface{}:
|
||||
result := make(map[string]interface{}, len(v))
|
||||
case map[string]any:
|
||||
result := make(map[string]any, len(v))
|
||||
for key, val := range v {
|
||||
result[camelToSnake(key)] = convertKeysToSnake(val)
|
||||
}
|
||||
return result
|
||||
case []interface{}:
|
||||
result := make([]interface{}, len(v))
|
||||
case []any:
|
||||
result := make([]any, len(v))
|
||||
for i, val := range v {
|
||||
result[i] = convertKeysToSnake(val)
|
||||
}
|
||||
|
|
@ -342,16 +342,16 @@ func rewriteWorkspacePath(path string) string {
|
|||
return path
|
||||
}
|
||||
|
||||
func getMap(data map[string]interface{}, key string) (map[string]interface{}, bool) {
|
||||
func getMap(data map[string]any, key string) (map[string]any, bool) {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
m, ok := v.(map[string]interface{})
|
||||
m, ok := v.(map[string]any)
|
||||
return m, ok
|
||||
}
|
||||
|
||||
func getString(data map[string]interface{}, key string) (string, bool) {
|
||||
func getString(data map[string]any, key string) (string, bool) {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
return "", false
|
||||
|
|
@ -360,7 +360,7 @@ func getString(data map[string]interface{}, key string) (string, bool) {
|
|||
return s, ok
|
||||
}
|
||||
|
||||
func getFloat(data map[string]interface{}, key string) (float64, bool) {
|
||||
func getFloat(data map[string]any, key string) (float64, bool) {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
return 0, false
|
||||
|
|
@ -369,7 +369,7 @@ func getFloat(data map[string]interface{}, key string) (float64, bool) {
|
|||
return f, ok
|
||||
}
|
||||
|
||||
func getBool(data map[string]interface{}, key string) (bool, bool) {
|
||||
func getBool(data map[string]any, key string) (bool, bool) {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
return false, false
|
||||
|
|
@ -378,19 +378,19 @@ func getBool(data map[string]interface{}, key string) (bool, bool) {
|
|||
return b, ok
|
||||
}
|
||||
|
||||
func getBoolOrDefault(data map[string]interface{}, key string, defaultVal bool) bool {
|
||||
func getBoolOrDefault(data map[string]any, key string, defaultVal bool) bool {
|
||||
if v, ok := getBool(data, key); ok {
|
||||
return v
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
func getStringSlice(data map[string]interface{}, key string) []string {
|
||||
func getStringSlice(data map[string]any, key string) []string {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
return []string{}
|
||||
}
|
||||
arr, ok := v.([]interface{})
|
||||
arr, ok := v.([]any)
|
||||
if !ok {
|
||||
return []string{}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ func Run(opts Options) (*Result, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := os.Stat(openclawHome); os.IsNotExist(err) {
|
||||
if _, err = os.Stat(openclawHome); os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("OpenClaw installation not found at %s", openclawHome)
|
||||
}
|
||||
|
||||
|
|
@ -161,7 +161,7 @@ func Execute(actions []Action, openclawHome, picoClawHome string) *Result {
|
|||
fmt.Printf(" ✓ Converted config: %s\n", action.Destination)
|
||||
}
|
||||
case ActionCreateDir:
|
||||
if err := os.MkdirAll(action.Destination, 0755); err != nil {
|
||||
if err := os.MkdirAll(action.Destination, 0o755); err != nil {
|
||||
result.Errors = append(result.Errors, err)
|
||||
} else {
|
||||
result.DirsCreated++
|
||||
|
|
@ -174,9 +174,13 @@ func Execute(actions []Action, openclawHome, picoClawHome string) *Result {
|
|||
continue
|
||||
}
|
||||
result.BackupsCreated++
|
||||
fmt.Printf(" ✓ Backed up %s -> %s.bak\n", filepath.Base(action.Destination), filepath.Base(action.Destination))
|
||||
fmt.Printf(
|
||||
" ✓ Backed up %s -> %s.bak\n",
|
||||
filepath.Base(action.Destination),
|
||||
filepath.Base(action.Destination),
|
||||
)
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(action.Destination), 0755); err != nil {
|
||||
if err := os.MkdirAll(filepath.Dir(action.Destination), 0o755); err != nil {
|
||||
result.Errors = append(result.Errors, err)
|
||||
continue
|
||||
}
|
||||
|
|
@ -188,7 +192,7 @@ func Execute(actions []Action, openclawHome, picoClawHome string) *Result {
|
|||
fmt.Printf(" ✓ Copied %s\n", relPath(action.Source, openclawHome))
|
||||
}
|
||||
case ActionCopy:
|
||||
if err := os.MkdirAll(filepath.Dir(action.Destination), 0755); err != nil {
|
||||
if err := os.MkdirAll(filepath.Dir(action.Destination), 0o755); err != nil {
|
||||
result.Errors = append(result.Errors, err)
|
||||
continue
|
||||
}
|
||||
|
|
@ -226,7 +230,7 @@ func executeConfigMigration(srcConfigPath, dstConfigPath, picoClawHome string) e
|
|||
incoming = MergeConfig(existing, incoming)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(dstConfigPath), 0755); err != nil {
|
||||
if err := os.MkdirAll(filepath.Dir(dstConfigPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return config.SaveConfig(dstConfigPath, incoming)
|
||||
|
|
|
|||
|
|
@ -40,43 +40,43 @@ func TestCamelToSnake(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestConvertKeysToSnake(t *testing.T) {
|
||||
input := map[string]interface{}{
|
||||
input := map[string]any{
|
||||
"apiKey": "test-key",
|
||||
"apiBase": "https://example.com",
|
||||
"nested": map[string]interface{}{
|
||||
"nested": map[string]any{
|
||||
"maxTokens": float64(8192),
|
||||
"allowFrom": []interface{}{"user1", "user2"},
|
||||
"deeperLevel": map[string]interface{}{
|
||||
"allowFrom": []any{"user1", "user2"},
|
||||
"deeperLevel": map[string]any{
|
||||
"clientId": "abc",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := convertKeysToSnake(input)
|
||||
m, ok := result.(map[string]interface{})
|
||||
m, ok := result.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("expected map[string]interface{}")
|
||||
}
|
||||
|
||||
if _, ok := m["api_key"]; !ok {
|
||||
if _, ok = m["api_key"]; !ok {
|
||||
t.Error("expected key 'api_key' after conversion")
|
||||
}
|
||||
if _, ok := m["api_base"]; !ok {
|
||||
if _, ok = m["api_base"]; !ok {
|
||||
t.Error("expected key 'api_base' after conversion")
|
||||
}
|
||||
|
||||
nested, ok := m["nested"].(map[string]interface{})
|
||||
nested, ok := m["nested"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("expected nested map")
|
||||
}
|
||||
if _, ok := nested["max_tokens"]; !ok {
|
||||
if _, ok = nested["max_tokens"]; !ok {
|
||||
t.Error("expected key 'max_tokens' in nested map")
|
||||
}
|
||||
if _, ok := nested["allow_from"]; !ok {
|
||||
if _, ok = nested["allow_from"]; !ok {
|
||||
t.Error("expected key 'allow_from' in nested map")
|
||||
}
|
||||
|
||||
deeper, ok := nested["deeper_level"].(map[string]interface{})
|
||||
deeper, ok := nested["deeper_level"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("expected deeper_level map")
|
||||
}
|
||||
|
|
@ -89,15 +89,15 @@ func TestLoadOpenClawConfig(t *testing.T) {
|
|||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "openclaw.json")
|
||||
|
||||
openclawConfig := map[string]interface{}{
|
||||
"providers": map[string]interface{}{
|
||||
"anthropic": map[string]interface{}{
|
||||
openclawConfig := map[string]any{
|
||||
"providers": map[string]any{
|
||||
"anthropic": map[string]any{
|
||||
"apiKey": "sk-ant-test123",
|
||||
"apiBase": "https://api.anthropic.com",
|
||||
},
|
||||
},
|
||||
"agents": map[string]interface{}{
|
||||
"defaults": map[string]interface{}{
|
||||
"agents": map[string]any{
|
||||
"defaults": map[string]any{
|
||||
"maxTokens": float64(4096),
|
||||
"model": "claude-3-opus",
|
||||
},
|
||||
|
|
@ -108,7 +108,7 @@ func TestLoadOpenClawConfig(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(configPath, data, 0644); err != nil {
|
||||
if err = os.WriteFile(configPath, data, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
|
@ -117,11 +117,11 @@ func TestLoadOpenClawConfig(t *testing.T) {
|
|||
t.Fatalf("LoadOpenClawConfig: %v", err)
|
||||
}
|
||||
|
||||
providers, ok := result["providers"].(map[string]interface{})
|
||||
providers, ok := result["providers"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("expected providers map")
|
||||
}
|
||||
anthropic, ok := providers["anthropic"].(map[string]interface{})
|
||||
anthropic, ok := providers["anthropic"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("expected anthropic map")
|
||||
}
|
||||
|
|
@ -129,11 +129,11 @@ func TestLoadOpenClawConfig(t *testing.T) {
|
|||
t.Errorf("api_key = %v, want sk-ant-test123", anthropic["api_key"])
|
||||
}
|
||||
|
||||
agents, ok := result["agents"].(map[string]interface{})
|
||||
agents, ok := result["agents"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("expected agents map")
|
||||
}
|
||||
defaults, ok := agents["defaults"].(map[string]interface{})
|
||||
defaults, ok := agents["defaults"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("expected defaults map")
|
||||
}
|
||||
|
|
@ -144,16 +144,16 @@ func TestLoadOpenClawConfig(t *testing.T) {
|
|||
|
||||
func TestConvertConfig(t *testing.T) {
|
||||
t.Run("providers mapping", func(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"providers": map[string]interface{}{
|
||||
"anthropic": map[string]interface{}{
|
||||
data := map[string]any{
|
||||
"providers": map[string]any{
|
||||
"anthropic": map[string]any{
|
||||
"api_key": "sk-ant-test",
|
||||
"api_base": "https://api.anthropic.com",
|
||||
},
|
||||
"openrouter": map[string]interface{}{
|
||||
"openrouter": map[string]any{
|
||||
"api_key": "sk-or-test",
|
||||
},
|
||||
"groq": map[string]interface{}{
|
||||
"groq": map[string]any{
|
||||
"api_key": "gsk-test",
|
||||
},
|
||||
},
|
||||
|
|
@ -178,9 +178,9 @@ func TestConvertConfig(t *testing.T) {
|
|||
})
|
||||
|
||||
t.Run("unsupported provider warning", func(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"providers": map[string]interface{}{
|
||||
"unknown_provider": map[string]interface{}{
|
||||
data := map[string]any{
|
||||
"providers": map[string]any{
|
||||
"unknown_provider": map[string]any{
|
||||
"api_key": "sk-test",
|
||||
},
|
||||
},
|
||||
|
|
@ -199,14 +199,14 @@ func TestConvertConfig(t *testing.T) {
|
|||
})
|
||||
|
||||
t.Run("channels mapping", func(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"channels": map[string]interface{}{
|
||||
"telegram": map[string]interface{}{
|
||||
data := map[string]any{
|
||||
"channels": map[string]any{
|
||||
"telegram": map[string]any{
|
||||
"enabled": true,
|
||||
"token": "tg-token-123",
|
||||
"allow_from": []interface{}{"user1"},
|
||||
"allow_from": []any{"user1"},
|
||||
},
|
||||
"discord": map[string]interface{}{
|
||||
"discord": map[string]any{
|
||||
"enabled": true,
|
||||
"token": "disc-token-456",
|
||||
},
|
||||
|
|
@ -232,9 +232,9 @@ func TestConvertConfig(t *testing.T) {
|
|||
})
|
||||
|
||||
t.Run("unsupported channel warning", func(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"channels": map[string]interface{}{
|
||||
"email": map[string]interface{}{
|
||||
data := map[string]any{
|
||||
"channels": map[string]any{
|
||||
"email": map[string]any{
|
||||
"enabled": true,
|
||||
},
|
||||
},
|
||||
|
|
@ -253,9 +253,9 @@ func TestConvertConfig(t *testing.T) {
|
|||
})
|
||||
|
||||
t.Run("agent defaults", func(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"agents": map[string]interface{}{
|
||||
"defaults": map[string]interface{}{
|
||||
data := map[string]any{
|
||||
"agents": map[string]any{
|
||||
"defaults": map[string]any{
|
||||
"model": "claude-3-opus",
|
||||
"max_tokens": float64(4096),
|
||||
"temperature": 0.5,
|
||||
|
|
@ -287,7 +287,7 @@ func TestConvertConfig(t *testing.T) {
|
|||
})
|
||||
|
||||
t.Run("empty config", func(t *testing.T) {
|
||||
data := map[string]interface{}{}
|
||||
data := map[string]any{}
|
||||
|
||||
cfg, warnings, err := ConvertConfig(data)
|
||||
if err != nil {
|
||||
|
|
@ -389,9 +389,9 @@ func TestPlanWorkspaceMigration(t *testing.T) {
|
|||
srcDir := t.TempDir()
|
||||
dstDir := t.TempDir()
|
||||
|
||||
os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents"), 0644)
|
||||
os.WriteFile(filepath.Join(srcDir, "SOUL.md"), []byte("# Soul"), 0644)
|
||||
os.WriteFile(filepath.Join(srcDir, "USER.md"), []byte("# User"), 0644)
|
||||
os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents"), 0o644)
|
||||
os.WriteFile(filepath.Join(srcDir, "SOUL.md"), []byte("# Soul"), 0o644)
|
||||
os.WriteFile(filepath.Join(srcDir, "USER.md"), []byte("# User"), 0o644)
|
||||
|
||||
actions, err := PlanWorkspaceMigration(srcDir, dstDir, false)
|
||||
if err != nil {
|
||||
|
|
@ -420,8 +420,8 @@ func TestPlanWorkspaceMigration(t *testing.T) {
|
|||
srcDir := t.TempDir()
|
||||
dstDir := t.TempDir()
|
||||
|
||||
os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents from OpenClaw"), 0644)
|
||||
os.WriteFile(filepath.Join(dstDir, "AGENTS.md"), []byte("# Existing Agents"), 0644)
|
||||
os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents from OpenClaw"), 0o644)
|
||||
os.WriteFile(filepath.Join(dstDir, "AGENTS.md"), []byte("# Existing Agents"), 0o644)
|
||||
|
||||
actions, err := PlanWorkspaceMigration(srcDir, dstDir, false)
|
||||
if err != nil {
|
||||
|
|
@ -443,8 +443,8 @@ func TestPlanWorkspaceMigration(t *testing.T) {
|
|||
srcDir := t.TempDir()
|
||||
dstDir := t.TempDir()
|
||||
|
||||
os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents"), 0644)
|
||||
os.WriteFile(filepath.Join(dstDir, "AGENTS.md"), []byte("# Existing"), 0644)
|
||||
os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents"), 0o644)
|
||||
os.WriteFile(filepath.Join(dstDir, "AGENTS.md"), []byte("# Existing"), 0o644)
|
||||
|
||||
actions, err := PlanWorkspaceMigration(srcDir, dstDir, true)
|
||||
if err != nil {
|
||||
|
|
@ -463,8 +463,8 @@ func TestPlanWorkspaceMigration(t *testing.T) {
|
|||
dstDir := t.TempDir()
|
||||
|
||||
memDir := filepath.Join(srcDir, "memory")
|
||||
os.MkdirAll(memDir, 0755)
|
||||
os.WriteFile(filepath.Join(memDir, "MEMORY.md"), []byte("# Memory"), 0644)
|
||||
os.MkdirAll(memDir, 0o755)
|
||||
os.WriteFile(filepath.Join(memDir, "MEMORY.md"), []byte("# Memory"), 0o644)
|
||||
|
||||
actions, err := PlanWorkspaceMigration(srcDir, dstDir, false)
|
||||
if err != nil {
|
||||
|
|
@ -494,8 +494,8 @@ func TestPlanWorkspaceMigration(t *testing.T) {
|
|||
dstDir := t.TempDir()
|
||||
|
||||
skillDir := filepath.Join(srcDir, "skills", "weather")
|
||||
os.MkdirAll(skillDir, 0755)
|
||||
os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# Weather"), 0644)
|
||||
os.MkdirAll(skillDir, 0o755)
|
||||
os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# Weather"), 0o644)
|
||||
|
||||
actions, err := PlanWorkspaceMigration(srcDir, dstDir, false)
|
||||
if err != nil {
|
||||
|
|
@ -518,7 +518,7 @@ func TestFindOpenClawConfig(t *testing.T) {
|
|||
t.Run("finds openclaw.json", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "openclaw.json")
|
||||
os.WriteFile(configPath, []byte("{}"), 0644)
|
||||
os.WriteFile(configPath, []byte("{}"), 0o644)
|
||||
|
||||
found, err := findOpenClawConfig(tmpDir)
|
||||
if err != nil {
|
||||
|
|
@ -532,7 +532,7 @@ func TestFindOpenClawConfig(t *testing.T) {
|
|||
t.Run("falls back to config.json", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.json")
|
||||
os.WriteFile(configPath, []byte("{}"), 0644)
|
||||
os.WriteFile(configPath, []byte("{}"), 0o644)
|
||||
|
||||
found, err := findOpenClawConfig(tmpDir)
|
||||
if err != nil {
|
||||
|
|
@ -546,8 +546,8 @@ func TestFindOpenClawConfig(t *testing.T) {
|
|||
t.Run("prefers openclaw.json over config.json", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
openclawPath := filepath.Join(tmpDir, "openclaw.json")
|
||||
os.WriteFile(openclawPath, []byte("{}"), 0644)
|
||||
os.WriteFile(filepath.Join(tmpDir, "config.json"), []byte("{}"), 0644)
|
||||
os.WriteFile(openclawPath, []byte("{}"), 0o644)
|
||||
os.WriteFile(filepath.Join(tmpDir, "config.json"), []byte("{}"), 0o644)
|
||||
|
||||
found, err := findOpenClawConfig(tmpDir)
|
||||
if err != nil {
|
||||
|
|
@ -593,19 +593,19 @@ func TestRunDryRun(t *testing.T) {
|
|||
picoClawHome := t.TempDir()
|
||||
|
||||
wsDir := filepath.Join(openclawHome, "workspace")
|
||||
os.MkdirAll(wsDir, 0755)
|
||||
os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0644)
|
||||
os.WriteFile(filepath.Join(wsDir, "AGENTS.md"), []byte("# Agents"), 0644)
|
||||
os.MkdirAll(wsDir, 0o755)
|
||||
os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0o644)
|
||||
os.WriteFile(filepath.Join(wsDir, "AGENTS.md"), []byte("# Agents"), 0o644)
|
||||
|
||||
configData := map[string]interface{}{
|
||||
"providers": map[string]interface{}{
|
||||
"anthropic": map[string]interface{}{
|
||||
configData := map[string]any{
|
||||
"providers": map[string]any{
|
||||
"anthropic": map[string]any{
|
||||
"apiKey": "test-key",
|
||||
},
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(configData)
|
||||
os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644)
|
||||
os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0o644)
|
||||
|
||||
opts := Options{
|
||||
DryRun: true,
|
||||
|
|
@ -634,33 +634,33 @@ func TestRunFullMigration(t *testing.T) {
|
|||
picoClawHome := t.TempDir()
|
||||
|
||||
wsDir := filepath.Join(openclawHome, "workspace")
|
||||
os.MkdirAll(wsDir, 0755)
|
||||
os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul from OpenClaw"), 0644)
|
||||
os.WriteFile(filepath.Join(wsDir, "AGENTS.md"), []byte("# Agents from OpenClaw"), 0644)
|
||||
os.WriteFile(filepath.Join(wsDir, "USER.md"), []byte("# User from OpenClaw"), 0644)
|
||||
os.MkdirAll(wsDir, 0o755)
|
||||
os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul from OpenClaw"), 0o644)
|
||||
os.WriteFile(filepath.Join(wsDir, "AGENTS.md"), []byte("# Agents from OpenClaw"), 0o644)
|
||||
os.WriteFile(filepath.Join(wsDir, "USER.md"), []byte("# User from OpenClaw"), 0o644)
|
||||
|
||||
memDir := filepath.Join(wsDir, "memory")
|
||||
os.MkdirAll(memDir, 0755)
|
||||
os.WriteFile(filepath.Join(memDir, "MEMORY.md"), []byte("# Memory notes"), 0644)
|
||||
os.MkdirAll(memDir, 0o755)
|
||||
os.WriteFile(filepath.Join(memDir, "MEMORY.md"), []byte("# Memory notes"), 0o644)
|
||||
|
||||
configData := map[string]interface{}{
|
||||
"providers": map[string]interface{}{
|
||||
"anthropic": map[string]interface{}{
|
||||
configData := map[string]any{
|
||||
"providers": map[string]any{
|
||||
"anthropic": map[string]any{
|
||||
"apiKey": "sk-ant-migrate-test",
|
||||
},
|
||||
"openrouter": map[string]interface{}{
|
||||
"openrouter": map[string]any{
|
||||
"apiKey": "sk-or-migrate-test",
|
||||
},
|
||||
},
|
||||
"channels": map[string]interface{}{
|
||||
"telegram": map[string]interface{}{
|
||||
"channels": map[string]any{
|
||||
"telegram": map[string]any{
|
||||
"enabled": true,
|
||||
"token": "tg-migrate-test",
|
||||
},
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(configData)
|
||||
os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644)
|
||||
os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0o644)
|
||||
|
||||
opts := Options{
|
||||
Force: true,
|
||||
|
|
@ -754,7 +754,7 @@ func TestRunMutuallyExclusiveFlags(t *testing.T) {
|
|||
func TestBackupFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
filePath := filepath.Join(tmpDir, "test.md")
|
||||
os.WriteFile(filePath, []byte("original content"), 0644)
|
||||
os.WriteFile(filePath, []byte("original content"), 0o644)
|
||||
|
||||
if err := backupFile(filePath); err != nil {
|
||||
t.Fatalf("backupFile: %v", err)
|
||||
|
|
@ -775,7 +775,7 @@ func TestCopyFile(t *testing.T) {
|
|||
srcPath := filepath.Join(tmpDir, "src.md")
|
||||
dstPath := filepath.Join(tmpDir, "dst.md")
|
||||
|
||||
os.WriteFile(srcPath, []byte("file content"), 0644)
|
||||
os.WriteFile(srcPath, []byte("file content"), 0o644)
|
||||
|
||||
if err := copyFile(srcPath, dstPath); err != nil {
|
||||
t.Fatalf("copyFile: %v", err)
|
||||
|
|
@ -795,18 +795,18 @@ func TestRunConfigOnly(t *testing.T) {
|
|||
picoClawHome := t.TempDir()
|
||||
|
||||
wsDir := filepath.Join(openclawHome, "workspace")
|
||||
os.MkdirAll(wsDir, 0755)
|
||||
os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0644)
|
||||
os.MkdirAll(wsDir, 0o755)
|
||||
os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0o644)
|
||||
|
||||
configData := map[string]interface{}{
|
||||
"providers": map[string]interface{}{
|
||||
"anthropic": map[string]interface{}{
|
||||
configData := map[string]any{
|
||||
"providers": map[string]any{
|
||||
"anthropic": map[string]any{
|
||||
"apiKey": "sk-config-only",
|
||||
},
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(configData)
|
||||
os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644)
|
||||
os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0o644)
|
||||
|
||||
opts := Options{
|
||||
Force: true,
|
||||
|
|
@ -835,18 +835,18 @@ func TestRunWorkspaceOnly(t *testing.T) {
|
|||
picoClawHome := t.TempDir()
|
||||
|
||||
wsDir := filepath.Join(openclawHome, "workspace")
|
||||
os.MkdirAll(wsDir, 0755)
|
||||
os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0644)
|
||||
os.MkdirAll(wsDir, 0o755)
|
||||
os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0o644)
|
||||
|
||||
configData := map[string]interface{}{
|
||||
"providers": map[string]interface{}{
|
||||
"anthropic": map[string]interface{}{
|
||||
configData := map[string]any{
|
||||
"providers": map[string]any{
|
||||
"anthropic": map[string]any{
|
||||
"apiKey": "sk-ws-only",
|
||||
},
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(configData)
|
||||
os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644)
|
||||
os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0o644)
|
||||
|
||||
opts := Options{
|
||||
Force: true,
|
||||
|
|
|
|||
|
|
@ -9,16 +9,19 @@ import (
|
|||
|
||||
"github.com/anthropics/anthropic-sdk-go"
|
||||
"github.com/anthropics/anthropic-sdk-go/option"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
||||
)
|
||||
|
||||
type ToolCall = protocoltypes.ToolCall
|
||||
type FunctionCall = protocoltypes.FunctionCall
|
||||
type LLMResponse = protocoltypes.LLMResponse
|
||||
type UsageInfo = protocoltypes.UsageInfo
|
||||
type Message = protocoltypes.Message
|
||||
type ToolDefinition = protocoltypes.ToolDefinition
|
||||
type ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
|
||||
type (
|
||||
ToolCall = protocoltypes.ToolCall
|
||||
FunctionCall = protocoltypes.FunctionCall
|
||||
LLMResponse = protocoltypes.LLMResponse
|
||||
UsageInfo = protocoltypes.UsageInfo
|
||||
Message = protocoltypes.Message
|
||||
ToolDefinition = protocoltypes.ToolDefinition
|
||||
ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
|
||||
)
|
||||
|
||||
const defaultBaseURL = "https://api.anthropic.com"
|
||||
|
||||
|
|
@ -61,7 +64,13 @@ func NewProviderWithTokenSourceAndBaseURL(token string, tokenSource func() (stri
|
|||
return p
|
||||
}
|
||||
|
||||
func (p *Provider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
|
||||
func (p *Provider) Chat(
|
||||
ctx context.Context,
|
||||
messages []Message,
|
||||
tools []ToolDefinition,
|
||||
model string,
|
||||
options map[string]any,
|
||||
) (*LLMResponse, error) {
|
||||
var opts []option.RequestOption
|
||||
if p.tokenSource != nil {
|
||||
tok, err := p.tokenSource()
|
||||
|
|
@ -92,7 +101,12 @@ func (p *Provider) BaseURL() string {
|
|||
return p.baseURL
|
||||
}
|
||||
|
||||
func buildParams(messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (anthropic.MessageNewParams, error) {
|
||||
func buildParams(
|
||||
messages []Message,
|
||||
tools []ToolDefinition,
|
||||
model string,
|
||||
options map[string]any,
|
||||
) (anthropic.MessageNewParams, error) {
|
||||
var system []anthropic.TextBlockParam
|
||||
var anthropicMessages []anthropic.MessageParam
|
||||
|
||||
|
|
@ -170,7 +184,7 @@ func translateTools(tools []ToolDefinition) []anthropic.ToolUnionParam {
|
|||
if desc := t.Function.Description; desc != "" {
|
||||
tool.Description = anthropic.String(desc)
|
||||
}
|
||||
if req, ok := t.Function.Parameters["required"].([]interface{}); ok {
|
||||
if req, ok := t.Function.Parameters["required"].([]any); ok {
|
||||
required := make([]string, 0, len(req))
|
||||
for _, r := range req {
|
||||
if s, ok := r.(string); ok {
|
||||
|
|
@ -195,10 +209,10 @@ func parseResponse(resp *anthropic.Message) *LLMResponse {
|
|||
content += tb.Text
|
||||
case "tool_use":
|
||||
tu := block.AsToolUse()
|
||||
var args map[string]interface{}
|
||||
var args map[string]any
|
||||
if err := json.Unmarshal(tu.Input, &args); err != nil {
|
||||
log.Printf("anthropic: failed to decode tool call input for %q: %v", tu.Name, err)
|
||||
args = map[string]interface{}{"raw": string(tu.Input)}
|
||||
args = map[string]any{"raw": string(tu.Input)}
|
||||
}
|
||||
toolCalls = append(toolCalls, ToolCall{
|
||||
ID: tu.ID,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ func TestBuildParams_BasicMessage(t *testing.T) {
|
|||
messages := []Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
}
|
||||
params, err := buildParams(messages, nil, "claude-sonnet-4.6", map[string]interface{}{
|
||||
params, err := buildParams(messages, nil, "claude-sonnet-4.6", map[string]any{
|
||||
"max_tokens": 1024,
|
||||
})
|
||||
if err != nil {
|
||||
|
|
@ -37,7 +37,7 @@ func TestBuildParams_SystemMessage(t *testing.T) {
|
|||
{Role: "system", Content: "You are helpful"},
|
||||
{Role: "user", Content: "Hi"},
|
||||
}
|
||||
params, err := buildParams(messages, nil, "claude-sonnet-4.6", map[string]interface{}{})
|
||||
params, err := buildParams(messages, nil, "claude-sonnet-4.6", map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatalf("buildParams() error: %v", err)
|
||||
}
|
||||
|
|
@ -62,13 +62,13 @@ func TestBuildParams_ToolCallMessage(t *testing.T) {
|
|||
{
|
||||
ID: "call_1",
|
||||
Name: "get_weather",
|
||||
Arguments: map[string]interface{}{"city": "SF"},
|
||||
Arguments: map[string]any{"city": "SF"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_1"},
|
||||
}
|
||||
params, err := buildParams(messages, nil, "claude-sonnet-4.6", map[string]interface{}{})
|
||||
params, err := buildParams(messages, nil, "claude-sonnet-4.6", map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatalf("buildParams() error: %v", err)
|
||||
}
|
||||
|
|
@ -84,17 +84,17 @@ func TestBuildParams_WithTools(t *testing.T) {
|
|||
Function: ToolFunctionDefinition{
|
||||
Name: "get_weather",
|
||||
Description: "Get weather for a city",
|
||||
Parameters: map[string]interface{}{
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"city": map[string]interface{}{"type": "string"},
|
||||
"properties": map[string]any{
|
||||
"city": map[string]any{"type": "string"},
|
||||
},
|
||||
"required": []interface{}{"city"},
|
||||
"required": []any{"city"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
params, err := buildParams([]Message{{Role: "user", Content: "Hi"}}, tools, "claude-sonnet-4.6", map[string]interface{}{})
|
||||
params, err := buildParams([]Message{{Role: "user", Content: "Hi"}}, tools, "claude-sonnet-4.6", map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatalf("buildParams() error: %v", err)
|
||||
}
|
||||
|
|
@ -154,19 +154,19 @@ func TestProvider_ChatRoundTrip(t *testing.T) {
|
|||
return
|
||||
}
|
||||
|
||||
var reqBody map[string]interface{}
|
||||
var reqBody map[string]any
|
||||
json.NewDecoder(r.Body).Decode(&reqBody)
|
||||
|
||||
resp := map[string]interface{}{
|
||||
resp := map[string]any{
|
||||
"id": "msg_test",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": reqBody["model"],
|
||||
"stop_reason": "end_turn",
|
||||
"content": []map[string]interface{}{
|
||||
"content": []map[string]any{
|
||||
{"type": "text", "text": "Hello! How can I help you?"},
|
||||
},
|
||||
"usage": map[string]interface{}{
|
||||
"usage": map[string]any{
|
||||
"input_tokens": 15,
|
||||
"output_tokens": 8,
|
||||
},
|
||||
|
|
@ -178,7 +178,7 @@ func TestProvider_ChatRoundTrip(t *testing.T) {
|
|||
|
||||
provider := NewProviderWithClient(createAnthropicTestClient(server.URL, "test-token"))
|
||||
messages := []Message{{Role: "user", Content: "Hello"}}
|
||||
resp, err := provider.Chat(t.Context(), messages, nil, "claude-sonnet-4.6", map[string]interface{}{"max_tokens": 1024})
|
||||
resp, err := provider.Chat(t.Context(), messages, nil, "claude-sonnet-4.6", map[string]any{"max_tokens": 1024})
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error: %v", err)
|
||||
}
|
||||
|
|
@ -221,19 +221,19 @@ func TestProvider_ChatUsesTokenSource(t *testing.T) {
|
|||
return
|
||||
}
|
||||
|
||||
var reqBody map[string]interface{}
|
||||
var reqBody map[string]any
|
||||
json.NewDecoder(r.Body).Decode(&reqBody)
|
||||
|
||||
resp := map[string]interface{}{
|
||||
resp := map[string]any{
|
||||
"id": "msg_test",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": reqBody["model"],
|
||||
"stop_reason": "end_turn",
|
||||
"content": []map[string]interface{}{
|
||||
"content": []map[string]any{
|
||||
{"type": "text", "text": "ok"},
|
||||
},
|
||||
"usage": map[string]interface{}{
|
||||
"usage": map[string]any{
|
||||
"input_tokens": 1,
|
||||
"output_tokens": 1,
|
||||
},
|
||||
|
|
@ -247,7 +247,13 @@ func TestProvider_ChatUsesTokenSource(t *testing.T) {
|
|||
return "refreshed-token", nil
|
||||
}, server.URL)
|
||||
|
||||
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hello"}}, nil, "claude-sonnet-4.6", map[string]interface{}{})
|
||||
_, err := p.Chat(
|
||||
t.Context(),
|
||||
[]Message{{Role: "user", Content: "hello"}},
|
||||
nil,
|
||||
"claude-sonnet-4.6",
|
||||
map[string]any{},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error: %v", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,13 @@ func NewAntigravityProvider() *AntigravityProvider {
|
|||
// Chat implements LLMProvider.Chat using the Cloud Code Assist v1internal API.
|
||||
// The v1internal endpoint wraps the standard Gemini request in an envelope with
|
||||
// project, model, request, requestType, userAgent, and requestId fields.
|
||||
func (p *AntigravityProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
|
||||
func (p *AntigravityProvider) Chat(
|
||||
ctx context.Context,
|
||||
messages []Message,
|
||||
tools []ToolDefinition,
|
||||
model string,
|
||||
options map[string]any,
|
||||
) (*LLMResponse, error) {
|
||||
accessToken, projectID, err := p.tokenSource()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("antigravity auth: %w", err)
|
||||
|
|
@ -58,7 +64,7 @@ func (p *AntigravityProvider) Chat(ctx context.Context, messages []Message, tool
|
|||
model = strings.TrimPrefix(model, "google-antigravity/")
|
||||
model = strings.TrimPrefix(model, "antigravity/")
|
||||
|
||||
logger.DebugCF("provider.antigravity", "Starting chat", map[string]interface{}{
|
||||
logger.DebugCF("provider.antigravity", "Starting chat", map[string]any{
|
||||
"model": model,
|
||||
"project": projectID,
|
||||
"requestId": fmt.Sprintf("agent-%d-%s", time.Now().UnixMilli(), randomString(9)),
|
||||
|
|
@ -68,7 +74,7 @@ func (p *AntigravityProvider) Chat(ctx context.Context, messages []Message, tool
|
|||
innerRequest := p.buildRequest(messages, tools, model, options)
|
||||
|
||||
// Wrap in v1internal envelope (matches pi-ai SDK format)
|
||||
envelope := map[string]interface{}{
|
||||
envelope := map[string]any{
|
||||
"project": projectID,
|
||||
"model": model,
|
||||
"request": innerRequest,
|
||||
|
|
@ -115,7 +121,7 @@ func (p *AntigravityProvider) Chat(ctx context.Context, messages []Message, tool
|
|||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
logger.ErrorCF("provider.antigravity", "API call failed", map[string]interface{}{
|
||||
logger.ErrorCF("provider.antigravity", "API call failed", map[string]any{
|
||||
"status_code": resp.StatusCode,
|
||||
"response": string(respBody),
|
||||
"model": model,
|
||||
|
|
@ -133,7 +139,9 @@ func (p *AntigravityProvider) Chat(ctx context.Context, messages []Message, tool
|
|||
|
||||
// Check for empty response (some models might return valid success but empty text)
|
||||
if llmResp.Content == "" && len(llmResp.ToolCalls) == 0 {
|
||||
return nil, fmt.Errorf("antigravity: model returned an empty response (this model might be invalid or restricted)")
|
||||
return nil, fmt.Errorf(
|
||||
"antigravity: model returned an empty response (this model might be invalid or restricted)",
|
||||
)
|
||||
}
|
||||
|
||||
return llmResp, nil
|
||||
|
|
@ -168,12 +176,12 @@ type antigravityPart struct {
|
|||
|
||||
type antigravityFunctionCall struct {
|
||||
Name string `json:"name"`
|
||||
Args map[string]interface{} `json:"args"`
|
||||
Args map[string]any `json:"args"`
|
||||
}
|
||||
|
||||
type antigravityFunctionResponse struct {
|
||||
Name string `json:"name"`
|
||||
Response map[string]interface{} `json:"response"`
|
||||
Response map[string]any `json:"response"`
|
||||
}
|
||||
|
||||
type antigravityTool struct {
|
||||
|
|
@ -183,7 +191,7 @@ type antigravityTool struct {
|
|||
type antigravityFuncDecl struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Parameters interface{} `json:"parameters,omitempty"`
|
||||
Parameters any `json:"parameters,omitempty"`
|
||||
}
|
||||
|
||||
type antigravitySystemPrompt struct {
|
||||
|
|
@ -195,7 +203,12 @@ type antigravityGenConfig struct {
|
|||
Temperature float64 `json:"temperature,omitempty"`
|
||||
}
|
||||
|
||||
func (p *AntigravityProvider) buildRequest(messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) antigravityRequest {
|
||||
func (p *AntigravityProvider) buildRequest(
|
||||
messages []Message,
|
||||
tools []ToolDefinition,
|
||||
model string,
|
||||
options map[string]any,
|
||||
) antigravityRequest {
|
||||
req := antigravityRequest{}
|
||||
toolCallNames := make(map[string]string)
|
||||
|
||||
|
|
@ -215,7 +228,7 @@ func (p *AntigravityProvider) buildRequest(messages []Message, tools []ToolDefin
|
|||
Parts: []antigravityPart{{
|
||||
FunctionResponse: &antigravityFunctionResponse{
|
||||
Name: toolName,
|
||||
Response: map[string]interface{}{
|
||||
Response: map[string]any{
|
||||
"result": msg.Content,
|
||||
},
|
||||
},
|
||||
|
|
@ -237,9 +250,13 @@ func (p *AntigravityProvider) buildRequest(messages []Message, tools []ToolDefin
|
|||
for _, tc := range msg.ToolCalls {
|
||||
toolName, toolArgs, thoughtSignature := normalizeStoredToolCall(tc)
|
||||
if toolName == "" {
|
||||
logger.WarnCF("provider.antigravity", "Skipping tool call with empty name in history", map[string]interface{}{
|
||||
logger.WarnCF(
|
||||
"provider.antigravity",
|
||||
"Skipping tool call with empty name in history",
|
||||
map[string]any{
|
||||
"tool_call_id": tc.ID,
|
||||
})
|
||||
},
|
||||
)
|
||||
continue
|
||||
}
|
||||
if tc.ID != "" {
|
||||
|
|
@ -264,7 +281,7 @@ func (p *AntigravityProvider) buildRequest(messages []Message, tools []ToolDefin
|
|||
Parts: []antigravityPart{{
|
||||
FunctionResponse: &antigravityFunctionResponse{
|
||||
Name: toolName,
|
||||
Response: map[string]interface{}{
|
||||
Response: map[string]any{
|
||||
"result": msg.Content,
|
||||
},
|
||||
},
|
||||
|
|
@ -311,7 +328,7 @@ func (p *AntigravityProvider) buildRequest(messages []Message, tools []ToolDefin
|
|||
return req
|
||||
}
|
||||
|
||||
func normalizeStoredToolCall(tc ToolCall) (string, map[string]interface{}, string) {
|
||||
func normalizeStoredToolCall(tc ToolCall) (string, map[string]any, string) {
|
||||
name := tc.Name
|
||||
args := tc.Arguments
|
||||
thoughtSignature := ""
|
||||
|
|
@ -324,11 +341,11 @@ func normalizeStoredToolCall(tc ToolCall) (string, map[string]interface{}, strin
|
|||
}
|
||||
|
||||
if args == nil {
|
||||
args = map[string]interface{}{}
|
||||
args = map[string]any{}
|
||||
}
|
||||
|
||||
if len(args) == 0 && tc.Function != nil && tc.Function.Arguments != "" {
|
||||
var parsed map[string]interface{}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(tc.Function.Arguments), &parsed); err == nil && parsed != nil {
|
||||
args = parsed
|
||||
}
|
||||
|
|
@ -485,7 +502,10 @@ func (p *AntigravityProvider) parseSSEResponse(body string) (*LLMResponse, error
|
|||
Function: &FunctionCall{
|
||||
Name: part.FunctionCall.Name,
|
||||
Arguments: string(argumentsJSON),
|
||||
ThoughtSignature: extractPartThoughtSignature(part.ThoughtSignature, part.ThoughtSignatureSnake),
|
||||
ThoughtSignature: extractPartThoughtSignature(
|
||||
part.ThoughtSignature,
|
||||
part.ThoughtSignatureSnake,
|
||||
),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -556,24 +576,24 @@ var geminiUnsupportedKeywords = map[string]bool{
|
|||
"maxProperties": true,
|
||||
}
|
||||
|
||||
func sanitizeSchemaForGemini(schema map[string]interface{}) map[string]interface{} {
|
||||
func sanitizeSchemaForGemini(schema map[string]any) map[string]any {
|
||||
if schema == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make(map[string]interface{})
|
||||
result := make(map[string]any)
|
||||
for k, v := range schema {
|
||||
if geminiUnsupportedKeywords[k] {
|
||||
continue
|
||||
}
|
||||
// Recursively sanitize nested objects
|
||||
switch val := v.(type) {
|
||||
case map[string]interface{}:
|
||||
case map[string]any:
|
||||
result[k] = sanitizeSchemaForGemini(val)
|
||||
case []interface{}:
|
||||
sanitized := make([]interface{}, len(val))
|
||||
case []any:
|
||||
sanitized := make([]any, len(val))
|
||||
for i, item := range val {
|
||||
if m, ok := item.(map[string]interface{}); ok {
|
||||
if m, ok := item.(map[string]any); ok {
|
||||
sanitized[i] = sanitizeSchemaForGemini(m)
|
||||
} else {
|
||||
sanitized[i] = item
|
||||
|
|
@ -604,7 +624,9 @@ func createAntigravityTokenSource() func() (string, string, error) {
|
|||
return "", "", fmt.Errorf("loading auth credentials: %w", err)
|
||||
}
|
||||
if cred == nil {
|
||||
return "", "", fmt.Errorf("no credentials for google-antigravity. Run: picoclaw auth login --provider google-antigravity")
|
||||
return "", "", fmt.Errorf(
|
||||
"no credentials for google-antigravity. Run: picoclaw auth login --provider google-antigravity",
|
||||
)
|
||||
}
|
||||
|
||||
// Refresh if needed
|
||||
|
|
@ -625,7 +647,9 @@ func createAntigravityTokenSource() func() (string, string, error) {
|
|||
}
|
||||
|
||||
if cred.IsExpired() {
|
||||
return "", "", fmt.Errorf("antigravity credentials expired. Run: picoclaw auth login --provider google-antigravity")
|
||||
return "", "", fmt.Errorf(
|
||||
"antigravity credentials expired. Run: picoclaw auth login --provider google-antigravity",
|
||||
)
|
||||
}
|
||||
|
||||
projectID := cred.ProjectID
|
||||
|
|
@ -633,7 +657,7 @@ func createAntigravityTokenSource() func() (string, string, error) {
|
|||
// Try to fetch project ID from API
|
||||
fetchedID, err := FetchAntigravityProjectID(cred.AccessToken)
|
||||
if err != nil {
|
||||
logger.WarnCF("provider.antigravity", "Could not fetch project ID, using fallback", map[string]interface{}{
|
||||
logger.WarnCF("provider.antigravity", "Could not fetch project ID, using fallback", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
projectID = "rising-fact-p41fc" // Default fallback (same as OpenCode)
|
||||
|
|
@ -650,8 +674,8 @@ func createAntigravityTokenSource() func() (string, string, error) {
|
|||
|
||||
// FetchAntigravityProjectID retrieves the Google Cloud project ID from the loadCodeAssist endpoint.
|
||||
func FetchAntigravityProjectID(accessToken string) (string, error) {
|
||||
reqBody, _ := json.Marshal(map[string]interface{}{
|
||||
"metadata": map[string]interface{}{
|
||||
reqBody, _ := json.Marshal(map[string]any{
|
||||
"metadata": map[string]any{
|
||||
"ideType": "IDE_UNSPECIFIED",
|
||||
"platform": "PLATFORM_UNSPECIFIED",
|
||||
"pluginType": "GEMINI",
|
||||
|
|
@ -695,7 +719,7 @@ func FetchAntigravityProjectID(accessToken string) (string, error) {
|
|||
|
||||
// FetchAntigravityModels fetches available models from the Cloud Code Assist API.
|
||||
func FetchAntigravityModels(accessToken, projectID string) ([]AntigravityModelInfo, error) {
|
||||
reqBody, _ := json.Marshal(map[string]interface{}{
|
||||
reqBody, _ := json.Marshal(map[string]any{
|
||||
"project": projectID,
|
||||
})
|
||||
|
||||
|
|
@ -717,14 +741,18 @@ func FetchAntigravityModels(accessToken, projectID string) ([]AntigravityModelIn
|
|||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("fetchAvailableModels failed (HTTP %d): %s", resp.StatusCode, truncateString(string(body), 200))
|
||||
return nil, fmt.Errorf(
|
||||
"fetchAvailableModels failed (HTTP %d): %s",
|
||||
resp.StatusCode,
|
||||
truncateString(string(body), 200),
|
||||
)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Models map[string]struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
QuotaInfo struct {
|
||||
RemainingFraction interface{} `json:"remainingFraction"`
|
||||
RemainingFraction any `json:"remainingFraction"`
|
||||
ResetTime string `json:"resetTime"`
|
||||
IsExhausted bool `json:"isExhausted"`
|
||||
} `json:"quotaInfo"`
|
||||
|
|
@ -800,7 +828,7 @@ func (p *AntigravityProvider) parseAntigravityError(statusCode int, body []byte)
|
|||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Status string `json:"status"`
|
||||
Details []map[string]interface{} `json:"details"`
|
||||
Details []map[string]any `json:"details"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
|
|
@ -813,7 +841,7 @@ func (p *AntigravityProvider) parseAntigravityError(statusCode int, body []byte)
|
|||
// Try to extract quota reset info
|
||||
for _, detail := range errResp.Error.Details {
|
||||
if typeVal, ok := detail["@type"].(string); ok && strings.HasSuffix(typeVal, "ErrorInfo") {
|
||||
if metadata, ok := detail["metadata"].(map[string]interface{}); ok {
|
||||
if metadata, ok := detail["metadata"].(map[string]any); ok {
|
||||
if delay, ok := metadata["quotaResetDelay"].(string); ok {
|
||||
return fmt.Errorf("antigravity rate limit exceeded: %s (reset in %s)", msg, delay)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,9 @@ func NewClaudeCliProvider(workspace string) *ClaudeCliProvider {
|
|||
}
|
||||
|
||||
// Chat implements LLMProvider.Chat by executing the claude CLI.
|
||||
func (p *ClaudeCliProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
|
||||
func (p *ClaudeCliProvider) Chat(
|
||||
ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any,
|
||||
) (*LLMResponse, error) {
|
||||
systemPrompt := p.buildSystemPrompt(messages, tools)
|
||||
prompt := p.messagesToPrompt(messages)
|
||||
|
||||
|
|
@ -111,7 +113,9 @@ func (p *ClaudeCliProvider) buildToolsPrompt(tools []ToolDefinition) string {
|
|||
sb.WriteString("## Available Tools\n\n")
|
||||
sb.WriteString("When you need to use a tool, respond with ONLY a JSON object:\n\n")
|
||||
sb.WriteString("```json\n")
|
||||
sb.WriteString(`{"tool_calls":[{"id":"call_xxx","type":"function","function":{"name":"tool_name","arguments":"{...}"}}]}`)
|
||||
sb.WriteString(
|
||||
`{"tool_calls":[{"id":"call_xxx","type":"function","function":{"name":"tool_name","arguments":"{...}"}}]}`,
|
||||
)
|
||||
sb.WriteString("\n```\n\n")
|
||||
sb.WriteString("CRITICAL: The 'arguments' field MUST be a JSON-encoded STRING.\n\n")
|
||||
sb.WriteString("### Tool Definitions:\n\n")
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ func TestIntegration_RealClaudeCLI(t *testing.T) {
|
|||
resp, err := p.Chat(ctx, []Message{
|
||||
{Role: "user", Content: "Respond with only the word 'pong'. Nothing else."},
|
||||
}, nil, "", nil)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() with real CLI error = %v", err)
|
||||
}
|
||||
|
|
@ -75,7 +74,6 @@ func TestIntegration_RealClaudeCLI_WithSystemPrompt(t *testing.T) {
|
|||
{Role: "system", Content: "You are a calculator. Only respond with numbers. No text."},
|
||||
{Role: "user", Content: "What is 2+2?"},
|
||||
}, nil, "", nil)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error = %v", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,12 +30,12 @@ func createMockCLI(t *testing.T, stdout, stderr string, exitCode int) string {
|
|||
dir := t.TempDir()
|
||||
|
||||
if stdout != "" {
|
||||
if err := os.WriteFile(filepath.Join(dir, "stdout.txt"), []byte(stdout), 0644); err != nil {
|
||||
if err := os.WriteFile(filepath.Join(dir, "stdout.txt"), []byte(stdout), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if stderr != "" {
|
||||
if err := os.WriteFile(filepath.Join(dir, "stderr.txt"), []byte(stderr), 0644); err != nil {
|
||||
if err := os.WriteFile(filepath.Join(dir, "stderr.txt"), []byte(stderr), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
|
@ -51,7 +51,7 @@ func createMockCLI(t *testing.T, stdout, stderr string, exitCode int) string {
|
|||
sb.WriteString(fmt.Sprintf("exit %d\n", exitCode))
|
||||
|
||||
script := filepath.Join(dir, "claude")
|
||||
if err := os.WriteFile(script, []byte(sb.String()), 0755); err != nil {
|
||||
if err := os.WriteFile(script, []byte(sb.String()), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return script
|
||||
|
|
@ -67,7 +67,7 @@ func createSlowMockCLI(t *testing.T, sleepSeconds int) string {
|
|||
dir := t.TempDir()
|
||||
script := filepath.Join(dir, "claude")
|
||||
content := fmt.Sprintf("#!/bin/sh\nsleep %d\necho '{\"type\":\"result\",\"result\":\"late\"}'\n", sleepSeconds)
|
||||
if err := os.WriteFile(script, []byte(content), 0755); err != nil {
|
||||
if err := os.WriteFile(script, []byte(content), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return script
|
||||
|
|
@ -88,7 +88,7 @@ cat <<'EOFMOCK'
|
|||
{"type":"result","result":"ok","session_id":"test"}
|
||||
EOFMOCK
|
||||
`, argsFile)
|
||||
if err := os.WriteFile(script, []byte(content), 0755); err != nil {
|
||||
if err := os.WriteFile(script, []byte(content), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return script
|
||||
|
|
@ -137,7 +137,6 @@ func TestChat_Success(t *testing.T) {
|
|||
resp, err := p.Chat(context.Background(), []Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
}, nil, "", nil)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error = %v", err)
|
||||
}
|
||||
|
|
@ -193,7 +192,6 @@ func TestChat_WithToolCallsInResponse(t *testing.T) {
|
|||
resp, err := p.Chat(context.Background(), []Message{
|
||||
{Role: "user", Content: "What's the weather?"},
|
||||
}, nil, "", nil)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error = %v", err)
|
||||
}
|
||||
|
|
@ -403,7 +401,6 @@ func TestChat_EmptyWorkspaceDoesNotSetDir(t *testing.T) {
|
|||
resp, err := p.Chat(context.Background(), []Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
}, nil, "", nil)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() with empty workspace error = %v", err)
|
||||
}
|
||||
|
|
@ -622,10 +619,10 @@ func TestBuildSystemPrompt_WithTools(t *testing.T) {
|
|||
Function: ToolFunctionDefinition{
|
||||
Name: "get_weather",
|
||||
Description: "Get weather for a location",
|
||||
Parameters: map[string]interface{}{
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"location": map[string]interface{}{"type": "string"},
|
||||
"properties": map[string]any{
|
||||
"location": map[string]any{"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -29,7 +29,9 @@ func NewClaudeProviderWithTokenSource(token string, tokenSource func() (string,
|
|||
}
|
||||
}
|
||||
|
||||
func NewClaudeProviderWithTokenSourceAndBaseURL(token string, tokenSource func() (string, error), apiBase string) *ClaudeProvider {
|
||||
func NewClaudeProviderWithTokenSourceAndBaseURL(
|
||||
token string, tokenSource func() (string, error), apiBase string,
|
||||
) *ClaudeProvider {
|
||||
return &ClaudeProvider{
|
||||
delegate: anthropicprovider.NewProviderWithTokenSourceAndBaseURL(token, tokenSource, apiBase),
|
||||
}
|
||||
|
|
@ -39,7 +41,9 @@ func newClaudeProviderWithDelegate(delegate *anthropicprovider.Provider) *Claude
|
|||
return &ClaudeProvider{delegate: delegate}
|
||||
}
|
||||
|
||||
func (p *ClaudeProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
|
||||
func (p *ClaudeProvider) Chat(
|
||||
ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any,
|
||||
) (*LLMResponse, error) {
|
||||
resp, err := p.delegate.Chat(ctx, messages, tools, model, options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
|
||||
"github.com/anthropics/anthropic-sdk-go"
|
||||
anthropicoption "github.com/anthropics/anthropic-sdk-go/option"
|
||||
|
||||
anthropicprovider "github.com/sipeed/picoclaw/pkg/providers/anthropic"
|
||||
)
|
||||
|
||||
|
|
@ -22,19 +23,19 @@ func TestClaudeProvider_ChatRoundTrip(t *testing.T) {
|
|||
return
|
||||
}
|
||||
|
||||
var reqBody map[string]interface{}
|
||||
var reqBody map[string]any
|
||||
json.NewDecoder(r.Body).Decode(&reqBody)
|
||||
|
||||
resp := map[string]interface{}{
|
||||
resp := map[string]any{
|
||||
"id": "msg_test",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": reqBody["model"],
|
||||
"stop_reason": "end_turn",
|
||||
"content": []map[string]interface{}{
|
||||
"content": []map[string]any{
|
||||
{"type": "text", "text": "Hello! How can I help you?"},
|
||||
},
|
||||
"usage": map[string]interface{}{
|
||||
"usage": map[string]any{
|
||||
"input_tokens": 15,
|
||||
"output_tokens": 8,
|
||||
},
|
||||
|
|
@ -48,7 +49,7 @@ func TestClaudeProvider_ChatRoundTrip(t *testing.T) {
|
|||
provider := newClaudeProviderWithDelegate(delegate)
|
||||
|
||||
messages := []Message{{Role: "user", Content: "Hello"}}
|
||||
resp, err := provider.Chat(t.Context(), messages, nil, "claude-sonnet-4.6", map[string]interface{}{"max_tokens": 1024})
|
||||
resp, err := provider.Chat(t.Context(), messages, nil, "claude-sonnet-4.6", map[string]any{"max_tokens": 1024})
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error: %v", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ func ReadCodexCliCredentials() (accessToken, accountID string, expiresAt time.Ti
|
|||
}
|
||||
|
||||
var auth CodexCliAuth
|
||||
if err := json.Unmarshal(data, &auth); err != nil {
|
||||
if err = json.Unmarshal(data, &auth); err != nil {
|
||||
return "", "", time.Time{}, fmt.Errorf("parsing %s: %w", authPath, err)
|
||||
}
|
||||
|
||||
|
|
@ -59,7 +59,9 @@ func CreateCodexCliTokenSource() func() (string, string, error) {
|
|||
}
|
||||
|
||||
if time.Now().After(expiresAt) {
|
||||
return "", "", fmt.Errorf("codex cli credentials expired (auth.json last modified > 1h ago). Run: codex login")
|
||||
return "", "", fmt.Errorf(
|
||||
"codex cli credentials expired (auth.json last modified > 1h ago). Run: codex login",
|
||||
)
|
||||
}
|
||||
|
||||
return token, accountID, nil
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ func TestReadCodexCliCredentials_Valid(t *testing.T) {
|
|||
"account_id": "org-test123"
|
||||
}
|
||||
}`
|
||||
if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil {
|
||||
if err := os.WriteFile(authPath, []byte(authJSON), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
|
@ -58,7 +58,7 @@ func TestReadCodexCliCredentials_EmptyToken(t *testing.T) {
|
|||
authPath := filepath.Join(tmpDir, "auth.json")
|
||||
|
||||
authJSON := `{"tokens": {"access_token": "", "refresh_token": "r", "account_id": "a"}}`
|
||||
if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil {
|
||||
if err := os.WriteFile(authPath, []byte(authJSON), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
|
@ -74,7 +74,7 @@ func TestReadCodexCliCredentials_InvalidJSON(t *testing.T) {
|
|||
tmpDir := t.TempDir()
|
||||
authPath := filepath.Join(tmpDir, "auth.json")
|
||||
|
||||
if err := os.WriteFile(authPath, []byte("not json"), 0600); err != nil {
|
||||
if err := os.WriteFile(authPath, []byte("not json"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
|
@ -91,7 +91,7 @@ func TestReadCodexCliCredentials_NoAccountID(t *testing.T) {
|
|||
authPath := filepath.Join(tmpDir, "auth.json")
|
||||
|
||||
authJSON := `{"tokens": {"access_token": "tok123", "refresh_token": "ref456"}}`
|
||||
if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil {
|
||||
if err := os.WriteFile(authPath, []byte(authJSON), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
|
@ -112,12 +112,12 @@ func TestReadCodexCliCredentials_NoAccountID(t *testing.T) {
|
|||
func TestReadCodexCliCredentials_CodexHomeEnv(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
customDir := filepath.Join(tmpDir, "custom-codex")
|
||||
if err := os.MkdirAll(customDir, 0755); err != nil {
|
||||
if err := os.MkdirAll(customDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
authJSON := `{"tokens": {"access_token": "custom-token", "refresh_token": "r"}}`
|
||||
if err := os.WriteFile(filepath.Join(customDir, "auth.json"), []byte(authJSON), 0600); err != nil {
|
||||
if err := os.WriteFile(filepath.Join(customDir, "auth.json"), []byte(authJSON), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
|
@ -137,7 +137,7 @@ func TestCreateCodexCliTokenSource_Valid(t *testing.T) {
|
|||
authPath := filepath.Join(tmpDir, "auth.json")
|
||||
|
||||
authJSON := `{"tokens": {"access_token": "fresh-token", "refresh_token": "r", "account_id": "acc"}}`
|
||||
if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil {
|
||||
if err := os.WriteFile(authPath, []byte(authJSON), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
|
@ -161,7 +161,7 @@ func TestCreateCodexCliTokenSource_Expired(t *testing.T) {
|
|||
authPath := filepath.Join(tmpDir, "auth.json")
|
||||
|
||||
authJSON := `{"tokens": {"access_token": "old-token", "refresh_token": "r"}}`
|
||||
if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil {
|
||||
if err := os.WriteFile(authPath, []byte(authJSON), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,9 @@ func NewCodexCliProvider(workspace string) *CodexCliProvider {
|
|||
}
|
||||
|
||||
// Chat implements LLMProvider.Chat by executing the codex CLI in non-interactive mode.
|
||||
func (p *CodexCliProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
|
||||
func (p *CodexCliProvider) Chat(
|
||||
ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any,
|
||||
) (*LLMResponse, error) {
|
||||
if p.command == "" {
|
||||
return nil, fmt.Errorf("codex command not configured")
|
||||
}
|
||||
|
|
@ -133,7 +135,9 @@ func (p *CodexCliProvider) buildToolsPrompt(tools []ToolDefinition) string {
|
|||
sb.WriteString("## Available Tools\n\n")
|
||||
sb.WriteString("When you need to use a tool, respond with ONLY a JSON object:\n\n")
|
||||
sb.WriteString("```json\n")
|
||||
sb.WriteString(`{"tool_calls":[{"id":"call_xxx","type":"function","function":{"name":"tool_name","arguments":"{...}"}}]}`)
|
||||
sb.WriteString(
|
||||
`{"tool_calls":[{"id":"call_xxx","type":"function","function":{"name":"tool_name","arguments":"{...}"}}]}`,
|
||||
)
|
||||
sb.WriteString("\n```\n\n")
|
||||
sb.WriteString("CRITICAL: The 'arguments' field MUST be a JSON-encoded STRING.\n\n")
|
||||
sb.WriteString("### Tool Definitions:\n\n")
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ func TestIntegration_RealCodexCLI(t *testing.T) {
|
|||
resp, err := p.Chat(ctx, []Message{
|
||||
{Role: "user", Content: "Respond with only the word 'pong'. Nothing else."},
|
||||
}, nil, "", nil)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() with real CLI error = %v", err)
|
||||
}
|
||||
|
|
@ -64,7 +63,6 @@ func TestIntegration_RealCodexCLI_WithSystemPrompt(t *testing.T) {
|
|||
{Role: "system", Content: "You are a calculator. Only respond with numbers. No text."},
|
||||
{Role: "user", Content: "What is 2+2?"},
|
||||
}, nil, "", nil)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error = %v", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -292,10 +292,10 @@ func TestBuildPrompt_WithTools(t *testing.T) {
|
|||
Function: ToolFunctionDefinition{
|
||||
Name: "get_weather",
|
||||
Description: "Get current weather",
|
||||
Parameters: map[string]interface{}{
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"city": map[string]interface{}{"type": "string"},
|
||||
"properties": map[string]any{
|
||||
"city": map[string]any{"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -409,7 +409,7 @@ func createMockCodexCLI(t *testing.T, events []string) string {
|
|||
sb.WriteString(fmt.Sprintf("echo '%s'\n", event))
|
||||
}
|
||||
|
||||
if err := os.WriteFile(scriptPath, []byte(sb.String()), 0755); err != nil {
|
||||
if err := os.WriteFile(scriptPath, []byte(sb.String()), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return scriptPath
|
||||
|
|
@ -480,7 +480,7 @@ echo "$@" > "` + filepath.Join(tmpDir, "args.txt") + `"
|
|||
echo '{"type":"item.completed","item":{"id":"1","type":"agent_message","text":"ok"}}'
|
||||
echo '{"type":"turn.completed"}'`
|
||||
|
||||
if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
|
||||
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
|
@ -522,7 +522,7 @@ func TestCodexCliProvider_MockCLI_ContextCancel(t *testing.T) {
|
|||
scriptPath := filepath.Join(tmpDir, "codex")
|
||||
script := "#!/bin/bash\nsleep 60"
|
||||
|
||||
if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
|
||||
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,12 +10,15 @@ import (
|
|||
"github.com/openai/openai-go/v3"
|
||||
"github.com/openai/openai-go/v3/option"
|
||||
"github.com/openai/openai-go/v3/responses"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/auth"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
)
|
||||
|
||||
const codexDefaultModel = "gpt-5.2"
|
||||
const codexDefaultInstructions = "You are Codex, a coding assistant."
|
||||
const (
|
||||
codexDefaultModel = "gpt-5.2"
|
||||
codexDefaultInstructions = "You are Codex, a coding assistant."
|
||||
)
|
||||
|
||||
type CodexProvider struct {
|
||||
client *openai.Client
|
||||
|
|
@ -44,22 +47,30 @@ func NewCodexProvider(token, accountID string) *CodexProvider {
|
|||
}
|
||||
}
|
||||
|
||||
func NewCodexProviderWithTokenSource(token, accountID string, tokenSource func() (string, string, error)) *CodexProvider {
|
||||
func NewCodexProviderWithTokenSource(
|
||||
token, accountID string, tokenSource func() (string, string, error),
|
||||
) *CodexProvider {
|
||||
p := NewCodexProvider(token, accountID)
|
||||
p.tokenSource = tokenSource
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *CodexProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
|
||||
func (p *CodexProvider) Chat(
|
||||
ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any,
|
||||
) (*LLMResponse, error) {
|
||||
var opts []option.RequestOption
|
||||
accountID := p.accountID
|
||||
resolvedModel, fallbackReason := resolveCodexModel(model)
|
||||
if fallbackReason != "" {
|
||||
logger.WarnCF("provider.codex", "Requested model is not compatible with Codex backend, using fallback", map[string]interface{}{
|
||||
logger.WarnCF(
|
||||
"provider.codex",
|
||||
"Requested model is not compatible with Codex backend, using fallback",
|
||||
map[string]any{
|
||||
"requested_model": model,
|
||||
"resolved_model": resolvedModel,
|
||||
"reason": fallbackReason,
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
if p.tokenSource != nil {
|
||||
tok, accID, err := p.tokenSource()
|
||||
|
|
@ -74,10 +85,14 @@ func (p *CodexProvider) Chat(ctx context.Context, messages []Message, tools []To
|
|||
if accountID != "" {
|
||||
opts = append(opts, option.WithHeader("Chatgpt-Account-Id", accountID))
|
||||
} else {
|
||||
logger.WarnCF("provider.codex", "No account id found for Codex request; backend may reject with 400", map[string]interface{}{
|
||||
logger.WarnCF(
|
||||
"provider.codex",
|
||||
"No account id found for Codex request; backend may reject with 400",
|
||||
map[string]any{
|
||||
"requested_model": model,
|
||||
"resolved_model": resolvedModel,
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
params := buildCodexParams(messages, tools, resolvedModel, options, p.enableWebSearch)
|
||||
|
|
@ -98,7 +113,7 @@ func (p *CodexProvider) Chat(ctx context.Context, messages []Message, tools []To
|
|||
}
|
||||
err := stream.Err()
|
||||
if err != nil {
|
||||
fields := map[string]interface{}{
|
||||
fields := map[string]any{
|
||||
"requested_model": model,
|
||||
"resolved_model": resolvedModel,
|
||||
"messages_count": len(messages),
|
||||
|
|
@ -124,7 +139,7 @@ func (p *CodexProvider) Chat(ctx context.Context, messages []Message, tools []To
|
|||
return nil, fmt.Errorf("codex API call: %w", err)
|
||||
}
|
||||
if resp == nil {
|
||||
fields := map[string]interface{}{
|
||||
fields := map[string]any{
|
||||
"requested_model": model,
|
||||
"resolved_model": resolvedModel,
|
||||
"messages_count": len(messages),
|
||||
|
|
@ -184,7 +199,9 @@ func resolveCodexModel(model string) (string, string) {
|
|||
return codexDefaultModel, "unsupported model family"
|
||||
}
|
||||
|
||||
func buildCodexParams(messages []Message, tools []ToolDefinition, model string, options map[string]interface{}, enableWebSearch bool) responses.ResponseNewParams {
|
||||
func buildCodexParams(
|
||||
messages []Message, tools []ToolDefinition, model string, options map[string]any, enableWebSearch bool,
|
||||
) responses.ResponseNewParams {
|
||||
var inputItems responses.ResponseInputParam
|
||||
var instructions string
|
||||
|
||||
|
|
@ -197,7 +214,9 @@ func buildCodexParams(messages []Message, tools []ToolDefinition, model string,
|
|||
inputItems = append(inputItems, responses.ResponseInputItemUnionParam{
|
||||
OfFunctionCallOutput: &responses.ResponseInputItemFunctionCallOutputParam{
|
||||
CallID: msg.ToolCallID,
|
||||
Output: responses.ResponseInputItemFunctionCallOutputOutputUnionParam{OfString: openai.Opt(msg.Content)},
|
||||
Output: responses.ResponseInputItemFunctionCallOutputOutputUnionParam{
|
||||
OfString: openai.Opt(msg.Content),
|
||||
},
|
||||
},
|
||||
})
|
||||
} else {
|
||||
|
|
@ -221,7 +240,7 @@ func buildCodexParams(messages []Message, tools []ToolDefinition, model string,
|
|||
for _, tc := range msg.ToolCalls {
|
||||
name, args, ok := resolveCodexToolCall(tc)
|
||||
if !ok {
|
||||
logger.WarnCF("provider.codex", "Skipping invalid tool call in history", map[string]interface{}{
|
||||
logger.WarnCF("provider.codex", "Skipping invalid tool call in history", map[string]any{
|
||||
"call_id": tc.ID,
|
||||
})
|
||||
continue
|
||||
|
|
@ -246,7 +265,9 @@ func buildCodexParams(messages []Message, tools []ToolDefinition, model string,
|
|||
inputItems = append(inputItems, responses.ResponseInputItemUnionParam{
|
||||
OfFunctionCallOutput: &responses.ResponseInputItemFunctionCallOutputParam{
|
||||
CallID: msg.ToolCallID,
|
||||
Output: responses.ResponseInputItemFunctionCallOutputOutputUnionParam{OfString: openai.Opt(msg.Content)},
|
||||
Output: responses.ResponseInputItemFunctionCallOutputOutputUnionParam{
|
||||
OfString: openai.Opt(msg.Content),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -341,9 +362,9 @@ func parseCodexResponse(resp *responses.Response) *LLMResponse {
|
|||
}
|
||||
}
|
||||
case "function_call":
|
||||
var args map[string]interface{}
|
||||
var args map[string]any
|
||||
if err := json.Unmarshal([]byte(item.Arguments), &args); err != nil {
|
||||
args = map[string]interface{}{"raw": item.Arguments}
|
||||
args = map[string]any{"raw": item.Arguments}
|
||||
}
|
||||
toolCalls = append(toolCalls, ToolCall{
|
||||
ID: item.CallID,
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ func TestBuildCodexParams_BasicMessage(t *testing.T) {
|
|||
messages := []Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
}
|
||||
params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{
|
||||
params := buildCodexParams(messages, nil, "gpt-4o", map[string]any{
|
||||
"max_tokens": 2048,
|
||||
"temperature": 0.7,
|
||||
}, true)
|
||||
|
|
@ -39,7 +39,7 @@ func TestBuildCodexParams_SystemAsInstructions(t *testing.T) {
|
|||
{Role: "system", Content: "You are helpful"},
|
||||
{Role: "user", Content: "Hi"},
|
||||
}
|
||||
params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{}, true)
|
||||
params := buildCodexParams(messages, nil, "gpt-4o", map[string]any{}, true)
|
||||
if !params.Instructions.Valid() {
|
||||
t.Fatal("Instructions should be set")
|
||||
}
|
||||
|
|
@ -54,12 +54,12 @@ func TestBuildCodexParams_ToolCallConversation(t *testing.T) {
|
|||
{
|
||||
Role: "assistant",
|
||||
ToolCalls: []ToolCall{
|
||||
{ID: "call_1", Name: "get_weather", Arguments: map[string]interface{}{"city": "SF"}},
|
||||
{ID: "call_1", Name: "get_weather", Arguments: map[string]any{"city": "SF"}},
|
||||
},
|
||||
},
|
||||
{Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_1"},
|
||||
}
|
||||
params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{}, false)
|
||||
params := buildCodexParams(messages, nil, "gpt-4o", map[string]any{}, false)
|
||||
if params.Input.OfInputItemList == nil {
|
||||
t.Fatal("Input.OfInputItemList should not be nil")
|
||||
}
|
||||
|
|
@ -87,7 +87,7 @@ func TestBuildCodexParams_ToolCallFunctionFallback(t *testing.T) {
|
|||
{Role: "tool", Content: "ok", ToolCallID: "call_1"},
|
||||
}
|
||||
|
||||
params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{}, false)
|
||||
params := buildCodexParams(messages, nil, "gpt-4o", map[string]any{}, false)
|
||||
if params.Input.OfInputItemList == nil {
|
||||
t.Fatal("Input.OfInputItemList should not be nil")
|
||||
}
|
||||
|
|
@ -114,16 +114,16 @@ func TestBuildCodexParams_WithTools(t *testing.T) {
|
|||
Function: ToolFunctionDefinition{
|
||||
Name: "get_weather",
|
||||
Description: "Get weather",
|
||||
Parameters: map[string]interface{}{
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"city": map[string]interface{}{"type": "string"},
|
||||
"properties": map[string]any{
|
||||
"city": map[string]any{"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, tools, "gpt-4o", map[string]interface{}{}, false)
|
||||
params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, tools, "gpt-4o", map[string]any{}, false)
|
||||
if len(params.Tools) != 1 {
|
||||
t.Fatalf("len(Tools) = %d, want 1", len(params.Tools))
|
||||
}
|
||||
|
|
@ -136,14 +136,14 @@ func TestBuildCodexParams_WithTools(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestBuildCodexParams_StoreIsFalse(t *testing.T) {
|
||||
params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, nil, "gpt-4o", map[string]interface{}{}, false)
|
||||
params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, nil, "gpt-4o", map[string]any{}, false)
|
||||
if !params.Store.Valid() || params.Store.Or(true) != false {
|
||||
t.Error("Store should be explicitly set to false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCodexParams_DefaultWebSearchEnabled(t *testing.T) {
|
||||
params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, nil, "gpt-4o", map[string]interface{}{}, true)
|
||||
params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, nil, "gpt-4o", map[string]any{}, true)
|
||||
if len(params.Tools) != 1 {
|
||||
t.Fatalf("len(Tools) = %d, want 1", len(params.Tools))
|
||||
}
|
||||
|
|
@ -151,7 +151,11 @@ func TestBuildCodexParams_DefaultWebSearchEnabled(t *testing.T) {
|
|||
t.Fatal("Tool should include built-in web_search")
|
||||
}
|
||||
if params.Tools[0].OfWebSearch.Type != responses.WebSearchToolTypeWebSearch {
|
||||
t.Errorf("Web search tool type = %q, want %q", params.Tools[0].OfWebSearch.Type, responses.WebSearchToolTypeWebSearch)
|
||||
t.Errorf(
|
||||
"Web search tool type = %q, want %q",
|
||||
params.Tools[0].OfWebSearch.Type,
|
||||
responses.WebSearchToolTypeWebSearch,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -162,7 +166,7 @@ func TestBuildCodexParams_WebSearchFunctionReplacedWithBuiltin(t *testing.T) {
|
|||
Function: ToolFunctionDefinition{
|
||||
Name: "web_search",
|
||||
Description: "local web search",
|
||||
Parameters: map[string]interface{}{
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
},
|
||||
},
|
||||
|
|
@ -172,14 +176,14 @@ func TestBuildCodexParams_WebSearchFunctionReplacedWithBuiltin(t *testing.T) {
|
|||
Function: ToolFunctionDefinition{
|
||||
Name: "read_file",
|
||||
Description: "read file",
|
||||
Parameters: map[string]interface{}{
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, tools, "gpt-4o", map[string]interface{}{}, true)
|
||||
params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, tools, "gpt-4o", map[string]any{}, true)
|
||||
if len(params.Tools) != 2 {
|
||||
t.Fatalf("len(Tools) = %d, want 2", len(params.Tools))
|
||||
}
|
||||
|
|
@ -296,7 +300,7 @@ func TestCodexProvider_ChatRoundTrip(t *testing.T) {
|
|||
return
|
||||
}
|
||||
|
||||
var reqBody map[string]interface{}
|
||||
var reqBody map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
|
|
@ -309,38 +313,38 @@ func TestCodexProvider_ChatRoundTrip(t *testing.T) {
|
|||
http.Error(w, "max_output_tokens is not supported", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
toolsAny, ok := reqBody["tools"].([]interface{})
|
||||
toolsAny, ok := reqBody["tools"].([]any)
|
||||
if !ok || len(toolsAny) != 1 {
|
||||
http.Error(w, "missing default web search tool", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
toolObj, ok := toolsAny[0].(map[string]interface{})
|
||||
toolObj, ok := toolsAny[0].(map[string]any)
|
||||
if !ok || toolObj["type"] != "web_search" {
|
||||
http.Error(w, "expected web_search tool", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
resp := map[string]interface{}{
|
||||
resp := map[string]any{
|
||||
"id": "resp_test",
|
||||
"object": "response",
|
||||
"status": "completed",
|
||||
"output": []map[string]interface{}{
|
||||
"output": []map[string]any{
|
||||
{
|
||||
"id": "msg_1",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"status": "completed",
|
||||
"content": []map[string]interface{}{
|
||||
"content": []map[string]any{
|
||||
{"type": "output_text", "text": "Hi from Codex!"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"usage": map[string]interface{}{
|
||||
"usage": map[string]any{
|
||||
"input_tokens": 12,
|
||||
"output_tokens": 6,
|
||||
"total_tokens": 18,
|
||||
"input_tokens_details": map[string]interface{}{"cached_tokens": 0},
|
||||
"output_tokens_details": map[string]interface{}{"reasoning_tokens": 0},
|
||||
"input_tokens_details": map[string]any{"cached_tokens": 0},
|
||||
"output_tokens_details": map[string]any{"reasoning_tokens": 0},
|
||||
},
|
||||
}
|
||||
writeCompletedSSE(w, resp)
|
||||
|
|
@ -351,7 +355,7 @@ func TestCodexProvider_ChatRoundTrip(t *testing.T) {
|
|||
provider.client = createOpenAITestClient(server.URL, "test-token", "acc-123")
|
||||
|
||||
messages := []Message{{Role: "user", Content: "Hello"}}
|
||||
resp, err := provider.Chat(t.Context(), messages, nil, "gpt-4o", map[string]interface{}{"max_tokens": 1024})
|
||||
resp, err := provider.Chat(t.Context(), messages, nil, "gpt-4o", map[string]any{"max_tokens": 1024})
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error: %v", err)
|
||||
}
|
||||
|
|
@ -373,7 +377,7 @@ func TestCodexProvider_ChatRoundTrip_WebSearchDisabled(t *testing.T) {
|
|||
return
|
||||
}
|
||||
|
||||
var reqBody map[string]interface{}
|
||||
var reqBody map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
|
|
@ -383,27 +387,27 @@ func TestCodexProvider_ChatRoundTrip_WebSearchDisabled(t *testing.T) {
|
|||
return
|
||||
}
|
||||
|
||||
resp := map[string]interface{}{
|
||||
resp := map[string]any{
|
||||
"id": "resp_test",
|
||||
"object": "response",
|
||||
"status": "completed",
|
||||
"output": []map[string]interface{}{
|
||||
"output": []map[string]any{
|
||||
{
|
||||
"id": "msg_1",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"status": "completed",
|
||||
"content": []map[string]interface{}{
|
||||
"content": []map[string]any{
|
||||
{"type": "output_text", "text": "Hi from Codex!"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"usage": map[string]interface{}{
|
||||
"usage": map[string]any{
|
||||
"input_tokens": 4,
|
||||
"output_tokens": 3,
|
||||
"total_tokens": 7,
|
||||
"input_tokens_details": map[string]interface{}{"cached_tokens": 0},
|
||||
"output_tokens_details": map[string]interface{}{"reasoning_tokens": 0},
|
||||
"input_tokens_details": map[string]any{"cached_tokens": 0},
|
||||
"output_tokens_details": map[string]any{"reasoning_tokens": 0},
|
||||
},
|
||||
}
|
||||
writeCompletedSSE(w, resp)
|
||||
|
|
@ -415,7 +419,7 @@ func TestCodexProvider_ChatRoundTrip_WebSearchDisabled(t *testing.T) {
|
|||
provider.client = createOpenAITestClient(server.URL, "test-token", "acc-123")
|
||||
|
||||
messages := []Message{{Role: "user", Content: "Hello"}}
|
||||
resp, err := provider.Chat(t.Context(), messages, nil, "gpt-4o", map[string]interface{}{})
|
||||
resp, err := provider.Chat(t.Context(), messages, nil, "gpt-4o", map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error: %v", err)
|
||||
}
|
||||
|
|
@ -439,7 +443,7 @@ func TestCodexProvider_ChatRoundTrip_TokenSourceFallbackAccountID(t *testing.T)
|
|||
return
|
||||
}
|
||||
|
||||
var reqBody map[string]interface{}
|
||||
var reqBody map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
|
|
@ -465,27 +469,27 @@ func TestCodexProvider_ChatRoundTrip_TokenSourceFallbackAccountID(t *testing.T)
|
|||
return
|
||||
}
|
||||
|
||||
resp := map[string]interface{}{
|
||||
resp := map[string]any{
|
||||
"id": "resp_test",
|
||||
"object": "response",
|
||||
"status": "completed",
|
||||
"output": []map[string]interface{}{
|
||||
"output": []map[string]any{
|
||||
{
|
||||
"id": "msg_1",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"status": "completed",
|
||||
"content": []map[string]interface{}{
|
||||
"content": []map[string]any{
|
||||
{"type": "output_text", "text": "Hi from Codex!"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"usage": map[string]interface{}{
|
||||
"usage": map[string]any{
|
||||
"input_tokens": 8,
|
||||
"output_tokens": 4,
|
||||
"total_tokens": 12,
|
||||
"input_tokens_details": map[string]interface{}{"cached_tokens": 0},
|
||||
"output_tokens_details": map[string]interface{}{"reasoning_tokens": 0},
|
||||
"input_tokens_details": map[string]any{"cached_tokens": 0},
|
||||
"output_tokens_details": map[string]any{"reasoning_tokens": 0},
|
||||
},
|
||||
}
|
||||
writeCompletedSSE(w, resp)
|
||||
|
|
@ -499,7 +503,7 @@ func TestCodexProvider_ChatRoundTrip_TokenSourceFallbackAccountID(t *testing.T)
|
|||
}
|
||||
|
||||
messages := []Message{{Role: "user", Content: "Hello"}}
|
||||
resp, err := provider.Chat(t.Context(), messages, nil, "gpt-4o", map[string]interface{}{"temperature": 0.7})
|
||||
resp, err := provider.Chat(t.Context(), messages, nil, "gpt-4o", map[string]any{"temperature": 0.7})
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error: %v", err)
|
||||
}
|
||||
|
|
@ -515,7 +519,7 @@ func TestCodexProvider_ChatRoundTrip_ModelFallbackFromUnsupported(t *testing.T)
|
|||
return
|
||||
}
|
||||
|
||||
var reqBody map[string]interface{}
|
||||
var reqBody map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
|
|
@ -533,27 +537,27 @@ func TestCodexProvider_ChatRoundTrip_ModelFallbackFromUnsupported(t *testing.T)
|
|||
return
|
||||
}
|
||||
|
||||
resp := map[string]interface{}{
|
||||
resp := map[string]any{
|
||||
"id": "resp_test",
|
||||
"object": "response",
|
||||
"status": "completed",
|
||||
"output": []map[string]interface{}{
|
||||
"output": []map[string]any{
|
||||
{
|
||||
"id": "msg_1",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"status": "completed",
|
||||
"content": []map[string]interface{}{
|
||||
"content": []map[string]any{
|
||||
{"type": "output_text", "text": "Hi from Codex!"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"usage": map[string]interface{}{
|
||||
"usage": map[string]any{
|
||||
"input_tokens": 8,
|
||||
"output_tokens": 4,
|
||||
"total_tokens": 12,
|
||||
"input_tokens_details": map[string]interface{}{"cached_tokens": 0},
|
||||
"output_tokens_details": map[string]interface{}{"reasoning_tokens": 0},
|
||||
"input_tokens_details": map[string]any{"cached_tokens": 0},
|
||||
"output_tokens_details": map[string]any{"reasoning_tokens": 0},
|
||||
},
|
||||
}
|
||||
writeCompletedSSE(w, resp)
|
||||
|
|
@ -588,7 +592,12 @@ func TestResolveCodexModel(t *testing.T) {
|
|||
wantFallback bool
|
||||
}{
|
||||
{name: "empty", input: "", wantModel: codexDefaultModel, wantFallback: true},
|
||||
{name: "unsupported namespace", input: "anthropic/claude-3.5", wantModel: codexDefaultModel, wantFallback: true},
|
||||
{
|
||||
name: "unsupported namespace",
|
||||
input: "anthropic/claude-3.5",
|
||||
wantModel: codexDefaultModel,
|
||||
wantFallback: true,
|
||||
},
|
||||
{name: "non-openai prefixed", input: "glm-4.7", wantModel: codexDefaultModel, wantFallback: true},
|
||||
{name: "openai prefix", input: "openai/gpt-5.2", wantModel: "gpt-5.2", wantFallback: false},
|
||||
{name: "direct gpt", input: "gpt-4o", wantModel: "gpt-4o", wantFallback: false},
|
||||
|
|
@ -622,8 +631,8 @@ func createOpenAITestClient(baseURL, token, accountID string) *openai.Client {
|
|||
return &c
|
||||
}
|
||||
|
||||
func writeCompletedSSE(w http.ResponseWriter, response map[string]interface{}) {
|
||||
event := map[string]interface{}{
|
||||
func writeCompletedSSE(w http.ResponseWriter, response map[string]any) {
|
||||
event := map[string]any{
|
||||
"type": "response.completed",
|
||||
"sequence_number": 1,
|
||||
"response": response,
|
||||
|
|
|
|||
|
|
@ -110,7 +110,11 @@ func (fc *FallbackChain) Execute(
|
|||
Model: candidate.Model,
|
||||
Skipped: true,
|
||||
Reason: FailoverRateLimit,
|
||||
Error: fmt.Errorf("provider %s in cooldown (%s remaining)", candidate.Provider, remaining.Round(time.Second)),
|
||||
Error: fmt.Errorf(
|
||||
"provider %s in cooldown (%s remaining)",
|
||||
candidate.Provider,
|
||||
remaining.Round(time.Second),
|
||||
),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
|
|
|||
|
|
@ -462,7 +462,13 @@ func TestResolveCandidates_EmptyPrimary(t *testing.T) {
|
|||
func TestFallbackExhaustedError_Message(t *testing.T) {
|
||||
e := &FallbackExhaustedError{
|
||||
Attempts: []FallbackAttempt{
|
||||
{Provider: "openai", Model: "gpt-4", Error: errors.New("rate limited"), Reason: FailoverRateLimit, Duration: 500 * time.Millisecond},
|
||||
{
|
||||
Provider: "openai",
|
||||
Model: "gpt-4",
|
||||
Error: errors.New("rate limited"),
|
||||
Reason: FailoverRateLimit,
|
||||
Duration: 500 * time.Millisecond,
|
||||
},
|
||||
{Provider: "anthropic", Model: "claude", Skipped: true},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,9 @@ package providers
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
json "encoding/json"
|
||||
|
||||
copilot "github.com/github/copilot-sdk/go"
|
||||
)
|
||||
|
||||
|
|
@ -17,7 +16,6 @@ type GitHubCopilotProvider struct {
|
|||
}
|
||||
|
||||
func NewGitHubCopilotProvider(uri string, connectMode string, model string) (*GitHubCopilotProvider, error) {
|
||||
|
||||
var session *copilot.Session
|
||||
if connectMode == "" {
|
||||
connectMode = "grpc"
|
||||
|
|
@ -31,7 +29,9 @@ func NewGitHubCopilotProvider(uri string, connectMode string, model string) (*Gi
|
|||
CLIUrl: uri,
|
||||
})
|
||||
if err := client.Start(context.Background()); err != nil {
|
||||
return nil, fmt.Errorf("Can't connect to Github Copilot, https://github.com/github/copilot-sdk/blob/main/docs/getting-started.md#connecting-to-an-external-cli-server for details")
|
||||
return nil, fmt.Errorf(
|
||||
"Can't connect to Github Copilot, https://github.com/github/copilot-sdk/blob/main/docs/getting-started.md#connecting-to-an-external-cli-server for details",
|
||||
)
|
||||
}
|
||||
defer client.Stop()
|
||||
session, _ = client.CreateSession(context.Background(), &copilot.SessionConfig{
|
||||
|
|
@ -49,7 +49,9 @@ func NewGitHubCopilotProvider(uri string, connectMode string, model string) (*Gi
|
|||
}
|
||||
|
||||
// Chat sends a chat request to GitHub Copilot
|
||||
func (p *GitHubCopilotProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
|
||||
func (p *GitHubCopilotProvider) Chat(
|
||||
ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any,
|
||||
) (*LLMResponse, error) {
|
||||
type tempMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
|
|
@ -73,10 +75,8 @@ func (p *GitHubCopilotProvider) Chat(ctx context.Context, messages []Message, to
|
|||
FinishReason: "stop",
|
||||
Content: content,
|
||||
}, nil
|
||||
|
||||
}
|
||||
|
||||
func (p *GitHubCopilotProvider) GetDefaultModel() string {
|
||||
|
||||
return "gpt-4.1"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,13 @@ func NewHTTPProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField st
|
|||
}
|
||||
}
|
||||
|
||||
func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
|
||||
func (p *HTTPProvider) Chat(
|
||||
ctx context.Context,
|
||||
messages []Message,
|
||||
tools []ToolDefinition,
|
||||
model string,
|
||||
options map[string]any,
|
||||
) (*LLMResponse, error) {
|
||||
return p.delegate.Chat(ctx, messages, tools, model, options)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,15 +15,17 @@ import (
|
|||
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
||||
)
|
||||
|
||||
type ToolCall = protocoltypes.ToolCall
|
||||
type FunctionCall = protocoltypes.FunctionCall
|
||||
type LLMResponse = protocoltypes.LLMResponse
|
||||
type UsageInfo = protocoltypes.UsageInfo
|
||||
type Message = protocoltypes.Message
|
||||
type ToolDefinition = protocoltypes.ToolDefinition
|
||||
type ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
|
||||
type ExtraContent = protocoltypes.ExtraContent
|
||||
type GoogleExtra = protocoltypes.GoogleExtra
|
||||
type (
|
||||
ToolCall = protocoltypes.ToolCall
|
||||
FunctionCall = protocoltypes.FunctionCall
|
||||
LLMResponse = protocoltypes.LLMResponse
|
||||
UsageInfo = protocoltypes.UsageInfo
|
||||
Message = protocoltypes.Message
|
||||
ToolDefinition = protocoltypes.ToolDefinition
|
||||
ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
|
||||
ExtraContent = protocoltypes.ExtraContent
|
||||
GoogleExtra = protocoltypes.GoogleExtra
|
||||
)
|
||||
|
||||
type Provider struct {
|
||||
apiKey string
|
||||
|
|
@ -60,14 +62,20 @@ func NewProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string
|
|||
}
|
||||
}
|
||||
|
||||
func (p *Provider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
|
||||
func (p *Provider) Chat(
|
||||
ctx context.Context,
|
||||
messages []Message,
|
||||
tools []ToolDefinition,
|
||||
model string,
|
||||
options map[string]any,
|
||||
) (*LLMResponse, error) {
|
||||
if p.apiBase == "" {
|
||||
return nil, fmt.Errorf("API base not configured")
|
||||
}
|
||||
|
||||
model = normalizeModel(model, p.apiBase)
|
||||
|
||||
requestBody := map[string]interface{}{
|
||||
requestBody := map[string]any{
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
}
|
||||
|
|
@ -83,7 +91,8 @@ func (p *Provider) Chat(ctx context.Context, messages []Message, tools []ToolDef
|
|||
if fieldName == "" {
|
||||
// Fallback: detect from model name for backward compatibility
|
||||
lowerModel := strings.ToLower(model)
|
||||
if strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "o1") || strings.Contains(lowerModel, "gpt-5") {
|
||||
if strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "o1") ||
|
||||
strings.Contains(lowerModel, "gpt-5") {
|
||||
fieldName = "max_completion_tokens"
|
||||
} else {
|
||||
fieldName = "max_tokens"
|
||||
|
|
@ -173,7 +182,7 @@ func parseResponse(body []byte) (*LLMResponse, error) {
|
|||
choice := apiResponse.Choices[0]
|
||||
toolCalls := make([]ToolCall, 0, len(choice.Message.ToolCalls))
|
||||
for _, tc := range choice.Message.ToolCalls {
|
||||
arguments := make(map[string]interface{})
|
||||
arguments := make(map[string]any)
|
||||
name := ""
|
||||
|
||||
// Extract thought_signature from Gemini/Google-specific extra content
|
||||
|
|
@ -238,7 +247,7 @@ func normalizeModel(model, apiBase string) string {
|
|||
}
|
||||
}
|
||||
|
||||
func asInt(v interface{}) (int, bool) {
|
||||
func asInt(v any) (int, bool) {
|
||||
switch val := v.(type) {
|
||||
case int:
|
||||
return val, true
|
||||
|
|
@ -253,7 +262,7 @@ func asInt(v interface{}) (int, bool) {
|
|||
}
|
||||
}
|
||||
|
||||
func asFloat(v interface{}) (float64, bool) {
|
||||
func asFloat(v any) (float64, bool) {
|
||||
switch val := v.(type) {
|
||||
case float64:
|
||||
return val, true
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue